package photos import ( "context" "errors" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "shared-album/internal/auth" ) func TestObjectStateTransitions(t *testing.T) { pool := testPool(t) ctx := context.Background() st := NewStore(pool) objID := auth.NewID() obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg", MimeType: "image/jpeg", Size: 42} t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID) }) if err := st.CreateObject(ctx, obj); err != nil { t.Fatalf("create object: %v", err) } if obj.State != ObjectPending { t.Fatalf("new object state = %q, want %q", obj.State, ObjectPending) } if obj.CreatedAt.IsZero() { t.Fatalf("created_at not populated") } if err := st.ActivateObject(ctx, objID); err != nil { t.Fatalf("activate: %v", err) } if got := objectState(t, ctx, pool, objID); got != ObjectReady { t.Fatalf("state after activate = %q", got) } if err := st.MarkObjectDeleting(ctx, objID); err != nil { t.Fatalf("mark deleting: %v", err) } if got := objectState(t, ctx, pool, objID); got != ObjectDeleting { t.Fatalf("state after mark = %q", got) } if err := st.DeleteObjectRow(ctx, objID); err != nil { t.Fatalf("delete row: %v", err) } if err := st.DeleteObjectRow(ctx, objID); !errors.Is(err, ErrObjectNotFound) { t.Fatalf("second delete = %v, want ErrObjectNotFound", err) } } func TestClaimStalePending(t *testing.T) { pool := testPool(t) ctx := context.Background() st := NewStore(pool) objID := auth.NewID() obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg"} t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID) }) if err := st.CreateObject(ctx, obj); err != nil { t.Fatalf("create object: %v", err) } if _, err := pool.Exec(ctx, `UPDATE objects SET created_at = now() - interval '1 hour' WHERE id = $1`, objID); err != nil { t.Fatalf("age object: %v", err) } objs, err := st.ClaimStalePending(ctx, 10*time.Minute, 100) if err != nil { t.Fatalf("claim stale: %v", err) } if !containsObject(objs, objID) { t.Fatalf("stale pending object not claimed: %+v", objs) } if got := objectState(t, ctx, pool, objID); got != ObjectDeleting { t.Fatalf("claimed stale object state = %q, want deleting", got) } } func TestBindObjectRequiresReadyAndShares(t *testing.T) { pool := testPool(t) ctx := context.Background() st := NewStore(pool) user := createUser(t, ctx, pool) p1 := createPhoto(t, ctx, pool, user) p2 := createPhoto(t, ctx, pool, user) objID := auth.NewID() obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg"} t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM photo_objects WHERE object_id = $1`, objID) _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID) }) if err := st.CreateObject(ctx, obj); err != nil { t.Fatalf("create object: %v", err) } if err := st.BindObject(ctx, p1, objID, RoleOriginal); !errors.Is(err, ErrObjectNotReady) { t.Fatalf("bind pending object = %v, want ErrObjectNotReady", err) } if err := st.ActivateObject(ctx, objID); err != nil { t.Fatalf("activate: %v", err) } if err := st.BindObject(ctx, p1, objID, RoleOriginal); err != nil { t.Fatalf("bind p1: %v", err) } if err := st.BindObject(ctx, p2, objID, RoleOriginal); err != nil { t.Fatalf("bind p2: %v", err) } if n, err := st.ObjectRefCount(ctx, objID); err != nil || n != 2 { t.Fatalf("ref count = %d err=%v, want 2", n, err) } siblings, err := st.PhotosByOriginalObject(ctx, objID) if err != nil || len(siblings) != 2 { t.Fatalf("siblings = %v err=%v, want 2", siblings, err) } } func TestClaimGarbageRespectsReferences(t *testing.T) { pool := testPool(t) ctx := context.Background() st := NewStore(pool) user := createUser(t, ctx, pool) photo := createPhoto(t, ctx, pool, user) boundID := auth.NewID() freeID := auth.NewID() t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM photo_objects WHERE object_id IN ($1, $2)`, boundID, freeID) _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id IN ($1, $2)`, boundID, freeID) }) for _, id := range []string{boundID, freeID} { if err := st.CreateObject(ctx, &Object{ID: id, StorageKey: "objects/" + id + "/original.jpg"}); err != nil { t.Fatalf("create object: %v", err) } if err := st.ActivateObject(ctx, id); err != nil { t.Fatalf("activate: %v", err) } } if err := st.BindObject(ctx, photo, boundID, RoleOriginal); err != nil { t.Fatalf("bind: %v", err) } claimed, err := st.ClaimGarbage(ctx, 100) if err != nil { t.Fatalf("claim garbage: %v", err) } if !containsObject(claimed, freeID) { t.Fatalf("unreferenced object not claimed: %+v", claimed) } if containsObject(claimed, boundID) { t.Fatalf("referenced object was claimed for deletion") } // Removing the last reference makes both collectible. if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo); err != nil { t.Fatalf("delete photo: %v", err) } claimed, err = st.ClaimGarbage(ctx, 100) if err != nil { t.Fatalf("claim garbage 2: %v", err) } if !containsObject(claimed, boundID) { t.Fatalf("object with no remaining references not claimed: %+v", claimed) } } func objectState(t *testing.T, ctx context.Context, pool *pgxpool.Pool, id string) string { t.Helper() var state string if err := pool.QueryRow(ctx, `SELECT state FROM objects WHERE id = $1`, id).Scan(&state); err != nil { t.Fatalf("read state: %v", err) } return state } func containsObject(objs []Object, id string) bool { for _, o := range objs { if o.ID == id { return true } } return false }