Files
ds-galo/backend/internal/e2e/copy_edit_test.go
iu 8dd8cb6f0c fix(photos): копия фото не наследует отредактированную версию
Clone переносил все привязки photo_objects, включая роли edit*, хотя
правка принадлежит фото, чьи правки её породили. Теперь копия делит
только обычные варианты и показывается в исходном виде.

- EditRoles() и IsEditRole() в edit_render.go — единый список ролей;
- фильтр edit-ролей в Store.Clone и SQLiteStore.Clone;
- миграции 000016 (Postgres) и 000008 (SQLite) убирают привязки у
  копий, созданных до фикса;
- контрактная проверка копирования, сервисные и e2e-тесты.
2026-10-21 21:01:33 +03:00

165 lines
5.4 KiB
Go

package e2e
import (
"context"
"io/fs"
"path/filepath"
"testing"
"time"
assets "galo"
"galo/internal/albums"
"galo/internal/photos"
)
// A copy of an edited photo must not inherit the edited version: the edit
// belongs to the photo whose edits produced it.
func TestCopyDoesNotShareEditedVersion(t *testing.T) {
ctx := context.Background()
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "copy@test.local", "", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Source"})
if err != nil {
t.Fatalf("create album: %v", err)
}
target, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Target"})
if err != nil {
t.Fatalf("create target album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload: %v", err)
}
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
w.ProcessOnce(ctx)
if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{Light: photos.LightParams{Exposure: 0.5}}); err != nil {
t.Fatalf("edit photo: %v", err)
}
w.ProcessOnce(ctx)
source, err := st.photos.Get(ctx, p.ID, user.ID)
if err != nil {
t.Fatalf("get source: %v", err)
}
if source.EditThumbURL == "" || source.EditPreviewURL == "" {
t.Fatalf("source has no edited version: %+v", source)
}
clones, err := st.photos.CopyBatch(ctx, []string{p.ID}, user.ID, target.ID)
if err != nil || len(clones) != 1 {
t.Fatalf("copy: %v len=%d", err, len(clones))
}
clone := clones[0]
// The copy shares the base variants with the source.
if clone.ThumbURL != source.ThumbURL || clone.PreviewURL != source.PreviewURL {
t.Fatalf("clone does not share base variants: src=%+v clone=%+v", source, clone)
}
// But it shows the original picture and carries no edit parameters.
if clone.EditURL != "" || clone.EditThumbURL != "" || clone.EditMediumURL != "" || clone.EditPreviewURL != "" {
t.Fatalf("clone shares the edited version: %+v", clone)
}
if len(clone.EditParams) != 0 {
t.Fatalf("clone inherited edit params: %+v", clone.EditParams)
}
}
// The migration drops the edit bindings that copies carried before the fix,
// while the edited version of the photo that owns them stays untouched.
func TestMigrationDropsEditBindingsOfCopies(t *testing.T) {
ctx := context.Background()
st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "mig@test.local", "", "password123", "M")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Source"})
if err != nil {
t.Fatalf("create album: %v", err)
}
target, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Target"})
if err != nil {
t.Fatalf("create target album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload: %v", err)
}
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 2)
w.ProcessOnce(ctx)
if _, err := st.photos.EditPhoto(ctx, p.ID, user.ID, photos.EditParams{Light: photos.LightParams{Exposure: 0.5}}); err != nil {
t.Fatalf("edit photo: %v", err)
}
w.ProcessOnce(ctx)
var editObjectID string
if err := st.db.QueryRowContext(ctx,
`SELECT object_id FROM photo_objects WHERE photo_id = $1 AND role = 'edit_thumb'`, p.ID).Scan(&editObjectID); err != nil {
t.Fatalf("edited thumb object: %v", err)
}
clones, err := st.photos.CopyBatch(ctx, []string{p.ID}, user.ID, target.ID)
if err != nil || len(clones) != 1 {
t.Fatalf("copy: %v len=%d", err, len(clones))
}
clone := clones[0]
// Reproduce the state left by the old code: the copy carries the edited
// version of the source photo.
if _, err := st.db.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, 'edit_thumb')`,
clone.ID, editObjectID); err != nil {
t.Fatalf("bind edited version to the copy: %v", err)
}
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
up, err := fs.ReadFile(migFS, "000008_photo_edit_copy_bindings.up.sql")
if err != nil {
t.Fatalf("read migration: %v", err)
}
if _, err := st.db.ExecContext(ctx, string(up)); err != nil {
t.Fatalf("apply migration: %v", err)
}
var n int
if err := st.db.QueryRowContext(ctx,
`SELECT count(*) FROM photo_objects WHERE photo_id = $1 AND role LIKE 'edit%'`, clone.ID).Scan(&n); err != nil {
t.Fatalf("count copy edit bindings: %v", err)
}
if n != 0 {
t.Fatalf("copy still carries %d edit bindings", n)
}
// The photo that owns the edited version keeps it.
if err := st.db.QueryRowContext(ctx,
`SELECT count(*) FROM photo_objects WHERE photo_id = $1 AND role = 'edit_thumb'`, p.ID).Scan(&n); err != nil {
t.Fatalf("count source edit bindings: %v", err)
}
if n != 1 {
t.Fatalf("source edit bindings = %d, want 1", n)
}
// A bound object is never garbage, so the edited version stays alive.
claimed, err := st.repos.Photos.ClaimGarbage(ctx, 10)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
for _, o := range claimed {
if o.ID == editObjectID {
t.Fatalf("edited version of the owner was claimed as garbage")
}
}
}