import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DataSource } from 'typeorm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AssetKind, PublishStatus } from '../src/common/enums'; import { ENTITIES, Scene } from '../src/entities'; import { Asset } from '../src/entities/asset.entity'; import { CatalogService } from '../src/catalog/catalog.service'; import { ScenesService } from '../src/scenes/scenes.service'; import { StorageService } from '../src/storage/storage.service'; /** * **带真实数据库**的端到端验证(库内跑的是 sql.js,不需要外部 MySQL)。 * * 这一层补上单元测试覆盖不到的两件事: * * 1. **实体注册**:`Scene` 必须真的在 DataSource 的 metadata 里 —— 生产事故就是 * `src/entities/index.ts` 的 `ENTITIES` 漏了 `Scene`,导致启动正常、但第一次查它 * 就抛 `No metadata for "Scene" was found.`,进而把 `catalog/export` 一起打挂。 * 2. **上传落盘链路**:走**真实的** `StorageService`(multer diskStorage 的落盘形状: * 文件对象只有 `path`、没有 `buffer`)→ 归档 → 落库 → `published()` → `catalog/export`。 * * 断言用的是真实 SQL(TypeORM 建表 + 插入 + 查询),不是 mock。 */ /** * sqljs 兼容垫片:SQLite 本身对列类型名非常宽松(只有 5 种 affinity),但 TypeORM 的 * sqlite 驱动会按 `normalizeType` 的结果去校验 `column.type`,于是 MySQL 专有写法 * (`char(64)` / `mediumtext`)会被判"不支持"。这里把它们映射成等价的 sqlite 类型, * 让**真实的实体**(Asset / Scene)能在内存库里建表 —— 只影响本测试进程。 */ function installSqljsTypeShim(): void { // eslint-disable-next-line @typescript-eslint/no-var-requires const { AbstractSqliteDriver } = require('typeorm/driver/sqlite-abstract/AbstractSqliteDriver'); const aliases: Record = { char: 'varchar', mediumtext: 'text', longtext: 'text', tinytext: 'text', enum: 'varchar', }; const original = AbstractSqliteDriver.prototype.normalizeType; AbstractSqliteDriver.prototype.normalizeType = function patched(column: { type?: unknown; isPrimary?: boolean; isGenerated?: boolean; }) { const raw = typeof column?.type === 'string' ? column.type.toLowerCase() : ''; // SQLite 的 AUTOINCREMENT 只允许出现在 `INTEGER PRIMARY KEY` 上, // 而本项目用的是 `bigint` 自增主键(MySQL 习惯)——测试里映射成 integer。 if (raw === 'bigint' && column?.isPrimary && column?.isGenerated) return 'integer'; if (aliases[raw]) return aliases[raw]; return original.call(this, column); }; } installSqljsTypeShim(); const storageRoot = mkdtempSync(join(tmpdir(), 'opendance-e2e-')); const uploadDir = mkdtempSync(join(tmpdir(), 'opendance-upload-')); afterAll(() => { rmSync(storageRoot, { recursive: true, force: true }); rmSync(uploadDir, { recursive: true, force: true }); }); function tinyGlb(): Buffer { const json = Buffer.from( JSON.stringify({ asset: { version: '2.0', generator: 'opendance e2e' }, 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]); } /** 模拟 multer 的 diskStorage 产物:只落盘、不带 buffer */ function diskUpload(name: string) { const path = join(uploadDir, `${Date.now()}-${Math.random().toString(16).slice(2)}-${name}`); writeFileSync(path, tinyGlb()); return { originalname: name, mimetype: 'model/gltf-binary', path, size: tinyGlb().length }; } const SOURCE = { sourceType: 'original', sourceName: 'e2e 来源', sourceUrl: 'https://example.com/e2e', license: 'MIT', author: 'e2e', }; const CONFIG = { physics: { gravity: { x: 0, y: -3.5, z: 0 }, wind: { base: { x: 2, y: 0, z: -1 }, gustAmplitude: 4, gustFrequency: 0.5, space: 'world' }, ground: { friction: 0.8, restitution: 0.1, slopeLimit: 30 }, colliders: 'auto', }, camera: { mode: 'orbit', distance: 6, height: 2 }, }; let dataSource: DataSource; beforeAll(async () => { dataSource = new DataSource({ type: 'sqljs', entities: ENTITIES, synchronize: true, dropSchema: true, logging: false, }); await dataSource.initialize(); }, 60_000); afterAll(async () => { await dataSource?.destroy(); }); describe('实体注册(生产事故的根因)', () => { it('Scene 有 metadata 且 getRepository 不抛错', () => { expect(dataSource.hasMetadata(Scene)).toBe(true); expect(() => dataSource.getRepository(Scene)).not.toThrow(); }); it('反向验证:ENTITIES 漏掉 Scene 时会复现生产上的报错原文', async () => { const broken = new DataSource({ type: 'sqljs', // 故意漏掉 Scene —— 复现 src/entities/index.ts 曾经的写法 entities: ENTITIES.filter((e) => e !== Scene), synchronize: false, logging: false, }); await broken.initialize(); try { expect(broken.hasMetadata(Scene)).toBe(false); // 关键:**getRepository 不会抛**(TypeORM 0.3 是惰性的)——这正是"启动完全正常" // 的原因;错误要到**第一次真正查询**时才暴露,与生产现象完全一致。 expect(() => broken.getRepository(Scene)).not.toThrow(); await expect(broken.getRepository(Scene).find()).rejects.toThrow( /No metadata for "Scene" was found/, ); } finally { await broken.destroy(); } }); }); describe('场景上传 → 目录导出(真实数据库 + 真实落盘)', () => { it('diskStorage 形状的上传:归档、落库、published()、catalog/export 全部打通', async () => { const storage = new StorageService( { storageDir: storageRoot, uploadMaxBytes: 512 * 1024 * 1024 } as never, dataSource.getRepository(Asset), ); await storage.onModuleInit(); const scenes = new ScenesService( dataSource.getRepository(Scene), storage, { assimpBin: 'assimp', modelConvertTimeoutMs: 1000, uploadMaxBytes: 512 * 1024 * 1024 } as never, ); // 1) 上传(直接导入 .glb) const upload = diskUpload('stage.glb'); const scene = await scenes.create({ name: 'e2e 舞台', config: JSON.stringify(CONFIG), ...SOURCE }, upload as never); expect(scene.id).toBeGreaterThan(0); expect(scene.status).toBe(PublishStatus.Pending); expect(String(scene.conversionNote)).toContain('直接导入'); expect(scene.config?.physics.gravity.y).toBeCloseTo(-3.5); expect(scene.config?.physics.wind.gustAmplitude).toBeCloseTo(4); expect(scene.config?.camera.mode).toBe('orbit'); // 2) 归档:GLB 已进 storage,临时目录里不再残留该文件 const assetRows = await dataSource.getRepository(Asset).find({ where: { kind: AssetKind.Scene } }); expect(assetRows.length).toBe(1); expect(assetRows[0].storedPath).toMatch(/\.glb$/); const tmpLeft = readdirSync(join(storageRoot, 'tmp')); expect(tmpLeft).not.toContain(upload.path.slice(upload.path.lastIndexOf('/') + 1)); // 3) 未发布时 published() 为空(不上架) expect(await scenes.published()).toEqual([]); // 4) 发布后 published() 能取到,且物理参数完整 await dataSource.getRepository(Scene).update({ id: scene.id }, { status: PublishStatus.Published }); const published = await scenes.published(); expect(published.length).toBe(1); expect(published[0].config?.physics.ground.friction).toBeCloseTo(0.8); expect(published[0].glbUrl).toMatch(/^\/static\//); // 5) 目录导出带上场景,且指纹随之变化 const repository = (rows: unknown[]) => ({ find: async () => rows }); const catalog = new CatalogService( repository([]) as never, repository([]) as never, repository([]) as never, { toDtos: async (rows: unknown[]) => rows } as never, { toDtos: async (rows: unknown[]) => rows } as never, { toDtos: async (rows: unknown[]) => rows, resolveDefaultModelId: async () => null } as never, scenes, { contractVersions: { 'rest-api': '0.5.0' } } as never, ); const bundle = await catalog.exportBundle(); expect(bundle.scenes.map((s) => s.name)).toContain('e2e 舞台'); expect(bundle.scenes[0].config?.physics.gravity.y).toBeCloseTo(-3.5); const version = await catalog.version(); expect(version.revision).toMatch(/^[0-9a-f]{64}$/); // 6) 场景子系统报告为可用(不再降级) expect(scenes.diagnostics()).toEqual({ available: true, error: null }); // 7) 删除场景:行被软删除,published() 不再返回 await scenes.remove(scene.id); expect(await scenes.published()).toEqual([]); }, 30_000); it('越界/非法参数在真实落库时也被收敛(不会把非法物理下发到客户端)', async () => { const storage = new StorageService( { storageDir: storageRoot, uploadMaxBytes: 512 * 1024 * 1024 } as never, dataSource.getRepository(Asset), ); await storage.onModuleInit(); const scenes = new ScenesService( dataSource.getRepository(Scene), storage, { assimpBin: 'assimp', modelConvertTimeoutMs: 1000, uploadMaxBytes: 512 * 1024 * 1024 } as never, ); const scene = await scenes.create( { name: 'e2e 越界场景', config: JSON.stringify({ physics: { gravity: { y: -9999 }, ground: { friction: 5 }, colliders: '不存在的枚举' }, camera: { mode: '无此模式' }, }), ...SOURCE, }, diskUpload('clamped.glb') as never, ); expect(scene.config?.physics.gravity.y).toBeCloseTo(-200); expect(scene.config?.physics.ground.friction).toBeCloseTo(1); expect(scene.config?.physics.colliders).toBe('auto'); expect(scene.config?.camera.mode).toBe('follow'); // 落库后重新读出来仍是收敛后的值(transformer 往返正常) const reloaded = await scenes.findOne(scene.id); expect(reloaded.config?.physics.gravity.y).toBeCloseTo(-200); await scenes.remove(scene.id); }, 30_000); });