fix(photos): копия фото не наследует отредактированную версию

Clone переносил все привязки photo_objects, включая роли edit*, хотя
правка принадлежит фото, чьи правки её породили. Теперь копия делит
только обычные варианты и показывается в исходном виде.

- EditRoles() и IsEditRole() в edit_render.go — единый список ролей;
- фильтр edit-ролей в Store.Clone и SQLiteStore.Clone;
- миграции 000016 (Postgres) и 000008 (SQLite) убирают привязки у
  копий, созданных до фикса;
- контрактная проверка копирования, сервисные и e2e-тесты.
This commit is contained in:
iu
2026-10-21 21:01:33 +03:00
parent 759e78ff79
commit 8dd8cb6f0c
12 changed files with 344 additions and 6 deletions
+54
View File
@@ -74,6 +74,60 @@ func TestCopySharesStorageObjects(t *testing.T) {
}
}
func TestCopyDoesNotShareEditedVersion(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
svc, cl, _ := newRecordingServiceFS(t, pool)
user := createUser(t, ctx, pool)
albumA := createAlbum(t, ctx, pool, user)
albumB := createAlbum(t, ctx, pool, user)
p, err := svc.Upload(ctx, UploadInput{
AlbumID: albumA, UserID: user, Filename: "p.png", MimeType: "image/png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload: %v", err)
}
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
w := NewWorker(svc, time.Second, 10, time.Minute, 2)
w.processVariantJobs(ctx)
// The edit belongs to the photo: the parameters live in its metadata and
// the background render binds the edited version to it.
if _, err := svc.EditPhoto(ctx, p.ID, user, EditParams{Light: LightParams{Exposure: 0.5}}); err != nil {
t.Fatalf("edit photo: %v", err)
}
w.processEditJobs(ctx)
src, err := svc.store.GetByID(ctx, p.ID, "")
if err != nil {
t.Fatalf("get source: %v", err)
}
if src.EditThumbKey == "" || src.EditPreviewKey == "" {
t.Fatalf("source has no edited version: %+v", src)
}
clones, err := svc.CopyBatch(ctx, []string{p.ID}, user, albumB)
if err != nil || len(clones) != 1 {
t.Fatalf("copy: %v len=%d", err, len(clones))
}
clone := clones[0]
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, clone.ID) })
// The copy shares the base variants with the source.
if clone.ThumbKey != src.ThumbKey || clone.MediumKey != src.MediumKey || clone.PreviewKey != src.PreviewKey {
t.Fatalf("clone does not share base variants: src=%+v clone=%+v", src, clone)
}
// But it never inherits the edited version or its parameters.
if clone.EditKey != "" || clone.EditThumbKey != "" || clone.EditMediumKey != "" || clone.EditPreviewKey != "" {
t.Fatalf("clone shares the edited version: %+v", clone)
}
if len(clone.EditParams) != 0 {
t.Fatalf("clone inherited edit params: %+v", clone.EditParams)
}
}
func sameSet(a, b []string) bool {
if len(a) != len(b) {
return false
+16
View File
@@ -19,6 +19,22 @@ const (
RoleEditPreview = "edit_preview"
)
// EditRoles lists the roles of the edited version. The edited version belongs
// to the photo whose edits produced it, so copies of that photo never share
// these roles.
func EditRoles() []string {
return []string{RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview}
}
// IsEditRole reports whether the role belongs to the edited version.
func IsEditRole(role string) bool {
switch role {
case RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview:
return true
}
return false
}
// applyLight renders the light adjustments into a copy of the image. The
// original pixels are never modified in place.
func applyLight(src image.Image, p LightParams) *image.NRGBA {
+1 -2
View File
@@ -74,8 +74,7 @@ func (s *Service) ResetEdit(ctx context.Context, photoID, userID string) (*model
if err := s.store.DeleteEditJob(ctx, photoID); err != nil {
return nil, err
}
if err := s.store.DeleteObjectBindings(ctx, photoID,
RoleEdit, RoleEditThumb, RoleEditMedium, RoleEditPreview); err != nil {
if err := s.store.DeleteObjectBindings(ctx, photoID, EditRoles()...); err != nil {
return nil, err
}
return s.publishEdit(ctx, photoID, userID)
+17
View File
@@ -34,6 +34,23 @@ func TestParseEditParamsClampsValues(t *testing.T) {
}
}
func TestEditRolesCoverTheEditedVersion(t *testing.T) {
roles := EditRoles()
if len(roles) != 4 {
t.Fatalf("edit roles = %v, want four", roles)
}
for _, role := range roles {
if !IsEditRole(role) {
t.Fatalf("role %q must be an edit role", role)
}
}
for _, role := range []string{RoleOriginal, RoleThumb, RoleMedium, RolePreview, RolePlayback} {
if IsEditRole(role) {
t.Fatalf("role %q must not be an edit role", role)
}
}
}
func TestEditParamsIsEmpty(t *testing.T) {
var zero EditParams
if !zero.IsEmpty() {
+10 -2
View File
@@ -250,10 +250,18 @@ func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, upl
mediaKind(src.Kind), src.DurationMS); err != nil {
return err
}
// The edited version belongs to the photo whose edits produced it, so the
// copy shares the base variants only.
roles := EditRoles()
args := []any{id, src.ID}
for _, role := range roles {
args = append(args, role)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
id, src.ID); err != nil {
SELECT $1, object_id, role FROM photo_objects
WHERE photo_id = $2 AND role NOT IN (`+sqliteutil.Placeholders(3, len(roles))+`)`,
args...); err != nil {
return err
}
return tx.Commit()
+5 -2
View File
@@ -290,10 +290,13 @@ func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderI
mediaKind(src.Kind), src.DurationMS); err != nil {
return err
}
// The edited version belongs to the photo whose edits produced it, so the
// copy shares the base variants only.
if _, err := tx.Exec(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
id, src.ID); err != nil {
SELECT $1, object_id, role FROM photo_objects
WHERE photo_id = $2 AND role <> ALL($3::text[])`,
id, src.ID, EditRoles()); err != nil {
return err
}
return tx.Commit(ctx)