feat: implement shared-album multi-user photo album
Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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/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)
|
||||
}
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user