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") } } }