flow-authoring-parity-integration.test.mjs
128 lines 5.2 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 13 hours ago
1 /**
2 * Tier 2 — INTEGRATION: triple-surface parity + one-record + disabled gate.
3 *
4 * MCP `flow_propose`, Hub `POST /api/v1/flows`, and CLI `flow propose` all
5 * converge on the single `handleFlowProposeRequest` handler; this proves they
6 * produce a deep-equal envelope, create exactly one `/proposals` record, and all
7 * refuse identically when `FLOW_AUTHORING_WRITES` is off.
8 *
9 * @see lib/flow/flow-authoring.mjs
10 * @see docs/FLOW-AUTHORING-WRITEBACK-CONTRACT-7A-L1.md §1, §7
11 */
12 import { describe, it, beforeEach, afterEach } from 'node:test';
13 import assert from 'node:assert/strict';
14 import fs from 'node:fs';
15 import path from 'node:path';
16 import { fileURLToPath } from 'node:url';
17 import { handleFlowProposeRequest } from '../lib/flow/flow-authoring.mjs';
18 import { createProposal, listProposals } from '../hub/proposals-store.mjs';
19 import { getRepoRoot } from '../lib/repo-root.mjs';
20
21 const __dirname = path.dirname(fileURLToPath(import.meta.url));
22 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-authoring-parity');
23 const starterDir = path.join(getRepoRoot(), 'flows/starter');
24
25 function loadStarter(name) {
26 return JSON.parse(fs.readFileSync(path.join(starterDir, name), 'utf8'));
27 }
28
29 function stripVolatile(payload) {
30 const copy = structuredClone(payload);
31 delete copy.proposal_id;
32 return copy;
33 }
34
35 describe('Flow authoring — triple-surface parity', () => {
36 const bundle = loadStarter('flow_capture_to_note.json');
37
38 function freshDataDir(name) {
39 const d = path.join(tmpRoot, name);
40 fs.rmSync(d, { recursive: true, force: true });
41 fs.mkdirSync(d, { recursive: true });
42 return d;
43 }
44
45 beforeEach(() => {
46 fs.rmSync(tmpRoot, { recursive: true, force: true });
47 fs.mkdirSync(tmpRoot, { recursive: true });
48 process.env.FLOW_AUTHORING_WRITES = '1';
49 });
50
51 afterEach(() => {
52 fs.rmSync(tmpRoot, { recursive: true, force: true });
53 delete process.env.FLOW_AUTHORING_WRITES;
54 });
55
56 it('Hub, CLI, and MCP produce a deep-equal envelope for the same authorized request', () => {
57 const hubDir = freshDataDir('hub');
58 const cliDir = freshDataDir('cli');
59 const mcpDir = freshDataDir('mcp');
60
61 const hub = handleFlowProposeRequest({
62 dataDir: hubDir, vaultId: 'default', userId: 'u-hub', role: 'admin',
63 kind: 'new', flow: bundle.flow, steps: bundle.steps, intent: 'add it', createProposal,
64 });
65 const cli = handleFlowProposeRequest({
66 dataDir: cliDir, vaultId: 'default', cliScopes: ['personal', 'project', 'org'],
67 kind: 'new', flow: bundle.flow, steps: bundle.steps, intent: 'add it', createProposal,
68 });
69 const mcp = handleFlowProposeRequest({
70 dataDir: mcpDir, vaultId: 'default', cliScopes: ['personal', 'project', 'org'],
71 kind: 'new', flow: bundle.flow, steps: bundle.steps, intent: 'add it', createProposal,
72 });
73
74 assert.equal(hub.ok, true);
75 assert.equal(cli.ok, true);
76 assert.equal(mcp.ok, true);
77 assert.deepEqual(stripVolatile(hub.payload), stripVolatile(cli.payload));
78 assert.deepEqual(stripVolatile(cli.payload), stripVolatile(mcp.payload));
79 });
80
81 it('each surface creates exactly one /proposals record (source flow)', () => {
82 const dir = freshDataDir('one-record');
83 handleFlowProposeRequest({
84 dataDir: dir, vaultId: 'default', role: 'admin', kind: 'new',
85 flow: bundle.flow, steps: bundle.steps, intent: 'add it', createProposal,
86 });
87 const { proposals, total } = listProposals(dir, { source: 'flow' });
88 assert.equal(total, 1);
89 assert.equal(proposals[0].source, 'flow');
90 assert.equal(proposals[0].status, 'proposed');
91 assert.equal(proposals[0].flow_meta.kind, 'new');
92 });
93
94 it('FLOW_AUTHORING_WRITES=off ⇒ all three return FLOW_AUTHORING_DISABLED', () => {
95 delete process.env.FLOW_AUTHORING_WRITES;
96 const dir = freshDataDir('off');
97 for (const ctx of [
98 { role: 'admin' },
99 { cliScopes: ['personal', 'project', 'org'] },
100 { cliScopes: ['personal', 'project', 'org'] },
101 ]) {
102 const r = handleFlowProposeRequest({
103 dataDir: dir, vaultId: 'default', ...ctx, kind: 'new',
104 flow: bundle.flow, steps: bundle.steps, intent: 'add it', createProposal,
105 });
106 assert.equal(r.ok, false);
107 assert.equal(r.code, 'FLOW_AUTHORING_DISABLED');
108 }
109 assert.equal(listProposals(dir, { source: 'flow' }).total, 0);
110 });
111 });
112
113 describe('Flow authoring — Hub route wiring contract (source match)', () => {
114 it('registers the three POST routes gated by FLOW_AUTHORING_WRITE_ROLES', () => {
115 const src = fs.readFileSync(path.join(getRepoRoot(), 'hub/server.mjs'), 'utf8');
116 assert.match(src, /app\.post\('\/api\/v1\/flows', FLOW_AUTHORING_WRITE_ROLES/);
117 assert.match(src, /app\.post\('\/api\/v1\/flows\/:id\/proposals', FLOW_AUTHORING_WRITE_ROLES/);
118 assert.match(src, /app\.post\('\/api\/v1\/flows\/import', FLOW_AUTHORING_WRITE_ROLES/);
119 assert.match(src, /handleFlowProposeRequest/);
120 });
121
122 it('approve handler skips the note check for flow proposals and reconciles the index', () => {
123 const src = fs.readFileSync(path.join(getRepoRoot(), 'hub/server.mjs'), 'utf8');
124 assert.match(src, /proposal\.source !== FLOW_PROPOSAL_SOURCE/);
125 assert.match(src, /precheckApprovedFlowProposal\(config\.data_dir, proposal\)/);
126 assert.match(src, /applyFlowProposalToIndex\(/);
127 });
128 });
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 13 hours ago