path-list-unit.test.mjs
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