Files
guestguard/test/integration/csv_import_test.go
T
Kwaku Danso e5b187c575 feat(tier2): event branding + UX polish — Block D
Backend
- Migration 0010 adds event_branding (one row per event; all fields
  nullable so a brand-new event renders with defaults)
- BrandingRepo with COALESCE/NULLIF upsert semantics: nil pointer
  preserves the existing value, "" clears the field to NULL
- internal/uploads package: ImageStore interface + LocalFSStore (dev),
  pure-stdlib decode + re-encode that strips EXIF and rejects anything
  that isn't valid JPEG/PNG. Size cap 2 MB, random 16-byte filenames
- GET /events/{id}/branding (viewer+) returns the row plus the
  AllowedFonts list so the frontend picker stays in sync
- PUT /events/{id}/branding (editor+) validates hex colours, font
  allowlist, and refuses image URLs whose path doesn't start with
  /uploads/ (blocks arbitrary-origin <img> smuggling on guest pages)
- POST /uploads/image (authed) → fresh CDN URL; GET /uploads/{file}
  serves with year-long cache (immutable random names)
- GET /access/{token} now embeds the host's branding so the RSVP page
  can render in their colours/font with their logo + cover
- docker-compose mounts a named volume for uploads
- Custom-domain sub-block deferred to Tier 3 per the plan

Frontend
- BrandingCard.vue: colour pickers, font dropdown, logo + cover upload
  with progressive disclosure, live preview pane that re-renders on
  every keystroke
- RSVP page applies branding via CSS vars at the section root, so
  primary colour theme + font cascade through every child card. Cover
  image renders as a banner above the form; logo lands in the header
- Submit button background switches to var(--brand-primary) when set
- Mounted on the event detail page below the guests block

Plus the small UX fixes from the e2e walkthrough:
- Nav: dropped the top-level "Events" link; the logo doubles as the
  home affordance (→ /dashboard when signed in, → / otherwise). Account
  + Billing + Sign out live under a profile dropdown (avatar with
  initials, opens on click, closes on outside-click / Esc / route nav)
- Renamed "Back to dashboard" → "Back to events" across event detail,
  billing, account, and new-event pages

Tests
- TestBrandingGetReturnsDefaults / TestBrandingPutPersists /
  TestBrandingPutRejectsBadInputs / TestUploadAndServeImage /
  TestUploadRejectsNonImage — all pass
- Domain tests for IsValidHexColor + IsAllowedFont
- Full integration suite green (176s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:04:09 +01:00

194 lines
6.3 KiB
Go

//go:build integration
package integration_test
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/alchemistkay/guestguard/internal/api"
"github.com/alchemistkay/guestguard/internal/storage"
)
// TestCsvImportFlow walks the happy path: preview, then commit, then a
// re-import to confirm dedup is honoured.
func TestCsvImportFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, host, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Import Event", "import-event")
_ = host
const csvOne = `name,email,phone,plus_ones
Alex,alex@example.test,+447700900111,1
Sam,sam@example.test,,0
Jordan,,+15551234567,2
,,,
Mira,malformed-email,,0
`
// Preview.
var preview struct {
Rows []map[string]any `json:"rows"`
Errors []map[string]any `json:"errors"`
TotalCount int `json:"total_count"`
}
resp := postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import/preview", token, csvOne)
must(t, json.NewDecoder(resp.Body).Decode(&preview), "decode preview")
resp.Body.Close()
if len(preview.Rows) != 3 {
t.Fatalf("preview rows: got %d want 3 (errors=%+v)", len(preview.Rows), preview.Errors)
}
if len(preview.Errors) != 1 {
t.Fatalf("preview errors: got %d want 1: %+v", len(preview.Errors), preview.Errors)
}
// Commit.
var commit struct {
Added int `json:"added"`
Skipped int `json:"skipped"`
SkippedEmails []string `json:"skipped_emails"`
}
resp = postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csvOne)
must(t, json.NewDecoder(resp.Body).Decode(&commit), "decode commit")
resp.Body.Close()
if commit.Added != 3 || commit.Skipped != 0 {
t.Fatalf("commit: added=%d skipped=%d (want 3/0)", commit.Added, commit.Skipped)
}
// Re-import the same file — emails should dedup.
var commit2 struct {
Added int `json:"added"`
Skipped int `json:"skipped"`
SkippedEmails []string `json:"skipped_emails"`
}
resp = postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csvOne)
must(t, json.NewDecoder(resp.Body).Decode(&commit2), "decode commit2")
resp.Body.Close()
// Jordan has no email, so they re-import as a new row each time.
// Alex + Sam have emails and should be skipped.
if commit2.Skipped != 2 {
t.Fatalf("re-import dedup: skipped=%d want 2 (added=%d)", commit2.Skipped, commit2.Added)
}
// Verify the row count in the DB matches expectations: 3 from first
// commit + 1 (Jordan again) from second.
var count int
must(t, db.Pool.QueryRow(ctx,
"SELECT count(*) FROM guests WHERE event_id = $1", eventID,
).Scan(&count), "count guests")
if count != 4 {
t.Fatalf("guest count: got %d want 4", count)
}
}
// TestCsvImportAtomicRollback verifies that a runtime error mid-batch
// leaves NO partial rows. We trigger this by injecting a name longer than
// the column allows (VARCHAR(255)) on row 3 of 4.
func TestCsvImportAtomicRollback(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Atomic Event", "atomic-event")
longName := bytes.Repeat([]byte("Aa"), 200) // 400 chars > VARCHAR(255)
csv := "name,email\nAlice,a@example.test\nBob,b@example.test\n" +
string(longName) + ",c@example.test\nDave,d@example.test\n"
resp := postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csv)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500 on row insert error, got %d body=%s", resp.StatusCode, body)
}
var count int
must(t, db.Pool.QueryRow(ctx,
"SELECT count(*) FROM guests WHERE event_id = $1", eventID,
).Scan(&count), "count after rollback")
if count != 0 {
t.Fatalf("expected 0 guests after rollback, got %d", count)
}
}
// --- helpers shared with other tests in this dir ---
// setupAuthedAPI builds a fresh API server + a verified host + bearer
// token. Tests that just need a logged-in host can use this directly.
func setupAuthedAPI(t *testing.T, ctx context.Context) (srv *httptest.Server, db *storage.DB, hostID [16]byte, bearer string) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dsn := startPostgres(t, ctx)
var err error
db, err = storage.NewDB(ctx, dsn)
must(t, err, "connect db")
t.Cleanup(db.Close)
must(t, db.Migrate(ctx), "migrate")
// Block D — branding test pulls GG_UPLOADS_DIR from env to control
// the on-disk location of uploaded images. Other tests don't care,
// so an empty value (the default) leaves uploads disabled (POST
// /uploads/image returns 503) and the rest of the server still works.
apiSrv, err := api.NewServer(api.ServerDeps{
Logger: logger,
DB: db,
TokenTTL: 24 * time.Hour,
JWTSecret: testJWTSecret,
JWTIssuer: testJWTIssuer,
AccessTokenTTL: 5 * time.Minute,
RefreshTokenTTL: 24 * time.Hour,
EmailVerificationTTL: 1 * time.Hour,
PasswordResetTTL: 1 * time.Hour,
PublicBaseURL: "http://localhost",
UploadsDir: os.Getenv("GG_UPLOADS_DIR"),
UploadsPublicURL: os.Getenv("GG_UPLOADS_PUBLIC_URL"),
})
must(t, err, "build api server")
srv = httptest.NewServer(apiSrv.Handler())
t.Cleanup(srv.Close)
host := insertHost(t, ctx, db.Pool)
hostID = host
bearer = issueHostToken(t, host)
return
}
func postMultipart(t *testing.T, url, bearer, body string) *http.Response {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", "guests.csv")
must(t, err, "create form file")
_, err = fw.Write([]byte(body))
must(t, err, "write csv")
must(t, mw.Close(), "close mw")
req, err := http.NewRequest(http.MethodPost, url, &buf)
must(t, err, "build req")
req.Header.Set("Content-Type", mw.FormDataContentType())
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
must(t, err, "do multipart")
return resp
}