flow-store-unit.test.mjs
244 lines 8.1 KB
Raw
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor ⚠ breaking 3 days 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 resolveStarterFlowsDir,
24 FLOW_ID_RE,
25 FLOW_STEP_ID_RE,
26 FLOW_RUN_ID_RE,
27 SEMVER_RE,
28 } from '../lib/flow/flow-store.mjs';
29 import { getRepoRoot } from '../lib/repo-root.mjs';
30
31 const __dirname = path.dirname(fileURLToPath(import.meta.url));
32 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-store-unit');
33 const starterDir = path.join(getRepoRoot(), 'flows/starter');
34 const maliciousBundle = JSON.parse(
35 fs.readFileSync(path.join(__dirname, 'fixtures', 'flow', 'malicious-step-bundle.json'), 'utf8'),
36 );
37
38 describe('Flow store — persistence', () => {
39 const dataDir = path.join(tmpRoot, 'persist');
40
41 beforeEach(() => {
42 fs.rmSync(tmpRoot, { recursive: true, force: true });
43 fs.mkdirSync(dataDir, { recursive: true });
44 });
45
46 afterEach(() => {
47 fs.rmSync(tmpRoot, { recursive: true, force: true });
48 });
49
50 it('loadFlowStore returns empty vaults for missing and malformed files', () => {
51 assert.deepEqual(loadFlowStore(dataDir), { vaults: {} });
52 fs.writeFileSync(getFlowStorePath(dataDir), '{not json', 'utf8');
53 assert.deepEqual(loadFlowStore(dataDir), { vaults: {} });
54 fs.writeFileSync(getFlowStorePath(dataDir), '{"nope":1}', 'utf8');
55 assert.deepEqual(loadFlowStore(dataDir), { vaults: {} });
56 });
57
58 it('saveFlowStore writes atomically and round-trips', () => {
59 const store = {
60 vaults: {
61 default: {
62 flows: [{ flow_id: 'flow_x', version: '1.0.0' }],
63 steps: [],
64 runs: [],
65 candidates: [],
66 projections: [],
67 },
68 },
69 };
70 saveFlowStore(dataDir, store);
71 const loaded = loadFlowStore(dataDir);
72 assert.equal(loaded.vaults.default.flows[0].flow_id, 'flow_x');
73 const files = fs.readdirSync(dataDir);
74 assert.ok(files.some((f) => f.startsWith('hub_flow_store.json')));
75 assert.ok(!files.some((f) => f.endsWith('.tmp')));
76 });
77 });
78
79 describe('Flow store — id and semver helpers', () => {
80 it('resolveStarterFlowsDir finds bundled flows/starter from bridge module URL', () => {
81 const dir = resolveStarterFlowsDir(
82 new URL('../hub/bridge/flow-run-routes.mjs', import.meta.url).href,
83 );
84 assert.ok(fs.existsSync(dir), dir);
85 assert.ok(fs.existsSync(path.join(dir, 'flow_session_to_flow.json')));
86 });
87
88 it('regexes accept canonical ids and reject malformed', () => {
89 assert.ok(FLOW_ID_RE.test('flow_weekly_review'));
90 assert.ok(!FLOW_ID_RE.test('weekly_review'));
91 assert.ok(FLOW_STEP_ID_RE.test('flow_weekly_review#1'));
92 assert.ok(!FLOW_STEP_ID_RE.test('flow_weekly_review#0'));
93 assert.ok(FLOW_RUN_ID_RE.test('run_2026w25'));
94 assert.ok(!FLOW_RUN_ID_RE.test('run_'));
95 assert.ok(SEMVER_RE.test('1.4.0'));
96 assert.ok(!SEMVER_RE.test('1.4'));
97 });
98
99 it('buildFlowStepId composes flow_id#ordinal', () => {
100 assert.equal(buildFlowStepId('flow_weekly_review', 3), 'flow_weekly_review#3');
101 });
102 });
103
104 describe('Flow store — validation and seeding', () => {
105 const dataDir = path.join(tmpRoot, 'seed');
106 const vaultId = 'default';
107
108 beforeEach(() => {
109 fs.rmSync(tmpRoot, { recursive: true, force: true });
110 fs.mkdirSync(dataDir, { recursive: true });
111 });
112
113 afterEach(() => {
114 fs.rmSync(tmpRoot, { recursive: true, force: true });
115 });
116
117 it('validateFlowBundle accepts a starter bundle shape', () => {
118 const bundle = JSON.parse(
119 fs.readFileSync(path.join(starterDir, 'flow_capture_to_note.json'), 'utf8'),
120 );
121 const result = validateFlowBundle(bundle);
122 assert.equal(result.ok, true);
123 });
124
125 it('validateFlowBundle rejects anatomy-incomplete steps', () => {
126 const bad = {
127 flow: maliciousBundle.flow,
128 steps: [{ ...maliciousBundle.steps[0], trigger: '' }],
129 };
130 const result = validateFlowBundle(bad);
131 assert.equal(result.ok, false);
132 });
133
134 it('seedStarterFlows rejects invalid bundles without partial write', () => {
135 const badStarter = path.join(tmpRoot, 'bad-starter');
136 fs.mkdirSync(badStarter, { recursive: true });
137 fs.writeFileSync(
138 path.join(badStarter, 'flow_bad.json'),
139 JSON.stringify({ flow: { flow_id: 'flow_bad' }, steps: [] }),
140 'utf8',
141 );
142 const { seeded } = seedStarterFlows(dataDir, vaultId, { starterDir: badStarter });
143 assert.equal(seeded, 0);
144 assert.deepEqual(loadFlowStore(dataDir), { vaults: {} });
145 });
146
147 it('seedStarterFlows seeds canonical starters idempotently', () => {
148 const first = seedStarterFlows(dataDir, vaultId, { starterDir });
149 assert.ok(first.seeded >= 6);
150 const second = seedStarterFlows(dataDir, vaultId, { starterDir });
151 assert.equal(second.seeded, 0);
152 assert.ok(second.skipped >= 6);
153 });
154
155 it('getFlow seeds missing starters when vault already has authored flows', () => {
156 saveFlowStore(dataDir, {
157 vaults: {
158 [vaultId]: {
159 flows: [
160 {
161 schema: 'knowtation.flow/v0',
162 flow_id: 'flow_custom_authored',
163 title: 'Custom',
164 version: '0.0.1',
165 scope: 'personal',
166 summary: 'pre-existing',
167 tags: [],
168 steps: [],
169 inputs: [],
170 updated: '2026-01-01T00:00:00Z',
171 truncated: false,
172 },
173 ],
174 steps: [],
175 runs: [],
176 candidates: [],
177 projections: [],
178 },
179 },
180 });
181 const got = getFlow(dataDir, vaultId, 'flow_session_to_flow', {
182 filterScopes: new Set(['personal']),
183 version: '0.1.0',
184 starterDir,
185 });
186 assert.ok(got);
187 assert.equal(got.flow.flow_id, 'flow_session_to_flow');
188 assert.equal(got.flow.version, '0.1.0');
189 });
190 });
191
192 describe('Flow store — read ops and projections', () => {
193 const dataDir = path.join(tmpRoot, 'read');
194 const vaultId = 'default';
195 const visible = new Set(['personal', 'project']);
196
197 beforeEach(() => {
198 fs.rmSync(tmpRoot, { recursive: true, force: true });
199 fs.mkdirSync(dataDir, { recursive: true });
200 seedStarterFlows(dataDir, vaultId, { starterDir });
201 });
202
203 afterEach(() => {
204 fs.rmSync(tmpRoot, { recursive: true, force: true });
205 });
206
207 it('flowSummaryForClient drops step bodies and keeps summary fields', () => {
208 const got = getFlow(dataDir, vaultId, 'flow_capture_to_note', { filterScopes: visible });
209 assert.ok(got);
210 const summary = flowSummaryForClient(got.flow, got.steps.length);
211 assert.equal(summary.schema, 'knowtation.flow/v0');
212 assert.equal(summary.step_count, got.steps.length);
213 assert.ok(!('inputs' in summary));
214 assert.ok(!('vault_mirror_path' in summary));
215 assert.ok(!('steps' in summary));
216 });
217
218 it('flowDefinitionForClient emits spec fields only', () => {
219 const got = getFlow(dataDir, vaultId, 'flow_capture_to_note', { filterScopes: visible });
220 assert.ok(got);
221 const def = flowDefinitionForClient(got.flow, got.steps);
222 assert.equal(def.flow.schema, 'knowtation.flow/v0');
223 assert.equal(def.steps[0].schema, 'knowtation.flow_step/v0');
224 assert.ok(def.steps[0].verification);
225 });
226
227 it('listFlows stamps flow_list schema discriminator', () => {
228 const list = listFlows(dataDir, vaultId, {
229 filterScopes: visible,
230 effectiveScope: 'project',
231 });
232 assert.equal(list.schema, 'knowtation.flow_list/v0');
233 assert.ok(list.flows.length >= 6);
234 });
235
236 it('getFlow orders steps by ascending ordinal', () => {
237 const got = getFlow(dataDir, vaultId, 'flow_overseer_handover', { filterScopes: visible });
238 assert.ok(got);
239 assert.equal(got.schema, 'knowtation.flow_get/v0');
240 const ordinals = got.steps.map((s) => s.ordinal);
241 assert.deepEqual(ordinals, [...ordinals].sort((a, b) => a - b));
242 assert.equal(got.steps.length, 6);
243 });
244 });
File History 1 commit
sha256:fbe982a22c05c6fe2e93876f250deecdb43d883f648b4e1810c7546caa9f17db docs: activate KNOWTATION- board identity and preserve livi… Human minor 3 days ago