文件
OmniCard/backend/test/modules/nfc/device.test.ts
wikiboss 6c3d5d323a refactor(arch): 落地平台架构演进第一二阶段整改,消除OOM与越权风险并支持高并发缓冲与AI多租户BYOK
- [P0安全底座] 多租户数据闭环:公开留资与雷达足迹自动逆向追溯绑定名片 tenantId 与归属销售 userId
- [P0性能稳定] 消除内存全量加载:公海池分批带条件扫描回收,雷达国家分布改用 SQL 原生 GROUP BY 聚合查询彻底消除 OOM 隐患
- [P0商业变现] 商业化配额门禁切面:在 Cool-Admin modifyBefore 生命周期织入 assertQuotaAvailable 校验,阻断超额名片创建
- [P0环境解耦] 客户端解耦本地硬编码:request.uts 支持 api_base_url 本地覆盖,生产数据库名修正为 crmmpdata
- [P1高并发削峰] 雷达足迹批量缓冲引擎:引入 CrmRadarBufferService 环形缓冲队列与阈值/定时批量 Bulk Insert 落盘
- [P2中枢租户化] AI 智能中枢多租户 BYOK 与 Token 成本风控:支持企业自带 Key 隔离、Token 消耗账本、用量配额门禁与名片人设个性化联动
- [跨端规范] 对齐 Uni-app x 移动微官网原生首屏与底部原生 tabBar 规范
- [测试驱动] 全工程 116 个测试套件,1,175 个单元与集成测试用例全部 100% 绿色通过,管理后台 Vite 打包无错误
2026-09-21 11:14:10 +08:00

412 行
13 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import { getMetadataArgsStorage } from 'typeorm';
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core';
import { BaseEntity } from '../../../src/comm/entity/base';
import { NfcDeviceEntity } from '../../../src/modules/nfc/entity/device';
import { NfcDeviceService } from '../../../src/modules/nfc/service/device';
import {
AdminNfcDeviceController,
adminNfcDeviceCurdOption,
} from '../../../src/modules/nfc/controller/admin/device';
import type {
NFCMaterial,
NFCSceneMode,
NFCStatus,
} from '../../../src/comm/types/omni';
/**
* CoolController 元数据(脚本路径解析为异步)
*/
interface CoolControllerMeta {
prefix?: string;
curdOption?: CurdOption;
}
/**
* 等待 CoolController 将 CRUD 配置写入类元数据
*/
async function waitCoolMeta(
target: new (...args: never[]) => unknown
): Promise<CoolControllerMeta> {
const started = Date.now();
while (Date.now() - started < 2000) {
const meta = getClassMetadata(CONTROLLER_KEY, target) as
| CoolControllerMeta
| undefined;
if (meta?.curdOption) {
return meta;
}
await new Promise<void>(resolve => setImmediate(resolve));
}
throw new Error(`${target.name} 的 CoolController 元数据未就绪`);
}
interface DeviceFindOptions {
where?: Partial<
Pick<NfcDeviceEntity, 'id' | 'uid' | 'status' | 'material' | 'cardId' | 'sceneMode'>
>;
order?: Partial<
Record<'id' | 'updateTime' | 'uid', 'ASC' | 'DESC' | 'asc' | 'desc'>
>;
}
/**
* 内存 Repository,覆盖 add / page 所需能力
*/
class MemoryDeviceRepo {
private data: NfcDeviceEntity[] = [];
private currentId = 1;
constructor(initialData: Array<Partial<NfcDeviceEntity>> = []) {
this.data = initialData.map(item => this.createEntity(item));
if (this.data.length > 0) {
this.currentId = Math.max(...this.data.map(d => Number(d.id) || 0)) + 1;
}
}
private createEntity(item: Partial<NfcDeviceEntity>): NfcDeviceEntity {
const entity = new NfcDeviceEntity();
Object.assign(entity, {
id: item.id || this.currentId++,
createTime: item.createTime || new Date(),
updateTime: item.updateTime || new Date(),
material: item.material || 'pvc',
status: item.status || 'unbound',
sceneMode: item.sceneMode || 'business',
...item,
});
return entity;
}
async find(options?: DeviceFindOptions): Promise<NfcDeviceEntity[]> {
let result = [...this.data];
if (options?.where) {
const where = options.where;
result = result.filter(item => {
const keys = Object.keys(where) as Array<keyof typeof where>;
return keys.every(key => {
const expected = where[key];
return expected === undefined || item[key] === expected;
});
});
}
if (options?.order) {
const orderKeys = Object.keys(options.order) as Array<
keyof NonNullable<DeviceFindOptions['order']>
>;
result.sort((a, b) => {
for (const key of orderKeys) {
const dir = (options.order?.[key] || 'ASC').toUpperCase();
if (a[key] < b[key]) return dir === 'ASC' ? -1 : 1;
if (a[key] > b[key]) return dir === 'ASC' ? 1 : -1;
}
return 0;
});
}
return result.map(item => ({ ...item } as NfcDeviceEntity));
}
async findOneBy(
where: Partial<Pick<NfcDeviceEntity, 'id' | 'uid'>>
): Promise<NfcDeviceEntity | null> {
const found = this.data.find(item => {
const keys = Object.keys(where) as Array<keyof typeof where>;
return keys.every(key => {
const expected = where[key];
return expected === undefined || item[key] === expected;
});
});
return found ? ({ ...found } as NfcDeviceEntity) : null;
}
async save(
entityOrEntities:
| Partial<NfcDeviceEntity>
| Array<Partial<NfcDeviceEntity>>
): Promise<NfcDeviceEntity | NfcDeviceEntity[]> {
if (Array.isArray(entityOrEntities)) {
const savedList: NfcDeviceEntity[] = [];
for (const item of entityOrEntities) {
savedList.push((await this.save(item)) as NfcDeviceEntity);
}
return savedList;
}
const item = entityOrEntities;
if (item.id) {
const index = this.data.findIndex(d => d.id === item.id);
if (index !== -1) {
this.data[index] = {
...this.data[index],
...item,
updateTime: new Date(),
} as NfcDeviceEntity;
return { ...this.data[index] } as NfcDeviceEntity;
}
}
const newEntity = this.createEntity(item);
this.data.push(newEntity);
return { ...newEntity } as NfcDeviceEntity;
}
getSnapshot(): NfcDeviceEntity[] {
return [...this.data];
}
}
function createService(
seed: Array<Partial<NfcDeviceEntity>> = []
): NfcDeviceService {
const service = new NfcDeviceService();
service.nfcDeviceEntity = new MemoryDeviceRepo(
seed
) as unknown as NfcDeviceService['nfcDeviceEntity'];
return service;
}
function seedDevices(): Array<Partial<NfcDeviceEntity>> {
return [
{
id: 1,
uid: '04:7A:B2:8F:1C:6D:80',
batchNo: 'BATCH-2026-01',
material: 'pvc',
status: 'active',
sceneMode: 'business',
cardId: 100,
},
{
id: 2,
uid: '04:11:22:33:44:55:66',
batchNo: 'BATCH-2026-02',
material: 'metal',
status: 'unbound',
sceneMode: 'social',
},
];
}
describe('T21 NFC 设备台账 (nfc_device)', () => {
describe('1. 实体 NfcDeviceEntity 表结构与索引', () => {
const metadata = getMetadataArgsStorage();
it('应继承 comm/entity/base BaseEntity,表名为 nfc_device', () => {
const entity = new NfcDeviceEntity();
expect(entity).toBeInstanceOf(BaseEntity);
const table = metadata.tables.find(t => t.target === NfcDeviceEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('nfc_device');
});
it('必须声明 uid / card_id / batch_no / material / status / scene_mode / last_scanned_at', () => {
const columns = metadata.columns.filter(
c => c.target === NfcDeviceEntity
);
const expected: Record<string, string> = {
uid: 'uid',
cardId: 'card_id',
batchNo: 'batch_no',
material: 'material',
status: 'status',
sceneMode: 'scene_mode',
lastScannedAt: 'last_scanned_at',
};
for (const [prop, colName] of Object.entries(expected)) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.name || col?.propertyName).toBe(colName);
}
});
it('uid 必须唯一索引,cardId / status 必须建索引', () => {
const indexes = metadata.indices.filter(
i => i.target === NfcDeviceEntity
);
const uidIndex = indexes.find(
i => Array.isArray(i.columns) && i.columns.includes('uid')
);
expect(uidIndex).toBeDefined();
expect(uidIndex?.unique).toBe(true);
const hasCardIdIndex = indexes.some(
i => Array.isArray(i.columns) && i.columns.includes('cardId')
);
const hasStatusIndex = indexes.some(
i => Array.isArray(i.columns) && i.columns.includes('status')
);
expect(hasCardIdIndex).toBe(true);
expect(hasStatusIndex).toBe(true);
});
});
describe('2. Service add / page 内存仓库', () => {
it('add 未传 status 时应默认 unbound', async () => {
const service = createService();
const created = await service.add({
uid: '04:AA:BB:CC:DD:EE:FF',
batchNo: 'BATCH-NEW',
material: 'wood' as NFCMaterial,
});
expect(created.id).toBeDefined();
const saved = await service.nfcDeviceEntity.findOneBy({
uid: '04:AA:BB:CC:DD:EE:FF',
});
expect(saved?.status).toBe('unbound');
expect(saved?.batchNo).toBe('BATCH-NEW');
expect(saved?.material).toBe('wood');
});
it('add 传入 status 时应保留原值', async () => {
const service = createService();
await service.add({
uid: '04:11:00:22:33:44:55',
batchNo: 'BATCH-ACTIVE',
status: 'active' as NFCStatus,
sceneMode: 'business' as NFCSceneMode,
});
const saved = await service.nfcDeviceEntity.findOneBy({
uid: '04:11:00:22:33:44:55',
});
expect(saved?.status).toBe('active');
});
it('uid 重复时应抛 CoolCommException', async () => {
const service = createService(seedDevices());
await expect(
service.add({
uid: '04:7A:B2:8F:1C:6D:80',
batchNo: 'BATCH-DUP',
})
).rejects.toThrow(CoolCommException);
});
it('page 应按内存仓库返回分页数据,并按 updateTime 倒序', async () => {
const service = createService(seedDevices());
const result = await service.page({ page: 1, size: 10 });
expect(result.list.length).toBe(2);
expect(result.pagination.total).toBe(2);
expect(result.pagination.page).toBe(1);
expect(result.pagination.size).toBe(10);
});
it('page 应支持 status / material / cardId / sceneMode 精确筛选', async () => {
const service = createService(seedDevices());
const byStatus = await service.page({ status: 'unbound', page: 1, size: 10 });
expect(byStatus.list).toHaveLength(1);
expect(byStatus.list[0].uid).toBe('04:11:22:33:44:55:66');
const byMaterial = await service.page({
material: 'pvc',
page: 1,
size: 10,
});
expect(byMaterial.list).toHaveLength(1);
expect(byMaterial.list[0].batchNo).toBe('BATCH-2026-01');
const byCard = await service.page({ cardId: 100, page: 1, size: 10 });
expect(byCard.list).toHaveLength(1);
expect(byCard.list[0].id).toBe(1);
const byScene = await service.page({
sceneMode: 'social',
page: 1,
size: 10,
});
expect(byScene.list).toHaveLength(1);
expect(byScene.list[0].sceneMode).toBe('social');
});
it('page 应支持 uid / batchNo 关键字模糊检索', async () => {
const service = createService(seedDevices());
const byUid = await service.page({ keyWord: '04:7A', page: 1, size: 10 });
expect(byUid.list).toHaveLength(1);
expect(byUid.list[0].uid).toBe('04:7A:B2:8F:1C:6D:80');
const byBatch = await service.page({
keyWord: '2026-02',
page: 1,
size: 10,
});
expect(byBatch.list).toHaveLength(1);
expect(byBatch.list[0].batchNo).toBe('BATCH-2026-02');
});
});
describe('3. 后台控制器装饰器 /admin/nfc/device', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/nfc/controller/admin/device.ts'
);
let meta: CoolControllerMeta;
let option: CurdOption;
beforeAll(async () => {
meta = await waitCoolMeta(AdminNfcDeviceController);
option = meta.curdOption as CurdOption;
});
it('文件路径应符合 /admin/nfc/device 自动路由约定', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
expect(controllerFile.replace(/\\/g, '/')).toMatch(
/modules\/nfc\/controller\/admin\/device\.ts$/
);
});
it('路由前缀应为 /admin/nfc/device', () => {
expect(meta.prefix).toBe('/admin/nfc/device');
});
it('控制器应继承 BaseController 并绑定实体与服务', () => {
expect(AdminNfcDeviceController.prototype).toBeInstanceOf(
BaseController
);
expect(option.entity).toBe(NfcDeviceEntity);
expect(option.service).toBe(NfcDeviceService);
expect(adminNfcDeviceCurdOption.entity).toBe(NfcDeviceEntity);
expect(adminNfcDeviceCurdOption.service).toBe(NfcDeviceService);
});
it('装饰器 api 必须包含 add/delete/update/info/list/page', () => {
expect(option.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
expect(option.api).toContain('page');
});
it('pageQueryOp 应支持 uid/batchNo 模糊检索,status/material/cardId/sceneMode 精确筛选,并按 updateTime 倒序', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.keyWordLikeFields).toEqual(
expect.arrayContaining(['a.uid', 'a.batch_no'])
);
expect(pageQueryOp.fieldEq).toEqual(
expect.arrayContaining([
'a.status',
'a.material',
{ column: 'a.card_id', requestParam: 'cardId' },
{ column: 'a.scene_mode', requestParam: 'sceneMode' },
])
);
expect(pageQueryOp.addOrderBy).toEqual({ update_time: 'desc' });
});
});
});