update
This commit is contained in:
102
tests/unit/main/literature-db-failure-selfheal.test.ts
Normal file
102
tests/unit/main/literature-db-failure-selfheal.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// tests/unit/main/literature-db-failure-selfheal.test.ts
|
||||
//
|
||||
// 行为锁(audit-r243,2026-10-21):**DB 写失败时不得留下「盘上有、库里没有」
|
||||
// 的半成品**,且**必须显式触发一次自愈 reload**。
|
||||
//
|
||||
// ── 缺陷 1:导入后 DB 写失败 ⇒ 孤儿文件 ──────────────────────────────
|
||||
// LiteratureService.importFile 的拷贝(importLiteratureFile)在 writeLock
|
||||
// **之外**先落盘,DB 行在锁内写。DB 写失败(SQLITE_BUSY / 磁盘满)⇒
|
||||
// 抛错给用户,但那份**已经拷进 literature/ 的文件**留在盘上:既没有
|
||||
// resources 行、也没有 byId 条目 ⇒ 用户重试一次就多出「name (1).ext」,
|
||||
// 列表里堆一堆「导入了但搜不到、也不在收藏里」的孤儿文件。
|
||||
// 修法:DB 写失败 ⇒ 删掉刚拷出来的那份(回滚到「没导过」),错误照抛。
|
||||
//
|
||||
// ── 缺陷 2:目录搬移/改名后 DB 失败 ⇒ 没人自愈 ──────────────────────
|
||||
// moveDir / renameDir 的注释都写着「失败 throw 且 byId 保持原状,
|
||||
// chokidar 事件触发的 reload 自愈」—— 但**那个事件不会来**:
|
||||
// 目录搬移走 withWatcherReleased(Windows 上 chokidar 持目录句柄 ⇒
|
||||
// 改名必 EPERM),watcher 在 fs.rename 期间是关着的,重启又带
|
||||
// ignoreInitial: true ⇒ 搬移产生的 unlink / add 事件一个都收不到。
|
||||
// ⇒ DB 中途失败时:磁盘已搬到新位置,DB 里一半行还是旧 absPath/id,
|
||||
// byId 也停在旧键 —— 三态漂移且**没有自愈触发器**。
|
||||
// 修法:失败时**显式**排一次 reload(按磁盘真值重建 byId + disk↔DB 差集)。
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
upsertResource: vi.fn(),
|
||||
deleteResource: vi.fn(),
|
||||
renameResource: vi.fn(),
|
||||
listResources: vi.fn(() => [] as unknown[]),
|
||||
listResourceRelPaths: vi.fn(() => [] as string[]),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/main/db/resources-repo', () => ({
|
||||
upsertResource: repo.upsertResource,
|
||||
deleteResource: repo.deleteResource,
|
||||
renameResource: repo.renameResource,
|
||||
listResources: repo.listResources,
|
||||
listResourceRelPaths: repo.listResourceRelPaths,
|
||||
}));
|
||||
|
||||
import { setRootPath } from '../../../src/main/utils/safe-write';
|
||||
import { LiteratureService } from '../../../src/main/literature/literature-service';
|
||||
|
||||
describe('literature DB 失败的自愈(audit-r243)', () => {
|
||||
let root: string;
|
||||
let svc: LiteratureService;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(join(tmpdir(), 'rkms-litdb-'));
|
||||
setRootPath(root);
|
||||
await fs.mkdir(join(root, 'literature'), { recursive: true });
|
||||
repo.upsertResource.mockReset();
|
||||
repo.renameResource.mockReset();
|
||||
svc = new LiteratureService();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await svc.stop();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('导入后 DB 写失败 ⇒ 刚拷进来的文件被回收(不留孤儿)', async () => {
|
||||
await svc.start({ skipInitialLoad: true });
|
||||
const src = join(root, 'paper.pdf');
|
||||
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
|
||||
repo.upsertResource.mockImplementationOnce(() => {
|
||||
throw new Error('SQLITE_BUSY: database is locked');
|
||||
});
|
||||
|
||||
await expect(svc.importFile(src)).rejects.toThrow();
|
||||
|
||||
const left = await fs.readdir(join(root, 'literature'));
|
||||
expect(left, `literature/ 里留下孤儿文件:${left.join(', ')}`).toEqual([]);
|
||||
// 源文件当然不能被动
|
||||
expect((await fs.stat(src)).isFile()).toBe(true);
|
||||
});
|
||||
|
||||
it('目录搬移后 DB 失败 ⇒ 显式排一次 reload(chokidar 事件被 watcher 释放吞掉了)', async () => {
|
||||
await fs.mkdir(join(root, 'literature', 'sub'), { recursive: true });
|
||||
await fs.writeFile(join(root, 'literature', 'sub', 'p.pdf'), 'PDF-BYTES', 'utf-8');
|
||||
await svc.start(); // 不 skipInitialLoad:需要 byId 里有子树行
|
||||
expect(svc.list().length).toBeGreaterThan(0);
|
||||
|
||||
const reloadSpy = vi.spyOn(
|
||||
svc as unknown as { scheduleReload: () => void },
|
||||
'scheduleReload',
|
||||
);
|
||||
repo.renameResource.mockImplementationOnce(() => {
|
||||
throw new Error('SQLITE_BUSY: database is locked');
|
||||
});
|
||||
|
||||
await expect(svc.moveDir('sub', 'moved')).rejects.toThrow();
|
||||
|
||||
expect(
|
||||
reloadSpy.mock.calls.length,
|
||||
'DB 失败后没排 reload ⇒ 三态漂移(盘已搬 / DB 旧 id / byId 旧键)永远没人收',
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user