// tests/lib/revision-guard.test.mjs // Unit tests for lib/util/revision-guard.mjs (v4.2) import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync, copyFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; import { revisionGuard } from '../../lib/util/revision-guard.mjs'; import { atomicWriteMarkdown } from '../../lib/util/markdown-write.mjs'; function sha256(path) { return createHash('sha256').update(readFileSync(path)).digest('hex'); } const ALWAYS_VALID = () => ({ valid: true, errors: [], warnings: [] }); const ALWAYS_INVALID = () => ({ valid: false, errors: [{ code: 'TEST', message: 'forced fail' }], warnings: [] }); test('revisionGuard — validator-PASS commits revision and deletes bak', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n'); const r = revisionGuard( path, ({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }), ALWAYS_VALID, ); assert.equal(r.outcome, 'applied'); assert.ok(!existsSync(path + '.local.bak'), 'bak should be deleted on success'); const text = readFileSync(path, 'utf-8'); assert.match(text, /revision: 1/); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('revisionGuard — validator-FAIL rolls back to byte-identical pre-revision', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n'); const before = sha256(path); const r = revisionGuard( path, ({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }), ALWAYS_INVALID, ); assert.equal(r.outcome, 'rolled-back'); const after = sha256(path); assert.equal(after, before, 'rollback must restore byte-identical content'); assert.ok(!existsSync(path + '.local.bak'), 'bak should be cleaned up after rollback'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('revisionGuard — pre-existing .local.bak aborts with operator guidance', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n'); const bak = path + '.local.bak'; writeFileSync(bak, 'stale backup from prior run'); const r = revisionGuard(path, ({ frontmatter, body }) => ({ frontmatter, body }), ALWAYS_VALID); assert.equal(r.outcome, 'mutator-failed'); assert.match(r.error, /pre-existing backup/); // Original file untouched, stale bak preserved for operator inspection assert.equal(readFileSync(bak, 'utf-8'), 'stale backup from prior run'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('revisionGuard — mutator that throws restores original via bak', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n'); const before = sha256(path); const r = revisionGuard( path, () => { throw new Error('boom'); }, ALWAYS_VALID, ); assert.equal(r.outcome, 'mutator-failed'); assert.match(r.error, /boom/); const after = sha256(path); assert.equal(after, before, 'mutator-throw must preserve original'); assert.ok(!existsSync(path + '.local.bak'), 'bak cleaned up after mutator-throw'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('revisionGuard — mutator returns invalid object rejected before validator runs', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { plan_version: '1.7' }, '# Hello\n'); const before = sha256(path); let validatorCalled = false; const r = revisionGuard( path, () => null, // not an object () => { validatorCalled = true; return { valid: true, errors: [], warnings: [] }; }, ); assert.equal(r.outcome, 'mutator-failed'); assert.equal(validatorCalled, false, 'validator must not run if mutator returned invalid result'); const after = sha256(path); assert.equal(after, before, 'invalid mutator must preserve original'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('revisionGuard — sha256 fields populated and stable', () => { const dir = mkdtempSync(join(tmpdir(), 'rg-test-')); try { const path = join(dir, 'plan.md'); atomicWriteMarkdown(path, { plan_version: '1.7', revision: 0 }, '# Hello\n'); const before = sha256(path); const r = revisionGuard( path, ({ frontmatter, body }) => ({ frontmatter: { ...frontmatter, revision: 1 }, body }), ALWAYS_VALID, ); assert.equal(r.sha256_before, before); assert.equal(typeof r.sha256_after, 'string'); assert.notEqual(r.sha256_after, r.sha256_before, 'sha256 must change after applied revision'); } finally { rmSync(dir, { recursive: true, force: true }); } });