import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, describe, expect, it, vi } from 'vitest'; import { AssetKind, PublishStatus } from '../src/common/enums'; import { ScenesService } from '../src/scenes/scenes.service'; /** * 场景上传必须兼容 multer 的 **diskStorage**。 * * 现场(真实部署):上传中间件在 `storage.module.ts` 里配置为 `diskStorage`, * 落盘后的文件对象**只有 `path`、没有 `buffer`**;而 `ScenesService.create()` * 当初按内存存储写,判断的是 `!file.buffer`,于是**任何场景都传不上来**, * 一律 400「上传场景必须带 file 字段」—— 冒烟脚本里 6 项场景断言全红。 * * 正确的做法与 models/motions 一致:`requireUploadedFile(file)` 校验落盘、 * 修正 latin1 文件名编码,直接导入走 `persistTempFile()`(顺带清理临时文件), * 需要转换时再 `readFile(upload.path)`。 */ const dir = mkdtempSync(join(tmpdir(), 'opendance-scene-upload-')); afterAll(() => rmSync(dir, { recursive: true, force: true })); /** 一个最小的 GLB(JSON-only),仅用于让"直接导入"这条路径有文件可读 */ function tinyGlb(): Buffer { const json = Buffer.from( JSON.stringify({ asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ name: 'Floor_col' }], meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], accessors: [{ count: 3 }], }), 'utf8', ); const padded = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]); const header = Buffer.alloc(12); header.write('glTF', 0, 'ascii'); header.writeUInt32LE(2, 4); header.writeUInt32LE(12 + 8 + padded.length, 8); const chunkHeader = Buffer.alloc(8); chunkHeader.writeUInt32LE(padded.length, 0); chunkHeader.writeUInt32LE(0x4e4f534a, 4); return Buffer.concat([header, chunkHeader, padded]); } /** 造一个 **diskStorage 形状**的上传文件:有 path,没有 buffer */ function diskUpload(name = 'stage.glb') { const path = join(dir, `${Date.now()}-${Math.random().toString(16).slice(2)}${name.slice(name.lastIndexOf('.'))}`); writeFileSync(path, tinyGlb()); const size = tinyGlb().length; return { originalname: name, mimetype: 'model/gltf-binary', path, size }; } const SOURCE = { sourceType: 'original', sourceName: '测试来源', sourceUrl: 'https://example.com/s', license: 'MIT', author: 'QA', }; function buildService() { const persisted = { id: 42, kind: AssetKind.Scene, sha256: 'a'.repeat(64), storedPath: 'scene/2026/09/aa/x.glb', size: 216, }; const persistTempFile = vi.fn(async () => ({ asset: persisted, url: '/static/scene/x.glb' })); const persistBuffer = vi.fn(async () => ({ asset: persisted, url: '/static/scene/x.glb' })); const storage = { persistTempFile, persistBuffer, absolutePath: vi.fn(() => join(dir, 'not-a-real-file.glb')), url: vi.fn(() => '/static/scene/x.glb'), findMany: vi.fn(async () => new Map()), }; const scenesRepository = { create: vi.fn((value: Record) => value), save: vi.fn(async (value: Record) => ({ ...value, id: 7, createdAt: new Date(), updatedAt: new Date(), deletedAt: null, })), find: vi.fn(async () => []), }; const config = { uploadMaxBytes: 512 * 1024 * 1024, assimpBin: 'assimp', modelConvertTimeoutMs: 1000 }; const service = new ScenesService(scenesRepository as never, storage as never, config as never); return { service, storage, persistTempFile, persistBuffer, scenesRepository }; } describe('场景上传兼容 multer diskStorage', () => { it('diskStorage 形状的文件(只有 path)不会再被判成"缺少 file 字段"', async () => { const { service, persistTempFile } = buildService(); const dto = await service.create({ name: '冒烟场景', ...SOURCE }, diskUpload() as never); expect(dto).toBeTruthy(); // 直接导入走 persistTempFile(落盘的临时文件被归档) expect(persistTempFile).toHaveBeenCalledTimes(1); const call = persistTempFile.mock.calls[0] as unknown[]; expect(call[0]).toBe(AssetKind.Scene); expect(String(call[1])).toMatch(/\.glb$/); expect(call[2]).toBe('stage.glb'); }); it('GLB 直传仍标注"未做重编码",并落 pending 待审', async () => { const { service } = buildService(); const dto = await service.create({ name: '冒烟场景', ...SOURCE }, diskUpload() as never); expect(String(dto.conversionNote)).toContain('直接导入'); expect(dto.status).toBe(PublishStatus.Pending); }); it('文件名用 latin1 乱码传入时会被还原(与 models/motions 同一套)', async () => { const { service, persistTempFile } = buildService(); // busboy 会把 UTF-8 文件名按 latin1 解出来 const mangled = Buffer.from('舞台.glb', 'utf8').toString('latin1'); await service.create({ name: '中文名场景', ...SOURCE }, diskUpload(mangled) as never); const call = persistTempFile.mock.calls[0] as unknown[]; expect(call[2]).toBe('舞台.glb'); }); it('缺少文件时仍然 400(错误语义没有被削弱)', async () => { const { service } = buildService(); await expect(service.create({ name: '无文件', ...SOURCE }, undefined)).rejects.toThrow(/必须带 file 字段/); }); it('超过 UPLOAD_MAX_BYTES 时 400 且不落盘', async () => { const { service, persistTempFile } = buildService(); const upload = diskUpload(); await expect( service.create({ name: '超大场景', ...SOURCE }, { ...upload, size: 999 * 1024 * 1024 } as never), ).rejects.toThrow(/超过上限/); expect(persistTempFile).not.toHaveBeenCalled(); }); });