mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
0015996cd9
The `preview / smoke-tests` check has been red on every open PR for days on a STALE test assertion, not a real regression. #3392 (81fe8f7c90, 2026-07-26) swapped the live approve gate from `assertListingAssetsComplete` to `assertListingMeetsFloor`, changing the emitted message from "Listing is missing required assets: ..." to "Listing needs at least an icon and cover before it can be published (missing: icon, cover)." `tests/preview-apps-external-approve.spec.ts` was last touched 2026-07-23 (76706c367d, #3317) and still pinned the OLD sentence verbatim, so it failed. The gate is working correctly; the test was wrong. Changes (test-only, no production code): - Assert the gate's INTENT — the error names each missing FLOOR asset (`icon` and `cover`) — instead of pinning the sentence verbatim, so a future reword does not re-break it. - Fix the now-wrong comments naming `assertListingAssetsComplete` as the live approve gate (spec header + the inline comment above the assertion). - Sibling sweep: the `preview-apps-external-delist.spec.ts` header made the same stale claim (naming the old gate and "icon+cover+>=1 screenshot"); corrected to the floor gate + the scan-clean gate. Its conclusion (approve is unreachable in a preview) still holds and is unchanged. Deliberately NOT changed: `app-listings.router.offsite-authz.test.ts` also contains "missing required assets", but that is a unit test asserting against its OWN mocked error string — it is self-consistent and not stale. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
255 lines
11 KiB
TypeScript
255 lines
11 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { APIRequestContext } from '@playwright/test';
|
|
import { storageStatePath } from './preview-fixtures';
|
|
import { trpcMutation, trpcQuery } from './preview-trpc';
|
|
|
|
/**
|
|
* Preview-e2e: App Blocks W13 P3a — OFF-SITE (external-link) APPROVE/REJECT leg
|
|
* (PR-b), run as `mod`. Exercises the two SAFE + SELF-CLEANING moderation paths:
|
|
*
|
|
* (1) REJECT: submit (mod, per-preview slug) → `rejectExternalRequest(reason)`
|
|
* → the request leaves the pending queue and its DRAFT listing is deleted
|
|
* (reject is terminal + releases the slug → self-cleaning, no leftover row).
|
|
*
|
|
* (2) APPROVE-GATE: submit (mod, NO assets — no icon/cover/screenshot) →
|
|
* `approveExternalRequest` → asserts a BAD_REQUEST (the publish-FLOOR gate
|
|
* `assertListingMeetsFloor` fires, naming the missing `icon` + `cover`),
|
|
* proving approve is BLOCKED without assets. Then `withdrawExternalRequest`
|
|
* cleans the still-pending draft.
|
|
*
|
|
* WHY NOT an approve-SUCCESS → store-render spec here: a successful approve leaves
|
|
* an APPROVED listing with NO delete path in P3a (approve is one-way; there is no
|
|
* un-approve / delete-approved proc yet), which would POLLUTE the shared dev store
|
|
* across concurrent previews. That path (approve success + the store Visit-anchor
|
|
* invariant) is DEFERRED to PR-c (the UI + a listing-management surface). This spec
|
|
* deliberately only drives the two self-cleaning paths.
|
|
*
|
|
* ROLE — why `mod`: `submit/withdrawExternalRequest` are `appDeveloperProcedure`
|
|
* (`app-blocks-author`) and `approve/rejectExternalRequest`/`listPendingRequests`
|
|
* are `moderatorProcedure`. The `mod` fixture satisfies BOTH (mods author via the
|
|
* app-blocks-author floor), and v1 ALLOWS mod self-approve (reviewer==submitter),
|
|
* so the whole leg runs as a single mod — like every sibling apps smoke spec.
|
|
*
|
|
* GATES (Tekton `pr-smoke-test` is authoritative — do NOT run browser-mode locally
|
|
* on NixOS).
|
|
*
|
|
* SAFE + SELF-CLEANING (the dev DB is shared across concurrent previews):
|
|
* - Each scenario uses its OWN per-preview slug so two previews never collide on
|
|
* `AppListing.slug @unique`. A same-preview re-run pre-withdraws any leftover
|
|
* pending row before submitting.
|
|
* - No asset upload / no successful approve → NO Image rows, NO Tekton build, NO
|
|
* CF DNS, NO approved store row.
|
|
* - We withdraw in `finally` (deletes the draft + releases the slug) so a
|
|
* mid-test failure leaves nothing behind.
|
|
*/
|
|
|
|
const ROLE = 'mod' as const;
|
|
const PREVIEW_URL = process.env.PREVIEW_URL ?? '';
|
|
|
|
/** Per-preview + per-scenario slug so concurrent previews never collide. */
|
|
function previewSlug(suffix: string): string {
|
|
let label = 'local';
|
|
try {
|
|
label = new URL(PREVIEW_URL).hostname.split('.')[0] || 'local';
|
|
} catch {
|
|
/* fall through to default */
|
|
}
|
|
const sanitized = label.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
const slug = `ci-ext-${suffix}-${sanitized}`.slice(0, 40).replace(/-+$/, '');
|
|
return /[a-z0-9]$/.test(slug) ? slug : `${slug}0`;
|
|
}
|
|
|
|
const EXTERNAL_URL = 'https://example.com/ci-smoke-external-approve';
|
|
|
|
type SubmitResult = { listingId: string; publishRequestId: string; slug: string };
|
|
type PendingItem = { id: string; slug: string; appListingId: string | null };
|
|
type PendingList = { items: PendingItem[]; nextCursor: string | null };
|
|
|
|
function submitInput(slug: string, connectClientId: string) {
|
|
return {
|
|
slug,
|
|
name: 'CI Smoke — external approve/reject (P3a PR-b)',
|
|
externalUrl: EXTERNAL_URL,
|
|
tagline: 'a pure external-link app',
|
|
category: 'utility',
|
|
contentRating: 'g',
|
|
changelog: 'ci-smoke approve/reject',
|
|
// W13 merged external+connect model (#3227): every external listing links the
|
|
// caller's OWN OAuth client. This pure external-link app discloses NO scopes,
|
|
// so an empty requested-scope mask (0) + empty justifications is the minimal
|
|
// valid connect shape (0 ⊆ any client ceiling; no scopes → no justifications).
|
|
connectClientId,
|
|
requestedScopes: 0,
|
|
scopeJustifications: {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The W13 merged external+connect model requires every external listing to link
|
|
* the caller's OWN OAuth client (existence/ownership/not-app-block checked in the
|
|
* service). Create a throwaway client per test and delete it on cleanup —
|
|
* self-cleaning like the draft listing + slug.
|
|
*/
|
|
async function createConnectClient(request: APIRequestContext): Promise<string> {
|
|
const res = await trpcMutation<{ clientId: string }>(request, 'oauthClient.create', {
|
|
name: 'CI Smoke — external-listing connect client',
|
|
redirectUris: ['https://example.com/ci-smoke-oauth-callback'],
|
|
});
|
|
return res.clientId;
|
|
}
|
|
|
|
async function deleteConnectClient(
|
|
request: APIRequestContext,
|
|
clientId: string | null
|
|
): Promise<void> {
|
|
if (!clientId) return;
|
|
await trpcMutation(request, 'oauthClient.delete', { id: clientId }).catch(() => {});
|
|
}
|
|
|
|
/** Page the oldest-first pending queue to find our row by slug. */
|
|
async function findPendingBySlug(
|
|
request: APIRequestContext,
|
|
slug: string
|
|
): Promise<PendingItem | null> {
|
|
let cursor: string | null = null;
|
|
for (let page = 0; page < 25; page++) {
|
|
const input: { limit: number; cursor?: string } = { limit: 100 };
|
|
if (cursor) input.cursor = cursor;
|
|
const list = await trpcQuery<PendingList>(request, 'appListings.listPendingRequests', input);
|
|
const hit = list.items.find((i) => i.slug === slug);
|
|
if (hit) return hit;
|
|
if (!list.nextCursor) break;
|
|
cursor = list.nextCursor;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Best-effort: withdraw any leftover pending row for this slug (self-clean). */
|
|
async function withdrawPendingForSlug(
|
|
request: APIRequestContext,
|
|
slug: string
|
|
): Promise<void> {
|
|
const row = await findPendingBySlug(request, slug).catch(() => null);
|
|
if (row?.id) {
|
|
await trpcMutation(request, 'appListings.withdrawExternalRequest', {
|
|
publishRequestId: row.id,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
test.describe('App Blocks P3a PR-b: off-site approve/reject (mod, self-cleaning)', () => {
|
|
test.use({ storageState: storageStatePath(ROLE) });
|
|
|
|
test('REJECT: submit → reject(reason) → request leaves the pending queue (draft deleted)', async ({
|
|
page,
|
|
}) => {
|
|
const SLUG = previewSlug('rej');
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
const request = page.request;
|
|
|
|
let publishRequestId: string | null = null;
|
|
let clientId: string | null = null;
|
|
try {
|
|
await withdrawPendingForSlug(request, SLUG);
|
|
clientId = await createConnectClient(request);
|
|
|
|
const result = await trpcMutation<SubmitResult>(
|
|
request,
|
|
'appListings.submitExternalListing',
|
|
submitInput(SLUG, clientId)
|
|
);
|
|
publishRequestId = result.publishRequestId;
|
|
expect(result.slug, 'slug echoes the submission').toBe(SLUG);
|
|
|
|
// It's in the pending queue before review.
|
|
const pending = await findPendingBySlug(request, SLUG);
|
|
expect(pending, 'the submitted request is pending before review').not.toBeNull();
|
|
|
|
// REJECT (reason ≥10) — terminal; deletes the draft listing.
|
|
await trpcMutation(request, 'appListings.rejectExternalRequest', {
|
|
publishRequestId,
|
|
rejectionReason: 'ci-smoke reject: not a real app, rejecting',
|
|
});
|
|
publishRequestId = null; // rejected + draft deleted — nothing to clean
|
|
|
|
// Gone from the pending queue.
|
|
const afterReject = await findPendingBySlug(request, SLUG);
|
|
expect(afterReject, 'the rejected request no longer appears in the pending queue').toBeNull();
|
|
} finally {
|
|
if (publishRequestId) {
|
|
await trpcMutation(request, 'appListings.withdrawExternalRequest', {
|
|
publishRequestId,
|
|
}).catch(() => {});
|
|
} else {
|
|
await withdrawPendingForSlug(request, SLUG);
|
|
}
|
|
await deleteConnectClient(request, clientId);
|
|
}
|
|
});
|
|
|
|
test('APPROVE-GATE: submit with NO assets → approve is BLOCKED (missing assets) → withdraw cleans', async ({
|
|
page,
|
|
}) => {
|
|
const SLUG = previewSlug('gate');
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
const request = page.request;
|
|
|
|
let publishRequestId: string | null = null;
|
|
let clientId: string | null = null;
|
|
try {
|
|
await withdrawPendingForSlug(request, SLUG);
|
|
clientId = await createConnectClient(request);
|
|
|
|
const result = await trpcMutation<SubmitResult>(
|
|
request,
|
|
'appListings.submitExternalListing',
|
|
submitInput(SLUG, clientId)
|
|
);
|
|
publishRequestId = result.publishRequestId;
|
|
|
|
// APPROVE with NO icon/cover/screenshot attached → the publish-FLOOR gate
|
|
// `assertListingMeetsFloor` MUST fire (the router maps it to a BAD_REQUEST /
|
|
// HTTP 400 whose message names the missing floor assets). trpcMutation throws
|
|
// on a non-2xx response.
|
|
let approveError: Error | null = null;
|
|
try {
|
|
await trpcMutation(request, 'appListings.approveExternalRequest', {
|
|
publishRequestId,
|
|
approvalNotes: 'ci-smoke approve (expected to be gate-blocked)',
|
|
});
|
|
} catch (err) {
|
|
approveError = err as Error;
|
|
}
|
|
expect(approveError, 'approve without assets must be rejected by the gate').not.toBeNull();
|
|
// Assert the gate's INTENT — the error names each missing FLOOR asset — rather
|
|
// than pinning the sentence verbatim. The exact wording has already churned
|
|
// once (#3392 swapped the live approve gate from `assertListingAssetsComplete`
|
|
// → `assertListingMeetsFloor`, silently breaking a verbatim match here), and the
|
|
// wording is not the contract under test; naming what's missing is.
|
|
const approveMessage = approveError?.message ?? '';
|
|
expect(approveMessage, 'the gate error names the missing icon').toMatch(/icon/i);
|
|
expect(approveMessage, 'the gate error names the missing cover').toMatch(/cover/i);
|
|
|
|
// The gate fired BEFORE any mutation → the request is still pending.
|
|
const stillPending = await findPendingBySlug(request, SLUG);
|
|
expect(stillPending, 'the gate-blocked request is still pending (no mutation)').not.toBeNull();
|
|
|
|
// WITHDRAW to clean (deletes the draft + releases the slug).
|
|
await trpcMutation(request, 'appListings.withdrawExternalRequest', { publishRequestId });
|
|
publishRequestId = null;
|
|
|
|
const afterWithdraw = await findPendingBySlug(request, SLUG);
|
|
expect(afterWithdraw, 'the withdrawn request no longer appears in the queue').toBeNull();
|
|
} finally {
|
|
if (publishRequestId) {
|
|
await trpcMutation(request, 'appListings.withdrawExternalRequest', {
|
|
publishRequestId,
|
|
}).catch(() => {});
|
|
} else {
|
|
await withdrawPendingForSlug(request, SLUG);
|
|
}
|
|
await deleteConnectClient(request, clientId);
|
|
}
|
|
});
|
|
});
|