diff --git a/web-server/package-lock.json b/web-server/package-lock.json index ff0e08c..7aeba15 100644 --- a/web-server/package-lock.json +++ b/web-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "opendance-web-server", - "version": "0.7.2", + "version": "0.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opendance-web-server", - "version": "0.7.2", + "version": "0.7.4", "license": "MIT", "dependencies": { "@nestjs/common": "^11.0.12", @@ -29,6 +29,7 @@ "@types/multer": "^1.4.12", "@types/node": "^22.13.10", "source-map-support": "^0.5.21", + "sql.js": "^1.14.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", @@ -5669,6 +5670,13 @@ "node": ">=14" } }, + "node_modules/sql.js": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", + "integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", diff --git a/web-server/package.json b/web-server/package.json index afc1176..04cf352 100644 --- a/web-server/package.json +++ b/web-server/package.json @@ -47,6 +47,7 @@ "@types/multer": "^1.4.12", "@types/node": "^22.13.10", "source-map-support": "^0.5.21", + "sql.js": "^1.14.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", diff --git a/web-server/test/scenes-e2e.spec.ts b/web-server/test/scenes-e2e.spec.ts new file mode 100644 index 0000000..5a0c0c3 --- /dev/null +++ b/web-server/test/scenes-e2e.spec.ts @@ -0,0 +1,263 @@ +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); +});