import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AssetKind } from '../src/common/enums'; import { StorageService } from '../src/storage/storage.service'; /** * 上传落盘的两条通路都必须能处理「数据库里已有记录,但文件已经不在了」。 * * 实测缺陷(T-WEB-017 期间发现):`persistTempFile`(动作 / 模型 / 场景上传走它) * 在去重命中时直接 `safeUnlink(tempPath)` 把刚上传的**唯一副本**删掉并返回旧记录; * 一旦记录指向的路径不存在(换过 `STORAGE_DIR`、只恢复了数据库、或手工删过文件), * 上传仍然返回 201,但后续动作预处理 500(`ENOENT ... .vmd`)、模型分析与缩略图 * 静默失败。`persistBuffer` 早已有兜底,`persistTempFile` 漏了 —— 本用例把它钉住。 */ let root: string; let assets: Array>; function buildService(): StorageService { const repo = { findOne: vi.fn(async ({ where }: { where: { sha256?: string } }) => assets.find((a) => a.sha256 === where.sha256) ?? null), create: (entity: Record) => entity, save: vi.fn(async (entity: Record) => { const saved = { id: assets.length + 1, ...entity }; assets.push(saved); return saved; }), }; // StorageService 的构造函数只依赖配置与仓储,这里用最小替身 return new StorageService({ storageDir: root } as never, repo as never); } beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'opendance-storage-')); assets = []; }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); describe('上传落盘:去重与文件缺失兜底', () => { it('首次上传:写入文件并登记记录', async () => { const service = buildService(); const temp = join(root, 'upload.tmp'); writeFileSync(temp, 'vmd-bytes'); const stored = await service.persistTempFile(AssetKind.Motion, temp, 'a.vmd', 'application/octet-stream'); expect(existsSync(service.absolutePath(stored.asset))).toBe(true); expect(readFileSync(service.absolutePath(stored.asset), 'utf8')).toBe('vmd-bytes'); expect(existsSync(temp)).toBe(false); // 临时文件已被移走 }); it('去重命中且文件仍在:丢弃临时文件、复用记录', async () => { const service = buildService(); const first = join(root, 'a.tmp'); writeFileSync(first, 'same-bytes'); const stored = await service.persistTempFile(AssetKind.Motion, first, 'a.vmd', 'application/octet-stream'); const second = join(root, 'b.tmp'); writeFileSync(second, 'same-bytes'); const again = await service.persistTempFile(AssetKind.Motion, second, 'a.vmd', 'application/octet-stream'); expect(again.asset.id).toBe(stored.asset.id); expect(existsSync(second)).toBe(false); expect(assets).toHaveLength(1); }); it('去重命中但**文件已丢失**:按原路径补写,而不是删掉唯一副本', async () => { const service = buildService(); const first = join(root, 'a.tmp'); writeFileSync(first, 'lost-bytes'); const stored = await service.persistTempFile(AssetKind.Motion, first, 'a.vmd', 'application/octet-stream'); const absolute = service.absolutePath(stored.asset); // 模拟「换过 STORAGE_DIR / 只恢复了数据库」:记录还在,文件没了 rmSync(absolute, { force: true }); expect(existsSync(absolute)).toBe(false); const second = join(root, 'b.tmp'); writeFileSync(second, 'lost-bytes'); const again = await service.persistTempFile(AssetKind.Motion, second, 'a.vmd', 'application/octet-stream'); expect(again.asset.id).toBe(stored.asset.id); expect(existsSync(absolute)).toBe(true); // 文件被补写回来 expect(readFileSync(absolute, 'utf8')).toBe('lost-bytes'); expect(existsSync(second)).toBe(false); // 临时文件已转移 }); it('persistBuffer 的内存通路同样兜底(回归保护)', async () => { const service = buildService(); const content = Buffer.from('generated-bytes'); const stored = await service.persistBuffer(AssetKind.Keypoints, content, 'k.ndjson', 'application/x-ndjson'); const absolute = service.absolutePath(stored.asset); rmSync(absolute, { force: true }); const again = await service.persistBuffer(AssetKind.Keypoints, content, 'k.ndjson', 'application/x-ndjson'); expect(again.asset.id).toBe(stored.asset.id); expect(existsSync(absolute)).toBe(true); }); it('空文件被拒绝(不会登记成一条空资产)', async () => { const service = buildService(); const temp = join(root, 'empty.tmp'); mkdirSync(root, { recursive: true }); writeFileSync(temp, ''); await expect( service.persistTempFile(AssetKind.Motion, temp, 'empty.vmd', 'application/octet-stream'), ).rejects.toThrow(/空/); expect(assets).toHaveLength(0); }); });