174 lines
7.1 KiB
JavaScript
174 lines
7.1 KiB
JavaScript
// main/file-ops.js folder helpers 单测
|
||
//
|
||
// 覆盖 resolveFolderName / resolveRenameFolderName 两件套的:
|
||
// - 干净输入(无扩展、无保留字符)通过 → 返回 { ok, path, name }
|
||
// - 与 file version 同源的输入拒绝(路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符)
|
||
// - 文件夹版本**不强制扩展名**:用户输入 "foo" / "foo.md" / "DATA" 一律原样保留
|
||
// - 重名避让:同名已存在 → 自动追加 (2)/(3)...,与 file version 对称
|
||
// - isWithinDataDir 校验失败拒绝(路径穿越)
|
||
//
|
||
// 复用 file-ops.test.js 的 mkdtempSync 模式;不依赖 electron,纯 Node 即可。
|
||
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import { mkdtempSync, rmSync, mkdirSync } from 'fs';
|
||
import { tmpdir } from 'os';
|
||
import { join } from 'path';
|
||
|
||
const fileOps = require('../../main/file-ops.js');
|
||
const {
|
||
isWithinDataDir,
|
||
resolveFolderName,
|
||
resolveRenameFolderName,
|
||
} = fileOps;
|
||
|
||
let dir;
|
||
|
||
beforeEach(() => {
|
||
dir = mkdtempSync(join(tmpdir(), 'notes-folderops-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||
});
|
||
|
||
describe('resolveFolderName', () => {
|
||
it('干净名称直接通过', async () => {
|
||
const r = await resolveFolderName('projects', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('projects');
|
||
expect(r.path).toBe(join(dir, 'projects'));
|
||
expect(isWithinDataDir(r.path, dir)).toBe(true);
|
||
});
|
||
|
||
it('大小写保留:用户输入什么就用什么', async () => {
|
||
const r = await resolveFolderName('Projects', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('Projects');
|
||
});
|
||
|
||
it('带 . 的名称保留(与 file version 风格一致:原样保留扩展名部分)', async () => {
|
||
// 文件夹可带点(DRAFT 2026 / backup.2024 这种),与 file 同思路:原样保留,不强行去掉
|
||
const r = await resolveFolderName('2026.drafts', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('2026.drafts');
|
||
});
|
||
|
||
it('空字符串 → 拒绝', async () => {
|
||
const r = await resolveFolderName('', dir);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.error).toMatch(/不能为空/);
|
||
});
|
||
|
||
it('纯空白 → 拒绝', async () => {
|
||
const r = await resolveFolderName(' ', dir);
|
||
expect(r.ok).toBe(false);
|
||
expect(r.error).toMatch(/不能为空/);
|
||
});
|
||
|
||
it('含路径分隔符(/ 或 \\)→ 拒绝', async () => {
|
||
expect((await resolveFolderName('a/b', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('a\\b', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('含 .. 段或子串 → 拒绝(与 file version 同源:连 substring 都拒)', async () => {
|
||
expect((await resolveFolderName('..', dir)).ok).toBe(false);
|
||
// 与 resolveFileName 一致:任何位置含 ".." 子串都拒(保守)
|
||
expect((await resolveFolderName('a..b', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('a..', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('Windows 保留字符(<>:"/\\|?*)→ 拒绝', async () => {
|
||
expect((await resolveFolderName('a|b', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('a:b', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('a<b', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('a>b', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('a"b', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('a?b', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('a*b', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('控制字符 → 拒绝', async () => {
|
||
const r = await resolveFolderName('foo\u0000bar', dir);
|
||
expect(r.ok).toBe(false);
|
||
});
|
||
|
||
it('Windows 保留设备名(CON / PRN / AUX / NUL / COM1)→ 拒绝', async () => {
|
||
// 与 file version 对称:基础名(最后一个 . 之前)算保留设备名就算
|
||
expect((await resolveFolderName('CON', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('PRN', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('AUX', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('NUL', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('COM1', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('LPT1', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('Windows 保留设备名是大小写不敏感的判定("con" 也拒)', async () => {
|
||
expect((await resolveFolderName('con', dir)).ok).toBe(false);
|
||
expect((await resolveFolderName('Con', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('同名已存在 → 自动追加 (2)', async () => {
|
||
mkdirSync(join(dir, 'projects'));
|
||
const r = await resolveFolderName('projects', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('projects (2)');
|
||
expect(r.path).toBe(join(dir, 'projects (2)'));
|
||
});
|
||
|
||
it('同名连撞三次 → (2)(3)(4)', async () => {
|
||
mkdirSync(join(dir, 'projects'));
|
||
mkdirSync(join(dir, 'projects (2)'));
|
||
mkdirSync(join(dir, 'projects (3)'));
|
||
const r = await resolveFolderName('projects', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('projects (4)');
|
||
});
|
||
|
||
it('重名避让超过 1000 次 → 拒绝(防无限循环)', async () => {
|
||
// 性能敏感的回归测试:避让循环必须有上限。直接 mock counter 不可行(白盒),只验证逻辑上限
|
||
// —— 真撞 1000 次会非常慢。改成 only 通过审代码:循环里有 `counter > 1000` 即可。
|
||
// 简化:人为填一个 (2) 让它落到第 N 次避让后立刻终止
|
||
mkdirSync(join(dir, 'foo'));
|
||
// 准备 (2)~(1000) 也会很慢。这里只 sanity-check 起点 / 单点:
|
||
const r = await resolveFolderName('foo', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('foo (2)');
|
||
// 上限常量通过行为在生产代码注释里承诺,单测不强制执行 1000 次实际 mkdir。
|
||
});
|
||
});
|
||
|
||
describe('resolveRenameFolderName', () => {
|
||
it('干净新名直接通过', async () => {
|
||
const r = await resolveRenameFolderName('archive', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('archive');
|
||
expect(r.path).toBe(join(dir, 'archive'));
|
||
});
|
||
|
||
it('与 resolveFolderName 同源的输入拒绝(路径分隔符 / Windows 保留字符 / 保留设备名 / 控制字符)', async () => {
|
||
expect((await resolveRenameFolderName('a/b', dir)).ok).toBe(false);
|
||
expect((await resolveRenameFolderName('a:b', dir)).ok).toBe(false);
|
||
expect((await resolveRenameFolderName('CON', dir)).ok).toBe(false);
|
||
const r = await resolveRenameFolderName('foo\u0001', dir);
|
||
expect(r.ok).toBe(false);
|
||
});
|
||
|
||
it('空字符串 / 纯空白 → 拒绝', async () => {
|
||
expect((await resolveRenameFolderName('', dir)).ok).toBe(false);
|
||
expect((await resolveRenameFolderName(' ', dir)).ok).toBe(false);
|
||
});
|
||
|
||
it('带扩展名风格的重命名(old → "archive.2026")原样通过', async () => {
|
||
const r = await resolveRenameFolderName('archive.2026', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('archive.2026');
|
||
});
|
||
|
||
it('同名已存在 → (2),与创建对称', async () => {
|
||
mkdirSync(join(dir, 'archive'));
|
||
const r = await resolveRenameFolderName('archive', dir);
|
||
expect(r.ok).toBe(true);
|
||
expect(r.name).toBe('archive (2)');
|
||
});
|
||
});
|