- pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls - SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx) - separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite - cmd/reconcile compares object storage with the journal (report, then --delete) - storage.Backend.List and photos.Repository.AllStorageKeys - DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults (sqlite ./galo.db, fs ./data) - Makefile reconcile/reconcile-delete; docs and .env.example updated
534 lines
18 KiB
Go
534 lines
18 KiB
Go
// 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"
|
|
|
|
"shared-album/internal/albums"
|
|
"shared-album/internal/auth"
|
|
"shared-album/internal/members"
|
|
"shared-album/internal/models"
|
|
"shared-album/internal/notifications"
|
|
"shared-album/internal/photos"
|
|
"shared-album/internal/sharelinks"
|
|
"shared-album/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
|
|
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},
|
|
}
|
|
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 contains(list []string, v string) bool {
|
|
for _, x := range list {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|