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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user