atomic.spec.ts 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. import { afterAll, describe, expect, it } from 'vitest'
  2. import * as fs from 'node:fs'
  3. import * as path from 'node:path'
  4. import * as os from 'node:os'
  5. import { writeBatchAtomic } from '../src/repo/atomic'
  6. const roots: string[] = []
  7. function mkRoot(): string {
  8. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'webnovel-b1-'))
  9. roots.push(dir)
  10. return dir
  11. }
  12. afterAll(() => { for (const r of roots) { try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟释放,%TEMP% 残留无害 */ } } })
  13. describe('批量原子写(机制 B1)', () => {
  14. it('全部成功:目录自动创建,内容正确', () => {
  15. const root = mkRoot()
  16. writeBatchAtomic(root, [
  17. { relPath: '定稿/卷01/0001-初见.md', content: '正文' },
  18. { relPath: '账本/线索.md', content: '账' },
  19. ])
  20. expect(fs.readFileSync(path.join(root, '定稿/卷01/0001-初见.md'), 'utf-8')).toBe('正文')
  21. expect(fs.readFileSync(path.join(root, '账本/线索.md'), 'utf-8')).toBe('账')
  22. })
  23. it('路径校验:拒绝绝对路径与 .. 穿越(B1/B2 衔接)', () => {
  24. const root = mkRoot()
  25. expect(() => writeBatchAtomic(root, [{ relPath: '../越界.md', content: 'x' }])).toThrow(/B1/)
  26. expect(() => writeBatchAtomic(root, [{ relPath: 'D:/绝对.md', content: 'x' }])).toThrow(/绝对/)
  27. expect(fs.readdirSync(root)).toEqual([])
  28. })
  29. it('中途失败:新建文件回滚删除,书仓零半成品', () => {
  30. const root = mkRoot()
  31. // 预置一个目录,使第二条目标标在 rename 阶段失败(目录不可作文件覆盖)
  32. fs.mkdirSync(path.join(root, '占位目录'))
  33. expect(() => writeBatchAtomic(root, [
  34. { relPath: 'a/新文件.md', content: '新' },
  35. { relPath: '占位目录', content: 'x' },
  36. ])).toThrow(/B1/)
  37. expect(fs.existsSync(path.join(root, 'a/新文件.md'))).toBe(false)
  38. expect(fs.statSync(path.join(root, '占位目录')).isDirectory()).toBe(true) // 既有目录不受损
  39. })
  40. it('中途失败:既有文件恢复原文,零丢失', () => {
  41. const root = mkRoot()
  42. fs.mkdirSync(path.join(root, 'a'), { recursive: true })
  43. fs.writeFileSync(path.join(root, 'a/旧.md'), '旧内容', 'utf-8')
  44. fs.mkdirSync(path.join(root, '占位目录'))
  45. expect(() => writeBatchAtomic(root, [
  46. { relPath: 'a/旧.md', content: '新内容' },
  47. { relPath: '占位目录', content: 'x' },
  48. ])).toThrow(/B1/)
  49. expect(fs.readFileSync(path.join(root, 'a/旧.md'), 'utf-8')).toBe('旧内容')
  50. })
  51. it('中途失败:中文路径新建文件也回滚删除', () => {
  52. const root = mkRoot()
  53. fs.mkdirSync(path.join(root, '占位目录'))
  54. expect(() => writeBatchAtomic(root, [
  55. { relPath: '账本/线索.md', content: '账' },
  56. { relPath: '占位目录', content: 'x' },
  57. ])).toThrow(/B1/)
  58. expect(fs.existsSync(path.join(root, '账本/线索.md'))).toBe(false)
  59. })
  60. })