This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
// tests/unit/main/db-id-swap-atomicity.test.ts
//
// 守卫 r201:资源「换 id」的 DB 写入必须是**一次事务**,不是两次独立事务。
//
// 背景(文献侧的真实 bug,本轮修):
// 文献的 id = 'lit:' + toPosix(absPath) ⇒ 改名 / 搬移都会换 id,
// DB 里必须「删旧行 + 写新行」。旧实现是**两个独立事务**:
// deleteResource(旧 id); // 事务 1
// upsertResource(新行); // 事务 2
// 中间任何失败(DB 忙 / 磁盘满 / FTS 报错 / 进程被杀)都会留下**三态漂移**:
// 磁盘文件在新路径、DB 里旧行没了新行没进、内存 byId 还指着旧 id
// ⇒ 这条文献从列表和搜索里**凭空消失**,而磁盘没动
// ⇒ **没有任何 watcher 事件能触发自愈**(搬移期间 watcher 是被主动
// 释放的),要等下次启动 resync 差集才回来。
// 笔记侧一直用 `renameResource`(单事务「upsert 新行 + 删旧行」),文献侧
// 漏了 —— 两侧语义不一致。
//
// 本测锁的契约:
// A. 文献改名 / 搬移 / 目录搬移 → DB 只发生**一次** renameResource 调用,
// **不再**出现独立的 deleteResource / upsertResource;
// B. **DB 写失败时不得留下半状态**:renameResource 抛错 ⇒ 错误上抛给
// 调用方,且 deleteResource **从未**被调用(⇒ 旧行还在 DB 里,不是
// 「旧行已删、新行未写」)。这条在旧实现下**必红**:deleteResource 已经
// 先执行了。
// C. 笔记侧:改名 / 搬移的 DB 换键与 byId 换键走**同一份** helper
// (applyNoteIdSwap),不再各写一份 —— 旧实现是 renameFile / moveFile
// 两处逐字重复(只差 log 文案与 i18n key),改一边漏另一边 = 「重命名
// 同步了 DB、搬移没同步」。
//
// 真库(electron app.getPath)在测试环境不可用 → resources-repo 整体 mock;
// 被测行为(调用哪个 DB 原语、失败时留下什么)全在 service 内。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { readFileSync } from 'fs';
const repo = vi.hoisted(() => ({
upsertResource: vi.fn(),
deleteResource: vi.fn(),
listResources: vi.fn(() => [] as unknown[]),
listResourceRelPaths: vi.fn(() => [] as string[]),
listStaleIndexIds: vi.fn(() => [] as string[]),
renameResource: vi.fn(),
}));
vi.mock('../../../src/main/db/resources-repo', () => ({
upsertResource: repo.upsertResource,
deleteResource: repo.deleteResource,
listResources: repo.listResources,
listResourceRelPaths: repo.listResourceRelPaths,
listStaleIndexIds: repo.listStaleIndexIds,
renameResource: repo.renameResource,
}));
// chokidar mock(audit-r229):真 watcher 会在测试写文件时异步发 'add'/'change',
// 排一次 reload ⇒ reload 走 upsertResource ⇒ 本文件「换 id 不得出现独立
// upsertResource」的断言变成**竞态**:全量跑测时机器负载高、awaitWriteFinish
// 的轮询被拖慢,reload 恰好落在 mockClear() 之后 ⇒ A3 偶发红
// (实测:单跑 6/6 绿,全量跑 A3 红,报 upsertResource 被调用 1 次,
// 参数是 import 阶段那一行)。mock 掉 watcher 让 reload 只由测试显式驱动 ——
// 与 tests/unit/main/literature-stat-refresh.test.ts 同一处置(r212 的教训)。
vi.mock('chokidar', () => {
const watch = vi.fn(() => {
const w = {
on: () => w,
close: vi.fn(async () => {}),
removeAllListeners: vi.fn(),
};
return w;
});
return { default: { watch }, watch };
});
import { setRootPath } from '../../../src/main/utils/safe-write';
import { NotesService } from '../../../src/main/notes/service';
import { LiteratureService } from '../../../src/main/literature/literature-service';
describe('资源换 id 的 DB 写入必须同事务(r201)', () => {
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(join(tmpdir(), 'rkms-idswap-'));
setRootPath(root);
await fs.mkdir(join(root, 'notes'), { recursive: true });
await fs.mkdir(join(root, 'literature'), { recursive: true });
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
// === 文献侧(本轮修的真实 bug)===
describe('LiteratureService —— 换 id 走单事务', () => {
let lit: LiteratureService;
beforeEach(async () => {
lit = new LiteratureService();
await lit.start({ skipInitialLoad: true });
});
afterEach(async () => {
await lit.stop();
});
async function importPaper(name: string): Promise<string> {
const src = join(root, name);
await fs.writeFile(src, 'PDF-BYTES', 'utf-8');
const imported = await lit.importFile(src);
return imported.id;
}
it('A1 改名 → DB 只发生一次 renameResource(旧 id → 新行),无独立 delete/upsert', async () => {
const id = await importPaper('paper.pdf');
// 导入本身会调 upsertResource 写新行 —— 清掉计数,只断言「改名这一步」
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
const renamed = await lit.renameFile(id as never, 'thesis');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(repo.renameResource).toHaveBeenCalledWith(
id,
expect.objectContaining({ id: renamed.id }),
'',
expect.stringContaining('thesis.pdf'),
);
// 两个独立事务的调用**不许**再出现
expect(repo.deleteResource).not.toHaveBeenCalled();
expect(repo.upsertResource).not.toHaveBeenCalled();
});
it('A2 搬进子目录 → 同样只发生一次 renameResource', async () => {
const id = await importPaper('paper.pdf');
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
const moved = await lit.moveFile(id as never, 'sub');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(repo.renameResource).toHaveBeenCalledWith(
id,
expect.objectContaining({ id: moved.id }),
'',
expect.stringContaining('sub'),
);
expect(repo.deleteResource).not.toHaveBeenCalled();
expect(repo.upsertResource).not.toHaveBeenCalled();
});
it('A3 目录搬移 → 子树里每个文献各一次 renameResource,idRemap 一一对应', async () => {
const id = await importPaper('paper.pdf');
// 先搬进子目录 —— 这一步已经换过一次 id,子树搬移要换的是**这个** id
const inSub = await lit.moveFile(id as never, 'sub');
repo.upsertResource.mockClear();
repo.deleteResource.mockClear();
repo.renameResource.mockClear();
const res = await lit.moveDir('sub', 'moved');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(res.idRemap).toHaveLength(1);
expect(res.idRemap[0].oldId).toBe(inSub.id);
expect(res.idRemap[0].newId).not.toBe(inSub.id);
expect(repo.deleteResource).not.toHaveBeenCalled();
expect(repo.upsertResource).not.toHaveBeenCalled();
});
it('B1 DB 写失败 → 错误上抛,且 deleteResource 从未被调用(不留「旧行已删」的半状态)', async () => {
const id = await importPaper('paper.pdf');
repo.renameResource.mockImplementationOnce(() => {
throw new Error('DB busy');
});
await expect(lit.renameFile(id as never, 'thesis')).rejects.toThrow();
// 关键断言:旧行**还在** DB 里(没有先被单独删掉)——
// 两个独立事务的旧实现在这里 deleteResource 已经执行过了。
expect(repo.deleteResource).not.toHaveBeenCalled();
});
it('B2 目录搬移时 DB 写失败 → 错误上抛,子树里没有任何一行被单独删掉', async () => {
const id = await importPaper('paper.pdf');
await lit.moveFile(id as never, 'sub');
repo.renameResource.mockImplementationOnce(() => {
throw new Error('DB busy');
});
await expect(lit.moveDir('sub', 'moved')).rejects.toThrow();
expect(repo.deleteResource).not.toHaveBeenCalled();
});
});
// === 笔记侧(本轮把两份逐字重复收成一份 helper)===
describe('NotesService —— 换 id 的时序不变量单点化', () => {
let svc: NotesService;
beforeEach(async () => {
svc = new NotesService();
await svc.start({ skipInitialLoad: true });
});
afterEach(async () => {
await svc.stop();
});
it('C1 改名 / 搬移的 DB 换键 + byId 换键走同一份 helper(源码里只有一处 renameResource 调用)', async () => {
// 行为面:改名与搬移都只发生一次 DB 换键,且内存索引跟着换
const n = await svc.create('a.md');
repo.renameResource.mockClear();
const renamed = await svc.renameFile(n.id, 'b');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(svc.findById(n.id)).toBeNull();
expect(svc.findById(renamed.id)).not.toBeNull();
repo.renameResource.mockClear();
const moved = await svc.moveFile(renamed.id, 'sub');
expect(repo.renameResource).toHaveBeenCalledTimes(1);
expect(svc.findById(renamed.id)).toBeNull();
expect(svc.findById(moved.id)).not.toBeNull();
// 结构面:notes/service.ts 里 `renameResource(` 只应出现一次
// (在 applyNoteIdSwap 内)。旧实现是 renameFile / moveFile /
// moveDir / renameDir 各写一份 ⇒ 改一边漏另一边 = 「重命名同步了
// DB、搬移没同步」这类漂移。
// 注:先剥掉整行注释 —— 注释里提到 renameResource( 不算调用点。
const src = readFileSync(
join(process.cwd(), 'src', 'main', 'notes', 'service.ts'),
'utf-8',
);
const code = src
.split('\n')
.filter((line) => !line.trim().startsWith('//'))
.join('\n');
const calls = code.match(/\brenameResource\(/g) ?? [];
expect(calls).toHaveLength(1);
// 两个调用点都走 helper
expect(src).toMatch(/await this\.applyNoteIdSwap\(/);
});
});
});