Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
505 lines
17 KiB
Go
505 lines
17 KiB
Go
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)
|
|
}
|
|
}
|