[object Object]

← back to Wallco Ai

Add Ollama-free integration tests (admin-gate, csv-export, age-themes-render, pairings-api)

925dc4e68e5cc3ffcfbdc022f2d906d14a5fe55c · 2026-05-11 22:12:47 -0700 · SteveStudio2

18 tests across 4 specs, all passing in <5s. No Ollama dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 925dc4e68e5cc3ffcfbdc022f2d906d14a5fe55c
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 22:12:47 2026 -0700

    Add Ollama-free integration tests (admin-gate, csv-export, age-themes-render, pairings-api)
    
    18 tests across 4 specs, all passing in <5s. No Ollama dependency.
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 tests/integration/admin-gate.spec.js        |  61 +++++++--------
 tests/integration/age-themes-render.spec.js |  10 ++-
 tests/integration/csv-export.spec.js        | 110 +++++++++-------------------
 tests/integration/pairings-api.spec.js      |  73 +++++++-----------
 4 files changed, 95 insertions(+), 159 deletions(-)

diff --git a/tests/integration/admin-gate.spec.js b/tests/integration/admin-gate.spec.js
index fb5412d..251ec70 100644
--- a/tests/integration/admin-gate.spec.js
+++ b/tests/integration/admin-gate.spec.js
@@ -5,21 +5,19 @@
  * and ?review=1 is absent. They return 200 (with content) when ?review=1 is
  * present, because the server trusts that query param as the admin signal.
  *
- * Host-header spoofing: Playwright cannot override the Host header for
- * requests it makes through page.goto() — the browser always sends
- * 'Host: 127.0.0.1' regardless. We instead use page.evaluate() with
- * fetch() directly, which lets us set arbitrary headers. The server checks
- * req.hostname (derived from Host header), so this correctly exercises the
- * gate.
+ * Host-header spoofing: page.request.fetch() is Playwright's HTTP client
+ * layer that sits outside the browser sandbox and correctly relays arbitrary
+ * headers including Host. The server reads req.hostname from the Host header,
+ * so spoofing "wallco.ai" correctly exercises the gate.
  *
- * NOTE: When using fetch via page.evaluate, the URL must be absolute
- * (including origin) because the page context is about:blank initially.
+ * Timeouts: page.request.fetch calls get an explicit 10s timeout to override
+ * the config's 8s actionTimeout, which can be tight on a loaded machine.
+ * page.goto uses waitUntil:'commit' (HTTP response received) rather than
+ * 'domcontentloaded' to avoid blocking on external font CDN resources.
  */
 'use strict';
 const { test, expect } = require('@playwright/test');
 
-const BASE = 'http://127.0.0.1:9792';
-
 // Dismiss the wallco.ai age-prompt overlay before any navigation.
 test.beforeEach(async ({ page }) => {
   await page.addInitScript(() => {
@@ -30,25 +28,22 @@ test.beforeEach(async ({ page }) => {
 // ── /age-themes ───────────────────────────────────────────────────────────────
 
 test('A-1 · /age-themes without ?review=1 and Host: wallco.ai returns 404', async ({ page }) => {
-  // Navigate to about:blank so we have a stable context for fetch.
-  await page.goto('about:blank');
-
-  const status = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/age-themes', {
-      headers: { Host: 'wallco.ai' },
-      redirect: 'follow',
-    });
-    return res.status;
-  }, BASE);
-
-  expect(status).toBe(404);
+  const response = await page.request.fetch('/age-themes', {
+    headers: { Host: 'wallco.ai' },
+    timeout: 10_000,
+  });
+  expect(response.status()).toBe(404);
 });
 
 test('A-2 · /age-themes?review=1 returns 200 with page content', async ({ page }) => {
-  const response = await page.goto(`${BASE}/age-themes?review=1`, { waitUntil: 'domcontentloaded' });
+  // waitUntil:'commit' fires as soon as the HTTP response is received —
+  // avoids blocking on external font CDN resources.
+  const response = await page.goto('/age-themes?review=1', { waitUntil: 'commit', timeout: 15_000 });
   expect(response.status()).toBe(200);
 
-  // Page must contain at least one h1 or a band-row element.
+  // Wait for DOM to be interactive before querying.
+  await page.waitForLoadState('domcontentloaded');
+
   const hasContent = await page.evaluate(() =>
     document.querySelectorAll('h1, section.band-row').length > 0
   );
@@ -58,20 +53,14 @@ test('A-2 · /age-themes?review=1 returns 200 with page content', async ({ page
 // ── /moodboard ────────────────────────────────────────────────────────────────
 
 test('A-3 · /moodboard without ?review=1 and Host: wallco.ai returns 404', async ({ page }) => {
-  await page.goto('about:blank');
-
-  const status = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/moodboard', {
-      headers: { Host: 'wallco.ai' },
-      redirect: 'follow',
-    });
-    return res.status;
-  }, BASE);
-
-  expect(status).toBe(404);
+  const response = await page.request.fetch('/moodboard', {
+    headers: { Host: 'wallco.ai' },
+    timeout: 10_000,
+  });
+  expect(response.status()).toBe(404);
 });
 
 test('A-4 · /moodboard?review=1 returns 200', async ({ page }) => {
-  const response = await page.goto(`${BASE}/moodboard?review=1`, { waitUntil: 'domcontentloaded' });
+  const response = await page.goto('/moodboard?review=1', { waitUntil: 'commit', timeout: 15_000 });
   expect(response.status()).toBe(200);
 });
diff --git a/tests/integration/age-themes-render.spec.js b/tests/integration/age-themes-render.spec.js
index 7f6ffea..7e130fe 100644
--- a/tests/integration/age-themes-render.spec.js
+++ b/tests/integration/age-themes-render.spec.js
@@ -10,6 +10,10 @@
  *
  * None of these checks require Ollama — the page is fully server-side rendered
  * from src/bands.json.
+ *
+ * waitUntil:'commit' is used for the initial navigation to avoid blocking on
+ * external Google Fonts CDN; we then wait for the first band-row to appear
+ * which confirms DOM is fully rendered (it's all SSR — no JS rendering).
  */
 'use strict';
 const { test, expect } = require('@playwright/test');
@@ -19,8 +23,8 @@ test.beforeEach(async ({ page }) => {
   await page.addInitScript(() => {
     try { localStorage.setItem('wallco-age-skipped', '1'); } catch {}
   });
-  await page.goto('/age-themes?review=1', { waitUntil: 'domcontentloaded' });
-  // Wait for the first band section to be visible before each test.
+  await page.goto('/age-themes?review=1', { waitUntil: 'commit', timeout: 15_000 });
+  // Wait for the first band section to confirm SSR content is present.
   await expect(page.locator('section.band-row').first()).toBeVisible({ timeout: 10_000 });
 });
 
@@ -33,7 +37,7 @@ test('C-1 · page has exactly 8 band-row sections', async ({ page }) => {
 
 // ── C-2: APCA badge count ─────────────────────────────────────────────────────
 
-test('C-2 · each band-row has exactly 6 APCA Lc badges (3 per swatch × 2 swatches)', async ({ page }) => {
+test('C-2 · each band-row has exactly 6 APCA Lc badges (3 per swatch x 2 swatches)', async ({ page }) => {
   const sections = page.locator('section.band-row');
   const count = await sections.count();
   expect(count).toBe(8);
diff --git a/tests/integration/csv-export.spec.js b/tests/integration/csv-export.spec.js
index 5bd6c4e..430aa1b 100644
--- a/tests/integration/csv-export.spec.js
+++ b/tests/integration/csv-export.spec.js
@@ -2,16 +2,11 @@
  * Spec B — CSV export endpoints: /api/export/keep.csv and /api/export/all.csv.
  *
  * These endpoints are pure I/O — they read reviews.json and return a CSV.
- * No Ollama involved. The pretest reset (npm run reset-state in tests/) wipes
- * reviews.json to {} before the parent test suite runs, so when these specs
- * run the file is either empty ({}) or has state from earlier tests in the
- * same suite run (the existing Ollama specs run before integration/ in the
- * default testDir order).
+ * No Ollama involved. All requests use page.request.fetch() so they work
+ * without a loaded browser page.
  *
- * To guarantee a known-empty state for the "header-only body" assertions we
- * reset reviews.json inline before those specific tests using fs.writeFileSync.
- * The other assertions (200, correct Content-Type, header columns) hold
- * regardless of reviews.json contents.
+ * For the "header-only" assertion (B-3) we reset reviews.json to an empty
+ * object inline before the test using fs.writeFileSync.
  *
  * Column order (from src/review.js lines 475 and 509):
  *   design_id, title, category, kind, dominant_hex, score_design, score_color,
@@ -22,59 +17,41 @@ const { test, expect } = require('@playwright/test');
 const fs   = require('node:fs');
 const path = require('node:path');
 
-const BASE         = 'http://127.0.0.1:9792';
 const REVIEWS_FILE = path.join(__dirname, '..', '..', 'data', 'reviews.json');
 
 const EXPECTED_HEADER = 'design_id,title,category,kind,dominant_hex,score_design,score_color,score_style,verdict,why,image_url,pinned_count,pinned_summary,reviewed_at';
 
-// ── helpers ───────────────────────────────────────────────────────────────────
-
-/** Reset reviews.json to empty object — inline state control. */
+/** Reset reviews.json to empty object — inline state control for B-3. */
 function emptyReviews() {
   fs.writeFileSync(REVIEWS_FILE, '{}');
 }
 
 // ── /api/export/keep.csv ──────────────────────────────────────────────────────
 
-test('B-1 · keep.csv returns 200 with text/csv content-type', async ({ page }) => {
-  await page.goto('about:blank');
-
-  const result = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/api/export/keep.csv');
-    return {
-      status:       res.status,
-      contentType:  res.headers.get('content-type'),
-      disposition:  res.headers.get('content-disposition'),
-    };
-  }, BASE);
-
-  expect(result.status).toBe(200);
-  expect(result.contentType).toContain('text/csv');
-  expect(result.disposition).toMatch(/^attachment;\s*filename="wallco-keep-/);
+test('B-1 · keep.csv returns 200 with text/csv content-type and attachment header', async ({ page }) => {
+  const response = await page.request.fetch('/api/export/keep.csv');
+
+  expect(response.status()).toBe(200);
+  expect(response.headers()['content-type']).toContain('text/csv');
+  expect(response.headers()['content-disposition']).toMatch(
+    /^attachment;\s*filename="wallco-keep-/
+  );
 });
 
 test('B-2 · keep.csv first line is exactly the expected column header', async ({ page }) => {
-  await page.goto('about:blank');
-
-  const body = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/api/export/keep.csv');
-    return res.text();
-  }, BASE);
-
+  const response = await page.request.fetch('/api/export/keep.csv');
+  const body = await response.text();
   const firstLine = body.split('\n')[0];
   expect(firstLine).toBe(EXPECTED_HEADER);
 });
 
 test('B-3 · keep.csv has exactly 1 line (header only) when reviews.json is empty', async ({ page }) => {
   emptyReviews();
-  await page.goto('about:blank');
 
-  const body = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/api/export/keep.csv');
-    return res.text();
-  }, BASE);
+  const response = await page.request.fetch('/api/export/keep.csv');
+  const body = await response.text();
 
-  // The server appends a trailing newline; strip it, then count lines.
+  // The server appends a trailing newline; strip it before counting.
   const lines = body.replace(/\n$/, '').split('\n');
   expect(lines).toHaveLength(1);
   expect(lines[0]).toBe(EXPECTED_HEADER);
@@ -82,48 +59,31 @@ test('B-3 · keep.csv has exactly 1 line (header only) when reviews.json is empt
 
 // ── /api/export/all.csv ───────────────────────────────────────────────────────
 
-test('B-4 · all.csv returns 200 with text/csv content-type', async ({ page }) => {
-  await page.goto('about:blank');
-
-  const result = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/api/export/all.csv');
-    return {
-      status:      res.status,
-      contentType: res.headers.get('content-type'),
-      disposition: res.headers.get('content-disposition'),
-    };
-  }, BASE);
-
-  expect(result.status).toBe(200);
-  expect(result.contentType).toContain('text/csv');
-  expect(result.disposition).toMatch(/^attachment;\s*filename="wallco-all-/);
+test('B-4 · all.csv returns 200 with text/csv content-type and attachment header', async ({ page }) => {
+  const response = await page.request.fetch('/api/export/all.csv');
+
+  expect(response.status()).toBe(200);
+  expect(response.headers()['content-type']).toContain('text/csv');
+  expect(response.headers()['content-disposition']).toMatch(
+    /^attachment;\s*filename="wallco-all-/
+  );
 });
 
 test('B-5 · all.csv first line is exactly the expected column header', async ({ page }) => {
-  await page.goto('about:blank');
-
-  const body = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/api/export/all.csv');
-    return res.text();
-  }, BASE);
-
+  const response = await page.request.fetch('/api/export/all.csv');
+  const body = await response.text();
   const firstLine = body.split('\n')[0];
   expect(firstLine).toBe(EXPECTED_HEADER);
 });
 
 test('B-6 · keep.csv and all.csv have identical header rows (regression guard)', async ({ page }) => {
-  await page.goto('about:blank');
-
-  const [keepBody, allBody] = await page.evaluate(async (base) => {
-    const [kr, ar] = await Promise.all([
-      fetch(base + '/api/export/keep.csv'),
-      fetch(base + '/api/export/all.csv'),
-    ]);
-    return [await kr.text(), await ar.text()];
-  }, BASE);
-
-  const keepHeader = keepBody.split('\n')[0];
-  const allHeader  = allBody.split('\n')[0];
+  const [keepResp, allResp] = await Promise.all([
+    page.request.fetch('/api/export/keep.csv'),
+    page.request.fetch('/api/export/all.csv'),
+  ]);
+
+  const keepHeader = (await keepResp.text()).split('\n')[0];
+  const allHeader  = (await allResp.text()).split('\n')[0];
 
   expect(keepHeader).toBe(EXPECTED_HEADER);
   expect(allHeader).toBe(EXPECTED_HEADER);
diff --git a/tests/integration/pairings-api.spec.js b/tests/integration/pairings-api.spec.js
index 2c768c5..7ea8fcd 100644
--- a/tests/integration/pairings-api.spec.js
+++ b/tests/integration/pairings-api.spec.js
@@ -1,71 +1,54 @@
 /**
  * Spec D — Pairings + moodboard + reviews API fast-path tests.
  *
- * These test the JSON API layer directly without any Ollama involvement:
- *   - /api/pairings/99999  (bogus ID) → 404 { error: "design not found" }
- *   - POST /api/moodboard/99999/pin without pin_key → 400
- *   - /api/reviews/all → 200 valid JSON object
+ * These test the JSON API layer directly without any Ollama involvement.
+ * All requests use page.request.fetch() which operates outside the browser
+ * sandbox and correctly sends/receives JSON without needing a loaded page.
  *
- * All requests are issued via page.evaluate fetch() so that we can inspect
- * status codes and JSON bodies without going through Playwright's
- * page.request helper (which has slightly different error semantics).
+ *   - GET  /api/pairings/99999           → 404 { error: "design not found" }
+ *   - POST /api/moodboard/99999/pin      → 400 when pin_key is absent
+ *   - GET  /api/reviews/all              → 200 valid JSON object (never array)
  */
 'use strict';
 const { test, expect } = require('@playwright/test');
 
-const BASE = 'http://127.0.0.1:9792';
-
-test.beforeEach(async ({ page }) => {
-  // Stable context for fetch calls.
-  await page.goto('about:blank');
-});
-
 // ── D-1: pairings — bogus ID ──────────────────────────────────────────────────
 
 test('D-1 · GET /api/pairings/99999 returns 404 with {error:"design not found"}', async ({ page }) => {
-  const result = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/api/pairings/99999');
-    const body = await res.json();
-    return { status: res.status, body };
-  }, BASE);
+  const response = await page.request.fetch('/api/pairings/99999');
 
-  expect(result.status).toBe(404);
-  expect(result.body).toHaveProperty('error');
-  expect(result.body.error).toBe('design not found');
+  expect(response.status()).toBe(404);
+
+  const body = await response.json();
+  expect(body).toHaveProperty('error');
+  expect(body.error).toBe('design not found');
 });
 
 // ── D-2: moodboard pin — missing pin_key ─────────────────────────────────────
 
 test('D-2 · POST /api/moodboard/99999/pin without pin_key returns 400', async ({ page }) => {
-  const result = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/api/moodboard/99999/pin', {
-      method:  'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body:    JSON.stringify({}),   // intentionally omit pin_key
-    });
-    const body = await res.json();
-    return { status: res.status, body };
-  }, BASE);
+  const response = await page.request.fetch('/api/moodboard/99999/pin', {
+    method:  'POST',
+    headers: { 'Content-Type': 'application/json' },
+    data:    '{}',   // intentionally omit pin_key
+  });
+
+  expect(response.status()).toBe(400);
 
-  expect(result.status).toBe(400);
-  expect(result.body).toHaveProperty('error');
+  const body = await response.json();
+  expect(body).toHaveProperty('error');
 });
 
 // ── D-3: reviews/all — valid JSON object ──────────────────────────────────────
 
 test('D-3 · GET /api/reviews/all returns 200 with a valid JSON object', async ({ page }) => {
-  const result = await page.evaluate(async (base) => {
-    const res = await fetch(base + '/api/reviews/all');
-    const body = await res.json();
-    return {
-      status:   res.status,
-      bodyType: typeof body,
-      isArray:  Array.isArray(body),
-    };
-  }, BASE);
+  const response = await page.request.fetch('/api/reviews/all');
+
+  expect(response.status()).toBe(200);
+  expect(response.headers()['content-type']).toContain('application/json');
 
-  expect(result.status).toBe(200);
+  const body = await response.json();
   // reviews/all returns an object keyed by design ID, never an array.
-  expect(result.bodyType).toBe('object');
-  expect(result.isArray).toBe(false);
+  expect(typeof body).toBe('object');
+  expect(Array.isArray(body)).toBe(false);
 });

← 3a43f9b 404 v2 (debate consensus): numeral tightening + strip pill +  ·  back to Wallco Ai  ·  gamify: user votes + head-to-head polls + Elo + 5-variation 5bdabe9 →