Move physical file facts out of the photos table into an objects ledger:
- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix
Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.
OpenSpec change: media-object-layer
188 lines
5.5 KiB
Go
188 lines
5.5 KiB
Go
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
|
|
}
|