package httpapi import ( "bytes" "context" "encoding/json" "image" "image/color" "image/jpeg" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/golang-jwt/jwt/v5" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker" ) const testSecret = "test-secret" // Seed ids are UUID-formatted because the Postgres dialect uses uuid columns. const ( uAdmin = "11111111-1111-1111-1111-111111111111" uMember = "22222222-2222-2222-2222-222222222222" uBuyer = "33333333-3333-3333-3333-333333333333" uPending = "44444444-4444-4444-4444-444444444444" uFree = "55555555-5555-5555-5555-555555555555" evPaid = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" evFree = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" ) type testEnv struct { handler http.Handler db *store.DB worker *worker.Worker } // setup migrates a scratch DB (SQLite by default; Postgres when // PHOTO_TEST_PG holds a connection URL), seeds the minimal slice of the // main app schema that access.go reads, and returns a ready handler. func setup(t *testing.T) *testEnv { t.Helper() dir := t.TempDir() cfg := config.Config{ Port: 0, DBType: "sqlite", DatabaseURL: filepath.Join(dir, "test.db"), JWTSecret: testSecret, StoragePath: filepath.Join(dir, "photos"), MaxUploadMB: 5, WorkerConcurrency: 1, FrontendURL: "http://localhost:3000", } if pgURL := os.Getenv("PHOTO_TEST_PG"); pgURL != "" { cfg.DBType = "postgres" cfg.DatabaseURL = pgURL } db, err := store.Open(cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if cfg.DBType == "postgres" { // The scratch Postgres DB persists across tests; start clean. if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil { t.Fatal(err) } } if err := db.Migrate(context.Background()); err != nil { t.Fatal(err) } idType := "text" if cfg.DBType == "postgres" { idType = "uuid" } seed := []string{ `CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`, `CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`, `CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`, `CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`, `INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`, `INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`, `INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`, `INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`, `INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`, `INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`, `INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`, `INSERT INTO tickets VALUES ('t1','` + uBuyer + `','` + evPaid + `','confirmed')`, `INSERT INTO payments VALUES ('p1','t1','paid')`, `INSERT INTO tickets VALUES ('t2','` + uPending + `','` + evPaid + `','pending')`, `INSERT INTO payments VALUES ('p2','t2','pending')`, `INSERT INTO tickets VALUES ('t3','` + uFree + `','` + evFree + `','confirmed')`, } for _, q := range seed { if _, err := db.Exec(q); err != nil { t.Fatalf("seed %q: %v", q, err) } } st, err := storage.New(cfg) if err != nil { t.Fatal(err) } wrk := worker.New(db, st, nil, cfg.StoragePath, 1) srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk) return &testEnv{handler: srv.Handler(), db: db, worker: wrk} } func makeToken(t *testing.T, sub, email, role string) string { t.Helper() tv := 0 claims := jwt.MapClaims{ "sub": sub, "email": email, "role": role, "tokenVersion": tv, "iss": "spanglish", "aud": "spanglish-app", "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), } s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret)) if err != nil { t.Fatal(err) } return s } func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() var reader *bytes.Reader if body != nil { b, _ := json.Marshal(body) reader = bytes.NewReader(b) } else { reader = bytes.NewReader(nil) } req := httptest.NewRequest(method, path, reader) if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() e.handler.ServeHTTP(w, req) return w } func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T { t.Helper() var v T if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { t.Fatalf("decode %s: %v", w.Body.String(), err) } return v } func testJPEG(t *testing.T) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 800, 600)) for x := 0; x < 800; x += 10 { for y := 0; y < 600; y++ { img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: 128, A: 255}) } } var buf bytes.Buffer if err := jpeg.Encode(&buf, img, nil); err != nil { t.Fatal(err) } return buf.Bytes() } func TestHealth(t *testing.T) { e := setup(t) if w := e.request(t, "GET", "/api/photos/health", "", nil); w.Code != 200 { t.Fatalf("health: %d", w.Code) } } func TestAdminGate(t *testing.T) { e := setup(t) body := map[string]string{"title": "Test"} if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 { t.Fatalf("anon create: want 401, got %d", w.Code) } member := makeToken(t, uMember, "m@x.py", "user") if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 { t.Fatalf("member create: want 403, got %d", w.Code) } unknown := makeToken(t, newID(), "g@x.py", "admin") if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 { t.Fatalf("unknown-user token: want 401, got %d", w.Code) } } type galleryResp struct { Gallery galleryJSON `json:"gallery"` Photos []photoJSON `json:"photos"` } func createGallery(t *testing.T, e *testEnv, admin string, body map[string]any) galleryJSON { t.Helper() w := e.request(t, "POST", "/api/photos/galleries", admin, body) if w.Code != 201 { t.Fatalf("create gallery: %d %s", w.Code, w.Body.String()) } return decode[galleryResp](t, w).Gallery } func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte) photoJSON { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("files", "test photo.jpg") fw.Write(file) mw.Close() req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+admin) w := httptest.NewRecorder() e.handler.ServeHTTP(w, req) if w.Code != 201 { t.Fatalf("upload: %d %s", w.Code, w.Body.String()) } photos := decode[struct { Photos []photoJSON `json:"photos"` }](t, w).Photos if len(photos) != 1 { t.Fatalf("want 1 photo, got %d", len(photos)) } return photos[0] } // processQueue runs the worker until the photo is ready or failed. func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo { t.Helper() ctx, cancel := context.WithCancel(context.Background()) defer cancel() e.worker.Run(ctx) e.worker.Nudge() deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { p, err := e.db.GetPhoto(ctx, photoID) if err != nil { t.Fatal(err) } if p.Status == "ready" || (p.Status == "failed" && p.Attempts > 0) { return p } time.Sleep(50 * time.Millisecond) } t.Fatal("timeout waiting for processing") return store.Photo{} } func TestUploadProcessAndServe(t *testing.T) { e := setup(t) admin := makeToken(t, uAdmin, "a@x.py", "admin") g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"}) if g.Slug != "fiesta-de-junio" { t.Fatalf("slug: %q", g.Slug) } // Public galleries share a clean URL: the token grants nothing there. if g.ShareToken == "" { t.Fatalf("share token missing: %+v", g) } if strings.Contains(g.ShareURL, "token=") { t.Fatalf("public gallery share url must not carry a token: %s", g.ShareURL) } if !strings.HasSuffix(g.ShareURL, "/photos/"+g.Slug) { t.Fatalf("public share url: %s", g.ShareURL) } p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) if p.Status != "queued" || p.ContentType != "image/jpeg" { t.Fatalf("uploaded photo: %+v", p) } done := processQueue(t, e, p.ID) if done.Status != "ready" { t.Fatalf("processing failed: %s", done.LastError) } if done.Width != 800 || done.Height != 600 { t.Fatalf("dimensions: %dx%d", done.Width, done.Height) } for _, variant := range []string{"thumb", "preview", "original"} { w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/"+variant, "", nil) if w.Code != 200 { t.Fatalf("%s: %d", variant, w.Code) } if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { t.Fatalf("%s content type: %s", variant, ct) } } w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil) if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "test photo.jpg") { t.Fatalf("original disposition: %q", cd) } // Bad upload is rejected by magic bytes. var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("files", "notes.txt") fw.Write([]byte("not an image at all")) mw.Close() req := httptest.NewRequest("POST", "/api/photos/galleries/"+g.ID+"/photos", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+admin) rec := httptest.NewRecorder() e.handler.ServeHTTP(rec, req) if rec.Code != 415 { t.Fatalf("text upload: want 415, got %d %s", rec.Code, rec.Body.String()) } } func TestAccessMatrix(t *testing.T) { e := setup(t) admin := makeToken(t, uAdmin, "a@x.py", "admin") member := makeToken(t, uMember, "m@x.py", "user") buyer := makeToken(t, uBuyer, "b@x.py", "user") pending := makeToken(t, uPending, "p@x.py", "user") freeguy := makeToken(t, uFree, "f@x.py", "user") get := func(slug, token, bearer string) int { path := "/api/photos/public/galleries/" + slug if token != "" { path += "?token=" + token } return e.request(t, "GET", path, bearer, nil).Code } // private priv := createGallery(t, e, admin, map[string]any{"title": "Privada", "visibility": "private"}) for name, code := range map[string]int{"anon": get(priv.Slug, "", ""), "member": get(priv.Slug, "", member), "with-token": get(priv.Slug, priv.ShareToken, "")} { if code != 404 { t.Errorf("private/%s: want 404, got %d", name, code) } } if got := get(priv.Slug, "", admin); got != 200 { t.Errorf("private/admin: want 200, got %d", got) } // link link := createGallery(t, e, admin, map[string]any{"title": "Enlace", "visibility": "link"}) if got := get(link.Slug, "", ""); got != 404 { t.Errorf("link/anon: want 404, got %d", got) } if got := get(link.Slug, "wrong-token", ""); got != 404 { t.Errorf("link/bad-token: want 404, got %d", got) } if got := get(link.Slug, link.ShareToken, ""); got != 200 { t.Errorf("link/token: want 200, got %d", got) } // ticket, paid event tick := createGallery(t, e, admin, map[string]any{"title": "Con Entrada", "visibility": "ticket", "eventId": evPaid}) if got := get(tick.Slug, "", ""); got != 401 { t.Errorf("ticket/anon: want 401, got %d", got) } if got := get(tick.Slug, "", member); got != 403 { t.Errorf("ticket/no-ticket: want 403, got %d", got) } if got := get(tick.Slug, "", pending); got != 403 { t.Errorf("ticket/pending-payment: want 403, got %d", got) } if got := get(tick.Slug, "", buyer); got != 200 { t.Errorf("ticket/paid: want 200, got %d", got) } if got := get(tick.Slug, tick.ShareToken, ""); got != 200 { t.Errorf("ticket/share-token: want 200, got %d", got) } // ticket, free event: any confirmed ticket counts (review decision) free := createGallery(t, e, admin, map[string]any{"title": "Evento Gratis", "visibility": "ticket", "eventId": evFree}) if got := get(free.Slug, "", freeguy); got != 200 { t.Errorf("ticket/free-event-confirmed: want 200, got %d", got) } if got := get(free.Slug, "", member); got != 403 { t.Errorf("ticket/free-event-no-ticket: want 403, got %d", got) } // public index lists only public galleries pub := createGallery(t, e, admin, map[string]any{"title": "Publica", "visibility": "public"}) idx := decode[struct { Galleries []galleryJSON `json:"galleries"` }](t, e.request(t, "GET", "/api/photos/public/galleries", "", nil)) if len(idx.Galleries) != 1 || idx.Galleries[0].ID != pub.ID { t.Errorf("public index: %+v", idx.Galleries) } for _, g := range idx.Galleries { if g.ShareToken != "" { t.Errorf("public index leaks share token") } } } func TestReorderAndVisibilityUpdate(t *testing.T) { e := setup(t) admin := makeToken(t, uAdmin, "a@x.py", "admin") g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"}) jpg := testJPEG(t) p1 := uploadPhoto(t, e, admin, g.ID, jpg) p2 := uploadPhoto(t, e, admin, g.ID, jpg) if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 { t.Fatalf("reorder: %d %s", w.Code, w.Body.String()) } detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil)) if len(detail.Photos) != 2 || detail.Photos[0].ID != p2.ID { t.Fatalf("order not applied: %+v", detail.Photos) } if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin, map[string]any{"photoIds": []string{p1.ID}}); w.Code != 400 { t.Fatalf("partial reorder: want 400, got %d", w.Code) } if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin, map[string]any{"visibility": "banana"}); w.Code != 400 { t.Fatalf("bad visibility: want 400, got %d", w.Code) } if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin, map[string]any{"visibility": "link"}); w.Code != 200 { t.Fatalf("set link: %d %s", w.Code, w.Body.String()) } if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 404 { t.Fatalf("after switch to link, anon: want 404, got %d", w.Code) } } func TestEventGalleryRoute(t *testing.T) { e := setup(t) admin := makeToken(t, uAdmin, "a@x.py", "admin") buyer := makeToken(t, uBuyer, "b@x.py", "user") g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid}) // Event-linked galleries share via the event URL. if !strings.Contains(g.ShareURL, "/events/fiesta/gallery?token="+g.ShareToken) { t.Fatalf("share url should use event route: %s", g.ShareURL) } // /public/events/{slug}/gallery obeys the same access rules. if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", "", nil); w.Code != 401 { t.Fatalf("event gallery anon: want 401, got %d", w.Code) } w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", buyer, nil) if w.Code != 200 { t.Fatalf("event gallery buyer: want 200, got %d %s", w.Code, w.Body.String()) } if got := decode[galleryResp](t, w).Gallery.ID; got != g.ID { t.Fatalf("wrong gallery: %s != %s", got, g.ID) } if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery?token="+g.ShareToken, "", nil); w.Code != 200 { t.Fatalf("event gallery share token: want 200, got %d", w.Code) } // Unknown event slug and event without a gallery are both 404. if w := e.request(t, "GET", "/api/photos/public/events/nope/gallery", buyer, nil); w.Code != 404 { t.Fatalf("unknown event: want 404, got %d", w.Code) } if w := e.request(t, "GET", "/api/photos/public/events/gratis/gallery", buyer, nil); w.Code != 404 { t.Fatalf("event without gallery: want 404, got %d", w.Code) } // A standalone gallery keeps the /photos share URL; link mode carries // the token, public mode does not. solo := createGallery(t, e, admin, map[string]any{"title": "Sin Evento", "visibility": "link"}) if !strings.Contains(solo.ShareURL, "/photos/sin-evento?token=") { t.Fatalf("standalone share url: %s", solo.ShareURL) } pubEvent := createGallery(t, e, admin, map[string]any{"title": "Publica Con Evento", "visibility": "public", "eventId": evFree}) if !strings.HasSuffix(pubEvent.ShareURL, "/events/gratis/gallery") { t.Fatalf("public event gallery share url should be the clean event link: %s", pubEvent.ShareURL) } } func TestSlugCollision(t *testing.T) { e := setup(t) admin := makeToken(t, uAdmin, "a@x.py", "admin") a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"}) if a.Slug == b.Slug { t.Fatalf("slug collision: %s", a.Slug) } if b.Slug != "misma-fiesta-2" { t.Fatalf("second slug: %s", b.Slug) } if a.Visibility != "private" { t.Fatalf("default visibility: %s", a.Visibility) } }