path-list-unit.test.mjs
242 lines 8.7 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Tier 1 — UNIT: PATH_ID_RE, mint, field caps, fail-closed codes (KN-WORK-PATH-LIST-b).
3 *
4 * @see docs/KN-WORK-PATH-LIST-FREEZE.md §7
5 */
6 import { describe, it, 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 {
12 PATH_ID_RE,
13 mintPathId,
14 mintUniquePathId,
15 validateNotePath,
16 validateLearningPathRecord,
17 validateSteps,
18 learningPathSummaryForClient,
19 learningPathForClient,
20 upsertLearningPath,
21 listLearningPaths,
22 getLearningPath,
23 } from '../lib/path/path-store.mjs';
24 import { handlePathProposeRequest } from '../lib/path/path-write.mjs';
25 import { handlePathListRequest, handlePathGetRequest as handleGet } from '../lib/path/path-handlers.mjs';
26
27 const __dirname = path.dirname(fileURLToPath(import.meta.url));
28 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-path-list-unit');
29
30 function sampleSteps(n = 2) {
31 return Array.from({ length: n }, (_, i) => ({
32 title: `Step ${i + 1}`,
33 objective: `Do step ${i + 1}`,
34 source_document_ids: [],
35 }));
36 }
37
38 function samplePath(overrides = {}) {
39 const steps = overrides.steps ?? sampleSteps();
40 const idx = overrides.current_step_index ?? 0;
41 return {
42 schema: 'knowtation.learning_path/v0',
43 path_id: overrides.path_id ?? 'path_aabbccddeeff0011',
44 scope: overrides.scope ?? 'personal',
45 status: overrides.status ?? 'active',
46 title: overrides.title ?? 'Algebra through music',
47 summary: overrides.summary ?? 'Learn algebra with rhythm.',
48 goal: overrides.goal ?? 'Finish unit 1',
49 steps,
50 current_step_index: idx,
51 step_count: steps.length,
52 next_step_title: steps[idx]?.title ?? 'Step 1',
53 active_decisions: overrides.active_decisions ?? '',
54 workspace_id: overrides.workspace_id ?? 'ws-personal',
55 note_path: overrides.note_path ?? null,
56 created: overrides.created ?? '2026-08-18T00:00:00Z',
57 updated: overrides.updated ?? '2026-08-18T00:00:00Z',
58 ...overrides,
59 steps,
60 };
61 }
62
63 afterEach(() => {
64 fs.rmSync(tmpRoot, { recursive: true, force: true });
65 delete process.env.PATH_WRITES_ENABLED;
66 });
67
68 describe('path-list unit — ids', () => {
69 it('PATH_ID_RE accepts path_ + 16 hex and rejects loop_/sample-path', () => {
70 assert.ok(PATH_ID_RE.test('path_aabbccddeeff0011'));
71 assert.ok(!PATH_ID_RE.test('loop_school_trip'));
72 assert.ok(!PATH_ID_RE.test('sample-path'));
73 assert.ok(!PATH_ID_RE.test('path_'));
74 assert.ok(!PATH_ID_RE.test('PATH_AABB'));
75 });
76
77 it('mintPathId is path_ + 16 lowercase hex', () => {
78 const id = mintPathId();
79 assert.match(id, /^path_[a-f0-9]{16}$/);
80 assert.ok(PATH_ID_RE.test(id));
81 });
82
83 it('mintUniquePathId retries when the id already exists', () => {
84 const first = mintPathId();
85 const seen = new Set([first]);
86 const second = mintUniquePathId(seen);
87 assert.ok(PATH_ID_RE.test(second));
88 assert.notEqual(second, first);
89 });
90 });
91
92 describe('path-list unit — field caps and fail-closed codes', () => {
93 it('rejects client path_id on path_create', async () => {
94 process.env.PATH_WRITES_ENABLED = '1';
95 const dataDir = path.join(tmpRoot, 'create-id');
96 fs.mkdirSync(dataDir, { recursive: true });
97 const result = await handlePathProposeRequest({
98 dataDir,
99 vaultId: 'v',
100 cliScopes: ['personal'],
101 body: {
102 proposal_kind: 'path_create',
103 path_id: 'path_clientsupplied01',
104 title: 'T',
105 summary: 'S',
106 goal: 'G',
107 steps: sampleSteps(),
108 },
109 createProposal: async () => {
110 throw new Error('must not create');
111 },
112 });
113 assert.equal(result.ok, false);
114 assert.equal(result.status, 400);
115 assert.equal(result.code, 'PATH_ID_NOT_ALLOWED');
116 });
117
118 it('rejects control characters as PATH_TEXT_INVALID', () => {
119 const result = validateLearningPathRecord(
120 samplePath({ title: 'bad\u0000title' }),
121 );
122 assert.equal(result.ok, false);
123 assert.equal(result.code, 'PATH_TEXT_INVALID');
124 });
125
126 it('PATH_STEP_INDEX_INVALID when index >= steps.length', () => {
127 const result = validateLearningPathRecord(samplePath({ current_step_index: 9 }));
128 assert.equal(result.ok, false);
129 assert.equal(result.code, 'PATH_STEP_INDEX_INVALID');
130 });
131
132 it('note_path rejects ../ and https://', () => {
133 assert.equal(validateNotePath('../secret.md').ok, false);
134 assert.equal(validateNotePath('../secret.md').code, 'PATH_NOTE_PATH_INVALID');
135 assert.equal(validateNotePath('https://evil.example/x.md').ok, false);
136 assert.equal(validateNotePath('notes/ok.md').ok, true);
137 });
138
139 it('field caps: title 200, summary 2000, goal 180, 20 steps', () => {
140 assert.equal(validateLearningPathRecord(samplePath({ title: 'x'.repeat(201) })).ok, false);
141 assert.equal(validateLearningPathRecord(samplePath({ summary: 's'.repeat(2001) })).ok, false);
142 assert.equal(validateLearningPathRecord(samplePath({ goal: 'g'.repeat(181) })).ok, false);
143 assert.equal(validateSteps(sampleSteps(21)).ok, false);
144 assert.equal(validateSteps(sampleSteps(20)).ok, true);
145 });
146
147 it('unknown proposal_kind is 400 BAD_REQUEST', async () => {
148 process.env.PATH_WRITES_ENABLED = '1';
149 const dataDir = path.join(tmpRoot, 'kind');
150 fs.mkdirSync(dataDir, { recursive: true });
151 const result = await handlePathProposeRequest({
152 dataDir,
153 vaultId: 'v',
154 cliScopes: ['personal'],
155 body: { proposal_kind: 'path_delete', title: 'T', summary: 'S', goal: 'G', steps: sampleSteps() },
156 createProposal: async () => ({ proposal_id: 'nope' }),
157 });
158 assert.equal(result.ok, false);
159 assert.equal(result.status, 400);
160 assert.equal(result.code, 'BAD_REQUEST');
161 });
162
163 it('PATH_SCOPE_IMMUTABLE when update includes scope', async () => {
164 process.env.PATH_WRITES_ENABLED = '1';
165 const dataDir = path.join(tmpRoot, 'imm');
166 fs.mkdirSync(dataDir, { recursive: true });
167 const row = samplePath();
168 upsertLearningPath(dataDir, 'v', row);
169 const result = await handlePathProposeRequest({
170 dataDir,
171 vaultId: 'v',
172 cliScopes: ['personal'],
173 body: { proposal_kind: 'path_update', path_id: row.path_id, scope: 'org' },
174 createProposal: async () => ({ proposal_id: 'nope' }),
175 });
176 assert.equal(result.ok, false);
177 assert.equal(result.code, 'PATH_SCOPE_IMMUTABLE');
178 });
179 });
180
181 describe('path-list unit — list/get projections', () => {
182 it('default list omits archived; get returns archived', () => {
183 const dataDir = path.join(tmpRoot, 'arch');
184 fs.mkdirSync(dataDir, { recursive: true });
185 upsertLearningPath(dataDir, 'v', samplePath({ path_id: 'path_active000000001', status: 'active' }));
186 upsertLearningPath(dataDir, 'v', samplePath({ path_id: 'path_archived00000001', status: 'archived' }));
187 const listed = listLearningPaths(dataDir, 'v', {
188 visibleScopes: new Set(['personal']),
189 filterScopes: new Set(['personal']),
190 effectiveScope: 'personal',
191 });
192 assert.equal(listed.paths.some((p) => p.path_id === 'path_archived00000001'), false);
193 assert.equal(listed.paths.some((p) => p.path_id === 'path_active000000001'), true);
194 const got = getLearningPath(dataDir, 'v', 'path_archived00000001', {
195 visibleScopes: new Set(['personal']),
196 });
197 assert.ok(got);
198 assert.equal(got.status, 'archived');
199 });
200
201 it('next_step_title and step_count are derived', () => {
202 const steps = sampleSteps(3);
203 const rec = validateLearningPathRecord(samplePath({ steps, current_step_index: 1 }));
204 assert.equal(rec.ok, true);
205 assert.equal(rec.path.step_count, 3);
206 assert.equal(rec.path.next_step_title, 'Step 2');
207 const summary = learningPathSummaryForClient(rec.path);
208 assert.equal('steps' in summary, false);
209 assert.equal('summary' in summary, false);
210 assert.equal('note_path' in summary, false);
211 const full = learningPathForClient(rec.path);
212 assert.equal(full.steps.length, 3);
213 assert.equal(full.note_path, null);
214 });
215
216 it('handler get of invalid id is 404 PATH_NOT_FOUND', () => {
217 const dataDir = path.join(tmpRoot, 'get');
218 fs.mkdirSync(dataDir, { recursive: true });
219 const result = handleGet({
220 dataDir,
221 vaultId: 'v',
222 pathId: 'not-a-path-id',
223 cliScopes: ['personal'],
224 });
225 assert.equal(result.ok, false);
226 assert.equal(result.status, 404);
227 assert.equal(result.code, 'PATH_NOT_FOUND');
228 });
229
230 it('empty vault lists [] truncated false', () => {
231 const dataDir = path.join(tmpRoot, 'empty');
232 fs.mkdirSync(dataDir, { recursive: true });
233 const result = handlePathListRequest({
234 dataDir,
235 vaultId: 'empty-vault',
236 cliScopes: ['personal'],
237 });
238 assert.equal(result.ok, true);
239 assert.deepEqual(result.payload.paths, []);
240 assert.equal(result.payload.truncated, false);
241 });
242 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago