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" "shared-album/internal/sse" ) 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) } 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) defer pool.Close() 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) defer pool.Close() 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 TestMemberRepository(t *testing.T) { pool := testPool(t) defer pool.Close() 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 TestAlbumCover(t *testing.T) { pool := testPool(t) defer pool.Close() ctx := context.Background() owner := newUser(t, ctx, pool, "Owner") t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) }) albumStore := albums.NewStore(pool) photosStore := photos.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) } p1 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k1", Width: 1, Height: 1} p2 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k2", Width: 1, Height: 1} for _, p := range []*models.Photo{p1, p2} { if err := photosStore.Create(ctx, p); err != nil { t.Fatalf("create photo: %v", err) } } photosStore.SetCover(ctx, a.ID, p1.ID) got, _ := albumStore.GetByID(ctx, a.ID) if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID { t.Fatalf("auto-cover not set: %v", got.CoverPhotoID) } photosStore.SetCover(ctx, a.ID, p2.ID) got, _ = albumStore.GetByID(ctx, a.ID) if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID { t.Fatalf("auto-cover overwrote existing cover: %v", got.CoverPhotoID) } photosStore.ClearCover(ctx, a.ID, p2.ID) got, _ = albumStore.GetByID(ctx, a.ID) if got.CoverPhotoID == nil { t.Fatalf("clear with non-cover photo should be a no-op") } photosStore.ClearCover(ctx, a.ID, p1.ID) got, _ = albumStore.GetByID(ctx, a.ID) if got.CoverPhotoID != nil { t.Fatalf("cover not cleared: %v", got.CoverPhotoID) } if ok, err := photosStore.PhotoInAlbum(ctx, p1.ID, a.ID); err != nil || !ok { t.Fatalf("photo should belong to album: ok=%v err=%v", ok, err) } other := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "B"} if err := albumStore.Create(ctx, other); err != nil { t.Fatalf("create other album: %v", err) } pForeign := &models.Photo{ID: auth.NewID(), AlbumID: other.ID, UploaderID: owner.ID, StorageKey: "k3", Width: 1, Height: 1} if err := photosStore.Create(ctx, pForeign); err != nil { t.Fatalf("create foreign photo: %v", err) } if ok, _ := photosStore.PhotoInAlbum(ctx, pForeign.ID, a.ID); ok { t.Fatalf("foreign photo should not belong to album") } svc := albums.NewService(albumStore, members.NewStore(pool), sse.NewHub(16), nil, photosStore) if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &pForeign.ID}); err == nil { t.Fatalf("expected error when setting foreign photo as cover") } got, _ = albumStore.GetByID(ctx, a.ID) if got.CoverPhotoID != nil { t.Fatalf("foreign cover should not be applied: %v", got.CoverPhotoID) } if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &p2.ID}); err != nil { t.Fatalf("valid cover update: %v", err) } got, _ = albumStore.GetByID(ctx, a.ID) if got.CoverPhotoID == nil || *got.CoverPhotoID != p2.ID { t.Fatalf("valid cover not applied: %v", got.CoverPhotoID) } if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{ClearCover: true}); err != nil { t.Fatalf("clear cover via service: %v", err) } got, _ = albumStore.GetByID(ctx, a.ID) if got.CoverPhotoID != nil { t.Fatalf("cover not cleared via service: %v", got.CoverPhotoID) } } func TestShareLinkRepository(t *testing.T) { pool := testPool(t) defer pool.Close() 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") } }