Files
ds-galo/backend/internal/auth/handlers_test.go
iu 0bfee5f2f8 feat(photos): добавить пресеты кривой и починить переключение фото
- PCHIP для предпросмотра и серверного рендера
- пресеты всех каналов в профиле, импорт и экспорт
- мерж настроек auth без потери разделов
- сохранение вкладки и правок текущего фото при переключении
2026-10-23 22:56:55 +03:00

430 lines
14 KiB
Go

package auth
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"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.PATCH("/auth/me/preferences", h.UpdatePreferences)
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)
}
}
func curvePresetPayloadWith(name, channel string, points []map[string]float64) map[string]any {
linear := func() []map[string]float64 {
return []map[string]float64{
{"x": 0, "y": 0},
{"x": 1, "y": 1},
}
}
channels := map[string]any{
"rgb": linear(),
"red": linear(),
"green": linear(),
"blue": linear(),
}
channels[channel] = points
return map[string]any{"name": name, "channels": channels}
}
func curvePresetPayload(name string) map[string]any {
return curvePresetPayloadWith(name, "rgb", []map[string]float64{
{"x": 0, "y": 0},
{"x": 1, "y": 0.8},
})
}
func decodePreferences(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body struct {
Preferences map[string]any `json:"preferences"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode preferences %s: %v", rec.Body.String(), err)
}
return body.Preferences
}
func TestUpdatePreferencesMergesSections(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "prefs@test.local", "password": "password123", "name": "Prefs",
})
token := decodeAuth(t, reg).Tokens.AccessToken
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"photo_info": map[string]any{
"visible": []string{"camera"},
"labels": map[string]string{"camera": "Камера"},
},
})
if rec.Code != http.StatusOK {
t.Fatalf("save photo_info status = %d body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": []any{curvePresetPayload("Мой")},
})
if rec.Code != http.StatusOK {
t.Fatalf("save presets status = %d body = %s", rec.Code, rec.Body.String())
}
prefs := decodePreferences(t, rec)
if _, ok := prefs["photo_info"]; !ok {
t.Fatalf("photo_info lost after saving presets: %+v", prefs)
}
presets, ok := prefs["curve_presets"].([]any)
if !ok || len(presets) != 1 {
t.Fatalf("presets = %#v, want one", prefs["curve_presets"])
}
// Saving photo_info again must keep the presets.
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"photo_info": map[string]any{"visible": []string{"iso"}},
})
prefs = decodePreferences(t, rec)
if presets, ok := prefs["curve_presets"].([]any); !ok || len(presets) != 1 {
t.Fatalf("presets lost after saving photo_info: %+v", prefs)
}
info, _ := prefs["photo_info"].(map[string]any)
if visible, _ := info["visible"].([]any); len(visible) != 1 || visible[0] != "iso" {
t.Fatalf("photo_info not updated: %+v", prefs["photo_info"])
}
// The merged state is what GET /auth/me returns.
rec = doJSON(r, http.MethodGet, "/auth/me", token, nil)
var me struct {
Preferences map[string]any `json:"preferences"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
t.Fatalf("decode me: %v", err)
}
if _, ok := me.Preferences["photo_info"]; !ok {
t.Fatalf("me lost photo_info: %+v", me.Preferences)
}
if _, ok := me.Preferences["curve_presets"]; !ok {
t.Fatalf("me lost curve_presets: %+v", me.Preferences)
}
// An empty preset list clears only presets.
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": []any{},
})
prefs = decodePreferences(t, rec)
if presets, ok := prefs["curve_presets"].([]any); !ok || len(presets) != 0 {
t.Fatalf("presets not cleared: %+v", prefs["curve_presets"])
}
if _, ok := prefs["photo_info"]; !ok {
t.Fatalf("photo_info lost after clearing presets: %+v", prefs)
}
// Names are stored without surrounding whitespace.
rec = doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": []any{curvePresetPayload(" Мой ")},
})
prefs = decodePreferences(t, rec)
presets = prefs["curve_presets"].([]any)
first, _ := presets[0].(map[string]any)
if first["name"] != "Мой" {
t.Fatalf("preset name = %#v, want trimmed", first["name"])
}
}
func TestPreferencesArePrivate(t *testing.T) {
r := newTestRouter(t)
first := decodeAuth(t, doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "private1@test.local", "password": "password123", "name": "One",
}))
second := decodeAuth(t, doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "private2@test.local", "password": "password123", "name": "Two",
}))
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", first.Tokens.AccessToken, map[string]any{
"curve_presets": []any{curvePresetPayload("Секрет")},
})
if rec.Code != http.StatusOK {
t.Fatalf("save status = %d body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(r, http.MethodGet, "/auth/me", second.Tokens.AccessToken, nil)
var me struct {
Preferences map[string]any `json:"preferences"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
t.Fatalf("decode me: %v", err)
}
if _, ok := me.Preferences["curve_presets"]; ok {
t.Fatalf("other user sees presets: %+v", me.Preferences)
}
}
func TestUpdatePreferencesRejectsInvalidCurvePresets(t *testing.T) {
r := newTestRouter(t)
reg := doJSON(r, http.MethodPost, "/auth/register", "", map[string]string{
"email": "invalidprefs@test.local", "password": "password123", "name": "Invalid",
})
token := decodeAuth(t, reg).Tokens.AccessToken
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": []any{curvePresetPayload("Хороший")},
})
if rec.Code != http.StatusOK {
t.Fatalf("seed status = %d body = %s", rec.Code, rec.Body.String())
}
manyPoints := make([]map[string]float64, 17)
for i := range manyPoints {
manyPoints[i] = map[string]float64{"x": float64(i) / 16, "y": float64(i) / 16}
}
tooMany := make([]any, 101)
for i := range tooMany {
tooMany[i] = curvePresetPayload("preset-" + string(rune('a'+i%26)) + strings.Repeat("x", i/26))
}
missingChannel := curvePresetPayload("bad")
delete(missingChannel["channels"].(map[string]any), "blue")
cases := []struct {
name string
presets []any
}{
{"too long name", []any{curvePresetPayload(strings.Repeat("a", 65))}},
{"missing channel", []any{missingChannel}},
{"one point", []any{curvePresetPayloadWith("one", "red", []map[string]float64{
{"x": 0, "y": 0},
})}},
{"too many points", []any{curvePresetPayloadWith("many", "rgb", manyPoints)}},
{"x out of range", []any{curvePresetPayloadWith("range", "blue", []map[string]float64{
{"x": 1.1, "y": 0},
{"x": 1, "y": 1},
})}},
{"duplicate name", []any{curvePresetPayload("dup"), curvePresetPayload("dup")}},
{"too many presets", tooMany},
}
for _, tc := range cases {
rec := doJSON(r, http.MethodPatch, "/auth/me/preferences", token, map[string]any{
"curve_presets": tc.presets,
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d body = %s", tc.name, rec.Code, rec.Body.String())
}
}
rec = doJSON(r, http.MethodGet, "/auth/me", token, nil)
var me struct {
Preferences map[string]any `json:"preferences"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &me); err != nil {
t.Fatalf("decode me: %v", err)
}
presets, _ := me.Preferences["curve_presets"].([]any)
if len(presets) != 1 {
t.Fatalf("invalid requests changed presets: %+v", me.Preferences)
}
}