feat(photos): separate object storage from metadata layer
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
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUploadCreatesLedgerAndAlbumFreeKey(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
svc, cl := newTestService(t, pool)
|
||||
user := createUser(t, ctx, pool)
|
||||
album := createAlbum(t, ctx, pool, user)
|
||||
|
||||
p, err := svc.Upload(ctx, UploadInput{
|
||||
AlbumID: album,
|
||||
UserID: user,
|
||||
Filename: "photo.png",
|
||||
MimeType: "image/png",
|
||||
Data: makePNG(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
|
||||
|
||||
if !strings.HasPrefix(p.StorageKey, "objects/") {
|
||||
t.Fatalf("storage key %q is not an object key", p.StorageKey)
|
||||
}
|
||||
if strings.Contains(p.StorageKey, album) {
|
||||
t.Fatalf("storage key leaks album id: %q", p.StorageKey)
|
||||
}
|
||||
if p.OriginalURL == "" {
|
||||
t.Fatalf("original url not set")
|
||||
}
|
||||
if p.ThumbURL != "" || p.PreviewURL != "" {
|
||||
t.Fatalf("variants should be pending, got thumb=%q preview=%q", p.ThumbURL, p.PreviewURL)
|
||||
}
|
||||
|
||||
orig, err := svc.store.OriginalObject(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("original object: %v", err)
|
||||
}
|
||||
if orig.State != ObjectReady {
|
||||
t.Fatalf("original object state = %q, want ready", orig.State)
|
||||
}
|
||||
if n, err := svc.store.ObjectRefCount(ctx, orig.ID); err != nil || n != 1 {
|
||||
t.Fatalf("original ref count = %d err=%v, want 1", n, err)
|
||||
}
|
||||
|
||||
var jobs int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM variant_jobs WHERE photo_id = $1`, p.ID).Scan(&jobs); err != nil {
|
||||
t.Fatalf("count jobs: %v", err)
|
||||
}
|
||||
if jobs != 1 {
|
||||
t.Fatalf("variant jobs = %d, want 1", jobs)
|
||||
}
|
||||
|
||||
list, _, err := svc.List(ctx, album, user, "", nil, 50)
|
||||
if err != nil || len(list) != 1 {
|
||||
t.Fatalf("list: %v len=%d", err, len(list))
|
||||
}
|
||||
if list[0].MimeType != "image/png" || list[0].Width != 4 || list[0].Height != 4 {
|
||||
t.Fatalf("derived fields wrong: mime=%q %dx%d", list[0].MimeType, list[0].Width, list[0].Height)
|
||||
}
|
||||
if list[0].OriginalURL == "" {
|
||||
t.Fatalf("list original url not set")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user