flow-authoring-parity-integration.test.mjs
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