Files
Notes/tests/unit/fsync-parent-dir.test.js
2026-10-16 16:34:15 +08:00

129 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// main.js file:rename / folder:rename —— rename 后父目录 fsync(audit P1-6)
//
// 背景:fs.rename 在 POSIX 下只 fsync 自己的 inode,不 fsync 父目录的 directory entry。
// 崩溃时新名可能没落盘,恢复后看到的是「旧名 inode + 文件内容」。audit 要求
// 与 atomicWriteFile(main.js:1801-1809 模式)一致:rename 成功后对父目录做 fsync。
//
// 现状:folder:create 已经在 mkdir 后做了父目录 fsync(main.js:1854-1864),
// 但 file:rename(main.js:1986)和 folder:rename(main.js:2085)都没有。
//
// 修复:抽 fsyncParentDir(filePath, fsImpl?) helper → 打开父目录 → fsync → 关闭。
// - win32 上 NTFS journal 自带元数据持久化,helper 直接 noop(与 folder:create 一致)
// - fsImpl 注入便于单测:传 mock fsImpl,验证 open→sync→close 顺序与错误透传
// - production 调用走 main.js 实际 import 的 fs(Node 内置)
//
// 测试策略:纯函数 + 注入 fsImpl。覆盖:
// - POSIX 平台:open 父目录、sync 父 fh、close fh
// - win32 平台:直接 noop(不动 fsImpl)
// - open 失败透传错误
// - sync 失败:仍执行 close(finally)
// - close 失败:静默吞(与 folder:create 行为一致)
import { describe, it, expect, vi } from 'vitest';
import { fsyncParentDir } from '../../main/fsync-parent-dir.js';
function makeMockFs({ openOk = true, syncOk = true, closeOk = true } = {}) {
const calls = [];
const openErr = openOk ? null : Object.assign(new Error('EACCES'), { code: 'EACCES' });
const syncErr = syncOk ? null : Object.assign(new Error('EIO'), { code: 'EIO' });
const fh = {
sync: vi.fn(async () => {
calls.push({ kind: 'sync' });
if (syncErr) throw syncErr;
}),
close: vi.fn(async () => {
calls.push({ kind: 'close' });
if (!closeOk) throw new Error('close-fail');
}),
};
const fsImpl = {
open: vi.fn(async (p, mode) => {
calls.push({ kind: 'open', p, mode });
if (openErr) throw openErr;
return fh;
}),
};
return { fsImpl, fh, calls, openErr, syncErr };
}
describe('fsyncParentDir(audit P1-6)', () => {
it('POSIX:open 父目录 → sync → close,顺序正确', async () => {
const { fsImpl, fh, calls } = makeMockFs();
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
expect(r).toEqual({ ok: true });
// open 父目录(不是文件本身)
expect(fsImpl.open).toHaveBeenCalledTimes(1);
expect(fsImpl.open).toHaveBeenCalledWith('/data/notes', 'r');
// sync + close 都触发
expect(fh.sync).toHaveBeenCalledTimes(1);
expect(fh.close).toHaveBeenCalledTimes(1);
// 顺序:open → sync → close
expect(calls.map((c) => c.kind)).toEqual(['open', 'sync', 'close']);
});
it('win32:直接 noop,不调用 fsImpl', async () => {
const { fsImpl, fh } = makeMockFs();
const r = await fsyncParentDir('C:\\data\\notes\\foo.md', { fsImpl, platform: 'win32' });
expect(r).toEqual({ ok: true, skipped: 'win32' });
expect(fsImpl.open).not.toHaveBeenCalled();
expect(fh.sync).not.toHaveBeenCalled();
expect(fh.close).not.toHaveBeenCalled();
});
it('open 失败:透传错误,sync/close 不触发', async () => {
const { fsImpl, fh } = makeMockFs({ openOk: false });
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
expect(r.ok).toBe(false);
expect(r.error).toBe('EACCES');
expect(r.code).toBe('EACCES');
expect(fh.sync).not.toHaveBeenCalled();
expect(fh.close).not.toHaveBeenCalled();
});
it('sync 失败:finally 仍执行 close', async () => {
const { fsImpl, fh, calls } = makeMockFs({ syncOk: false });
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
expect(r.ok).toBe(false);
expect(r.error).toBe('EIO');
// close 仍触发(避免 fh 泄漏)
expect(fh.close).toHaveBeenCalledTimes(1);
expect(calls.map((c) => c.kind)).toEqual(['open', 'sync', 'close']);
});
it('close 失败:静默吞(与 folder:create 行为一致)', async () => {
const { fsImpl, fh } = makeMockFs({ closeOk: false });
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl, platform: 'linux' });
// close 失败不抛、不计入返回值(folder:create 模式 try/catch close)
expect(r.ok).toBe(true);
expect(fh.close).toHaveBeenCalledTimes(1);
});
it('根目录边界:parent = "/" 时仍能 open(POSIX 允许)', async () => {
const { fsImpl } = makeMockFs();
const r = await fsyncParentDir('/foo.md', { fsImpl, platform: 'linux' });
expect(r.ok).toBe(true);
expect(fsImpl.open).toHaveBeenCalledWith('/', 'r');
});
it('默认 platform/process:未指定时用 process.platform', async () => {
const { fsImpl } = makeMockFs();
// 不传 platform → 走 process.platform
const r = await fsyncParentDir('/data/notes/foo.md', { fsImpl });
// 在 linux 容器(CI)上 process.platform === 'linux' → 正常 open
if (process.platform === 'win32') {
expect(r).toEqual({ ok: true, skipped: 'win32' });
} else {
expect(r.ok).toBe(true);
expect(fsImpl.open).toHaveBeenCalledWith('/data/notes', 'r');
}
});
it('路径为相对路径:parent 用 path.dirname 解出', async () => {
const { fsImpl } = makeMockFs();
const r = await fsyncParentDir('notes/sub/foo.md', { fsImpl, platform: 'linux' });
expect(r.ok).toBe(true);
// path.dirname('notes/sub/foo.md') === 'notes/sub'
expect(fsImpl.open).toHaveBeenCalledWith('notes/sub', 'r');
});
});