Files
ds-galo/backend/internal/e2e/sqlite_test.go
T

448 lines
14 KiB
Go

package e2e
import (
"bytes"
"context"
"database/sql"
"errors"
"image"
"image/color"
"image/png"
"io/fs"
"path/filepath"
"testing"
"time"
assets "galo"
"galo/internal/albums"
"galo/internal/auth"
"galo/internal/db"
"galo/internal/photos"
"galo/internal/settings"
"galo/internal/sse"
"galo/internal/storage"
"galo/internal/store"
)
func makePNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for x := 0; x < 4; x++ {
for y := 0; y < 4; y++ {
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
type stack struct {
db *sql.DB
photos *photos.Service
albums *albums.Service
auth *auth.Service
hub *sse.Hub
tm *auth.TokenManager
repos store.Repos
storage storage.Backend
}
// openStack opens a migrated SQLite database at path and builds the services.
func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
t.Helper()
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
repos := store.NewSQLite(database)
hub := sse.NewHub(1)
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
if err != nil {
t.Fatalf("signer: %v", err)
}
blob, err := storage.NewFS(blobRoot, signer)
if err != nil {
t.Fatalf("fs: %v", err)
}
tm := auth.NewTokenManager("secret", 15*time.Minute, time.Hour)
authSvc := auth.NewService(repos.Auth, tm)
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob)
photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub)
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc, hub: hub, tm: tm, repos: repos, storage: blob}, func() { database.Close() }
}
func TestUsersSchemaSupportsPhone(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
var emailNotNull int
if err := st.db.QueryRowContext(ctx,
`SELECT "notnull" FROM pragma_table_info('users') WHERE name='email'`).Scan(&emailNotNull); err != nil {
t.Fatalf("email column: %v", err)
}
if emailNotNull != 0 {
t.Fatalf("users.email is still NOT NULL")
}
var phoneCols int
if err := st.db.QueryRowContext(ctx,
`SELECT count(*) FROM pragma_table_info('users') WHERE name='phone'`).Scan(&phoneCols); err != nil {
t.Fatalf("phone column: %v", err)
}
if phoneCols != 1 {
t.Fatalf("users.phone column missing")
}
insert := func(id, email, phone string) error {
var e any
if email != "" {
e = email
}
var p any
if phone != "" {
p = phone
}
_, err := st.db.ExecContext(ctx,
`INSERT INTO users (id, email, phone, password_hash, name) VALUES ($1, $2, $3, 'x', 'U')`,
id, e, p)
return err
}
// A phone-only account is allowed (email NULL).
if err := insert("u-phone-only", "", "+15550000001"); err != nil {
t.Fatalf("insert phone-only user: %v", err)
}
// A second email-less account is allowed: NULL emails do not collide.
if err := insert("u-phone-only-2", "", "+15550000002"); err != nil {
t.Fatalf("insert second phone-only user: %v", err)
}
// Phone must be unique.
if err := insert("u-dup-phone", "dup@test.local", "+15550000001"); err == nil {
t.Fatalf("duplicate phone accepted")
}
// A phone-only insert without a phone must be rejected by application
// logic; the schema itself still permits it, which is by design.
if err := insert("u-email-1", "same@test.local", ""); err != nil {
t.Fatalf("insert email user: %v", err)
}
if err := insert("u-email-dup", "same@test.local", ""); err == nil {
t.Fatalf("duplicate email accepted")
}
}
func TestDeleteAccountRemovesSharedAlbum(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
owner, _, err := st.auth.Register(ctx, "ownerdel@test.local", "", "password123", "Owner")
if err != nil {
t.Fatalf("register owner: %v", err)
}
member, _, err := st.auth.Register(ctx, "memberdel@test.local", "", "password123", "Member")
if err != nil {
t.Fatalf("register member: %v", err)
}
album, err := st.albums.Create(ctx, owner.ID, albums.CreateInput{Title: "Shared"})
if err != nil {
t.Fatalf("create album: %v", err)
}
if _, err := st.db.ExecContext(ctx,
`INSERT INTO album_members (album_id, user_id, role) VALUES ($1, $2, 'member')`, album.ID, member.ID); err != nil {
t.Fatalf("add member: %v", err)
}
if list, err := st.repos.Albums.ListForUser(ctx, member.ID); err != nil || len(list) != 1 {
t.Fatalf("member albums before delete = %d err=%v", len(list), err)
}
if err := st.auth.DeleteAccount(ctx, owner.ID, "password123"); err != nil {
t.Fatalf("delete account: %v", err)
}
if list, err := st.repos.Albums.ListForUser(ctx, member.ID); err != nil || len(list) != 0 {
t.Fatalf("member albums after owner delete = %d err=%v, want 0", len(list), err)
}
if _, err := st.repos.Albums.GetByID(ctx, album.ID); !errors.Is(err, albums.ErrNotFound) {
t.Fatalf("album survived owner deletion: %v", err)
}
if role, _ := st.repos.Members.Role(ctx, album.ID, member.ID); role != "" {
t.Fatalf("membership survived: %q", role)
}
if _, err := st.repos.Auth.GetUserByID(ctx, member.ID); err != nil {
t.Fatalf("member account removed by cascade: %v", err)
}
}
func TestUploadSetsDefaultCover(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "cover@test.local", "", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Cover"})
if err != nil {
t.Fatalf("create album: %v", err)
}
first, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload first: %v", err)
}
got, _, err := st.albums.Get(ctx, album.ID, user.ID)
if err != nil {
t.Fatalf("get album: %v", err)
}
if got.CoverPhotoID == nil || *got.CoverPhotoID != first.ID {
t.Fatalf("cover = %v, want first photo %s", got.CoverPhotoID, first.ID)
}
second, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload second: %v", err)
}
got, _, err = st.albums.Get(ctx, album.ID, user.ID)
if err != nil {
t.Fatalf("get album after second: %v", err)
}
if got.CoverPhotoID == nil || *got.CoverPhotoID != first.ID {
t.Fatalf("cover changed to %v, want still first %s (second=%s)", got.CoverPhotoID, first.ID, second.ID)
}
}
func TestCoverClearedWhenCoverPhotoLeaves(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
user, _, err := st.auth.Register(ctx, "clear@test.local", "", "password123", "C")
if err != nil {
t.Fatalf("register: %v", err)
}
albumA, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "A"})
if err != nil {
t.Fatalf("create A: %v", err)
}
albumB, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "B"})
if err != nil {
t.Fatalf("create B: %v", err)
}
cover, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: albumA.ID, UserID: user.ID, Filename: "cover.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload cover: %v", err)
}
// Trashing the cover clears it.
if err := st.photos.Delete(ctx, cover.ID, user.ID); err != nil {
t.Fatalf("delete cover: %v", err)
}
goal, _, _ := st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID != nil {
t.Fatalf("cover not cleared on delete: %v", goal.CoverPhotoID)
}
// A fresh upload becomes the cover, and moving it out clears it again.
next, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: albumA.ID, UserID: user.ID, Filename: "next.png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload next: %v", err)
}
goal, _, _ = st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID == nil || *goal.CoverPhotoID != next.ID {
t.Fatalf("cover = %v, want %s", goal.CoverPhotoID, next.ID)
}
if _, err := st.photos.MoveBatch(ctx, []string{next.ID}, user.ID, albumB.ID); err != nil {
t.Fatalf("move: %v", err)
}
goal, _, _ = st.albums.Get(ctx, albumA.ID, user.ID)
if goal.CoverPhotoID != nil {
t.Fatalf("cover not cleared on move: %v", goal.CoverPhotoID)
}
}
func TestWorkerPublishesPhotoUpdated(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
st, closeDB := openStack(t, path, t.TempDir())
defer closeDB()
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx, cancel := context.WithCancel(ctx)
defer cancel()
go worker.Run(wctx)
user, _, err := st.auth.Register(ctx, "evt@test.local", "", "password123", "E")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
if err != nil {
t.Fatalf("create album: %v", err)
}
sub := st.hub.SubscribeAlbum(album.ID)
defer sub.Close()
if _, err := st.photos.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t),
}); err != nil {
t.Fatalf("upload: %v", err)
}
select {
case ev := <-sub.Events():
if ev.Type != "photo.updated" {
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
}
case <-time.After(5 * time.Second):
t.Fatalf("no photo.updated event published for generated variants")
}
}
func TestSQLiteEndToEndWithRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
blobs := t.TempDir()
st, closeDB := openStack(t, path, blobs)
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx, cancel := context.WithCancel(ctx)
go worker.Run(wctx)
user, _, err := st.auth.Register(ctx, "e2e@test.local", "", "password123", "E2E")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
if err != nil {
t.Fatalf("create album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload: %v", err)
}
waitFor(t, "variants of first photo", func() bool {
got, err := st.photos.Get(ctx, p.ID, user.ID)
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
})
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
t.Fatalf("permanent delete: %v", err)
}
waitFor(t, "garbage collection", func() bool {
var n int
return st.db.QueryRowContext(ctx, `SELECT count(*) FROM objects`).Scan(&n) == nil && n == 0
})
// Stop the worker, then queue another upload whose variant job must survive
// the restart and be processed by a fresh worker over the same file.
cancel()
time.Sleep(50 * time.Millisecond)
p2, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload p2: %v", err)
}
got2, _ := st.photos.Get(ctx, p2.ID, user.ID)
if got2.PreviewURL != "" {
t.Fatalf("variants generated before restart")
}
closeDB()
st2, close2 := openStack(t, path, blobs)
defer close2()
worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute, 2)
wctx2, cancel2 := context.WithCancel(ctx)
defer cancel2()
go worker2.Run(wctx2)
waitFor(t, "variants of second photo after restart", func() bool {
got, err := st2.photos.Get(ctx, p2.ID, user.ID)
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
})
}
func TestJWTSecretPersistsAcrossRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
open := func() (*sql.DB, store.Repos) {
t.Helper()
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
return database, store.NewSQLite(database)
}
db1, repos1 := open()
secret1, err := repos1.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil || secret1 == "" {
t.Fatalf("ensure secret: %v %q", err, secret1)
}
tm1 := auth.NewTokenManager(secret1, 15*time.Minute, time.Hour)
token, err := tm1.GenerateAccess("user-1", "u@test.local")
if err != nil {
t.Fatalf("generate: %v", err)
}
_ = db1.Close()
db2, repos2 := open()
defer db2.Close()
secret2, err := repos2.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
t.Fatalf("ensure after restart: %v", err)
}
if secret2 != secret1 {
t.Fatalf("secret changed across restart")
}
tm2 := auth.NewTokenManager(secret2, 15*time.Minute, time.Hour)
if _, err := tm2.ParseAccess(token); err != nil {
t.Fatalf("token issued before restart not valid after: %v", err)
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}