// tests/kb-update/test-atomic-write.test.mjs // Unit tests for scripts/kb-update/lib/atomic-write.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { atomicWriteSync, atomicWriteJson } from '../../scripts/kb-update/lib/atomic-write.mjs'; async function withTmp(fn) { const dir = mkdtempSync(join(tmpdir(), 'aw-test-')); try { return await fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } } test('atomicWriteSync — writes string content to target', () => { withTmp((dir) => { const path = join(dir, 'state.txt'); atomicWriteSync(path, 'hello'); assert.equal(readFileSync(path, 'utf8'), 'hello'); }); }); test('atomicWriteSync — leaves no .tmp orphan after success', () => { withTmp((dir) => { const path = join(dir, 'state.txt'); atomicWriteSync(path, 'ok'); const orphans = readdirSync(dir).filter((f) => f.includes('.tmp.')); assert.deepEqual(orphans, []); }); }); test('atomicWriteSync — overwrites existing file', () => { withTmp((dir) => { const path = join(dir, 'state.txt'); writeFileSync(path, 'old'); atomicWriteSync(path, 'new'); assert.equal(readFileSync(path, 'utf8'), 'new'); }); }); test('atomicWriteSync — accepts Buffer content', () => { withTmp((dir) => { const path = join(dir, 'bin'); atomicWriteSync(path, Buffer.from([0x01, 0x02, 0x03])); const data = readFileSync(path); assert.equal(data.length, 3); assert.equal(data[0], 0x01); }); }); test('atomicWriteJson — round-trips object', () => { withTmp((dir) => { const path = join(dir, 'state.json'); const obj = { schema_version: 1, status: 'success', items: [1, 2, 3] }; atomicWriteJson(path, obj); const read = JSON.parse(readFileSync(path, 'utf8')); assert.deepEqual(read, obj); }); }); test('atomicWriteJson — pretty-prints with 2-space indent', () => { withTmp((dir) => { const path = join(dir, 'state.json'); atomicWriteJson(path, { a: 1, b: { c: 2 } }); const text = readFileSync(path, 'utf8'); assert.match(text, /\n {2}"a": 1/); assert.match(text, /\n {4}"c": 2/); }); }); test('atomicWriteJson — leaves no .tmp orphan', () => { withTmp((dir) => { const path = join(dir, 'state.json'); atomicWriteJson(path, { ok: true }); const orphans = readdirSync(dir).filter((f) => f.includes('.tmp.')); assert.deepEqual(orphans, []); }); }); test('atomicWriteSync — concurrent writes do not corrupt', async () => { await withTmp(async (dir) => { const path = join(dir, 'concurrent.json'); const writes = []; for (let i = 0; i < 50; i++) { writes.push( Promise.resolve().then(() => atomicWriteJson(path, { iter: i, payload: 'x'.repeat(100) })) ); } await Promise.all(writes); // Final read must be valid JSON, regardless of which write won. const text = readFileSync(path, 'utf8'); const obj = JSON.parse(text); assert.equal(typeof obj.iter, 'number'); assert.ok(obj.iter >= 0 && obj.iter < 50); assert.equal(obj.payload.length, 100); // No .tmp orphans const orphans = readdirSync(dir).filter((f) => f.includes('.tmp.')); assert.deepEqual(orphans, []); }); }); test('atomicWriteSync — tmp filename uses pid + random suffix', () => { // Indirect verification: write then immediately check the only file in dir is the target. withTmp((dir) => { const path = join(dir, 'target.json'); atomicWriteSync(path, '{}'); const files = readdirSync(dir); assert.deepEqual(files, ['target.json']); }); });