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>
This commit is contained in:
Zachary Lowden
2026-06-10 19:15:53 -05:00
committed by GitHub
parent ad8309c7e2
commit 2cc4432833
4 changed files with 394 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
import { expect, test } from '@playwright/test';
import { storageStatePath } from './preview-fixtures';
/**
* Generation cost-quote e2e for a deployed PR preview.
*
* De-mocks the orchestrator: instead of stubbing the price endpoint, this drives
* the REAL `/generate` page as a gate-passing PAID member (gold) and asserts the
* client actually fires the tRPC QUERY `orchestrator.whatIfFromGraph` and gets a
* numeric Buzz cost back. whatIf is a pure price quote (no Buzz balance needed),
* so it fires on-load once a model+workflow is selected — the default form
* preselects a model, so we PREFER the on-load path (no interaction).
*
* Runs only under playwright.preview.config.ts (needs PREVIEW_URL + minted
* storage states). Cost source of truth: superjson-wrapped, batched tRPC
* response `[{ result: { data: { json: { cost: { total } } } } }]`
* (see orchestration-new.service.ts ~L1452 and useWhatIfFromGraph.ts).
*/
// Use the PAID member — passes the preview gate AND is a real generation user.
test.describe('generation cost quote (gold)', () => {
test.use({ storageState: storageStatePath('gold') });
const WHATIF_URL = '/api/trpc/orchestrator.whatIfFromGraph';
/**
* Pull the first numeric `cost.total` out of a tRPC response body, tolerating:
* - batched array vs single object
* - superjson `{ json: ... }` unwrapping at the data layer
* - the `{ result: { data: ... } }` envelope
* Returns a number, or null if not found. Walks defensively because the exact
* nesting depends on tRPC batch-link + superjson transformer versions.
*/
function extractCostTotal(body: unknown): number | null {
const entries = Array.isArray(body) ? body : [body];
for (const entry of entries) {
// result.data, then optional superjson `.json`, then `.cost.total`.
const data = (entry as any)?.result?.data;
const payload = data?.json ?? data;
const total = payload?.cost?.total;
if (typeof total === 'number') return total;
}
return null;
}
// 1. Primary, network-based: whatIf fires on /generate load and quotes a cost.
test('whatIfFromGraph fires on /generate and returns a numeric cost', async ({ page }) => {
// Arm the response listener BEFORE navigating so an on-load fire isn't missed.
const whatIfResponse = page.waitForResponse(
(r) => r.url().includes(WHATIF_URL) && r.status() === 200,
{ timeout: 25_000 }
);
const resp = await page.goto('/generate', { waitUntil: 'domcontentloaded' });
expect(resp?.status(), 'HTTP status for /generate').toBeLessThan(400);
// Gate must not bounce a gold (paid) member.
expect(page.url(), 'should not redirect to /login').not.toContain('/login');
expect(page.url(), 'should not redirect to /preview-restricted').not.toContain(
'/preview-restricted'
);
// NOTE: relies on the default /generate form preselecting a valid model+workflow
// so whatIf fires without interaction. If a future default ships with no model
// preselected this will time out — see the UI-fallback test below for the signal.
const response = await whatIfResponse;
const body = await response.json();
const total = extractCostTotal(body);
expect(total, 'cost.total parsed from whatIfFromGraph response').not.toBeNull();
expect(typeof total, 'cost.total is numeric').toBe('number');
expect(total as number, 'cost.total is a non-negative quote').toBeGreaterThanOrEqual(0);
});
// The real pricing path is fully covered by the network assertion above. A DOM
// cost-near-submit check was dropped: the submit button + cost live in a gen
// panel that's collapsed by default on the preview viewport (button resolves
// but is `hidden`), making it a fragile, redundant assertion.
});
+165
View File
@@ -0,0 +1,165 @@
import { expect, test } from '@playwright/test';
import { storageStatePath } from './preview-fixtures';
import { trpcMutation, trpcQuery, uniqueToken } from './preview-trpc';
/**
* Moderation-surface tests for a deployed PR preview environment.
*
* Runs as the `mod` fixture — the ONLY role that both clears the preview gate and
* carries user.isModerator, which the /moderator/* tRPC procedures
* (moderatorProcedure) require. The mod fixture is also onboarding=15, so it
* passes guardedProcedure and can self-seed a post + report.
*
* Two render tests (the reports + images queues render behind the gate) and one
* end-to-end ACTION test (a mod self-seeds an isolated post -> reports it ->
* actions the report), kept as separate test()s so a render-selector miss can't
* mask the action coverage and vice-versa.
*
* Only runs under playwright.preview.config.ts (needs PREVIEW_URL + minted states).
*
* Verified tRPC shapes (civitai repo, paths relative to civitai/src):
* - post.create guardedProcedure, input postCreateSchema
* (server/schema/post.schema.ts:postCreateSchema) ->
* { title?: string|null, detail?: string|null, ... } ;
* returns the created Post incl. `id`
* (server/controllers/post.controller.ts createPostHandler `return post`).
* - report.create guardedProcedure, input createReportInputSchema
* (server/schema/report.schema.ts:104) — a discriminatedUnion
* on `reason`. The `Spam` variant (reportSpamSchema, :92) is the
* minimal shape: { type: ReportEntity, id: number,
* reason: 'Spam', details: {} }. `type` is z.enum(ReportEntity)
* and the Post entity string is 'post'
* (shared/utils/report-helpers.ts:8 `Post = 'post'`).
* Returns the created Report row incl. `id` + `status`
* (server/services/report.service.ts createReport `return createdReport`).
* - report.getAll moderatorProcedure, input getReportsSchema
* (server/schema/report.schema.ts:128) = getAllQuerySchema
* (page/limit) + { type: ReportEntity, filters?, sort? }.
* Row shape selects post.post.id
* (server/controllers/report.controller.ts:180) so a Post
* report row is matched via row.post?.post?.id.
* - report.setStatus moderatorProcedure, input setReportStatusSchema
* (server/schema/report.schema.ts:116) = { id: number,
* status: ReportStatus }. ReportStatus ∈
* Pending|Processing|Actioned|Unactioned (prisma/schema.prisma:1114).
*/
const ROLE = 'mod' as const;
// Mirror preview-smoke.spec.ts: assert we cleared the preview gate.
function assertGatePassed(page: import('@playwright/test').Page, path: string) {
expect(page.url(), `${path}: should not redirect to /login`).not.toContain('/login');
expect(page.url(), `${path}: should not redirect to /preview-restricted`).not.toContain(
'/preview-restricted'
);
}
test.describe('moderation surface (mod)', () => {
test.use({ storageState: storageStatePath(ROLE) });
test('/moderator/reports renders the report queue', async ({ page }) => {
const resp = await page.goto('/moderator/reports', { waitUntil: 'domcontentloaded' });
expect(resp?.status(), 'HTTP status for /moderator/reports').toBeLessThan(400);
assertGatePassed(page, '/moderator/reports');
// Page-loaded anchor: reports.tsx renders <Meta title="Reports" /> (line 219),
// so the document <title> is the most stable "the mod page rendered (not an
// error/redirect)" signal, independent of how many rows the prod-clone DB has.
await expect(page).toHaveTitle(/Reports/i, { timeout: 30_000 });
// Structural presence of the queue UI. reports.tsx renders a MantineReactTable
// (import line 30, JSX line 231), which emits a <table> with role="table".
// Be tolerant of 0..N rows on the prod clone — assert the grid scaffold exists,
// not any specific row.
// NOTE: if MantineReactTable's role/markup changes, widen this — the intent is
// "a table/grid structure is on the page". A visible table OR a non-empty main
// region both satisfy "the queue rendered".
const table = page.getByRole('table').first();
await expect(table).toBeVisible({ timeout: 30_000 });
});
test('/moderator/images renders the image review queue', async ({ page }) => {
const resp = await page.goto('/moderator/images', { waitUntil: 'domcontentloaded' });
expect(resp?.status(), 'HTTP status for /moderator/images').toBeLessThan(400);
assertGatePassed(page, '/moderator/images');
// images.tsx is an infinite list off trpc.image.getModeratorReviewQueue. It
// renders either image <Card>s (Mantine Card import line 5, usage line 366) OR,
// when the queue is empty, <NoContent message="There are no images that need
// review" /> (line 343). Tolerate BOTH branches: assert at least one of the
// known structural anchors is present so the test passes on a full or empty
// prod-clone queue.
// NOTE: broad OR over the empty-state copy and the card/list region. If a
// preview shows different empty copy, widen the regex — the structural intent
// is "the review queue surface rendered, not an error/redirect".
const queueRendered = page
.getByText(/no images that need review|need review|review queue/i)
.first()
.or(page.locator('.mantine-Card-root, [class*="Card-root"]').first());
await expect(queueRendered).toBeVisible({ timeout: 30_000 });
});
test('mod can self-seed a post, report it, and action the report', async ({ page }) => {
const token = uniqueToken('mod');
// Warm the context (cookies + an allowlisted Origin host) before hitting tRPC.
await page.goto('/', { waitUntil: 'domcontentloaded' });
assertGatePassed(page, '/');
// 1) Self-seed an isolated post (title+detail carry the unique token). post.create
// returns the created Post incl. id.
const post = await trpcMutation<{ id: number }>(page.request, 'post.create', {
title: token,
detail: token,
});
expect(post?.id, 'post.create should return a numeric post id').toEqual(expect.any(Number));
// 2) Report that post. Minimal valid variant of the createReportInputSchema
// discriminatedUnion is reason:'Spam' (reportSpamSchema → just baseDetailSchema).
// type:'post' is ReportEntity.Post. createReport returns the row incl. id+status.
// NOTE: 'Spam' / type:'post' verified in report.schema.ts:92 + report-helpers.ts:8.
// If the CSRF/origin gate (createContext.ts) rejects this direct tRPC POST with
// 403 live, the UI-driven fallback would be: open the post page → use the report
// menu → then drive setStatus from /moderator/reports' row status Badge → Menu.
const report = await trpcMutation<{ id: number; status: string }>(
page.request,
'report.create',
{ type: 'post', id: post.id, reason: 'Spam', details: {} }
);
// 3) Resolve the report id. Prefer it straight from report.create's return; fall
// back to report.getAll (moderatorProcedure) matching on the seeded post id.
let reportId: number | undefined = report?.id;
if (typeof reportId !== 'number') {
// getReportsSchema = page/limit (getAllQuerySchema) + type (ReportEntity).
// Newest-first isn't guaranteed by default, so request a generous page and
// match on the post relation's id (handler selects post.post.id).
const all = await trpcQuery<{
items: Array<{ id: number; post?: { post?: { id?: number } | null } | null }>;
}>(page.request, 'report.getAll', { page: 1, limit: 100, type: 'post' });
const mine = all?.items?.find((r) => r.post?.post?.id === post.id);
reportId = mine?.id;
}
expect(reportId, 'should resolve the seeded report id').toEqual(expect.any(Number));
// 4) Action the report. setReportStatusSchema = { id, status } with status ∈
// ReportStatus; 'Actioned' is a valid enum member.
// NOTE: setStatus's handler returns void (controller setReportStatusHandler), so
// we assert the mutation resolves without throwing — trpcMutation throws on any
// tRPC error envelope or non-2xx, so a clean resolve == success.
await expect(
trpcMutation(page.request, 'report.setStatus', { id: reportId, status: 'Actioned' })
).resolves.toBeDefined();
// Best-effort confirmation: re-query and assert our row now reads 'Actioned'.
// Kept non-fatal-shaped (still an assertion, but only runs if getAll is reachable
// for a mod, which it is) — the setStatus resolve above is the primary signal.
const after = await trpcQuery<{
items: Array<{ id: number; status?: string; post?: { post?: { id?: number } | null } | null }>;
}>(page.request, 'report.getAll', { page: 1, limit: 100, type: 'post' });
const updated = after?.items?.find((r) => r.id === reportId);
if (updated) {
expect(updated.status, 'seeded report should now be Actioned').toBe('Actioned');
}
});
});
+71
View File
@@ -0,0 +1,71 @@
import { expect, test } from '@playwright/test';
import { storageStatePath } from './preview-fixtures';
import { trpcMutation, uniqueToken } from './preview-trpc';
/**
* Mutation smoke: a normal (free) user self-seeds a Post and reports it, fully
* API-driven via tRPC so the flow is isolated per run by a unique token (no
* collision on the shared dev DB across concurrent previews, no flaky UI modal).
*
* Runs as `tester` (free member that PASSES the preview gate). Both mutations are
* guardedProcedures; the ci-smoke `tester` fixture is seeded with onboarding=15 so
* it clears `guardedProcedure`.
*
* Verified input shapes (against the worktree's schema files):
* - post.create (src/server/routers/post.router.ts:105 .input(postCreateSchema);
* src/server/schema/post.schema.ts:53 postCreateSchema) — guarded mutation, all
* fields optional: { title: z.string().trim().nullish(), detail: z.string().nullish() }.
* Returns the new post; createPostHandler (post.controller.ts:124) returns the
* post object, so `.id` is a number.
* - report.create (src/server/routers/report.router.ts:27 .input(createReportInputSchema);
* src/server/schema/report.schema.ts:104 createReportInputSchema) — guarded mutation,
* a discriminatedUnion('reason') over baseSchema { type: z.enum(ReportEntity);
* id: z.number(); details } extended per reason.
* * ReportEntity for a Post = 'post' (lowercase) — src/shared/utils/report-helpers.ts:8.
* * reason TOSViolation = 'TOSViolation' — src/shared/utils/prisma/enums.ts:333.
* * reportTOSViolationSchema (report.schema.ts:68) sets
* details: reportTosViolationDetailsSchema, which REQUIRES `violation: z.string()`
* (report.schema.ts:24). So `details: {}` would FAIL zod for a TOS report — we
* pass `details: { violation: <token> }`. (`comment` is optional.)
* createReportHandler (report.controller.ts:60) returns `result` (truthy on success).
*/
test.describe('tester self-seeds a post and reports it (mutation flow)', () => {
test.use({ storageState: storageStatePath('tester') });
test('post.create then report.create round-trips', async ({ page }) => {
// Warm the request context against the preview origin so page.request shares
// the auth cookie + a real navigated origin (the helper stamps Origin/Referer,
// but navigating once is the safe baseline).
await page.goto('/', { waitUntil: 'domcontentloaded' });
const token = uniqueToken('report');
// 1. Self-seed a Post carrying the unique token in its free-text fields.
const post = await trpcMutation<{ id: number } | null>(page.request, 'post.create', {
title: token,
detail: token,
});
expect(typeof post?.id, 'post.create should return a numeric post id').toBe('number');
// 2. Report that exact post for a TOS violation.
// NOTE: ReportEntity.Post is the lowercase string 'post' (report-helpers.ts:8),
// NOT 'Post'. reason 'TOSViolation' requires details.violation (a string), so we
// supply the token there — `details: {}` alone would fail zod for this reason.
const report = await trpcMutation(page.request, 'report.create', {
type: 'post',
id: post!.id,
reason: 'TOSViolation',
details: { violation: token },
});
// createReportHandler returns the created report row (truthy) on success; the
// tRPC helper already throws on any tRPC-level error, so reaching here means the
// report was accepted.
expect(report, 'report.create should resolve to a truthy result').toBeTruthy();
});
// A UI report-affordance check was dropped: the report action lives behind an
// entity action menu (not a top-level button), so a DOM-presence assertion on
// /images is fragile. The API-driven flow above already proves report.create.
});
+79
View File
@@ -0,0 +1,79 @@
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}`;
}