Files
opendance-web/web-server/test/scenes-e2e.spec.ts
T
zijunchiang d1c0dd6e7a test(scenes): 加带真实数据库的端到端用例(sql.js 内存库),把生产根因钉死
新增 test/scenes-e2e.spec.ts(4 项,全部真实 SQL,不是 mock):

1. Scene 在 DataSource 里有 metadata、getRepository 不抛错;
2. **反向验证生产根因**:当 ENTITIES 漏掉 Scene 时,`getRepository(Scene)` **不会抛**
   (TypeORM 0.3 是惰性的——这正是"启动完全正常"的原因),而**第一次查询**会 reject
   并抛出与生产完全一致的 `No metadata for "Scene" was found.`;
3. **上传→归档→落库→发布→目录导出 全链路**(真实 StorageService + 真实仓储):
   - 用 multer diskStorage 的文件形状(只有 path、没有 buffer)上传 .glb;
   - 断言 GLB 已归档进 storage、临时目录不再残留该文件(验证 diskStorage 修复);
   - 落 pending;`published()` 为空;发布后能取到完整物理参数(重力/风/地面/相机);
   - `CatalogService.exportBundle()` 的 `scenes[]` 含该场景且参数完整;
   - `catalog/version` 指纹为 64 位十六进制;`scenes.diagnostics()` 报告可用;
   - 删除后 `published()` 不再返回(软删除生效);
4. 越界/非法参数在**真实落库往返**(jsonText transformer)后仍是收敛值
   (重力 -200、摩擦 1、colliders auto、相机 follow)。

实现细节:库内用 `sql.js`(WASM,无原生依赖,devDependency)跑 TypeORM 的 sqljs 驱动;
SQLite 本身对列类型名宽松,但 TypeORM 的 sqlite 驱动会校验 normalizeType 的结果,
因此测试里加了一处**有注释的兼容垫片**:`char→varchar`、`mediumtext/longtext/tinytext→text`、
`enum→varchar`,以及 `bigint` 自增主键→`integer`(SQLite 的 AUTOINCREMENT 只允许
INTEGER PRIMARY KEY)。只影响本测试进程,不改动任何实体定义。

验证:npm run verify 通过(24 文件 / 289 项测试 + 契约/路由/版本一致 + 启动冒烟)。
2026-09-17 03:52:33 +08:00

264 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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);
});