248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
// 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\(/);
|
|
});
|
|
});
|
|
});
|