feat(photos): добавить пресеты кривой и починить переключение фото

- PCHIP для предпросмотра и серверного рендера
- пресеты всех каналов в профиле, импорт и экспорт
- мерж настроек auth без потери разделов
- сохранение вкладки и правок текущего фото при переключении
This commit is contained in:
iu
2026-10-23 22:56:55 +03:00
parent 3b8406fb4c
commit 0bfee5f2f8
20 changed files with 2127 additions and 105 deletions
+214
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -30,6 +31,7 @@ func newTestRouter(t *testing.T) *gin.Engine {
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
}
@@ -213,3 +215,215 @@ func TestDeleteMeHandler(t *testing.T) {
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)
}
}