feat(auth): add user profile, phone login, and account deletion

This commit is contained in:
iu
2026-10-12 14:26:32 +03:00
parent 1cb9aaf198
commit 86aff695df
46 changed files with 2893 additions and 134 deletions
+215
View File
@@ -0,0 +1,215 @@
package auth
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"galo/internal/models"
)
type authResponse struct {
User models.SelfUser `json:"user"`
Tokens *TokenPair `json:"tokens"`
}
func newTestRouter(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
h := NewHandler(newTestService(t))
r.POST("/auth/register", h.Register)
r.POST("/auth/login", h.Login)
authed := r.Group("")
authed.Use(h.svc.Middleware())
authed.GET("/auth/me", h.Me)
authed.PATCH("/auth/me", h.UpdateMe)
authed.POST("/auth/me/password", h.ChangePassword)
authed.POST("/auth/me/identifiers", h.AddIdentifier)
authed.DELETE("/auth/me", h.DeleteMe)
return r
}
func doJSON(r *gin.Engine, method, path, token string, body any) *httptest.ResponseRecorder {
var buf bytes.Buffer
if body != nil {
_ = json.NewEncoder(&buf).Encode(body)
} else {
buf.WriteString("{}")
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
func decodeAuth(t *testing.T, rec *httptest.ResponseRecorder) authResponse {
t.Helper()
var out authResponse
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
return out
}
func TestRegisterHandlerPhoneOnly(t *testing.T) {
r := newTestRouter(t)
rec := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"phone": "+15550000021", "password": "password123", "name": "Phone",
})
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
out := decodeAuth(t, rec)
if out.User.Phone != "+15550000021" || out.User.Email != "" {
t.Fatalf("user = %+v", out.User)
}
if out.Tokens == nil || out.Tokens.AccessToken == "" {
t.Fatalf("no tokens")
}
}
func TestRegisterHandlerRequiresIdentifier(t *testing.T) {
r := newTestRouter(t)
rec := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"password": "password123", "name": "NoId",
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
}
func TestLoginHandlerByIdentifier(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "handler@test.local", "password": "password123", "name": "H",
})
if reg.Code != http.StatusCreated {
t.Fatalf("register status = %d", reg.Code)
}
rec := doJSON(r, http.MethodPost, "/auth/login", "", map[string]string{
"identifier": "Handler@Test.Local", "password": "password123",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d body = %s", rec.Code, rec.Body.String())
}
if out := decodeAuth(t, rec); out.Tokens == nil {
t.Fatalf("no tokens")
}
}
func TestMeHandlerExposesPhoneToOwner(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "me@test.local", "phone": "+15550000022", "password": "password123", "name": "Me",
})
out := decodeAuth(t, reg)
rec := doJSON(r, http.MethodGet, "/auth/me", out.Tokens.AccessToken, nil)
if rec.Code != http.StatusOK {
t.Fatalf("me status = %d body = %s", rec.Code, rec.Body.String())
}
var body struct {
User models.SelfUser `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.User.Phone != "+15550000022" {
t.Fatalf("phone not exposed to owner: %s", rec.Body.String())
}
}
func TestUpdateMeHandler(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "upd@test.local", "password": "password123", "name": "Old",
})
token := decodeAuth(t, reg).Tokens.AccessToken
if rec := doJSON(r, http.MethodPatch, "/auth/me", token, map[string]string{"name": " "}); rec.Code != http.StatusBadRequest {
t.Fatalf("empty name status = %d body = %s", rec.Code, rec.Body.String())
}
rec := doJSON(r, http.MethodPatch, "/auth/me", token, map[string]string{"name": "New"})
if rec.Code != http.StatusOK {
t.Fatalf("update status = %d body = %s", rec.Code, rec.Body.String())
}
var body struct {
User models.SelfUser `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body.User.Name != "New" {
t.Fatalf("name not updated: %s", rec.Body.String())
}
}
func TestChangePasswordHandler(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "cp@test.local", "password": "password123", "name": "CP",
})
out := decodeAuth(t, reg)
if rec := doJSON(r, http.MethodPost, "/auth/me/password", out.Tokens.AccessToken, map[string]string{
"current_password": "wrong", "new_password": "newpassword",
}); rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong current status = %d", rec.Code)
}
if rec := doJSON(r, http.MethodPost, "/auth/me/password", out.Tokens.AccessToken, map[string]string{
"current_password": "password123", "new_password": "newpassword",
}); rec.Code != http.StatusNoContent {
t.Fatalf("change status = %d body = %s", rec.Code, rec.Body.String())
}
}
func TestAddIdentifierHandler(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "addh@test.local", "password": "password123", "name": "Add",
})
token := decodeAuth(t, reg).Tokens.AccessToken
if rec := doJSON(r, http.MethodPost, "/auth/me/identifiers", token, map[string]string{"email": "x@test.local"}); rec.Code != http.StatusBadRequest {
t.Fatalf("re-set email status = %d", rec.Code)
}
rec := doJSON(r, http.MethodPost, "/auth/me/identifiers", token, map[string]string{"phone": "+15550000023"})
if rec.Code != http.StatusOK {
t.Fatalf("add status = %d body = %s", rec.Code, rec.Body.String())
}
var body struct {
User models.SelfUser `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &body)
if body.User.Phone != "+15550000023" {
t.Fatalf("phone not added: %s", rec.Body.String())
}
}
func TestDeleteMeHandler(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "delh@test.local", "password": "password123", "name": "Del",
})
token := decodeAuth(t, reg).Tokens.AccessToken
if rec := doJSON(r, http.MethodDelete, "/auth/me", token, map[string]string{"password": "wrong"}); rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong password status = %d", rec.Code)
}
if rec := doJSON(r, http.MethodDelete, "/auth/me", token, map[string]string{"password": "password123"}); rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d body = %s", rec.Code, rec.Body.String())
}
if rec := doJSON(r, http.MethodGet, "/auth/me", token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("me after delete status = %d", rec.Code)
}
}