const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); const atomic = require('../atomic-file'); const dirs = []; function fresh(tag) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `peoplelib-atomic-${tag}-`)); dirs.push(dir); return dir; } test.after(() => { for (const dir of dirs) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { /* ignore */ } } }); test('元数据写入在改名前 fsync 数据', () => { const dir = fresh('fsync'); const dest = path.join(dir, 'meta.json'); const order = []; const realFsync = fs.fsyncSync; const realRename = fs.renameSync; fs.fsyncSync = function tracked(fd) { order.push('fsync'); return realFsync.call(this, fd); }; fs.renameSync = function tracked(from, to) { order.push(`rename:${path.basename(String(from))}->${path.basename(String(to))}`); return realRename.call(this, from, to); }; try { atomic.writeJson(dest, { hello: '世界' }); } finally { fs.fsyncSync = realFsync; fs.renameSync = realRename; } // rename 只保证目录项替换原子,不保证数据已落盘; // 因此临时文件的 fsync 必须发生在改名之前 const firstRename = order.findIndex((step) => step.startsWith('rename:')); assert.ok(firstRename > 0, `改名前应先 fsync,实际顺序 ${order.join(',')}`); assert.strictEqual(order[0], 'fsync'); assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { hello: '世界' }); }); test('写入失败时清理临时文件并把备份换回原位', () => { const dir = fresh('rollback'); const dest = path.join(dir, 'meta.json'); atomic.writeJson(dest, { round: 1 }); const before = fs.readFileSync(dest, 'utf8'); const realRename = fs.renameSync; let failed = false; fs.renameSync = function failing(from, to) { if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) { failed = true; throw new Error('模拟替换失败'); } return realRename.apply(this, arguments); }; try { assert.throws(() => atomic.writeJson(dest, { round: 2 }), /模拟替换失败/); } finally { fs.renameSync = realRename; } assert.ok(failed); assert.strictEqual(fs.readFileSync(dest, 'utf8'), before); assert.strictEqual(fs.existsSync(`${dest}.tmp`), false); assert.strictEqual(fs.existsSync(`${dest}.bak`), false); }); test('目录 fsync 失败不影响写入结果', () => { const dir = fresh('dirfail'); const dest = path.join(dir, 'meta.json'); const realOpen = fs.openSync; // Windows 无法对目录取句柄,这条路径必须容错 fs.openSync = function guarded(target, flags, ...rest) { if (String(target) === dir) throw new Error('EISDIR'); return realOpen.call(this, target, flags, ...rest); }; try { atomic.writeJson(dest, { ok: true }); } finally { fs.openSync = realOpen; } assert.deepStrictEqual(JSON.parse(fs.readFileSync(dest, 'utf8')), { ok: true }); }); test('writeBytes 原子覆盖二进制文件且不残留临时文件', () => { const dir = fresh('bytes'); const dest = path.join(dir, 'book.epub'); fs.writeFileSync(dest, Buffer.from([1, 2, 3])); atomic.writeBytes(dest, Buffer.from([4, 5, 0, 255])); assert.deepStrictEqual([...fs.readFileSync(dest)], [4, 5, 0, 255]); assert.strictEqual(fs.existsSync(`${dest}.tmp`), false); assert.strictEqual(fs.existsSync(`${dest}.bak`), false); }); test('writeBytes 替换失败时恢复旧二进制内容', () => { const dir = fresh('bytes-rollback'); const dest = path.join(dir, 'book.epub'); fs.writeFileSync(dest, Buffer.from([1, 2, 3])); const realRename = fs.renameSync; let failed = false; fs.renameSync = function failing(from, to) { if (!failed && String(from) === `${dest}.tmp` && String(to) === dest) { failed = true; throw new Error('模拟二进制替换失败'); } return realRename.apply(this, arguments); }; try { assert.throws(() => atomic.writeBytes(dest, Buffer.from([9, 9])), /模拟二进制替换失败/); } finally { fs.renameSync = realRename; } assert.deepStrictEqual([...fs.readFileSync(dest)], [1, 2, 3]); assert.strictEqual(fs.existsSync(`${dest}.tmp`), false); assert.strictEqual(fs.existsSync(`${dest}.bak`), false); });