godcrm/scripts/smoke-c5-release-gate.mjs
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

206 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
// ADR-0003 §C-5 smoke — BDD release gate auto-publish / demote.
//
// Scenarios (all against PROD DB, inside space 11):
// S1 bootstrap — create a throwaway documents_registry table in the BDD
// project (marked `smoke_c5=true`) + seed a draft doc row there.
// S2 seed BDD — one bdd_specs + two Must bdd_criteria (status=pending).
// S3 gate red — maybeTransitionDocumentStatus returns {noop}.
// S4 verify one — still {noop}, ready=false.
// S5 verify both — {published: true}, registry.status flips to 'published',
// bdd_audit_log gets action='document_publish', pg_notify emitted.
// S6 idempotent — second call → {noop}.
// S7 regress one — {demoted: true}, registry.status → 'regressed-published',
// bdd_audit_log gets action='document_regress'.
// S8 cleanup — drop the throwaway table + rows + audit entries.
// ADR-0009 Phase 5: boot guard — aborts (exit 2) if POSTGRES_HOST is PROD.
import '../backend/test/setup.js';
import pg from 'pg';
import { maybeTransitionDocumentStatus } from '../backend/services/bdd/releaseGate.js';
const { Pool } = pg;
const pool = new Pool({
host: process.env.POSTGRES_HOST || 'localhost',
port: parseInt(process.env.POSTGRES_PORT || '5432', 10),
database: process.env.POSTGRES_DB || 'godcrm_prod',
user: process.env.POSTGRES_USER || 'godcrm',
password: process.env.POSTGRES_PASSWORD || 'godcrm_dev_2026',
});
async function bddTableId(client, name) {
const { rows } = await client.query(`
SELECT ut.id FROM universal_tables ut
JOIN projects p ON p.id = ut.project_id
WHERE p.space_id = 11 AND ut.name = $1 LIMIT 1
`, [name]);
return rows[0]?.id;
}
async function insert(client, tid, data, { createdBy = 1 } = {}) {
const baseId = `SMK-C5-${Date.now().toString(36).toUpperCase()}-${Math.floor(Math.random() * 1000)}`;
const { rows } = await client.query(
`INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at)
VALUES ($1, $2, $3::jsonb, $4, NOW(), NOW()) RETURNING id`,
[tid, baseId, JSON.stringify(data), createdBy]
);
return rows[0].id;
}
async function patch(client, rowId, patchObj) {
await client.query(
`UPDATE table_rows SET data = data || $1::jsonb, updated_at = NOW() WHERE id = $2`,
[JSON.stringify(patchObj), rowId]
);
}
async function main() {
const client = await pool.connect();
const created = {
registryTableId: null,
registryProjectId: null,
docRowId: null,
specRowId: null,
crit1Id: null,
crit2Id: null,
auditRowIds: [],
};
try {
// --- S1: bootstrap throwaway documents_registry table ---------------
const { rows: projectRows } = await client.query(
`SELECT id FROM projects WHERE space_id = 11 AND name = 'BDD / ADR-156' LIMIT 1`
);
const projectId = projectRows[0]?.id;
if (!projectId) throw new Error('BDD project not found');
created.registryProjectId = projectId;
const throwawayName = `_smoke_c5_registry_${Date.now()}`;
const { rows: utRows } = await client.query(`
INSERT INTO universal_tables (project_id, name, description, icon, table_type, is_system, created_at, updated_at)
VALUES ($1, $2, 'smoke-c5 registry', '🧪', 'documents_registry', 0, NOW(), NOW())
RETURNING id
`, [projectId, throwawayName]);
created.registryTableId = utRows[0].id;
console.log(`S1 ✓ throwaway registry table id=${created.registryTableId} name=${throwawayName}`);
// Seed a draft doc row
created.docRowId = await insert(client, created.registryTableId, {
name: 'SMK-C5 draft doc',
slug: 'smk-c5-draft',
status: 'draft',
});
console.log(`S1 ✓ draft doc row id=${created.docRowId}`);
// --- S2: seed BDD spec + two must criteria --------------------------
const specTid = await bddTableId(client, 'bdd_specs');
const critTid = await bddTableId(client, 'bdd_criteria');
const auditTid = await bddTableId(client, 'bdd_audit_log');
if (!specTid || !critTid || !auditTid) throw new Error('BDD tables missing');
created.specRowId = await insert(client, specTid, {
title: 'SMK-C5 spec',
source_doc_id: created.docRowId,
status: 'active',
});
created.crit1Id = await insert(client, critTid, {
code: 'SMK-C5-AC1',
title: 'must AC 1',
spec_id: created.specRowId,
source_doc_id: created.docRowId,
priority: 'must',
status: 'pending',
});
created.crit2Id = await insert(client, critTid, {
code: 'SMK-C5-AC2',
title: 'must AC 2',
spec_id: created.specRowId,
source_doc_id: created.docRowId,
priority: 'must',
status: 'pending',
});
console.log(`S2 ✓ spec=${created.specRowId} crit1=${created.crit1Id} crit2=${created.crit2Id}`);
// --- S3: gate red → noop --------------------------------------------
let r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S3' });
if (!r.noop) throw new Error(`S3 ✗ expected noop, got ${JSON.stringify(r)}`);
console.log(`S3 ✓ gate red: ${JSON.stringify(r)}`);
// --- S4: verify one must → still red --------------------------------
await patch(client, created.crit1Id, { status: 'verified' });
r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S4' });
if (!r.noop) throw new Error(`S4 ✗ expected noop, got ${JSON.stringify(r)}`);
console.log(`S4 ✓ 1/2 verified — still ${JSON.stringify(r)}`);
// --- S5: verify both → published ------------------------------------
await patch(client, created.crit2Id, { status: 'verified' });
r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S5' });
if (!r.published) throw new Error(`S5 ✗ expected published, got ${JSON.stringify(r)}`);
console.log(`S5 ✓ gate green → published: ${JSON.stringify(r)}`);
const { rows: regRows } = await client.query(
`SELECT data FROM table_rows WHERE id = $1`, [created.docRowId]
);
const regData = regRows[0].data;
if (regData.status !== 'published') throw new Error(`S5 ✗ registry.status=${regData.status}`);
if (!regData.published_at) throw new Error('S5 ✗ registry.published_at not set');
console.log(`S5 ✓ registry.status=published at ${regData.published_at}`);
const { rows: pubAudit } = await client.query(
`SELECT id, data FROM table_rows WHERE table_id = $1 AND data->>'action' = 'document_publish' AND (data->>'doc_id')::int = $2 ORDER BY id DESC LIMIT 1`,
[auditTid, created.docRowId]
);
if (!pubAudit.length) throw new Error('S5 ✗ no document_publish audit row');
created.auditRowIds.push(pubAudit[0].id);
console.log(`S5 ✓ audit row id=${pubAudit[0].id} caused_by=${pubAudit[0].data.caused_by}`);
// --- S6: idempotent — second call → noop ----------------------------
r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S6' });
if (!r.noop) throw new Error(`S6 ✗ expected noop on re-publish, got ${JSON.stringify(r)}`);
console.log(`S6 ✓ second call idempotent: ${JSON.stringify(r)}`);
// --- S7: regress one must → demoted to regressed-published ----------
await patch(client, created.crit1Id, { status: 'regressed' });
r = await maybeTransitionDocumentStatus(created.docRowId, { causedBy: 'smoke:S7' });
if (!r.demoted) throw new Error(`S7 ✗ expected demoted, got ${JSON.stringify(r)}`);
const { rows: regAfter } = await client.query(
`SELECT data FROM table_rows WHERE id = $1`, [created.docRowId]
);
if (regAfter[0].data.status !== 'regressed-published') {
throw new Error(`S7 ✗ status=${regAfter[0].data.status}`);
}
console.log(`S7 ✓ demoted to regressed-published: ${JSON.stringify(r)}`);
const { rows: regAudit } = await client.query(
`SELECT id FROM table_rows WHERE table_id = $1 AND data->>'action' = 'document_regress' AND (data->>'doc_id')::int = $2 ORDER BY id DESC LIMIT 1`,
[auditTid, created.docRowId]
);
if (!regAudit.length) throw new Error('S7 ✗ no document_regress audit row');
created.auditRowIds.push(regAudit[0].id);
console.log(`S7 ✓ audit row id=${regAudit[0].id}`);
console.log('\nC-5 release-gate smoke: ALL GREEN');
} finally {
// --- S8: cleanup ----------------------------------------------------
try {
for (const id of created.auditRowIds) {
await client.query(`DELETE FROM table_rows WHERE id = $1`, [id]);
}
for (const id of [created.crit1Id, created.crit2Id, created.specRowId, created.docRowId]) {
if (id) await client.query(`DELETE FROM table_rows WHERE id = $1`, [id]);
}
if (created.registryTableId) {
// drop columns first (there are none), then table
await client.query(`DELETE FROM universal_tables WHERE id = $1`, [created.registryTableId]);
}
console.log(`cleanup ✓ audit=[${created.auditRowIds.join(',')}] rows doc=${created.docRowId} spec=${created.specRowId} crit1=${created.crit1Id} crit2=${created.crit2Id} ut=${created.registryTableId}`);
} catch (e) {
console.error('cleanup error:', e.message);
}
client.release();
await pool.end();
}
}
main().catch((e) => {
console.error('C-5 smoke FAILED:', e.message);
process.exit(1);
});