e5b187c575
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>
194 lines
6.3 KiB
Go
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
|
|
}
|