Files
Zachary Lowden 2cc4432833 test(preview): tranche-2 e2e — generation whatIf, report loop, moderation (#2470)
* test(preview): tranche-2 e2e — generation whatIf, report loop, moderation

Extends the preview smoke harness to the audit's highest-risk untested flows,
self-seeding per-run uniquely-tagged fixtures (no shared-dev-DB collisions; the
mutation isolation answer to "seed per-PR"). All report-only.

- preview-trpc.ts: tiny superjson+batched tRPC client (CSRF Origin/Referer
  stamped) for self-seeding via page.request, + uniqueToken().
- preview-generation.spec.ts: assert the REAL orchestrator.whatIfFromGraph cost
  quote fires + returns a numeric cost (gold). De-mocks the pricing path; no Buzz.
- preview-report.spec.ts: tester self-seeds a Post (post.create) and reports it
  (report.create {type:'post',reason:'TOSViolation',details:{violation}}).
- preview-moderation.spec.ts: mod queues (/moderator/reports + /images) render;
  + a self-seeded report is actioned end-to-end (report.create -> setStatus).

tRPC input shapes verified against the schema files. NOT YET live-validated —
the CSRF origin gate on direct tRPC + the whatIf/mod selectors get confirmed by
the next report-only preview run (deferred: generation-submit, blocked on the
external Buzz service which can't be seeded via Postgres).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(preview): typecheck — reportId is number | undefined

the pipeline tsc (not playwright --list) caught: reportId inferred number
from report?.id, then assigned mine?.id (number | undefined) -> TS2322.
Type it explicitly; the runtime expect() still guards undefined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(preview): drop the two fragile secondary DOM checks

The live run validated every core tranche-2 flow (real whatIf cost, tRPC
self-seed report round-trip through the CSRF gate, mod render + report->action
loop). The only two reds were optional best-effort DOM checks that duplicate
passing core tests:
- generation "cost near submit button": the submit button is in a gen panel
  collapsed by default on the preview viewport (resolves but hidden). The
  network whatIf assertion already covers the cost.
- report "image page report affordance": the report control is behind an entity
  action menu, not a top-level button. report.create is already covered.

Removed both rather than leave known flakes in the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:15:53 -05:00

80 lines
3.1 KiB
TypeScript

import type { APIRequestContext } from '@playwright/test';
/**
* Minimal tRPC client for the preview e2e tests, used to SELF-SEED uniquely-tagged
* fixtures (e.g. a Post to report) so mutation tests don't collide on the shared
* dev-DB across concurrent previews. NOT a test file (excluded from testMatch).
*
* civitai is tRPC v11 with the **superjson** transformer and batching (src/server/
* trpc.ts, src/utils/trpc.ts). We mirror the real client's batched wire format:
* POST /api/trpc/<proc>?batch=1 body {"0":{"json":<input>}}
* GET /api/trpc/<proc>?batch=1&input=<urlenc({"0":{"json":<input>}})>
* response: [{ result: { data: { json: <output> } } }]
*
* Two server-side gotchas the recon surfaced:
* - CSRF/origin gate (src/server/createContext.ts): a cookie-authed request is
* rejected unless its Origin/Referer host is allowlisted. The preview's own
* host is (NEXTAUTH_URL = the preview URL), so we stamp Origin+Referer.
* - guardedProcedure (post/report create) needs onboarding complete + not muted;
* the ci-smoke fixtures are seeded with onboarding=15, so they pass.
*
* Pass `page.request` (which carries the test's storageState auth cookie) as the
* `request` arg from inside a test that set `storageState`.
*/
const PREVIEW_URL = process.env.PREVIEW_URL ?? '';
function csrfHeaders(): Record<string, string> {
return { origin: PREVIEW_URL, referer: `${PREVIEW_URL}/` };
}
function unwrap(body: unknown, proc: string): unknown {
const entry = Array.isArray(body) ? (body as unknown[])[0] : body;
const e = entry as { error?: unknown; result?: { data?: { json?: unknown } } };
if (e?.error) {
throw new Error(`tRPC ${proc} returned error: ${JSON.stringify(e.error).slice(0, 400)}`);
}
return e?.result?.data?.json;
}
export async function trpcMutation<T = unknown>(
request: APIRequestContext,
proc: string,
input: unknown
): Promise<T> {
const res = await request.post(`/api/trpc/${proc}?batch=1`, {
headers: { 'content-type': 'application/json', ...csrfHeaders() },
data: { '0': { json: input } },
});
const body = await res.json().catch(() => ({}));
if (!res.ok()) {
throw new Error(`tRPC mutation ${proc} -> HTTP ${res.status()}: ${JSON.stringify(body).slice(0, 400)}`);
}
return unwrap(body, proc) as T;
}
export async function trpcQuery<T = unknown>(
request: APIRequestContext,
proc: string,
input?: unknown
): Promise<T> {
const enc = encodeURIComponent(JSON.stringify({ '0': { json: input ?? {} } }));
const res = await request.get(`/api/trpc/${proc}?batch=1&input=${enc}`, {
headers: csrfHeaders(),
});
const body = await res.json().catch(() => ({}));
if (!res.ok()) {
throw new Error(`tRPC query ${proc} -> HTTP ${res.status()}: ${JSON.stringify(body).slice(0, 400)}`);
}
return unwrap(body, proc) as T;
}
/**
* Per-run unique tag, stamped into a seeded entity's free-text field so the test
* can find exactly its own fixture (and concurrent previews never collide).
*/
export function uniqueToken(label: string): string {
const rand = Math.random().toString(36).slice(2, 10);
return `e2e-${label}-${Date.now()}-${rand}`;
}