package e2e import ( "bytes" "context" "database/sql" "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 } // 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("http://app.test", "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) } authSvc := auth.NewService(repos.Auth, auth.NewTokenManager("secret", 15*time.Minute, time.Hour)) 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}, func() { database.Close() } } 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) }