//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 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") } }