package photos import ( "bytes" "context" "image" "image/color" "image/png" "os" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "galo/internal/auth" "galo/internal/config" "galo/internal/models" "galo/internal/roles" "galo/internal/sse" "galo/internal/storage" ) 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 testEnvOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } type stubAlbums struct{} func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) { return &models.Album{ID: albumID}, roles.Owner, nil } func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string { t.Helper() id := auth.NewID() if _, err := pool.Exec(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 't')`, id, id+"@test.local"); err != nil { t.Fatalf("create user: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) }) return id } func createAlbum(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID string) string { t.Helper() id := auth.NewID() if _, err := pool.Exec(ctx, `INSERT INTO albums (id, owner_id, title) VALUES ($1, $2, 't')`, id, ownerID); err != nil { t.Fatalf("create album: %v", err) } t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, id) }) return id } func createPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, uploaderID string) string { t.Helper() id := auth.NewID() if _, err := pool.Exec(ctx, `INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, NULL, $2)`, id, uploaderID); err != nil { t.Fatalf("create photo: %v", err) } return id } 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() } // newTestService builds a service backed by the real dev storage. It skips when // S3 env is absent so unit runs stay hermetic. func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) { t.Helper() endpoint := os.Getenv("S3_ENDPOINT") if endpoint == "" { t.Skip("S3_ENDPOINT not set") } signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour) if err != nil { t.Skipf("signer init: %v", err) } cl, err := storage.NewS3(context.Background(), config.S3{ Endpoint: endpoint, Region: testEnvOr("S3_REGION", "garage"), AccessKey: os.Getenv("S3_ACCESS_KEY"), SecretKey: os.Getenv("S3_SECRET_KEY"), Bucket: testEnvOr("S3_BUCKET", "galo-data"), UsePathStyle: testEnvOr("S3_USE_PATH_STYLE", "true") != "false", }, signer) if err != nil { t.Skipf("storage init: %v", err) } return NewService(NewStore(pool), stubAlbums{}, cl, sse.NewHub(1)), cl } // newTestServiceFS builds a service backed by a temporary local file-system // storage backend so the full flow can run without external infrastructure. func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) { t.Helper() signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour) if err != nil { t.Fatalf("storage init: %v", err) } fs, err := storage.NewFS(t.TempDir(), signer) if err != nil { t.Fatalf("storage init: %v", err) } return NewService(NewStore(pool), stubAlbums{}, fs, sse.NewHub(1)), fs } // cleanupPhoto removes a photo, its objects (rows and blobs) and bindings. func cleanupPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, cl storage.Backend, photoID string) { t.Helper() rows, err := pool.Query(ctx, `SELECT o.id, o.storage_key FROM photo_objects po JOIN objects o ON o.id = po.object_id WHERE po.photo_id = $1`, photoID) if err != nil { return } var ids, keys []string for rows.Next() { var id, key string if rows.Scan(&id, &key) == nil { ids = append(ids, id) keys = append(keys, key) } } rows.Close() _, _ = pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photoID) for _, id := range ids { _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, id) } if cl != nil && len(keys) > 0 { _ = cl.Delete(ctx, keys...) } }