- rename Go module, binary, systemd unit, PG_DATABASE default, frontend package and docs from shared-album to galo - add theme-aware halo emblem and favicon (new ui-branding capability) - add make build-release (versioned, stripped, static binary) and run the binary from the repo root
302 lines
8.8 KiB
Go
302 lines
8.8 KiB
Go
package repo_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"galo/internal/albums"
|
|
"galo/internal/auth"
|
|
"galo/internal/members"
|
|
"galo/internal/models"
|
|
"galo/internal/photos"
|
|
"galo/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")
|
|
}
|
|
}
|