flow-store-unit.test.mjs
199 lines 6.8 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 13 hours ago
1 /**
2 * Tier 1 — UNIT: Flow store helpers, validation, and projections.
3 *
4 * @see lib/flow/flow-store.mjs
5 * @see docs/FLOW-STORE-CONTRACT-7A-10.md §9
6 */
7 import { describe, it, beforeEach, afterEach } from 'node:test';
8 import assert from 'node:assert/strict';
9 import fs from 'node:fs';
10 import path from 'node:path';
11 import { fileURLToPath } from 'node:url';
12 import {
13 buildFlowStepId,
14 loadFlowStore,
15 saveFlowStore,
16 getFlowStorePath,
17 seedStarterFlows,
18 validateFlowBundle,
19 flowSummaryForClient,
20 flowDefinitionForClient,
21 listFlows,
22 getFlow,
23 FLOW_ID_RE,
24 FLOW_STEP_ID_RE,
25 FLOW_RUN_ID_RE,
26 SEMVER_RE,
27 } from '../lib/flow/flow-store.mjs';
28 import { getRepoRoot } from '../lib/repo-root.mjs';
29
30 const __dirname = path.dirname(fileURLToPath(import.meta.url));
31 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-store-unit');
32 const starterDir = path.join(getRepoRoot(), 'flows/starter');
33 const maliciousBundle = JSON.parse(
34 fs.readFileSync(path.join(__dirname, 'fixtures', 'flow', 'malicious-step-bundle.json'), 'utf8'),
35 );
36
37 describe('Flow store — persistence', () => {
38 const dataDir = path.join(tmpRoot, 'persist');
39
40 beforeEach(() => {
41 fs.rmSync(tmpRoot, { recursive: true, force: true });
42 fs.mkdirSync(dataDir, { recursive: true });
43 });
44
45 afterEach(() => {
46 fs.rmSync(tmpRoot, { recursive: true, force: true });
47 });
48
49 it('loadFlowStore returns empty vaults for missing and malformed files', () => {
50 assert.deepEqual(loadFlowStore(dataDir), { vaults: {} });
51 fs.writeFileSync(getFlowStorePath(dataDir), '{not json', 'utf8');
52 assert.deepEqual(loadFlowStore(dataDir), { vaults: {} });
53 fs.writeFileSync(getFlowStorePath(dataDir), '{"nope":1}', 'utf8');
54 assert.deepEqual(loadFlowStore(dataDir), { vaults: {} });
55 });
56
57 it('saveFlowStore writes atomically and round-trips', () => {
58 const store = {
59 vaults: {
60 default: {
61 flows: [{ flow_id: 'flow_x', version: '1.0.0' }],
62 steps: [],
63 runs: [],
64 candidates: [],
65 projections: [],
66 },
67 },
68 };
69 saveFlowStore(dataDir, store);
70 const loaded = loadFlowStore(dataDir);
71 assert.equal(loaded.vaults.default.flows[0].flow_id, 'flow_x');
72 const files = fs.readdirSync(dataDir);
73 assert.ok(files.some((f) => f.startsWith('hub_flow_store.json')));
74 assert.ok(!files.some((f) => f.endsWith('.tmp')));
75 });
76 });
77
78 describe('Flow store — id and semver helpers', () => {
79 it('regexes accept canonical ids and reject malformed', () => {
80 assert.ok(FLOW_ID_RE.test('flow_weekly_review'));
81 assert.ok(!FLOW_ID_RE.test('weekly_review'));
82 assert.ok(FLOW_STEP_ID_RE.test('flow_weekly_review#1'));
83 assert.ok(!FLOW_STEP_ID_RE.test('flow_weekly_review#0'));
84 assert.ok(FLOW_RUN_ID_RE.test('run_2026w25'));
85 assert.ok(!FLOW_RUN_ID_RE.test('run_'));
86 assert.ok(SEMVER_RE.test('1.4.0'));
87 assert.ok(!SEMVER_RE.test('1.4'));
88 });
89
90 it('buildFlowStepId composes flow_id#ordinal', () => {
91 assert.equal(buildFlowStepId('flow_weekly_review', 3), 'flow_weekly_review#3');
92 });
93 });
94
95 describe('Flow store — validation and seeding', () => {
96 const dataDir = path.join(tmpRoot, 'seed');
97 const vaultId = 'default';
98
99 beforeEach(() => {
100 fs.rmSync(tmpRoot, { recursive: true, force: true });
101 fs.mkdirSync(dataDir, { recursive: true });
102 });
103
104 afterEach(() => {
105 fs.rmSync(tmpRoot, { recursive: true, force: true });
106 });
107
108 it('validateFlowBundle accepts a starter bundle shape', () => {
109 const bundle = JSON.parse(
110 fs.readFileSync(path.join(starterDir, 'flow_capture_to_note.json'), 'utf8'),
111 );
112 const result = validateFlowBundle(bundle);
113 assert.equal(result.ok, true);
114 });
115
116 it('validateFlowBundle rejects anatomy-incomplete steps', () => {
117 const bad = {
118 flow: maliciousBundle.flow,
119 steps: [{ ...maliciousBundle.steps[0], trigger: '' }],
120 };
121 const result = validateFlowBundle(bad);
122 assert.equal(result.ok, false);
123 });
124
125 it('seedStarterFlows rejects invalid bundles without partial write', () => {
126 const badStarter = path.join(tmpRoot, 'bad-starter');
127 fs.mkdirSync(badStarter, { recursive: true });
128 fs.writeFileSync(
129 path.join(badStarter, 'flow_bad.json'),
130 JSON.stringify({ flow: { flow_id: 'flow_bad' }, steps: [] }),
131 'utf8',
132 );
133 const { seeded } = seedStarterFlows(dataDir, vaultId, { starterDir: badStarter });
134 assert.equal(seeded, 0);
135 assert.deepEqual(loadFlowStore(dataDir), { vaults: {} });
136 });
137
138 it('seedStarterFlows seeds canonical starters idempotently', () => {
139 const first = seedStarterFlows(dataDir, vaultId, { starterDir });
140 assert.ok(first.seeded >= 6);
141 const second = seedStarterFlows(dataDir, vaultId, { starterDir });
142 assert.equal(second.seeded, 0);
143 assert.ok(second.skipped >= 6);
144 });
145 });
146
147 describe('Flow store — read ops and projections', () => {
148 const dataDir = path.join(tmpRoot, 'read');
149 const vaultId = 'default';
150 const visible = new Set(['personal', 'project']);
151
152 beforeEach(() => {
153 fs.rmSync(tmpRoot, { recursive: true, force: true });
154 fs.mkdirSync(dataDir, { recursive: true });
155 seedStarterFlows(dataDir, vaultId, { starterDir });
156 });
157
158 afterEach(() => {
159 fs.rmSync(tmpRoot, { recursive: true, force: true });
160 });
161
162 it('flowSummaryForClient drops step bodies and keeps summary fields', () => {
163 const got = getFlow(dataDir, vaultId, 'flow_capture_to_note', { filterScopes: visible });
164 assert.ok(got);
165 const summary = flowSummaryForClient(got.flow, got.steps.length);
166 assert.equal(summary.schema, 'knowtation.flow/v0');
167 assert.equal(summary.step_count, got.steps.length);
168 assert.ok(!('inputs' in summary));
169 assert.ok(!('vault_mirror_path' in summary));
170 assert.ok(!('steps' in summary));
171 });
172
173 it('flowDefinitionForClient emits spec fields only', () => {
174 const got = getFlow(dataDir, vaultId, 'flow_capture_to_note', { filterScopes: visible });
175 assert.ok(got);
176 const def = flowDefinitionForClient(got.flow, got.steps);
177 assert.equal(def.flow.schema, 'knowtation.flow/v0');
178 assert.equal(def.steps[0].schema, 'knowtation.flow_step/v0');
179 assert.ok(def.steps[0].verification);
180 });
181
182 it('listFlows stamps flow_list schema discriminator', () => {
183 const list = listFlows(dataDir, vaultId, {
184 filterScopes: visible,
185 effectiveScope: 'project',
186 });
187 assert.equal(list.schema, 'knowtation.flow_list/v0');
188 assert.ok(list.flows.length >= 6);
189 });
190
191 it('getFlow orders steps by ascending ordinal', () => {
192 const got = getFlow(dataDir, vaultId, 'flow_overseer_handover', { filterScopes: visible });
193 assert.ok(got);
194 assert.equal(got.schema, 'knowtation.flow_get/v0');
195 const ordinals = got.steps.map((s) => s.ordinal);
196 assert.deepEqual(ordinals, [...ordinals].sort((a, b) => a - b));
197 assert.equal(got.steps.length, 6);
198 });
199 });
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 13 hours ago