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" "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 pg bool } // 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"), ViewTokenSecret: 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, auth_sessions CASCADE`); err != nil { t.Fatal(err) } } if err := db.Migrate(context.Background()); err != nil { t.Fatal(err) } idType := "text" expiresType := "integer" // sqlite: Better Auth stores epoch-ms integers boolType := "integer" falseLit := "0" if cfg.DBType == "postgres" { idType = "uuid" expiresType = "timestamp" boolType = "boolean" falseLit = "FALSE" } seed := []string{ `CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, account_status text NOT NULL DEFAULT 'active', banned ` + boolType + ` NOT NULL DEFAULT ` + falseLit + `)`, `CREATE TABLE auth_sessions (id text PRIMARY KEY, user_id ` + idType + `, token text NOT NULL UNIQUE, expires_at ` + expiresType + ` NOT NULL)`, `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','active',` + falseLit + `)`, `INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user','active',` + falseLit + `)`, `INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user','active',` + falseLit + `)`, `INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user','active',` + falseLit + `)`, `INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user','active',` + falseLit + `)`, `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(db), wrk) return &testEnv{handler: srv.Handler(), db: db, worker: wrk, pg: cfg.DBType == "postgres"} } // makeToken inserts a Better Auth session row for the user and returns a // bearer value in the cookie's "token.signature" format (the verifier // resolves the token part against auth_sessions; the signature is unused). func (e *testEnv) makeToken(t *testing.T, sub string) string { t.Helper() token := "tok-" + newID() expires := time.Now().Add(time.Hour) var expiresVal any = expires.UnixMilli() if e.pg { expiresVal = expires } if _, err := e.db.Exec(e.db.Rebind( `INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`), newID(), sub, token, expiresVal); err != nil { t.Fatal(err) } return token + ".testsig" } 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 := e.makeToken(t, uMember) 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 := e.makeToken(t, newID()) 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) } } func TestSessionValidation(t *testing.T) { e := setup(t) body := map[string]string{"title": "Test"} // Expired session -> 401 expiredTok := "tok-" + newID() var expiresVal any = time.Now().Add(-time.Hour).UnixMilli() if e.pg { expiresVal = time.Now().Add(-time.Hour) } if _, err := e.db.Exec(e.db.Rebind( `INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`), newID(), uAdmin, expiredTok, expiresVal); err != nil { t.Fatal(err) } if w := e.request(t, "POST", "/api/photos/galleries", expiredTok+".sig", body); w.Code != 401 { t.Fatalf("expired session: want 401, got %d", w.Code) } // Banned (suspended) user -> 401 even with a live session bannedTok := e.makeToken(t, uMember) if _, err := e.db.Exec(e.db.Rebind( `UPDATE users SET account_status = 'suspended' WHERE id = ?`), uMember); err != nil { t.Fatal(err) } if w := e.request(t, "POST", "/api/photos/galleries", bannedTok, body); w.Code != 401 { t.Fatalf("suspended user: want 401, got %d", w.Code) } if _, err := e.db.Exec(e.db.Rebind( `UPDATE users SET account_status = 'active' WHERE id = ?`), uMember); err != nil { t.Fatal(err) } // Session via the browser cookie (URL-encoded token.signature) -> works adminTok := e.makeToken(t, uAdmin) req := httptest.NewRequest("POST", "/api/photos/galleries", bytes.NewReader([]byte(`{"title":"Via cookie"}`))) req.Header.Set("Content-Type", "application/json") req.AddCookie(&http.Cookie{Name: "spanglish.session_token", Value: strings.ReplaceAll(adminTok, ".", "%2E")}) w := httptest.NewRecorder() e.handler.ServeHTTP(w, req) if w.Code != 200 && w.Code != 201 { t.Fatalf("cookie auth: want 2xx, got %d: %s", w.Code, w.Body.String()) } } 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 := e.makeToken(t, uAdmin) 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 := e.makeToken(t, uAdmin) member := e.makeToken(t, uMember) buyer := e.makeToken(t, uBuyer) pending := e.makeToken(t, uPending) freeguy := e.makeToken(t, uFree) 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: 403 with a distinct message so the frontend can show its // gate page (revealing existence is accepted for this community site) 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 != 403 { t.Errorf("private/%s: want 403, 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 != 403 { t.Errorf("link/anon: want 403, got %d", got) } if got := get(link.Slug, "wrong-token", ""); got != 403 { t.Errorf("link/bad-token: want 403, 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 := e.makeToken(t, uAdmin) 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 != 403 { t.Fatalf("after switch to link, anon: want 403, got %d", w.Code) } } // TestViewTokens covers the -tag reality: image requests cannot carry // an Authorization header, so authorized gallery responses embed a // short-lived gallery-scoped token in file URLs that the file handler // accepts anonymously. func TestViewTokens(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) g := createGallery(t, e, admin, map[string]any{"title": "Privada Con Fotos", "visibility": "private"}) p := uploadPhoto(t, e, admin, g.ID, testJPEG(t)) if processQueue(t, e, p.ID).Status != "ready" { t.Fatal("processing failed") } // The admin detail response must carry tokenized image URLs. detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil)) thumbURL := detail.Photos[0].URLs.Thumb if !strings.Contains(thumbURL, "?token=v1.") { t.Fatalf("private gallery photo URL should carry a view token: %s", thumbURL) } // That URL works with NO Authorization header, exactly like an tag. if w := e.request(t, "GET", thumbURL, "", nil); w.Code != 200 { t.Fatalf("anon fetch with view token: want 200, got %d %s", w.Code, w.Body.String()) } // Without the token (or with a forged one) the same file is denied. bare := strings.SplitN(thumbURL, "?", 2)[0] if w := e.request(t, "GET", bare, "", nil); w.Code != 403 { t.Fatalf("anon fetch without token: want 403, got %d", w.Code) } if w := e.request(t, "GET", bare+"?token=v1.9999999999.forged", "", nil); w.Code != 403 { t.Fatalf("forged token: want 403, got %d", w.Code) } // A view token for one gallery must not open another gallery's files. other := createGallery(t, e, admin, map[string]any{"title": "Otra Privada", "visibility": "private"}) op := uploadPhoto(t, e, admin, other.ID, testJPEG(t)) if processQueue(t, e, op.ID).Status != "ready" { t.Fatal("processing failed") } viewToken := strings.SplitN(thumbURL, "?token=", 2)[1] if w := e.request(t, "GET", "/api/photos/files/"+op.ID+"/thumb?token="+viewToken, "", nil); w.Code != 403 { t.Fatalf("cross-gallery token reuse: want 403, got %d", w.Code) } // Public galleries keep clean URLs (no token needed). pub := createGallery(t, e, admin, map[string]any{"title": "Publica Limpia", "visibility": "public"}) pp := uploadPhoto(t, e, admin, pub.ID, testJPEG(t)) if processQueue(t, e, pp.ID).Status != "ready" { t.Fatal("processing failed") } pubDetail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+pub.Slug, "", nil)) if strings.Contains(pubDetail.Photos[0].URLs.Thumb, "token=") { t.Fatalf("public photo URL should be clean: %s", pubDetail.Photos[0].URLs.Thumb) } } func TestEventGalleryRoute(t *testing.T) { e := setup(t) admin := e.makeToken(t, uAdmin) buyer := e.makeToken(t, uBuyer) 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 := e.makeToken(t, uAdmin) 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) } }