/** * 0188 — the governed amend path clears submitted HRM change requests. * * 0185 retains every submitted request as history and refuses DELETE. That * rule stands for production paths; this suite proves the ONE allowance * 0188 adds: under `openbooks.amend = on` (fixture teardown, sandbox wipe, * org purge) a submitted request can be removed, so an organisation that * holds one is not pinned forever. Both directions are asserted against a * live PostgreSQL: the refusal without the GUC, the deletion with it, and * the real fixture teardown of an org holding a committed submitted request. * * Self-skips without OPENBOOKS_DB_URL like every DB-backed suite. */ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import test from "node:test"; import { sql } from "drizzle-orm"; const DB = Boolean(process.env.OPENBOOKS_DB_URL); type Seed = { orgId: string; actorId: string; employmentId: string }; async function seed(): Promise { const [{ db }, { createScratchOrg, createScratchUser }] = await Promise.all([ import("@openbooks/engine/platform/db"), import("@openbooks/engine/testing/fixtures"), ]); const org = await createScratchOrg(); assert.ok(org.customerId && org.subsidiaryId, "scratch org must seed a party and a subsidiary"); const actorId = await createScratchUser(org.orgId, "HRM wipe actor", "hrm_wipe_actor"); const employmentId = randomUUID(); await db.execute(sql` insert into worker_employments (id, org_id, worker_party_id, employer_subsidiary_id, revision) values (${employmentId}, ${org.orgId}, ${org.customerId}, ${org.subsidiaryId}, 1)`); return { orgId: org.orgId, actorId, employmentId }; } /** Insert a draft and submit it against a real flow run; returns the request id. */ async function submittedRequest( run: { execute: (q: unknown) => Promise }, s: Seed, ): Promise { const id = randomUUID(); await run.execute(sql` insert into hrm_employment_change_requests (id, org_id, employment_id, expected_employment_revision, payload, payload_digest, payload_schema_version, created_by) values (${id}, ${s.orgId}, ${s.employmentId}, 1, ${JSON.stringify({ kind: "transfer", level: 1 })}::jsonb, ${"0".repeat(64)}, 'v1', ${s.actorId})`); const flowId = randomUUID(); await run.execute(sql` insert into flows (id, org_id, subject_kind, graph) values (${flowId}, ${s.orgId}, 'hrm_employment_change_request', '{}'::jsonb)`); const runId = randomUUID(); await run.execute(sql` insert into flow_runs (id, org_id, flow_id, subject_kind, subject_id, trigger) values (${runId}, ${s.orgId}, ${flowId}, 'hrm_employment_change_request', ${id}, 'on_submit')`); await run.execute(sql` update hrm_employment_change_requests set status = 'pending_approval', reason = 'wipe allowance proof', submitted_by = ${s.actorId}, submitted_at = now(), flow_run_id = ${runId} where id = ${id}`); return id; } test( "a submitted request refuses DELETE without the amend allowance and clears with it", { skip: !DB, timeout: 120_000 }, async (t) => { const { db } = await import("@openbooks/engine/platform/db"); const { dropScratchOrgReporting } = await import("@openbooks/engine/testing/fixtures"); const s = await seed(); t.after(async () => { await dropScratchOrgReporting(s.orgId); }); class Rollback extends Error {} await assert.rejects( db.transaction(async (tx) => { const id = await submittedRequest(tx, s); // Production shape: no GUC, the retention rule fires. await tx.execute(sql`savepoint no_guc`); await assert.rejects( tx.execute(sql`delete from hrm_employment_change_requests where id = ${id}`), (error: unknown) => /retained as history/.test( String(error) + String((error as { cause?: unknown }).cause ?? ""), ), "submitted request must refuse DELETE without the amend allowance", ); await tx.execute(sql`rollback to savepoint no_guc`); await tx.execute(sql`release savepoint no_guc`); const still = ( await tx.execute<{ n: number }>(sql` select count(*)::int as n from hrm_employment_change_requests where id = ${id}`) ).rows[0]!.n; assert.equal(still, 1, "the refused DELETE removed nothing"); // Governed amend path: the same statement clears exactly one row. await tx.execute(sql`select set_config('openbooks.amend', 'on', true)`); const deleted = ( await tx.execute<{ id: string }>(sql` delete from hrm_employment_change_requests where id = ${id} returning id`) ).rows; assert.equal(deleted.length, 1, "amend allowance must delete exactly the submitted row"); const gone = ( await tx.execute<{ n: number }>(sql` select count(*)::int as n from hrm_employment_change_requests where id = ${id}`) ).rows[0]!.n; assert.equal(gone, 0); throw new Rollback(); }), (error: unknown) => error instanceof Rollback, ); }, ); test( "fixture teardown clears an organisation that holds a committed submitted request", { skip: !DB, timeout: 120_000 }, async () => { const { db } = await import("@openbooks/engine/platform/db"); const { dropScratchOrgReporting } = await import("@openbooks/engine/testing/fixtures"); const s = await seed(); // Committed on purpose: this is the state that pinned scratch orgs before 0188. const id = await submittedRequest(db, s); const before = ( await db.execute<{ n: number }>(sql` select count(*)::int as n from hrm_employment_change_requests where org_id = ${s.orgId}`) ).rows[0]!.n; assert.equal(before, 1); await dropScratchOrgReporting(s.orgId); // Under the pooled lifecycle the org row is a reusable slot that is reset and // released, so the proof is that the org's HRM rows are gone — the exact // state that pinned scratch orgs before 0188 — not that the org row vanished. const after = ( await db.execute<{ n: number }>(sql` select count(*)::int as n from hrm_employment_change_requests where org_id = ${s.orgId} or id = ${id}`) ).rows[0]!.n; assert.equal(after, 0, "teardown must remove the submitted request with the rest of the org"); const runs = ( await db.execute<{ n: number }>(sql` select count(*)::int as n from flow_runs where org_id = ${s.orgId}`) ).rows[0]!.n; assert.equal(runs, 0, "the bound flow run is gone with it"); const employments = ( await db.execute<{ n: number }>(sql` select count(*)::int as n from worker_employments where org_id = ${s.orgId}`) ).rows[0]!.n; assert.equal(employments, 0, "the 0184 rows are gone with it"); }, );