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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user