// 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}, {"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 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.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) } 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") } 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) } // 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] { t.Fatalf("claimed = %+v, want original and thumb", 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) } } 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 }