// tools/router-task-id.test.mjs import { describe, it, expect } from 'vitest'; import { deriveTaskId } from './router-task-id.mjs'; describe('deriveTaskId (✅O17)', () => { it('первый план → присвоить из firstPlanHash', () => { expect(deriveTaskId({ existingTaskId: null, firstPlanHash: 'abc123' })).toBe('task:abc123'); }); it('существующий task-id СТАБИЛЕН через re-issue (новый хеш НЕ сбрасывает)', () => { const id = deriveTaskId({ existingTaskId: 'task:abc123', firstPlanHash: 'NEWHASH999' }); expect(id).toBe('task:abc123'); // персист, не сброс }); it('разные задачи (разный firstPlanHash, нет existing) → разные id', () => { expect(deriveTaskId({ existingTaskId: null, firstPlanHash: 'p1' })) .not.toBe(deriveTaskId({ existingTaskId: null, firstPlanHash: 'p2' })); }); it('пустой existing и пустой firstPlanHash → null (нечего якорить)', () => { expect(deriveTaskId({ existingTaskId: '', firstPlanHash: '' })).toBe(null); }); }); // loadTaskId/saveTaskId — персист-обвязка (инъектируемый fs, guard sessionId) import { loadTaskId, saveTaskId } from './router-task-id.mjs'; function memFs(files = {}, calls = []) { return { readFileSync(p) { if (!(p in files)) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } return files[p]; }, writeFileSync(p, data) { calls.push(['write', p]); files[p] = data; }, renameSync(from, to) { calls.push(['rename', from, to]); files[to] = files[from]; delete files[from]; }, _files: files, _calls: calls, }; } describe('loadTaskId/saveTaskId (персист first-plan-anchor)', () => { it('save → load возвращает тот же task-id', () => { const fsImpl = memFs(); saveTaskId({ taskId: 'task:abc', sessionId: 'sess1', runtimeDir: '/rt', fsImpl }); expect(loadTaskId({ sessionId: 'sess1', runtimeDir: '/rt', fsImpl })).toBe('task:abc'); }); it('F-C1 (sharp-edges): запись атомарна — temp→rename, НЕ прямой write в финальный путь (зеркало plan-lock writeAtomic)', () => { const fsImpl = memFs(); saveTaskId({ taskId: 'task:abc', sessionId: 'sess1', runtimeDir: '/rt', fsImpl }); const FINAL = '/rt/router-task-id-sess1.txt'; expect(fsImpl._calls.some((c) => c[0] === 'rename' && c[2] === FINAL)).toBe(true); expect(fsImpl._calls.filter((c) => c[0] === 'write').every((c) => c[1] !== FINAL)).toBe(true); expect(Object.keys(fsImpl._files)).toEqual([FINAL]); // без .tmp-осадка }); it('нет файла → null', () => { expect(loadTaskId({ sessionId: 'sess1', runtimeDir: '/rt', fsImpl: memFs() })).toBe(null); }); it('кривой sessionId (path-injection) → throw guard', () => { expect(() => loadTaskId({ sessionId: '../evil', runtimeDir: '/rt', fsImpl: memFs() })) .toThrow(/sessionId/); }); });