path-list-integration.test.mjs
231 lines 8.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Tier 2 — INTEGRATION: upsert → list → get, scope, write gate, propose/apply (KN-WORK-PATH-LIST-b).
3 *
4 * @see docs/KN-WORK-PATH-LIST-FREEZE.md §7
5 */
6 import { describe, it, beforeEach, afterEach } from 'node:test';
7 import assert from 'node:assert/strict';
8 import fs from 'node:fs';
9 import path from 'node:path';
10 import { fileURLToPath } from 'node:url';
11 import { upsertLearningPath, getLearningPath, loadLearningPaths } from '../lib/path/path-store.mjs';
12 import { handlePathListRequest, handlePathGetRequest } from '../lib/path/path-handlers.mjs';
13 import {
14 handlePathProposeRequest,
15 applyApprovedPathProposal,
16 getPathWritesEnabled,
17 } from '../lib/path/path-write.mjs';
18
19 const __dirname = path.dirname(fileURLToPath(import.meta.url));
20 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-path-list-integration');
21
22 function sampleSteps() {
23 return [
24 { title: 'Step 1', objective: 'Start', source_document_ids: [] },
25 { title: 'Step 2', objective: 'Finish', source_document_ids: [] },
26 ];
27 }
28
29 function samplePath(overrides = {}) {
30 const steps = sampleSteps();
31 return {
32 schema: 'knowtation.learning_path/v0',
33 path_id: 'path_aabbccddeeff0011',
34 scope: 'personal',
35 status: 'active',
36 title: 'Path A',
37 summary: 'Summary A',
38 goal: 'Goal A',
39 steps,
40 current_step_index: 0,
41 step_count: 2,
42 next_step_title: 'Step 1',
43 active_decisions: '',
44 workspace_id: 'ws-personal',
45 note_path: null,
46 created: '2026-08-18T00:00:00Z',
47 updated: '2026-08-18T00:00:00Z',
48 ...overrides,
49 };
50 }
51
52 function fakeCreateProposal(dataDir, input) {
53 const proposal_id = `prop_${Math.random().toString(16).slice(2, 10)}`;
54 const row = { proposal_id, status: 'proposed', ...input };
55 const fp = path.join(dataDir, 'hub_proposals.json');
56 const all = fs.existsSync(fp) ? JSON.parse(fs.readFileSync(fp, 'utf8')) : [];
57 all.push(row);
58 fs.writeFileSync(fp, JSON.stringify(all, null, 2), 'utf8');
59 return row;
60 }
61
62 beforeEach(() => {
63 fs.rmSync(tmpRoot, { recursive: true, force: true });
64 fs.mkdirSync(tmpRoot, { recursive: true });
65 delete process.env.PATH_WRITES_ENABLED;
66 });
67
68 afterEach(() => {
69 fs.rmSync(tmpRoot, { recursive: true, force: true });
70 delete process.env.PATH_WRITES_ENABLED;
71 });
72
73 describe('path-list integration', () => {
74 it('upsert → list → get', () => {
75 const dataDir = path.join(tmpRoot, 'ulg');
76 fs.mkdirSync(dataDir, { recursive: true });
77 const row = samplePath();
78 upsertLearningPath(dataDir, 'v', row);
79 const listed = handlePathListRequest({ dataDir, vaultId: 'v', cliScopes: ['personal'] });
80 assert.equal(listed.ok, true);
81 assert.equal(listed.payload.paths.length, 1);
82 assert.equal(listed.payload.paths[0].path_id, row.path_id);
83 const got = handlePathGetRequest({ dataDir, vaultId: 'v', pathId: row.path_id, cliScopes: ['personal'] });
84 assert.equal(got.ok, true);
85 assert.equal(got.payload.path.title, 'Path A');
86 assert.equal(got.payload.path.steps.length, 2);
87 });
88
89 it('scope deny: personal cannot get org path (404 not leak)', () => {
90 const dataDir = path.join(tmpRoot, 'scope');
91 fs.mkdirSync(dataDir, { recursive: true });
92 upsertLearningPath(dataDir, 'v', samplePath({ path_id: 'path_orgonly000000001', scope: 'org' }));
93 const listed = handlePathListRequest({ dataDir, vaultId: 'v', cliScopes: ['personal'] });
94 assert.equal(listed.payload.paths.length, 0);
95 const denied = handlePathListRequest({
96 dataDir,
97 vaultId: 'v',
98 cliScopes: ['personal'],
99 scope: 'org',
100 });
101 assert.equal(denied.ok, false);
102 assert.equal(denied.status, 403);
103 assert.equal(denied.code, 'PATH_SCOPE_DENIED');
104 const got = handlePathGetRequest({
105 dataDir,
106 vaultId: 'v',
107 pathId: 'path_orgonly000000001',
108 cliScopes: ['personal'],
109 });
110 assert.equal(got.status, 404);
111 assert.equal(got.code, 'PATH_NOT_FOUND');
112 });
113
114 it('workspace filter', () => {
115 const dataDir = path.join(tmpRoot, 'ws');
116 fs.mkdirSync(dataDir, { recursive: true });
117 upsertLearningPath(dataDir, 'v', samplePath({ path_id: 'path_wsone00000000001', workspace_id: 'ws-personal' }));
118 upsertLearningPath(dataDir, 'v', samplePath({ path_id: 'path_wstwo00000000002', workspace_id: 'ws-other' }));
119 const listed = handlePathListRequest({
120 dataDir,
121 vaultId: 'v',
122 cliScopes: ['personal'],
123 workspace_id: 'ws-personal',
124 });
125 assert.equal(listed.payload.paths.length, 1);
126 assert.equal(listed.payload.paths[0].path_id, 'path_wsone00000000001');
127 });
128
129 it('gate off propose 403 and store unchanged', async () => {
130 const dataDir = path.join(tmpRoot, 'gateoff');
131 fs.mkdirSync(dataDir, { recursive: true });
132 assert.equal(getPathWritesEnabled(), false);
133 const before = loadLearningPaths(dataDir, 'v');
134 const result = await handlePathProposeRequest({
135 dataDir,
136 vaultId: 'v',
137 cliScopes: ['personal'],
138 body: {
139 title: 'T',
140 summary: 'S',
141 goal: 'G',
142 steps: sampleSteps(),
143 },
144 createProposal: async () => {
145 throw new Error('must not create proposal');
146 },
147 });
148 assert.equal(result.ok, false);
149 assert.equal(result.status, 403);
150 assert.equal(result.code, 'PATH_WRITES_DISABLED');
151 assert.equal(loadLearningPaths(dataDir, 'v').length, before.length);
152 assert.equal(fs.existsSync(path.join(dataDir, 'hub_proposals.json')), false);
153 });
154
155 it('gate on create propose then apply → get', async () => {
156 process.env.PATH_WRITES_ENABLED = '1';
157 const dataDir = path.join(tmpRoot, 'apply');
158 fs.mkdirSync(dataDir, { recursive: true });
159 const proposed = await handlePathProposeRequest({
160 dataDir,
161 vaultId: 'v',
162 cliScopes: ['personal'],
163 body: {
164 title: 'Created',
165 summary: 'Created summary',
166 goal: 'Created goal',
167 steps: sampleSteps(),
168 },
169 createProposal: (dir, input) => fakeCreateProposal(dir, input),
170 });
171 assert.equal(proposed.ok, true);
172 const proposals = JSON.parse(fs.readFileSync(path.join(dataDir, 'hub_proposals.json'), 'utf8'));
173 const row = proposals.find((p) => p.proposal_id === proposed.payload.proposal_id);
174 const applied = applyApprovedPathProposal(dataDir, row);
175 assert.equal(applied.ok, true);
176 const got = handlePathGetRequest({
177 dataDir,
178 vaultId: 'v',
179 pathId: proposed.payload.path_id,
180 cliScopes: ['personal'],
181 });
182 assert.equal(got.ok, true);
183 assert.equal(got.payload.path.title, 'Created');
184 });
185
186 it('update then archive', async () => {
187 process.env.PATH_WRITES_ENABLED = '1';
188 const dataDir = path.join(tmpRoot, 'upd');
189 fs.mkdirSync(dataDir, { recursive: true });
190 const seed = samplePath();
191 upsertLearningPath(dataDir, 'v', seed);
192 const upd = await handlePathProposeRequest({
193 dataDir,
194 vaultId: 'v',
195 cliScopes: ['personal'],
196 body: { proposal_kind: 'path_update', path_id: seed.path_id, title: 'Updated title' },
197 createProposal: (dir, input) => fakeCreateProposal(dir, input),
198 });
199 assert.equal(upd.ok, true);
200 const proposals = JSON.parse(fs.readFileSync(path.join(dataDir, 'hub_proposals.json'), 'utf8'));
201 applyApprovedPathProposal(dataDir, proposals.find((p) => p.proposal_id === upd.payload.proposal_id));
202 assert.equal(getLearningPath(dataDir, 'v', seed.path_id, { visibleScopes: new Set(['personal']) }).title, 'Updated title');
203
204 const arch = await handlePathProposeRequest({
205 dataDir,
206 vaultId: 'v',
207 cliScopes: ['personal'],
208 body: { proposal_kind: 'path_archive', path_id: seed.path_id },
209 createProposal: (dir, input) => fakeCreateProposal(dir, input),
210 });
211 const all = JSON.parse(fs.readFileSync(path.join(dataDir, 'hub_proposals.json'), 'utf8'));
212 applyApprovedPathProposal(dataDir, all.find((p) => p.proposal_id === arch.payload.proposal_id));
213 const listed = handlePathListRequest({ dataDir, vaultId: 'v', cliScopes: ['personal'] });
214 assert.equal(listed.payload.paths.length, 0);
215 const got = handlePathGetRequest({
216 dataDir,
217 vaultId: 'v',
218 pathId: seed.path_id,
219 cliScopes: ['personal'],
220 });
221 assert.equal(got.payload.path.status, 'archived');
222 });
223
224 it('empty vault lists []', () => {
225 const dataDir = path.join(tmpRoot, 'empty');
226 fs.mkdirSync(dataDir, { recursive: true });
227 const listed = handlePathListRequest({ dataDir, vaultId: 'none', cliScopes: ['personal'] });
228 assert.deepEqual(listed.payload.paths, []);
229 assert.equal(listed.payload.truncated, false);
230 });
231 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago