// tests/unit/main/literature-db-only-gone-guard.test.ts // // 守 r219「db-only 清理把**还在磁盘上**的文献从索引里删掉」契约。 // // 现象(与 notes 域 r210 同一个 bug 类,第四次出现): // literature 有两条「db-only ⇒ 删索引行」路径,判「文件在不在」都用裸 // `existsSync(r.absPath)`: // ① reload step 5:DB 有、磁盘扫不到的行,`!existsSync` ⇒ deleteResource; // ② resyncExternal(60s 周期):byId 里 absPath 落在 literature/ 之外的 // 旧库迁移行,`existsSync` false ⇒ deleteResource + byId.delete。 // 而 `existsSync` 把「文件不存在」与「文件**读不到**」压成同一个 false // (Defender 扫描 / OneDrive placeholder / 同步盘瞬时占用 ⇒ EACCES / // EPERM / EBUSY)⇒ 一次**瞬时**错误就把还在磁盘上的文献从索引里删掉: // 列表 / 搜索里消失(本轮不会补回),用户以为文件被删了。 // ②的 try/catch 包 existsSync 还是**死代码** —— existsSync 自己吞掉所有 // 错误返回 false,catch 永远进不去。 // // 修法(r219):两条路都换成 `utils/file-check.ts` 的 `isReallyGone()` —— // **只有 ENOENT 才算「没了」**,其余错误 fail-safe 当作「还在」(代价 // 不对等:误判「还在」= 留一条可自愈的 ghost 行;误判「没了」= 删掉 // 用户的数据可见性)。notes 侧 r209/r210 的两处本地实现一并上收到 // file-check.ts 单一真相源,两个域不再各自维护孪生实现。 // // 本测锁四件事: // A. reload:db-only 行 stat 报 EPERM ⇒ 行**必须留下**(缺陷本体,修前红); // B. reload:db-only 行真 ENOENT ⇒ 行**仍被清理**(回归锚,守卫不许把 // 真 ghost 留下,修前绿); // C. resyncExternal:外部行 stat 报 EPERM ⇒ 行留下 + **不 emit changed** // (修前红); // D. resyncExternal:外部行真 ENOENT ⇒ 删 + emit changed 一次(修前绿)。 // // 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock // 成内存 Map;chokidar mock 掉(真 watcher 会被测试写文件触发,异步排额外 // reload ⇒ emit 计数竞态);「读不到」用 fs.promises.stat 按路径关键字 // ('locked')注入 EPERM —— 比 chmod 可靠(Windows 上 POSIX 权限位不可靠)。 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { promises as fs } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; interface FakeRow { id: string; kind: string; title: string; mtime: number; size: number; indexedAt: number; absPath: string; rootId: string | null; extractStatus: string; body: string; } const db = vi.hoisted(() => { const rows = new Map(); const delIds: string[] = []; return { rows, delIds }; }); vi.mock('../../../src/main/db/resources-repo', () => ({ upsertResource: vi.fn((r: FakeRow, body: string, absPath: string) => { db.rows.set(r.id, { ...r, body, absPath }); }), deleteResource: vi.fn((id: string) => { db.delIds.push(id); db.rows.delete(id); }), listResources: vi.fn((kind?: string) => [...db.rows.values()].filter((r) => !kind || r.kind === kind), ), renameResource: vi.fn((prevId: string, r: FakeRow, body: string, absPath: string) => { db.rows.delete(prevId); db.rows.set(r.id, { ...r, body, absPath }); }), updateResourceStat: vi.fn((id: string, size: number, mtime: number) => { const r = db.rows.get(id); if (r) { r.size = size; r.mtime = mtime; } }), })); // chokidar mock:让 reload 只由测试显式驱动(真 watcher 会被测试写文件 // 异步触发 ⇒ 额外排 reload ⇒ emit 计数竞态)。 vi.mock('chokidar', () => { const watch = vi.fn(() => { const w = { on: () => w, close: vi.fn(async () => {}), removeAllListeners: vi.fn(), }; return w; }); return { default: { watch }, watch }; }); // 「读不到」注入:开关打开后,路径含 'locked' 的: // - `promises.stat` reject(EPERM)—— isReallyGone 走这条路,被分类成 // 「读不到 ⇒ 还在」; // - `existsSync` 返回 false —— **这正是 existsSync 在 EPERM 下的真实行为** // (它吞掉所有错误返回 false)。不能只 mock statSync:Node 的 existsSync // 绑的是内部 statSync 引用,模块命名空间的 statSync mock 拦不到它。 // 用开关而不是「路径即开关」—— 有的用例需要先让文件正常进 byId,再注入。 const fsFail = vi.hoisted(() => ({ statLocked: false })); vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); const realStat = actual.promises.stat; const realExistsSync = actual.existsSync; const isLocked = (path: unknown): path is string => fsFail.statLocked && typeof path === 'string' && path.includes('locked'); return { ...actual, default: actual, existsSync: (path: unknown) => { if (isLocked(path)) return false; // existsSync 在 EPERM/EACCES 下的真实行为 return realExistsSync(path as never); }, promises: { ...actual.promises, stat: (path: unknown, opts: unknown) => { if (isLocked(path)) { const err = Object.assign(new Error(`EPERM: operation not permitted, stat '${path}'`), { code: 'EPERM', }); return Promise.reject(err); } return realStat(path as never, opts as never); }, }, }; }); import { setRootPath } from '../../../src/main/utils/safe-write'; import { LiteratureService } from '../../../src/main/literature/literature-service'; import { litResourceId } from '../../../src/shared/resource-id'; import { toPosix } from '../../../src/main/notes/path-mapping'; function seedRow(absPath: string): FakeRow { const id = litResourceId(toPosix(absPath)) as string; const row: FakeRow = { id, kind: 'literature', title: 'paper', mtime: 1000, size: 4, indexedAt: 1, absPath, rootId: null, extractStatus: 'done', body: '', }; db.rows.set(id, row); return row; } /** 调私有 resyncExternal(60s 周期等不起;行为锁直接打方法)。 */ async function runResyncExternal(svc: LiteratureService): Promise { await (svc as unknown as { resyncExternal(): Promise }).resyncExternal(); } describe('db-only 清理只删「真的没了」的文献行(r219)', () => { let root: string; let litDir: string; let svc: LiteratureService; beforeEach(async () => { root = await fs.mkdtemp(join(tmpdir(), 'rkms-litghost-')); setRootPath(root); litDir = join(root, 'literature'); await fs.mkdir(litDir, { recursive: true }); db.rows.clear(); db.delIds.length = 0; fsFail.statLocked = false; svc = new LiteratureService(); await svc.start({ skipInitialLoad: true }); }); afterEach(async () => { await svc.stop(); fsFail.statLocked = false; await fs.rm(root, { recursive: true, force: true }); }); it('A reload:db-only 行 stat 报 EPERM(读不到)⇒ 行必须留下', async () => { // 文件从未写过盘 ⇒ 磁盘扫不到 ⇒ db-only;stat 被注入 EPERM。 const row = seedRow(join(litDir, 'locked-paper.pdf')); fsFail.statLocked = true; await svc.load(); expect( db.rows.has(row.id), 'stat 读不到(EPERM)被当成「文件没了」⇒ 还在磁盘上的文献被从索引里删掉', ).toBe(true); expect(db.delIds).not.toContain(row.id); expect(svc.list().map((r) => r.id)).toContain(row.id); }); it('B reload:db-only 行真 ENOENT ⇒ 行仍被清理(回归锚)', async () => { // 文件从未写过盘,stat 走真 fs ⇒ ENOENT ⇒ 真 ghost,必须清。 const row = seedRow(join(litDir, 'gone-paper.pdf')); await svc.load(); expect(db.rows.has(row.id), '真 ghost 行被守卫误留').toBe(false); expect(db.delIds).toContain(row.id); }); it('C resyncExternal:外部行 stat 报 EPERM ⇒ 行留下 + 不 emit changed', async () => { // 旧库迁移行:absPath 在 literature/ 之外。先写真实文件让它进 byId。 const extDir = join(root, 'old-lib'); await fs.mkdir(extDir, { recursive: true }); const extPath = join(extDir, 'locked-ext.pdf'); await fs.writeFile(extPath, 'AAAA', 'utf-8'); const row = seedRow(extPath); await svc.load(); expect(svc.list().map((r) => r.id)).toContain(row.id); const changed = vi.fn(); svc.on('changed', changed); fsFail.statLocked = true; await runResyncExternal(svc); expect( db.rows.has(row.id), 'stat 读不到(EPERM)被当成「文件没了」⇒ 旧库文献被从索引里删掉', ).toBe(true); expect(db.delIds).not.toContain(row.id); expect(svc.list().map((r) => r.id)).toContain(row.id); expect(changed, '没有真实删除却发了 changed ⇒ 渲染层被拖一遍全量拉').not.toHaveBeenCalled(); }); it('D resyncExternal:外部行真 ENOENT ⇒ 删 + emit changed 一次(回归锚)', async () => { const extDir = join(root, 'old-lib'); await fs.mkdir(extDir, { recursive: true }); const extPath = join(extDir, 'gone-ext.pdf'); await fs.writeFile(extPath, 'AAAA', 'utf-8'); const row = seedRow(extPath); await svc.load(); expect(svc.list().map((r) => r.id)).toContain(row.id); await fs.rm(extPath); // 真被外部删掉 const changed = vi.fn(); svc.on('changed', changed); await runResyncExternal(svc); expect(db.rows.has(row.id), '真 ghost 行被守卫误留').toBe(false); expect(db.delIds).toContain(row.id); expect(svc.list().map((r) => r.id)).not.toContain(row.id); expect(changed).toHaveBeenCalledTimes(1); }); });