// Package contract holds a backend-agnostic test suite for the metadata // repositories. The same suite runs against PostgreSQL and SQLite so both // implementations are held to identical observable behaviour. package contract import ( "context" "errors" "testing" "time" "galo/internal/albums" "galo/internal/auth" "galo/internal/members" "galo/internal/models" "galo/internal/notifications" "galo/internal/photos" "galo/internal/settings" "galo/internal/sharelinks" "galo/internal/social" ) // Repos bundles one repository per domain plus a Reset hook that clears all // metadata so each subtest starts from an empty state. type Repos struct { Auth auth.Repository Albums albums.Repository Members members.Repository Photos photos.Repository Social social.Repository Notifications notifications.Repository ShareLinks sharelinks.Repository Settings settings.Repository Reset func(ctx context.Context) error } // Run executes the whole contract suite against the given repositories. func Run(t *testing.T, r Repos) { t.Helper() cases := []struct { name string fn func(*testing.T, context.Context, Repos) }{ {"Auth", testAuth}, {"Albums", testAlbums}, {"Members", testMembers}, {"Notifications", testNotifications}, {"ShareLinks", testShareLinks}, {"Social", testSocial}, {"Photos", testPhotos}, {"Objects", testObjects}, {"UserAvatar", testUserAvatar}, {"VariantJobKinds", testVariantJobKinds}, {"Settings", testSettings}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if err := r.Reset(context.Background()); err != nil { t.Fatalf("reset: %v", err) } tc.fn(t, context.Background(), r) }) } } func newID() string { return auth.NewID() } func seedUser(t *testing.T, ctx context.Context, r Repos, name string) *models.User { t.Helper() u := &models.User{ID: newID(), Email: newID()[:10] + "@test.local", PasswordHash: "x", Name: name} if err := r.Auth.CreateUser(ctx, u); err != nil { t.Fatalf("create user: %v", err) } return u } func seedAlbum(t *testing.T, ctx context.Context, r Repos, ownerID string) *models.Album { t.Helper() a := &models.Album{ID: newID(), OwnerID: ownerID, Title: "Album"} if err := r.Albums.Create(ctx, a); err != nil { t.Fatalf("create album: %v", err) } return a } func seedPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID string) *models.Photo { t.Helper() obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending} p := &models.Photo{ID: newID(), AlbumID: albumID, UploaderID: uploaderID, OriginalFilename: "p.jpg"} if err := r.Photos.CreateWithOriginal(ctx, p, obj); err != nil { t.Fatalf("create photo: %v", err) } if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil { t.Fatalf("activate object: %v", err) } return p } func seedVideoPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID string) *models.Photo { t.Helper() obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending} p := &models.Photo{ID: newID(), AlbumID: albumID, UploaderID: uploaderID, OriginalFilename: "v.mp4", Kind: photos.KindVideo} if err := r.Photos.CreateWithOriginal(ctx, p, obj); err != nil { t.Fatalf("create video photo: %v", err) } if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil { t.Fatalf("activate object: %v", err) } return p } func testAuth(t *testing.T, ctx context.Context, r Repos) { u := seedUser(t, ctx, r, "Tester") byEmail, err := r.Auth.GetUserByEmail(ctx, u.Email) if err != nil || byEmail.ID != u.ID { t.Fatalf("get by email: %v id=%v", err, byEmail) } if _, err := r.Auth.GetUserByID(ctx, u.ID); err != nil { t.Fatalf("get by id: %v", err) } if _, err := r.Auth.GetUserByEmail(ctx, "missing@test.local"); !errors.Is(err, auth.ErrNotFound) { t.Fatalf("missing email err = %v, want ErrNotFound", err) } if _, err := r.Auth.GetUserByPhone(ctx, "+15550000000"); !errors.Is(err, auth.ErrNotFound) { t.Fatalf("missing phone err = %v, want ErrNotFound", err) } // A phone-only account stores NULL email and is retrievable by phone. phoneUser := &models.User{ID: newID(), Phone: "+15550000001", PasswordHash: "x", Name: "Phone Only"} if err := r.Auth.CreateUser(ctx, phoneUser); err != nil { t.Fatalf("create phone-only user: %v", err) } byPhone, err := r.Auth.GetUserByPhone(ctx, phoneUser.Phone) if err != nil || byPhone.ID != phoneUser.ID || byPhone.Email != "" { t.Fatalf("get by phone: %v %+v", err, byPhone) } // Adding the missing identifier fills only the empty column. if err := r.Auth.AddIdentifier(ctx, phoneUser.ID, "added@test.local", ""); err != nil { t.Fatalf("add identifier: %v", err) } withEmail, _ := r.Auth.GetUserByID(ctx, phoneUser.ID) if withEmail.Email != "added@test.local" || withEmail.Phone != phoneUser.Phone { t.Fatalf("identifier not added: %+v", withEmail) } if err := r.Auth.UpdateName(ctx, u.ID, "Renamed"); err != nil { t.Fatalf("update name: %v", err) } if err := r.Auth.UpdatePasswordHash(ctx, u.ID, "newhash"); err != nil { t.Fatalf("update password: %v", err) } renamed, _ := r.Auth.GetUserByID(ctx, u.ID) if renamed.Name != "Renamed" || renamed.PasswordHash != "newhash" { t.Fatalf("user updates not applied: %+v", renamed) } if err := r.Auth.DeleteUser(ctx, phoneUser.ID); err != nil { t.Fatalf("delete user: %v", err) } if _, err := r.Auth.GetUserByID(ctx, phoneUser.ID); !errors.Is(err, auth.ErrNotFound) { t.Fatalf("deleted user still present") } if err := r.Auth.UpdatePreferences(ctx, u.ID, []byte(`{"theme":"dark"}`)); err != nil { t.Fatalf("update preferences: %v", err) } got, _ := r.Auth.GetUserByID(ctx, u.ID) if got.Preferences["theme"] != "dark" { t.Fatalf("preferences not applied: %+v", got.Preferences) } if err := r.Auth.UpdatePreferences(ctx, "missing", []byte(`{}`)); !errors.Is(err, auth.ErrNotFound) { t.Fatalf("update missing prefs err = %v, want ErrNotFound", err) } hash := newID() rt := &models.RefreshToken{ID: newID(), UserID: u.ID, TokenHash: hash, ExpiresAt: time.Now().Add(time.Hour)} if err := r.Auth.CreateRefreshToken(ctx, rt); err != nil { t.Fatalf("create refresh: %v", err) } fetched, err := r.Auth.GetRefreshToken(ctx, hash) if err != nil || fetched.ID != rt.ID { t.Fatalf("get refresh: %v id=%v", err, fetched) } if err := r.Auth.RevokeRefreshToken(ctx, rt.ID); err != nil { t.Fatalf("revoke refresh: %v", err) } fetched, _ = r.Auth.GetRefreshToken(ctx, hash) if fetched.RevokedAt == nil { t.Fatalf("refresh not revoked") } if err := r.Auth.RevokeAllUserTokens(ctx, u.ID); err != nil { t.Fatalf("revoke all: %v", err) } } func testAlbums(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") a := seedAlbum(t, ctx, r, owner.ID) if a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() { t.Fatalf("album timestamps not returned: %+v", a) } got, err := r.Albums.GetByID(ctx, a.ID) if err != nil || got.Title != "Album" { t.Fatalf("get: %v %+v", err, got) } if oid, err := r.Albums.OwnerID(ctx, a.ID); err != nil || oid != owner.ID { t.Fatalf("owner id: %v %v", oid, err) } list, err := r.Albums.ListForUser(ctx, owner.ID) if err != nil || len(list) != 1 { t.Fatalf("list: %v len=%d", err, len(list)) } a.Title = "Renamed" if err := r.Albums.Update(ctx, a); err != nil { t.Fatalf("update: %v", err) } got, _ = r.Albums.GetByID(ctx, a.ID) if got.Title != "Renamed" { t.Fatalf("update not applied: %q", got.Title) } if err := r.Albums.Delete(ctx, a.ID); err != nil { t.Fatalf("delete: %v", err) } if _, err := r.Albums.GetByID(ctx, a.ID); !errors.Is(err, albums.ErrNotFound) { t.Fatalf("after delete err = %v, want ErrNotFound", err) } } func testMembers(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") member := seedUser(t, ctx, r, "Member") a := seedAlbum(t, ctx, r, owner.ID) if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" { t.Fatalf("role before add = %q", role) } if err := r.Members.Add(ctx, a.ID, member.ID, "member", &owner.ID); err != nil { t.Fatalf("add: %v", err) } if role, err := r.Members.Role(ctx, a.ID, member.ID); err != nil || role != "member" { t.Fatalf("role = %q err=%v", role, err) } list, err := r.Members.List(ctx, a.ID) if err != nil || len(list) != 1 || list[0].User.Name != "Member" { t.Fatalf("list: %v %+v", err, list) } if err := r.Members.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil { t.Fatalf("update role: %v", err) } if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "editor" { t.Fatalf("role not updated: %q", role) } inv := &models.Invite{ID: newID(), AlbumID: a.ID, Token: newID(), Role: "viewer", CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour), MaxUses: 2} if err := r.Members.CreateInvite(ctx, inv); err != nil { t.Fatalf("create invite: %v", err) } if inv.CreatedAt.IsZero() { t.Fatalf("invite created_at not returned") } got, err := r.Members.GetInviteByToken(ctx, inv.Token) if err != nil || got.Role != "viewer" || got.MaxUses != 2 { t.Fatalf("get invite: %v %+v", err, got) } if err := r.Members.IncrementUses(ctx, inv.ID); err != nil { t.Fatalf("increment: %v", err) } got, _ = r.Members.GetInviteByToken(ctx, inv.Token) if got.Uses != 1 { t.Fatalf("uses = %d, want 1", got.Uses) } if _, err := r.Members.GetInviteByToken(ctx, "missing"); !errors.Is(err, members.ErrNotFound) { t.Fatalf("missing invite err = %v, want ErrNotFound", err) } if err := r.Members.Remove(ctx, a.ID, member.ID); err != nil { t.Fatalf("remove: %v", err) } if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" { t.Fatalf("member not removed: %q", role) } if err := r.Members.Remove(ctx, a.ID, member.ID); !errors.Is(err, members.ErrNotFound) { t.Fatalf("remove missing err = %v, want ErrNotFound", err) } } func testNotifications(t *testing.T, ctx context.Context, r Repos) { u := seedUser(t, ctx, r, "Notify") n := &models.Notification{ID: newID(), UserID: u.ID, Type: "test", PayloadJSON: map[string]any{"k": "v"}} if err := r.Notifications.Create(ctx, n); err != nil { t.Fatalf("create: %v", err) } if n.CreatedAt.IsZero() { t.Fatalf("notification created_at not returned") } list, err := r.Notifications.List(ctx, u.ID, 10) if err != nil || len(list) != 1 || list[0].PayloadJSON["k"] != "v" { t.Fatalf("list: %v %+v", err, list) } if err := r.Notifications.MarkRead(ctx, n.ID, u.ID); err != nil { t.Fatalf("mark read: %v", err) } list, _ = r.Notifications.List(ctx, u.ID, 10) if list[0].ReadAt == nil { t.Fatalf("notification not marked read") } if err := r.Notifications.MarkRead(ctx, newID(), u.ID); !errors.Is(err, notifications.ErrNotFound) { t.Fatalf("mark missing err = %v, want ErrNotFound", err) } } func testShareLinks(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") a := seedAlbum(t, ctx, r, owner.ID) l := &models.ShareLink{ID: newID(), AlbumID: a.ID, Token: newID(), CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour)} if err := r.ShareLinks.Create(ctx, l); err != nil { t.Fatalf("create: %v", err) } if l.CreatedAt.IsZero() { t.Fatalf("share link created_at not returned") } got, err := r.ShareLinks.GetByToken(ctx, l.Token) if err != nil || !got.Active(time.Now()) { t.Fatalf("get token: %v %+v", err, got) } list, err := r.ShareLinks.ListByAlbum(ctx, a.ID) if err != nil || len(list) != 1 { t.Fatalf("list: %v len=%d", err, len(list)) } if err := r.ShareLinks.Revoke(ctx, l.ID, a.ID); err != nil { t.Fatalf("revoke: %v", err) } got, _ = r.ShareLinks.GetByToken(ctx, l.Token) if got.Active(time.Now()) { t.Fatalf("link still active after revoke") } if err := r.ShareLinks.Revoke(ctx, l.ID, a.ID); !errors.Is(err, sharelinks.ErrNotFound) { t.Fatalf("revoke again err = %v, want ErrNotFound", err) } if _, err := r.ShareLinks.GetByToken(ctx, "missing"); !errors.Is(err, sharelinks.ErrNotFound) { t.Fatalf("missing token err = %v, want ErrNotFound", err) } } func testSocial(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") a := seedAlbum(t, ctx, r, owner.ID) p := seedPhoto(t, ctx, r, owner.ID, a.ID) if err := r.Social.Like(ctx, p.ID, owner.ID); err != nil { t.Fatalf("like: %v", err) } if err := r.Social.Like(ctx, p.ID, owner.ID); err != nil { t.Fatalf("like twice: %v", err) } if err := r.Social.Unlike(ctx, p.ID, owner.ID); err != nil { t.Fatalf("unlike: %v", err) } c := &models.Comment{ID: newID(), PhotoID: p.ID, UserID: owner.ID, Text: "hi"} if err := r.Social.CreateComment(ctx, c); err != nil { t.Fatalf("create comment: %v", err) } if c.CreatedAt.IsZero() || c.UpdatedAt.IsZero() { t.Fatalf("comment timestamps not returned: %+v", c) } list, err := r.Social.ListComments(ctx, p.ID) if err != nil || len(list) != 1 || list[0].Text != "hi" { t.Fatalf("list comments: %v %+v", err, list) } got, err := r.Social.GetComment(ctx, c.ID) if err != nil || got.Text != "hi" { t.Fatalf("get comment: %v %+v", err, got) } if err := r.Social.DeleteComment(ctx, c.ID); err != nil { t.Fatalf("delete comment: %v", err) } if _, err := r.Social.GetComment(ctx, c.ID); !errors.Is(err, social.ErrNotFound) { t.Fatalf("get deleted err = %v, want ErrNotFound", err) } if err := r.Social.DeleteComment(ctx, c.ID); !errors.Is(err, social.ErrNotFound) { t.Fatalf("delete again err = %v, want ErrNotFound", err) } } func testPhotos(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") a := seedAlbum(t, ctx, r, owner.ID) p := seedPhoto(t, ctx, r, owner.ID, a.ID) if p.CreatedAt.IsZero() { t.Fatalf("photo created_at not returned") } got, err := r.Photos.GetByID(ctx, p.ID, "") if err != nil || got.StorageKey == "" { t.Fatalf("get photo: %v %+v", err, got) } list, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Limit: 10}) if err != nil || len(list) != 1 { t.Fatalf("list: %v len=%d", err, len(list)) } tag, err := r.Photos.EnsureTag(ctx, owner.ID, "Vacation") if err != nil || tag.Name != "Vacation" { t.Fatalf("ensure tag: %v %+v", err, tag) } tag2, err := r.Photos.EnsureTag(ctx, owner.ID, "vacation") if err != nil || tag2.ID != tag.ID { t.Fatalf("ensure tag case-insensitive: %v %+v", err, tag2) } if err := r.Photos.AttachTag(ctx, p.ID, tag.ID); err != nil { t.Fatalf("attach tag: %v", err) } got, _ = r.Photos.GetByID(ctx, p.ID, owner.ID) if len(got.Tags) != 1 || got.Tags[0].Name != "Vacation" { t.Fatalf("photo tags = %+v", got.Tags) } if owned, _ := r.Photos.TagOwnedBy(ctx, tag.ID, owner.ID); !owned { t.Fatalf("tag not owned") } tags, err := r.Photos.ListTags(ctx, owner.ID, "vac") if err != nil || len(tags) != 1 { t.Fatalf("list tags: %v len=%d", err, len(tags)) } if err := r.Photos.DetachTag(ctx, p.ID, tag.ID); err != nil { t.Fatalf("detach tag: %v", err) } r.Photos.SetCoverIfEmpty(ctx, a.ID, p.ID) al, _ := r.Albums.GetByID(ctx, a.ID) if al.CoverPhotoID == nil || *al.CoverPhotoID != p.ID { t.Fatalf("cover not set: %+v", al.CoverPhotoID) } // The album exposes the cover's medium variant so the overview can render // it sharply. coverMedium := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectReady} if err := r.Photos.CreateObject(ctx, coverMedium); err != nil { t.Fatalf("create cover medium: %v", err) } if err := r.Photos.ActivateObject(ctx, coverMedium.ID); err != nil { t.Fatalf("activate cover medium: %v", err) } if err := r.Photos.BindObject(ctx, p.ID, coverMedium.ID, photos.RoleMedium); err != nil { t.Fatalf("bind cover medium: %v", err) } al, _ = r.Albums.GetByID(ctx, a.ID) if al.CoverMediumKey != coverMedium.StorageKey { t.Fatalf("cover medium key = %q want %q", al.CoverMediumKey, coverMedium.StorageKey) } // Clearing the cover only affects a matching photo id. r.Photos.ClearCoverIfMatches(ctx, a.ID, newID()) al, _ = r.Albums.GetByID(ctx, a.ID) if al.CoverPhotoID == nil || *al.CoverPhotoID != p.ID { t.Fatalf("cover cleared by non-matching id: %+v", al.CoverPhotoID) } r.Photos.ClearCoverIfMatches(ctx, a.ID, p.ID) al, _ = r.Albums.GetByID(ctx, a.ID) if al.CoverPhotoID != nil { t.Fatalf("cover not cleared: %+v", al.CoverPhotoID) } if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil { t.Fatalf("soft delete: %v", err) } if n, _ := r.Photos.TrashCount(ctx, owner.ID); n != 1 { t.Fatalf("trash count = %d, want 1", n) } trash, err := r.Photos.ListTrash(ctx, owner.ID) if err != nil || len(trash) != 1 { t.Fatalf("trash: %v len=%d", err, len(trash)) } if err := r.Photos.RestoreToAlbum(ctx, p.ID, a.ID); err != nil { t.Fatalf("restore: %v", err) } if n, _ := r.Photos.TrashCount(ctx, owner.ID); n != 0 { t.Fatalf("trash count after restore = %d", n) } if err := r.Photos.UpdateFilename(ctx, p.ID, "renamed.jpg"); err != nil { t.Fatalf("update filename: %v", err) } got, _ = r.Photos.GetByID(ctx, p.ID, "") if got.OriginalFilename != "renamed.jpg" { t.Fatalf("filename = %q", got.OriginalFilename) } if ok, _ := r.Photos.AlbumExists(ctx, a.ID); !ok { t.Fatalf("album should exist") } // The original object's content checksum is exposed so clients can match a // local file against a server photo. Identical content yields identical // checksums. csObjA := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/jpeg", Checksum: "sha256-same", State: photos.ObjectPending} csA := &models.Photo{ID: newID(), AlbumID: a.ID, UploaderID: owner.ID, OriginalFilename: "checksum-a.jpg"} if err := r.Photos.CreateWithOriginal(ctx, csA, csObjA); err != nil { t.Fatalf("create checksum photo: %v", err) } if err := r.Photos.ActivateObject(ctx, csObjA.ID); err != nil { t.Fatalf("activate checksum object: %v", err) } csObjB := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/jpeg", Checksum: "sha256-same", State: photos.ObjectPending} csB := &models.Photo{ID: newID(), AlbumID: a.ID, UploaderID: owner.ID, OriginalFilename: "checksum-b.jpg"} if err := r.Photos.CreateWithOriginal(ctx, csB, csObjB); err != nil { t.Fatalf("create checksum photo 2: %v", err) } if err := r.Photos.ActivateObject(ctx, csObjB.ID); err != nil { t.Fatalf("activate checksum object 2: %v", err) } checked, err := r.Photos.GetByID(ctx, csA.ID, "") if err != nil || checked.OriginalChecksum != "sha256-same" { t.Fatalf("original checksum via get = %q err=%v", checked.OriginalChecksum, err) } byList, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Limit: 10}) if err != nil { t.Fatalf("list with checksums: %v", err) } same := 0 for _, ph := range byList { if ph.OriginalChecksum == "sha256-same" { same++ } } if same != 2 { t.Fatalf("photos with identical content checksum in list = %d, want 2", same) } if err := r.Photos.HardDelete(ctx, p.ID); err != nil { t.Fatalf("hard delete: %v", err) } if _, err := r.Photos.GetByID(ctx, p.ID, ""); !errors.Is(err, photos.ErrNotFound) { t.Fatalf("get deleted err = %v, want ErrNotFound", err) } } func testObjects(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") a := seedAlbum(t, ctx, r, owner.ID) p := seedPhoto(t, ctx, r, owner.ID, a.ID) orig, err := r.Photos.OriginalObject(ctx, p.ID) if err != nil || orig.State != photos.ObjectReady { t.Fatalf("original object: %v %+v", err, orig) } keys, err := r.Photos.AllStorageKeys(ctx) if err != nil || !contains(keys, orig.StorageKey) { t.Fatalf("AllStorageKeys = %v err=%v, want %s", keys, err, orig.StorageKey) } if refs, _ := r.Photos.ObjectRefCount(ctx, orig.ID); refs != 1 { t.Fatalf("refs = %d, want 1", refs) } sibs, _ := r.Photos.PhotosByOriginalObject(ctx, orig.ID) if len(sibs) != 1 || sibs[0] != p.ID { t.Fatalf("siblings = %+v", sibs) } thumb := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending} if err := r.Photos.CreateObject(ctx, thumb); err != nil { t.Fatalf("create object: %v", err) } if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); !errors.Is(err, photos.ErrObjectNotReady) { t.Fatalf("bind pending err = %v, want ErrObjectNotReady", err) } if err := r.Photos.ActivateObject(ctx, thumb.ID); err != nil { t.Fatalf("activate: %v", err) } if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); err != nil { t.Fatalf("bind thumb: %v", err) } if refs, _ := r.Photos.ObjectRefCount(ctx, thumb.ID); refs != 1 { t.Fatalf("thumb refs = %d, want 1", refs) } // The medium role is a first-class binding, so it must be accepted by the // role constraint on every backend. medium := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending} if err := r.Photos.CreateObject(ctx, medium); err != nil { t.Fatalf("create medium object: %v", err) } if err := r.Photos.ActivateObject(ctx, medium.ID); err != nil { t.Fatalf("activate medium: %v", err) } if err := r.Photos.BindObject(ctx, p.ID, medium.ID, photos.RoleMedium); err != nil { t.Fatalf("bind medium: %v", err) } // The playback role is a first-class binding introduced for video, so it // must be accepted by the role constraint on every backend. playback := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "video/mp4", State: photos.ObjectPending} if err := r.Photos.CreateObject(ctx, playback); err != nil { t.Fatalf("create playback object: %v", err) } if err := r.Photos.ActivateObject(ctx, playback.ID); err != nil { t.Fatalf("activate playback: %v", err) } if err := r.Photos.BindObject(ctx, p.ID, playback.ID, photos.RolePlayback); err != nil { t.Fatalf("bind playback: %v", err) } // After the photo is gone, its objects have no bindings and are collectible. if err := r.Photos.HardDelete(ctx, p.ID); err != nil { t.Fatalf("hard delete: %v", err) } claimed, err := r.Photos.ClaimGarbage(ctx, 10) if err != nil { t.Fatalf("claim garbage: %v", err) } got := map[string]bool{} for _, o := range claimed { got[o.ID] = true } if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] || !got[playback.ID] { t.Fatalf("claimed = %+v, want original, thumb, medium and playback", got) } for _, o := range claimed { if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil { t.Fatalf("delete object row: %v", err) } } if again, _ := r.Photos.ClaimGarbage(ctx, 10); len(again) != 0 { t.Fatalf("garbage reclaimed: %+v", again) } // Stale pending objects are reaped. A negative age puts the threshold in // the future so the mechanism is exercised without depending on clock skew // between the test process and the database. stale := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending} if err := r.Photos.CreateObject(ctx, stale); err != nil { t.Fatalf("create stale: %v", err) } staleClaimed, err := r.Photos.ClaimStalePending(ctx, -time.Hour, 10) if err != nil || len(staleClaimed) == 0 { t.Fatalf("claim stale: %v %+v", err, staleClaimed) } // Variant queue: a fresh photo has one job. p2 := seedPhoto(t, ctx, r, owner.ID, a.ID) jobs, err := r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute) if err != nil { t.Fatalf("claim jobs: %v", err) } if !contains(jobs, p2.ID) { t.Fatalf("jobs = %+v, want %s", jobs, p2.ID) } if err := r.Photos.CompleteVariantJob(ctx, p2.ID); err != nil { t.Fatalf("complete job: %v", err) } if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p2.ID) { t.Fatalf("completed job still claimed: %+v", jobs) } // Failure schedules a retry and eventually abandons the job. p3 := seedPhoto(t, ctx, r, owner.ID, a.ID) if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil { t.Fatalf("fail job: %v", err) } if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p3.ID) { t.Fatalf("failed job retried immediately: %+v", jobs) } for i := 0; i < 3; i++ { if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil { t.Fatalf("fail job again: %v", err) } } if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p3.ID) { t.Fatalf("abandoned job still present: %+v", jobs) } // Fresh jobs are prioritized by photo freshness: the newest photo's job is // claimed first. The delay keeps the two photos' timestamps distinct. pOlder := seedPhoto(t, ctx, r, owner.ID, a.ID) time.Sleep(5 * time.Millisecond) pNewer := seedPhoto(t, ctx, r, owner.ID, a.ID) order, err := r.Photos.ClaimVariantJobs(ctx, "", 2, time.Minute) if err != nil { t.Fatalf("claim ordered jobs: %v", err) } if len(order) < 2 || order[0] != pNewer.ID || order[1] != pOlder.ID { t.Fatalf("claim order = %+v, want newest first [%s %s]", order, pNewer.ID, pOlder.ID) } } func testUserAvatar(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending} if err := r.Photos.CreateObject(ctx, obj); err != nil { t.Fatalf("create avatar object: %v", err) } if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotReady) { t.Fatalf("bind pending avatar err = %v, want ErrObjectNotReady", err) } if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil { t.Fatalf("activate avatar object: %v", err) } if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); err != nil { t.Fatalf("bind avatar: %v", err) } got, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar) if err != nil || got.ID != obj.ID || got.StorageKey != obj.StorageKey { t.Fatalf("avatar object: %v %+v", err, got) } // Roles are independent: no small variant was bound. if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatarSmall); !errors.Is(err, photos.ErrObjectNotFound) { t.Fatalf("unbound small role err = %v, want ErrObjectNotFound", err) } // A bound avatar blob survives garbage collection. claimed, err := r.Photos.ClaimGarbage(ctx, 10) if err != nil { t.Fatalf("claim garbage: %v", err) } if containsObject(claimed, obj.ID) { t.Fatalf("avatar object collected while bound: %+v", claimed) } // Replacing the binding (a new generation) makes the old blob collectible. replacement := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending} if err := r.Photos.CreateObject(ctx, replacement); err != nil { t.Fatalf("create replacement: %v", err) } if err := r.Photos.ActivateObject(ctx, replacement.ID); err != nil { t.Fatalf("activate replacement: %v", err) } if err := r.Photos.BindUserAvatar(ctx, owner.ID, replacement.ID, photos.RoleAvatar); err != nil { t.Fatalf("rebind avatar: %v", err) } claimed, _ = r.Photos.ClaimGarbage(ctx, 10) if !containsObject(claimed, obj.ID) { t.Fatalf("superseded avatar object not collected: %+v", claimed) } // Clearing removes every binding so the current blob is collected too. if err := r.Photos.ClearUserAvatar(ctx, owner.ID); err != nil { t.Fatalf("clear avatar: %v", err) } if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) { t.Fatalf("avatar still bound after clear: %v", err) } claimed, _ = r.Photos.ClaimGarbage(ctx, 10) if !containsObject(claimed, replacement.ID) { t.Fatalf("cleared avatar object not collected: %+v", claimed) } } func containsObject(list []photos.Object, id string) bool { for _, o := range list { if o.ID == id { return true } } return false } func testVariantJobKinds(t *testing.T, ctx context.Context, r Repos) { owner := seedUser(t, ctx, r, "Owner") a := seedAlbum(t, ctx, r, owner.ID) img := seedPhoto(t, ctx, r, owner.ID, a.ID) vid := seedVideoPhoto(t, ctx, r, owner.ID, a.ID) imageJobs, err := r.Photos.ClaimVariantJobs(ctx, photos.KindImage, 10, time.Minute) if err != nil { t.Fatalf("claim image jobs: %v", err) } if !contains(imageJobs, img.ID) || contains(imageJobs, vid.ID) { t.Fatalf("image claim = %+v, want only %s", imageJobs, img.ID) } videoJobs, err := r.Photos.ClaimVariantJobs(ctx, photos.KindVideo, 10, time.Minute) if err != nil { t.Fatalf("claim video jobs: %v", err) } if !contains(videoJobs, vid.ID) || contains(videoJobs, img.ID) { t.Fatalf("video claim = %+v, want only %s", videoJobs, vid.ID) } } func testSettings(t *testing.T, ctx context.Context, r Repos) { v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil }) if err != nil || v != "generated-1" { t.Fatalf("ensure first: %v %q", err, v) } v2, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-2", nil }) if err != nil || v2 != "generated-1" { t.Fatalf("ensure not idempotent: %v %q", err, v2) } got, ok, err := r.Settings.Get(ctx, settings.JWTSecretKey) if err != nil || !ok || got != "generated-1" { t.Fatalf("get: %v ok=%v %q", err, ok, got) } if _, ok, _ := r.Settings.Get(ctx, "missing"); ok { t.Fatalf("missing key reported present") } if err := r.Settings.Set(ctx, settings.JWTSecretKey, "rotated"); err != nil { t.Fatalf("set: %v", err) } if got, _, _ := r.Settings.Get(ctx, settings.JWTSecretKey); got != "rotated" { t.Fatalf("set not applied: %q", got) } } func contains(list []string, v string) bool { for _, x := range list { if x == v { return true } } return false }