From ecedb0ef127bfb34a0efe0365ad8278b045dccb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 00:57:14 +0000 Subject: [PATCH] test(e2e): state the seed-visibility precondition instead of depending on it (#665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm test:e2e` against a dev server that had been up for more than ten minutes failed 11 of 16 specs on "no seeded accounts returned" and "no seeded crm_account — the demo seed did not load". The seed had loaded. `global-setup.ts` signs UP `e2e-admin@hotcrm.test`, which lands as a plain org member owning nothing and holding no sharing grant; every seeded row starts out ownerless, which under `sharingModel: 'private'` is the only reason the suite could read it. Once `demo_bootstrap` (or `pnpm demo:staff`) claims those rows for the first user, the suite reads zero — and blamed the seed loader. Global setup now asserts the precondition. Two `?limit=1` reads separate the two states that both look like zero rows: `crm_account` is `private` and swept by `demo_bootstrap`, so it goes dark the moment the seeds are claimed; `crm_product` is `public_read` and in no sweep, so no ownership state can hide it. Products but no accounts means the seeds are there and claimed, and the run aborts with that sentence plus the remedy; neither means nothing was seeded, and says so. The guard cannot turn a passing run red: it returns on the first readable row without issuing the second probe, and waits out a seed that is still loading rather than calling it absent. Measured on both server modes — `objectstack start` (CI's webServer) seeds no dev admin, so the e2e account is the org's first user and the sweep claims the seeds FOR it; `objectstack dev` seeds admin@objectos.ai and the sweep claims them away. What the suite proves is unchanged: no sharing grant, no permission set, no switch to the seeded dev admin. `test/e2e-seed-precondition.test.ts` pins the guard's metadata premises against the real objects and flow, so adding `crm_product` to `CLAIMED_OBJECTS` fails rather than silently degrading the diagnosis back to the misleading one. --- .../e2e-seed-visibility-precondition.md | 29 ++ e2e/fixtures.ts | 3 +- e2e/global-setup.ts | 8 + e2e/seed-precondition.ts | 251 ++++++++++++++++++ e2e/smoke.spec.ts | 3 +- test/e2e-seed-precondition.test.ts | 202 ++++++++++++++ 6 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 .changeset/e2e-seed-visibility-precondition.md create mode 100644 e2e/seed-precondition.ts create mode 100644 test/e2e-seed-precondition.test.ts diff --git a/.changeset/e2e-seed-visibility-precondition.md b/.changeset/e2e-seed-visibility-precondition.md new file mode 100644 index 00000000..4baf463d --- /dev/null +++ b/.changeset/e2e-seed-visibility-precondition.md @@ -0,0 +1,29 @@ +--- +'hotcrm': patch +--- + +Fail the end-to-end suite with the actual reason when the demo seed is loaded but +invisible to the account it runs as. + +`pnpm test:e2e` against a dev server that has been up for more than ten minutes +failed eleven of sixteen specs on `no seeded accounts returned` and `no seeded +crm_account — the demo seed did not load`. The seed had loaded. `e2e/global-setup.ts` +signs **up** `e2e-admin@hotcrm.test`, which lands as a plain org member that owns +nothing and holds no sharing grant, and every seeded row starts out owned by nobody — +which under `sharingModel: 'private'` is the only reason it could read them at all. +Once `demo_bootstrap` (or `pnpm demo:staff`) claims those rows for the first user, the +suite reads zero, and reported it as a missing seed. + +Global setup now states that precondition instead of depending on it silently. Two +`?limit=1` reads separate the two states that both look like "zero rows": +`crm_account` is `private` and swept by `demo_bootstrap`, so it goes dark the moment +the seeds are claimed; `crm_product` is `public_read` and in no sweep, so no ownership +state can hide it. Products but no accounts means the seed is there and claimed — the +run aborts with that sentence and `pnpm demo:reset`; neither means nothing seeded, and +says so. The spec-level assertions, still reachable if the sweep fires mid-run, now +carry the same cause rather than blaming the seed loader. + +What the suite proves is unchanged: no sharing grant, no permission set, no switch to +the seeded dev admin. The guard also cannot turn a passing run red — it returns on the +first readable row, and waits out a seed that is still loading rather than calling it +absent. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 3751fb6b..72b2a7c6 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -2,6 +2,7 @@ import { test as base, expect, type APIRequestContext } from '@playwright/test'; import { TOKEN_ENV } from './global-setup'; +import { SEEDS_UNREADABLE_MID_RUN } from './seed-precondition'; /** * Authenticated e2e fixtures. @@ -82,6 +83,6 @@ export async function seededAccountId(api: APIRequestContext): Promise { const res = await api.get('/api/v1/data/crm_account?limit=1'); expect(res.ok(), `could not read seeded accounts: ${res.status()}`).toBeTruthy(); const [first] = recordsOf(await res.json()); - expect(first, 'no seeded crm_account — the demo seed did not load').toBeTruthy(); + expect(first, `no seeded crm_account — ${SEEDS_UNREADABLE_MID_RUN}`).toBeTruthy(); return first.id as string; } diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index edd37bbc..6e634ac8 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { request, type FullConfig } from '@playwright/test'; +import { assertSeedPrecondition } from './seed-precondition'; /** * One sign-in for the whole run. @@ -95,6 +96,13 @@ export default async function globalSetup(config: FullConfig): Promise { if (!token) throw new Error('auth succeeded but returned no session token'); process.env[TOKEN_ENV] = token; + + // Authentication is not the same thing as access. This account is a plain + // org `member`, and under `sharingModel: 'private'` it reads a seeded row + // only while that row is owned by nobody — see `./seed-precondition.ts`. + // Checking it here turns one environmental state into one instruction, + // instead of eleven specs failing on "no seeded accounts returned" (#665). + await assertSeedPrecondition(ctx, token, EMAIL); } finally { await ctx.dispose(); } diff --git a/e2e/seed-precondition.ts b/e2e/seed-precondition.ts new file mode 100644 index 00000000..e0122b76 --- /dev/null +++ b/e2e/seed-precondition.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The precondition the e2e suite has always depended on, now stated out loud. + * + * Eleven of the sixteen specs read seeded CRM rows, and whether those rows are + * *visible* to the user the suite runs as is an environmental property nothing + * declared (#665): + * + * - `global-setup.ts` signs **up** `e2e-admin@hotcrm.test`. That account is a + * plain org `member`. It owns nothing and holds no sharing grant. + * - `crm_account`, `crm_lead`, `crm_opportunity`, `crm_case`, `crm_task`, + * `crm_quote` and `crm_contract` are `sharingModel: 'private'`. The OWD + * baseline admits a row's owner and a share can only widen from there. + * - Seeded rows land **ownerless** (`{ isSystem: true }` writes skip the + * security plugin's `owner_id` injection — see `src/data/index.ts`), and an + * ownerless private row is admitted to everyone. That is the state a fresh + * database starts in and the one every spec was written against. + * - `demo_bootstrap`, on its ten-minute schedule, claims every ownerless row for + * the first user. `pnpm demo:staff` has the same effect. From that moment + * the e2e user reads **zero** private rows. + * + * CI never reaches that state, and not by luck — measured on both paths: + * + * - CI's `webServer` runs `objectstack start`, which seeds no dev admin. The + * account this suite signs up is therefore the org's FIRST user (`sys_user` + * holds exactly one row), so when the sweep runs it claims the seeds FOR + * the suite: every seeded account comes out carrying this account's id as + * `owner_id`, and the specs read them before and after the sweep alike. + * - `pnpm dev` seeds `admin@objectos.ai`. The first user is then that admin, + * the sweep claims the seeds away from this suite at the next wall-clock + * ten-minute boundary, and every seeded-row assertion fails from then on. + * + * So the failure is real, local-only, and permanent once it starts — and it + * reported itself as a missing seed. The seed is fine; the rows are somebody + * else's. + * + * This module makes that state a single, actionable failure in global setup + * instead of eleven misleading ones spread across the run. It does **not** + * change what the suite proves: no sharing grant, no permission set, no switch + * to the seeded dev admin. Making the specs independent of seed ownership + * altogether is deliberately a separate change. + */ + +/** + * The two probes, and why one is not enough. + * + * A single "are there accounts?" read cannot tell *seeds claimed* from *seeds + * never loaded* — both answer zero — and those two states have opposite + * remedies. A second read against an object whose visibility does not depend on + * ownership separates them for one extra request: + * + * - `crm_account` is `private` AND is swept by `demo_bootstrap`, so it goes + * to zero the moment the seeds are claimed. + * - `crm_product` is `public_read` AND is in no sweep (`demo_bootstrap`'s + * `CLAIMED_OBJECTS` covers leads, accounts, contacts, opportunities, cases, + * tasks, quotes and contracts — not products), so it stays visible to every + * authenticated member no matter who owns what. + * + * Both are seeded by `CrmSeedData`, so "products but no accounts" can only mean + * the accounts are hidden, and "neither" can only mean nothing seeded at all. + */ +export const CLAIMABLE_PROBE_OBJECT = 'crm_account'; +export const OWNERSHIP_BLIND_PROBE_OBJECT = 'crm_product'; + +/** What the two probes together say about the database under the suite. */ +export type SeedVisibility = + /** The suite can read seeded private rows — the state every spec assumes. */ + | 'visible' + /** Seeds loaded, but the private ones are owned by somebody else. */ + | 'claimed' + /** Nothing seeded at all — a different problem with a different remedy. */ + | 'absent'; + +/** Rows each probe returned for the e2e user. */ +export interface SeedProbeCounts { + claimable: number; + ownershipBlind: number; +} + +/** Read the two counts as one of the three states above. */ +export function classifySeedVisibility(counts: SeedProbeCounts): SeedVisibility { + if (counts.claimable > 0) return 'visible'; + return counts.ownershipBlind > 0 ? 'claimed' : 'absent'; +} + +/** + * The message the developer actually needs, for whichever state we are in. + * + * Both branches name the cause before the remedy: a message that only says what + * to type teaches nothing about why, and this failure recurs every ten minutes + * on a long-lived dev server. + */ +export function seedPreconditionMessage(state: 'claimed' | 'absent', email: string): string { + const shared = [ + '', + 'Remedy — run the suite the way CI does: a cold database served by `objectstack start`.', + '', + ' pnpm demo:reset # rm -rf .objectstack/data && rebuild', + ' pnpm start # terminal 1 — NOT `pnpm dev`, see below', + ' pnpm test:e2e # terminal 2', + '', + 'Why `start` and not `dev`: `objectstack dev` seeds the platform dev admin', + '(`admin@objectos.ai`), so the org\'s FIRST user is that admin and `demo_bootstrap` claims', + 'the seeds for it, away from this suite. `objectstack start` seeds no admin, so this', + 'suite\'s own account is the first user and the sweep claims the seeds FOR it — measured:', + 'on a cold `start` database `sys_user` holds exactly one row (this account) and every', + 'seeded account carries its id as `owner_id`. That is why CI has always been green.', + '', + 'To stay on `pnpm dev`, reset and run before the next sweep. The schedule is on wall-clock', + 'ten-minute boundaries, so that window is anywhere from seconds to ten minutes — measured:', + 'a dev server booted at :49 had every seeded account owned by the dev admin by :50. That', + 'race is what objectstack-ai/hotcrm#665 records; this guard reports it instead of letting', + '11 specs fail on a message about a seed that loaded perfectly well.', + ].join('\n'); + + if (state === 'claimed') { + return [ + `e2e precondition failed: the demo seed is loaded but INVISIBLE to ${email}.`, + '', + `\`${CLAIMABLE_PROBE_OBJECT}\` returned 0 rows while \`${OWNERSHIP_BLIND_PROBE_OBJECT}\` returned rows — so the seed`, + 'is there, and the private records are simply owned by another user. The `demo_bootstrap`', + 'scheduled flow (or `pnpm demo:staff`) claims every ownerless seeded record for the first', + 'user; under `sharingModel: \'private\'` this suite\'s account — a plain org member that owns', + 'nothing and holds no sharing grant — then reads zero of them.', + shared, + ].join('\n'); + } + + return [ + 'e2e precondition failed: this server has no demo seed data.', + '', + `Neither \`${CLAIMABLE_PROBE_OBJECT}\` nor \`${OWNERSHIP_BLIND_PROBE_OBJECT}\` returned a single row for ${email}, and`, + `\`${OWNERSHIP_BLIND_PROBE_OBJECT}\` is \`public_read\` and owned by nobody — no ownership or sharing state can`, + 'hide it. So the seed did not load, rather than having been claimed by another user.', + 'Check the server boot log for seed errors before rerunning.', + shared, + ].join('\n'); +} + +/** + * What a seeded-row assertion should say when it fails *despite* the guard. + * + * The guard runs once, in global setup. `demo_bootstrap` fires every ten + * minutes, so it can claim the seeds part-way through a run that started with + * them readable — which leaves `smoke.spec.ts` and `seededAccountId()` reachable + * on exactly the state the guard exists to explain. They say so themselves + * rather than repeating "the demo seed did not load", which is the one + * explanation this failure has never had. + */ +export const SEEDS_UNREADABLE_MID_RUN = + 'global setup verified these rows were readable, so they were not missing when the run ' + + 'started: `demo_bootstrap` most likely claimed the seeds mid-run and this account, a ' + + 'plain org member, can no longer see them (#665). Run `pnpm demo:reset` and rerun.'; + +/** + * The slice of Playwright's `APIRequestContext` this module uses. + * + * Declared structurally rather than imported so the guard can be exercised by + * the unit suite against a stub — the branch that must never fire on CI is + * worth a test that does not need a browser, a server or a database. + */ +export interface SeedProbeContext { + get( + url: string, + options?: { headers?: Record; failOnStatusCode?: boolean }, + ): Promise<{ + ok(): boolean; + status(): number; + text(): Promise; + json(): Promise; + }>; +} + +export interface SeedPreconditionOptions { + /** + * How long to keep re-probing before declaring the seeds unreadable. + * + * Not a nicety: `waitForQuiet` infers "the seed storm has passed" from health + * latency, which is a proxy, not a fact. A cold CI database that is still + * inserting when global setup reaches this point would answer zero rows and + * be classified `absent` — a green run turned red by the guard meant to + * protect it. Waiting converts that race into a pause. The guard can then + * only fire in states where the suite was already going to fail: this returns + * the instant the rows appear. + */ + timeoutMs?: number; + pollMs?: number; + /** Injected so the timeout path is testable without real waiting. */ + sleep?: (ms: number) => Promise; +} + +const defaultSleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Rows in a list response (`{ object, records }`), or `null` if not one. */ +function countRecords(body: unknown): number | null { + const records = (body as { records?: unknown } | null)?.records; + return Array.isArray(records) ? records.length : null; +} + +/** One authenticated `?limit=1` read; throws when the API itself is unhappy. */ +async function probe(ctx: SeedProbeContext, objectName: string, token: string): Promise { + const res = await ctx.get(`/api/v1/data/${objectName}?limit=1`, { + headers: { Authorization: `Bearer ${token}` }, + failOnStatusCode: false, + }); + if (!res.ok()) { + throw new Error( + `e2e precondition probe failed: GET /api/v1/data/${objectName} → ${res.status()}: ${await res.text()}\n` + + 'The suite authenticated, so this is not a credentials problem — the object is ' + + 'unreadable for this user, or the server is unhealthy. Neither is a seed-data state.', + ); + } + const count = countRecords(await res.json()); + if (count === null) { + throw new Error( + `e2e precondition probe failed: GET /api/v1/data/${objectName} answered 200 without a ` + + '`records` array. The list envelope changed shape; e2e/fixtures.ts reads the same key.', + ); + } + return count; +} + +/** + * Fail global setup loudly when the seeded rows this suite reads are not + * readable by the account it signed in as. + * + * Returns silently in the only state the specs are written for. + */ +export async function assertSeedPrecondition( + ctx: SeedProbeContext, + token: string, + email: string, + options: SeedPreconditionOptions = {}, +): Promise { + const { timeoutMs = 30_000, pollMs = 2_000, sleep = defaultSleep } = options; + const deadline = Date.now() + timeoutMs; + + let counts: SeedProbeCounts; + for (;;) { + const claimable = await probe(ctx, CLAIMABLE_PROBE_OBJECT, token); + if (claimable > 0) return; + counts = { claimable, ownershipBlind: await probe(ctx, OWNERSHIP_BLIND_PROBE_OBJECT, token) }; + if (Date.now() >= deadline) break; + await sleep(pollMs); + } + + const state = classifySeedVisibility(counts); + if (state === 'visible') return; + throw new Error(seedPreconditionMessage(state, email)); +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 4cd0411b..891f1a50 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { test, expect, recordsOf } from './fixtures'; +import { SEEDS_UNREADABLE_MID_RUN } from './seed-precondition'; /** * Server smoke tests — the routes are mounted and the CRM data is reachable. @@ -52,7 +53,7 @@ test('REST API serves seeded hotcrm records to an authenticated caller', async ( expect(res.ok(), `authenticated read failed: ${res.status()} ${await res.text()}`).toBeTruthy(); const records = recordsOf(await res.json()); - expect(records.length, 'no seeded accounts returned').toBeGreaterThan(0); + expect(records.length, `no seeded accounts returned — ${SEEDS_UNREADABLE_MID_RUN}`).toBeGreaterThan(0); // A real record, not just a 200 with an empty envelope. expect(typeof records[0].id).toBe('string'); expect(typeof records[0].name).toBe('string'); diff --git a/test/e2e-seed-precondition.test.ts b/test/e2e-seed-precondition.test.ts new file mode 100644 index 00000000..eed9ef76 --- /dev/null +++ b/test/e2e-seed-precondition.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + CLAIMABLE_PROBE_OBJECT, + OWNERSHIP_BLIND_PROBE_OBJECT, + SEEDS_UNREADABLE_MID_RUN, + assertSeedPrecondition, + classifySeedVisibility, + seedPreconditionMessage, + type SeedProbeContext, +} from '../e2e/seed-precondition'; +import { Account } from '../src/objects/account.object'; +import { Product } from '../src/objects/product.object'; +import { DemoBootstrapFlow } from '../src/flows/demo-bootstrap.flow'; +import { CrmSeedData } from '../src/data/index'; + +/** + * The e2e seed-visibility guard (#665). + * + * `e2e/global-setup.ts` now refuses to start a run whose seeded rows the e2e + * account cannot read, instead of letting eleven specs fail on "no seeded + * accounts returned" — a message about a seed that had loaded perfectly well. + * + * Two things are worth pinning, and Playwright can pin neither: the guard's + * METADATA PREMISES (which object goes dark when the seeds are claimed and + * which one cannot), and the branch that must never fire on CI. The guard is + * therefore written against a structural request-context interface so it runs + * here against a stub — no browser, no server, no database. + */ + +type Rec = Record; + +describe('the premises the guard reads the two probes by', () => { + const seeded = new Set((CrmSeedData as unknown as Array<{ object: string }>).map((d) => d.object)); + + /** + * Every `objectName` `demo_bootstrap` touches, loop bodies included. The + * sweep is what makes a seeded row stop being visible to a non-owner, so + * membership of this set is exactly the property the claimable probe needs. + */ + const swept = new Set(); + const collect = (nodes: Rec[]): void => { + for (const node of nodes) { + const objectName = node?.config?.objectName; + if (typeof objectName === 'string') swept.add(objectName); + const body = node?.config?.body?.nodes; + if (Array.isArray(body)) collect(body as Rec[]); + } + }; + collect((DemoBootstrapFlow.nodes ?? []) as unknown as Rec[]); + + it('both probes are actually seeded — an unseeded probe reads zero always', () => { + expect(seeded.has(CLAIMABLE_PROBE_OBJECT)).toBe(true); + expect(seeded.has(OWNERSHIP_BLIND_PROBE_OBJECT)).toBe(true); + }); + + it('the claimable probe is private AND swept, so it goes dark when the seeds are claimed', () => { + expect((Account as Rec).sharingModel).toBe('private'); + expect(swept.has(CLAIMABLE_PROBE_OBJECT)).toBe(true); + }); + + it('the ownership-blind probe is public_read AND swept by nothing, so it never goes dark', () => { + // Either half failing would collapse the guard's two states into one: a + // probe that can itself be hidden reports "the seed did not load" for an + // org whose seed loaded and was claimed, which is the exact misdiagnosis + // #665 is about. Adding `crm_product` to `CLAIMED_OBJECTS` must fail here. + expect((Product as Rec).sharingModel).toBe('public_read'); + expect(swept.has(OWNERSHIP_BLIND_PROBE_OBJECT)).toBe(false); + }); +}); + +describe('classifySeedVisibility', () => { + it('reads any readable private row as the state the specs are written for', () => { + expect(classifySeedVisibility({ claimable: 1, ownershipBlind: 0 })).toBe('visible'); + expect(classifySeedVisibility({ claimable: 5, ownershipBlind: 12 })).toBe('visible'); + }); + + it('reads "products but no accounts" as seeds claimed by another user', () => { + expect(classifySeedVisibility({ claimable: 0, ownershipBlind: 12 })).toBe('claimed'); + }); + + it('reads "neither" as no seed at all — a different problem', () => { + expect(classifySeedVisibility({ claimable: 0, ownershipBlind: 0 })).toBe('absent'); + }); +}); + +describe('the messages', () => { + it('name the cause and the remedy for a claimed seed', () => { + const msg = seedPreconditionMessage('claimed', 'e2e-admin@hotcrm.test'); + expect(msg).toContain('INVISIBLE'); + expect(msg).toContain('demo_bootstrap'); + expect(msg).toContain('pnpm demo:reset'); + expect(msg).toContain('e2e-admin@hotcrm.test'); + // The one thing it must not say, because it is what the old failure said. + expect(msg).not.toContain('the demo seed did not load'); + }); + + it('say something different, and true, when nothing is seeded', () => { + const msg = seedPreconditionMessage('absent', 'e2e-admin@hotcrm.test'); + expect(msg).toContain('no demo seed data'); + expect(msg).toContain('pnpm demo:reset'); + expect(msg).not.toContain('INVISIBLE'); + }); + + it('gives the mid-run assertions a cause instead of "the seed did not load"', () => { + // The guard runs once; `demo_bootstrap` fires every ten minutes, so the + // spec-level assertions stay reachable and carry this instead. + expect(SEEDS_UNREADABLE_MID_RUN).toContain('demo_bootstrap'); + expect(SEEDS_UNREADABLE_MID_RUN).toContain('pnpm demo:reset'); + }); +}); + +/** A request context answering a scripted number of rows per probe pass. */ +function stubContext(passes: Array>) { + const requested: string[] = []; + // The claimable probe opens every pass, so counting it advances the script. + let pass = -1; + const ctx: SeedProbeContext = { + async get(url) { + requested.push(url); + const objectName = url.replace('/api/v1/data/', '').split('?')[0]; + if (objectName === CLAIMABLE_PROBE_OBJECT) pass++; + const scripted = passes[Math.min(pass, passes.length - 1)][objectName]; + if (typeof scripted === 'object') { + return { + ok: () => false, + status: () => scripted.status, + text: async () => scripted.body ?? '', + json: async () => ({}), + }; + } + return { + ok: () => true, + status: () => 200, + text: async () => '', + json: async () => ({ object: objectName, records: Array.from({ length: scripted }, () => ({ id: 'x' })) }), + }; + }, + }; + return { ctx, requested }; +} + +const noWait = { timeoutMs: 0, pollMs: 0, sleep: async () => {} }; + +describe('assertSeedPrecondition', () => { + it('passes — and costs one request — when the private rows are readable', async () => { + // This is CI's state and a freshly reset local server's state. The second + // probe is never even issued, so nothing about the ownership-blind object + // can influence a run that was going to be green. + const { ctx, requested } = stubContext([{ [CLAIMABLE_PROBE_OBJECT]: 5 }]); + await expect(assertSeedPrecondition(ctx, 'tok', 'e2e-admin@hotcrm.test', noWait)).resolves.toBeUndefined(); + expect(requested).toEqual([`/api/v1/data/${CLAIMABLE_PROBE_OBJECT}?limit=1`]); + }); + + it('fails with the claimed-seed instruction when only the blind probe returns rows', async () => { + const { ctx } = stubContext([{ [CLAIMABLE_PROBE_OBJECT]: 0, [OWNERSHIP_BLIND_PROBE_OBJECT]: 12 }]); + await expect( + assertSeedPrecondition(ctx, 'tok', 'e2e-admin@hotcrm.test', noWait), + ).rejects.toThrow(/INVISIBLE[\s\S]*pnpm demo:reset/); + }); + + it('fails with the missing-seed message when neither returns rows', async () => { + const { ctx } = stubContext([{ [CLAIMABLE_PROBE_OBJECT]: 0, [OWNERSHIP_BLIND_PROBE_OBJECT]: 0 }]); + await expect( + assertSeedPrecondition(ctx, 'tok', 'e2e-admin@hotcrm.test', noWait), + ).rejects.toThrow(/no demo seed data/); + }); + + it('waits out a seed still loading rather than calling it absent', async () => { + // `waitForQuiet` infers "the seed storm has passed" from health latency, + // which is a proxy. A cold database still inserting when global setup + // reaches the guard would answer zero rows on the first pass — and a guard + // that failed there would turn a green CI run red, which is the one thing + // it must not do. It returns the instant the rows appear. + const { ctx } = stubContext([ + { [CLAIMABLE_PROBE_OBJECT]: 0, [OWNERSHIP_BLIND_PROBE_OBJECT]: 0 }, + { [CLAIMABLE_PROBE_OBJECT]: 9 }, + ]); + await expect( + assertSeedPrecondition(ctx, 'tok', 'e2e-admin@hotcrm.test', { timeoutMs: 5_000, pollMs: 0, sleep: async () => {} }), + ).resolves.toBeUndefined(); + }); + + it('reports an unhappy API as an API problem, not as a seed-data state', async () => { + const { ctx } = stubContext([{ [CLAIMABLE_PROBE_OBJECT]: { status: 500, body: 'boom' } }]); + await expect(assertSeedPrecondition(ctx, 'tok', 'e2e-admin@hotcrm.test', noWait)).rejects.toThrow( + /probe failed:.*→ 500/, + ); + }); + + it('rejects a 200 that is not a list envelope instead of reading it as zero rows', async () => { + const ctx: SeedProbeContext = { + async get() { + return { ok: () => true, status: () => 200, text: async () => '', json: async () => ({ data: [] }) }; + }, + }; + await expect(assertSeedPrecondition(ctx, 'tok', 'e2e-admin@hotcrm.test', noWait)).rejects.toThrow( + /without a `records` array/, + ); + }); +});