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>
This commit is contained in:
Kwaku Danso
2026-05-18 12:04:09 +01:00
parent 9842bd4f45
commit e5b187c575
30 changed files with 2310 additions and 199 deletions
+255
View File
@@ -0,0 +1,255 @@
//go:build integration
package integration_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/color"
"image/png"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Tier 2 Block D — branding + uploads.
type brandingResponse struct {
EventID string `json:"event_id"`
PrimaryColor *string `json:"primary_color"`
AccentColor *string `json:"accent_color"`
LogoURL *string `json:"logo_url"`
CoverImageURL *string `json:"cover_image_url"`
FontFamily *string `json:"font_family"`
GreetingMessage *string `json:"greeting_message"`
AllowedFonts []string `json:"allowed_fonts"`
}
// TestBrandingGetReturnsDefaults asserts a fresh event with no branding row
// gets a 200 with null fields + the allowed_fonts list.
func TestBrandingGetReturnsDefaults(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, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Branding Defaults", "branding-defaults")
var body brandingResponse
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/branding", srv.URL, eventID),
token, http.StatusOK, &body)
if body.PrimaryColor != nil || body.LogoURL != nil {
t.Fatalf("expected null fields on a fresh event: %+v", body)
}
if len(body.AllowedFonts) == 0 {
t.Fatal("expected allowed_fonts list to be populated")
}
}
// TestBrandingPutPersists asserts a PUT round-trips the values + clears
// empty-string fields back to null.
func TestBrandingPutPersists(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, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Branding Put", "branding-put")
// First write — sets all fields.
primary := "#22c55e"
accent := "#15803d"
font := "Playfair Display"
greeting := "Welcome!"
putJSON(t, fmt.Sprintf("%s/events/%s/branding", srv.URL, eventID), token,
map[string]any{
"primary_color": primary,
"accent_color": accent,
"font_family": font,
"greeting_message": greeting,
}, http.StatusOK, nil)
// Read back.
var got brandingResponse
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/branding", srv.URL, eventID),
token, http.StatusOK, &got)
if got.PrimaryColor == nil || *got.PrimaryColor != primary {
t.Errorf("primary_color: got %v want %s", got.PrimaryColor, primary)
}
if got.FontFamily == nil || *got.FontFamily != font {
t.Errorf("font_family: got %v want %s", got.FontFamily, font)
}
// Partial PUT — only update greeting; other fields stay.
putJSON(t, fmt.Sprintf("%s/events/%s/branding", srv.URL, eventID), token,
map[string]any{"greeting_message": "Updated!"}, http.StatusOK, nil)
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/branding", srv.URL, eventID),
token, http.StatusOK, &got)
if got.PrimaryColor == nil || *got.PrimaryColor != primary {
t.Errorf("primary_color should persist across partial PUT: got %v", got.PrimaryColor)
}
if got.GreetingMessage == nil || *got.GreetingMessage != "Updated!" {
t.Errorf("greeting_message: got %v", got.GreetingMessage)
}
}
// TestBrandingPutRejectsBadInputs covers the server-side validators.
func TestBrandingPutRejectsBadInputs(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, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Branding Validation", "branding-validation")
// Bad colour.
assertStatus(t, http.MethodPut, fmt.Sprintf("%s/events/%s/branding", srv.URL, eventID),
token, map[string]any{"primary_color": "rgb(0,0,0)"}, http.StatusBadRequest)
// Unknown font.
assertStatus(t, http.MethodPut, fmt.Sprintf("%s/events/%s/branding", srv.URL, eventID),
token, map[string]any{"font_family": "Comic Sans"}, http.StatusBadRequest)
// Logo URL not on our /uploads/ path → rejected to prevent
// arbitrary-origin <img> smuggling.
assertStatus(t, http.MethodPut, fmt.Sprintf("%s/events/%s/branding", srv.URL, eventID),
token, map[string]any{"logo_url": "https://evil.example/x.png"}, http.StatusBadRequest)
}
// TestUploadAndServeImage walks the full upload + retrieve loop: POST a
// PNG, get a URL back, fetch that URL and verify the bytes parse as PNG.
func TestUploadAndServeImage(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)
// Override the upload dir to a temp path so tests don't litter the
// real volume. The setup function reads env so we set it before
// constructing the API.
tmp := t.TempDir()
t.Setenv("GG_UPLOADS_DIR", tmp)
t.Setenv("GG_UPLOADS_PUBLIC_URL", "http://127.0.0.1:0/uploads") // overwritten below
srv, _, _, token := setupAuthedAPI(t, ctx)
// Encode a tiny PNG to upload.
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
img.Set(0, 0, color.RGBA{34, 197, 94, 255})
var pngBuf bytes.Buffer
if err := png.Encode(&pngBuf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("file", "logo.png")
must(t, err, "create form file")
_, _ = fw.Write(pngBuf.Bytes())
must(t, mw.Close(), "close mw")
req, err := http.NewRequest(http.MethodPost, srv.URL+"/uploads/image", &body)
must(t, err, "build req")
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
must(t, err, "do upload")
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("upload status=%d body=%s", resp.StatusCode, b)
}
var out struct{ URL string `json:"url"` }
must(t, json.NewDecoder(resp.Body).Decode(&out), "decode upload resp")
if out.URL == "" {
t.Fatal("empty URL in upload response")
}
// Sanity: the file landed on disk.
entries, err := os.ReadDir(tmp)
must(t, err, "read uploads dir")
if len(entries) == 0 {
t.Fatal("no files written to uploads dir")
}
_ = filepath.Base(out.URL)
// Now fetch it. The URL was built against the configured public base,
// which the test override points at 127.0.0.1:0 — replace the host
// with the actual test server host so we can pull the file.
path := "/uploads/" + filepath.Base(out.URL)
gotResp, err := http.Get(srv.URL + path)
must(t, err, "fetch upload")
defer gotResp.Body.Close()
if gotResp.StatusCode != http.StatusOK {
t.Fatalf("fetch status=%d", gotResp.StatusCode)
}
if ct := gotResp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
t.Errorf("content-type: %q", ct)
}
gotBytes, err := io.ReadAll(gotResp.Body)
must(t, err, "read upload bytes")
if _, _, err := image.Decode(bytes.NewReader(gotBytes)); err != nil {
t.Errorf("fetched bytes do not decode as image: %v", err)
}
}
// TestUploadRejectsNonImage confirms a text body is rejected at the API.
func TestUploadRejectsNonImage(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)
tmp := t.TempDir()
t.Setenv("GG_UPLOADS_DIR", tmp)
srv, _, _, token := setupAuthedAPI(t, ctx)
req, err := http.NewRequest(http.MethodPost, srv.URL+"/uploads/image",
bytes.NewReader([]byte("just some text, not an image")))
must(t, err, "build req")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
must(t, err, "do upload")
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 400, got %d body=%s", resp.StatusCode, b)
}
}
// --- helpers ---
func putJSON(t *testing.T, url, bearer string, body any, wantStatus int, out any) {
t.Helper()
b, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(b))
must(t, err, "build req")
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
must(t, err, "do put")
defer resp.Body.Close()
if resp.StatusCode != wantStatus {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("PUT %s status=%d want=%d body=%s", url, resp.StatusCode, wantStatus, body)
}
if out != nil {
must(t, json.NewDecoder(resp.Body).Decode(out), "decode put resp")
}
}
+7
View File
@@ -11,6 +11,7 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
@@ -142,6 +143,10 @@ func setupAuthedAPI(t *testing.T, ctx context.Context) (srv *httptest.Server, 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,
@@ -153,6 +158,8 @@ func setupAuthedAPI(t *testing.T, ctx context.Context) (srv *httptest.Server, db
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())