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 项测试 + 契约/路由/版本一致 + 启动冒烟)。
This commit is contained in:
2026-09-17 03:52:33 +08:00
parent 7a74f598be
commit d1c0dd6e7a
3 changed files with 274 additions and 2 deletions
+263
View File
@@ -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<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);
});