Files
ds-galo/backend/internal/repo/repo_test.go
iu 96268ee16c 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
2026-10-10 23:31:41 +03:00

302 lines
8.9 KiB
Go

package repo_test
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/models"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func newUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string) *models.User {
t.Helper()
u := &models.User{
ID: auth.NewID(),
Email: auth.NewID()[:8] + "@test.local",
PasswordHash: "x",
Name: name,
}
if err := auth.NewStore(pool).CreateUser(ctx, u); err != nil {
t.Fatalf("create user: %v", err)
}
return u
}
func TestUserRepository(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := auth.NewStore(pool)
u := newUser(t, ctx, pool, "Tester")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID) })
got, err := store.GetUserByEmail(ctx, u.Email)
if err != nil || got.ID != u.ID {
t.Fatalf("get by email: %v id=%v", err, got)
}
if _, err := store.GetUserByID(ctx, u.ID); err != nil {
t.Fatalf("get by id: %v", err)
}
tok := &models.RefreshToken{ID: auth.NewID(), UserID: u.ID, TokenHash: auth.HashRefreshToken("abc"), ExpiresAt: time.Now().Add(time.Hour)}
if err := store.CreateRefreshToken(ctx, tok); err != nil {
t.Fatalf("create refresh: %v", err)
}
fetched, err := store.GetRefreshToken(ctx, auth.HashRefreshToken("abc"))
if err != nil || fetched.ID != tok.ID {
t.Fatalf("get refresh: %v", err)
}
if err := store.RevokeRefreshToken(ctx, tok.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
fetched, _ = store.GetRefreshToken(ctx, auth.HashRefreshToken("abc"))
if fetched.RevokedAt == nil {
t.Fatalf("refresh not revoked")
}
}
func TestAlbumRepository(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := albums.NewStore(pool)
owner := newUser(t, ctx, pool, "Owner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Trip", Description: "d"}
if err := store.Create(ctx, a); err != nil {
t.Fatalf("create: %v", err)
}
got, err := store.GetByID(ctx, a.ID)
if err != nil || got.Title != "Trip" {
t.Fatalf("get: %v", err)
}
if ownerID, err := store.OwnerID(ctx, a.ID); err != nil || ownerID != owner.ID {
t.Fatalf("owner id: %v %v", ownerID, err)
}
list, err := store.ListForUser(ctx, owner.ID)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
a.Title = "Renamed"
if err := store.Update(ctx, a); err != nil {
t.Fatalf("update: %v", err)
}
got, _ = store.GetByID(ctx, a.ID)
if got.Title != "Renamed" {
t.Fatalf("update not applied")
}
if err := store.Delete(ctx, a.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := store.GetByID(ctx, a.ID); err != albums.ErrNotFound {
t.Fatalf("expected not found after delete, got %v", err)
}
}
func TestAlbumCoverRepository(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := albums.NewStore(pool)
owner := newUser(t, ctx, pool, "CoverOwner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Cover"}
if err := store.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
insertPhoto := func(id string) string {
t.Helper()
origObj := auth.NewID()
thumbObj := auth.NewID()
thumbKey := "objects/" + thumbObj + "/thumb.webp"
if _, err := pool.Exec(ctx,
`INSERT INTO objects (id, storage_key, mime_type, state) VALUES
($1, $2, 'image/jpeg', 'ready'),
($3, $4, 'image/webp', 'ready')`,
origObj, "objects/"+origObj+"/original.jpg", thumbObj, thumbKey); err != nil {
t.Fatalf("insert objects: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, $2, $3)`,
id, a.ID, owner.ID); err != nil {
t.Fatalf("insert photo: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, 'original'), ($1, $3, 'thumb')`,
id, origObj, thumbObj); err != nil {
t.Fatalf("insert bindings: %v", err)
}
return thumbKey
}
photo1 := auth.NewID()
thumb1 := insertPhoto(photo1)
got, err := store.GetByID(ctx, a.ID)
if err != nil || got.CoverThumbKey != "" {
t.Fatalf("expected empty cover key before set: %v %q", err, got.CoverThumbKey)
}
ps := photos.NewStore(pool)
ps.SetCoverIfEmpty(ctx, a.ID, photo1)
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
t.Fatalf("cover not set: %+v", got.CoverPhotoID)
}
if got.CoverThumbKey != thumb1 {
t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, thumb1)
}
photo2 := auth.NewID()
thumb2 := insertPhoto(photo2)
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
t.Fatalf("existing cover was overridden: %+v", got.CoverPhotoID)
}
if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo1); err != nil {
t.Fatalf("delete cover photo: %v", err)
}
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("cover not cleared after delete: %+v", got.CoverPhotoID)
}
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
list, err := store.ListForUser(ctx, owner.ID)
if err != nil || len(list) != 1 || list[0].CoverThumbKey != thumb2 {
t.Fatalf("list cover key: %v %+v", err, list)
}
}
func TestMemberRepository(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
owner := newUser(t, ctx, pool, "Owner")
member := newUser(t, ctx, pool, "Member")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id IN ($1, $2)`, owner.ID, member.ID)
})
albumStore := albums.NewStore(pool)
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
if err := albumStore.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
store := members.NewStore(pool)
if err := store.Add(ctx, a.ID, member.ID, "member", &owner.ID); err != nil {
t.Fatalf("add: %v", err)
}
role, err := store.Role(ctx, a.ID, member.ID)
if err != nil || role != "member" {
t.Fatalf("role: %v %q", err, role)
}
list, err := store.List(ctx, a.ID)
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
t.Fatalf("list members: %v %+v", err, list)
}
if err := store.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
t.Fatalf("update role: %v", err)
}
role, _ = store.Role(ctx, a.ID, member.ID)
if role != "editor" {
t.Fatalf("role not updated: %q", role)
}
inv := &models.Invite{ID: auth.NewID(), AlbumID: a.ID, Token: auth.NewID(), Role: "viewer", CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour), MaxUses: 2}
if err := store.CreateInvite(ctx, inv); err != nil {
t.Fatalf("create invite: %v", err)
}
got, err := store.GetInviteByToken(ctx, inv.Token)
if err != nil || got.Role != "viewer" || got.MaxUses != 2 {
t.Fatalf("get invite: %v %+v", err, got)
}
if err := store.IncrementUses(ctx, inv.ID); err != nil {
t.Fatalf("increment: %v", err)
}
got, _ = store.GetInviteByToken(ctx, inv.Token)
if got.Uses != 1 {
t.Fatalf("uses not incremented: %d", got.Uses)
}
if err := store.Remove(ctx, a.ID, member.ID); err != nil {
t.Fatalf("remove: %v", err)
}
role, _ = store.Role(ctx, a.ID, member.ID)
if role != "" {
t.Fatalf("member not removed")
}
}
func TestShareLinkRepository(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
owner := newUser(t, ctx, pool, "Owner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
albumsStore := albums.NewStore(pool)
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
if err := albumsStore.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
store := sharelinks.NewStore(pool)
l := &models.ShareLink{ID: auth.NewID(), AlbumID: a.ID, Token: auth.NewID(), CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour)}
if err := store.Create(ctx, l); err != nil {
t.Fatalf("create: %v", err)
}
got, err := store.GetByToken(ctx, l.Token)
if err != nil || !got.Active(time.Now()) {
t.Fatalf("get token: %v", err)
}
links, err := store.ListByAlbum(ctx, a.ID)
if err != nil || len(links) != 1 {
t.Fatalf("list: %v len=%d", err, len(links))
}
if err := store.Revoke(ctx, l.ID, a.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
got, _ = store.GetByToken(ctx, l.Token)
if got.Active(time.Now()) {
t.Fatalf("link still active after revoke")
}
}