import * as fs from 'fs'; import * as path from 'path'; import { getMetadataArgsStorage } from 'typeorm'; import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core'; import { BaseController, CurdOption } from '@cool-midway/core'; import { BaseEntity, transformerJson } from '../../../src/comm/entity/base'; import { NFCSceneRule } from '../../../src/comm/types/omni'; import { NfcRuleEntity } from '../../../src/modules/nfc/entity/rule'; import { NfcRuleService } from '../../../src/modules/nfc/service/rule'; import { AdminNfcRuleController, adminNfcRuleCurdOption, } from '../../../src/modules/nfc/controller/admin/rule'; /** * CoolController 元数据(脚本路径解析为异步) */ interface CoolControllerMeta { prefix?: string; curdOption?: CurdOption; } /** * 等待 CoolController 将 CRUD 配置写入类元数据 */ async function waitCoolMeta( target: new (...args: never[]) => unknown ): Promise { 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(resolve => setImmediate(resolve)); } throw new Error(`${target.name} 的 CoolController 元数据未就绪`); } function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] { return apis.map(api => (typeof api === 'string' ? api : api.method)); } interface RuleFindOptions { where?: Partial< Pick >; } /** * 内存 Repository,覆盖 resolveScene 查询 */ class MemoryRuleRepo { private data: NfcRuleEntity[] = []; private currentId = 1; constructor(initialData: Array> = []) { 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): NfcRuleEntity { const entity = new NfcRuleEntity(); Object.assign(entity, { id: item.id || this.currentId++, createTime: item.createTime || new Date(), updateTime: new Date(), ...item, }); return entity; } async find(options?: RuleFindOptions): Promise { let result = [...this.data]; if (options?.where) { const where = options.where; result = result.filter(item => { const keys = Object.keys(where) as Array; return keys.every(key => { const expected = where[key]; return expected === undefined || item[key] === expected; }); }); } return result.map(item => ({ ...item } as NfcRuleEntity)); } } function createService( seed: Array> = [] ): NfcRuleService { const service = new NfcRuleService(); service.nfcRuleEntity = new MemoryRuleRepo( seed ) as unknown as NfcRuleService['nfcRuleEntity']; return service; } /** 2026-09-21 周一 10:00(本地) */ function mondayAt10(): Date { return new Date(2026, 8, 21, 10, 0, 0); } /** 2026-09-26 周六 22:00(本地) */ function saturdayAt22(): Date { return new Date(2026, 8, 26, 22, 0, 0); } function seedPrdRules(): Array> { return [ { id: 1, tenantId: 10, deviceId: null, name: '工作日展会名片', sceneMode: 'business', targetCardId: 101, enabled: true, startTime: '09:00', endTime: '18:00', weekdays: [1, 2, 3, 4, 5], countryCode: null, }, { id: 2, tenantId: 10, deviceId: null, name: '晚间周末社交名片', sceneMode: 'social', targetCardId: 202, enabled: true, startTime: '18:00', endTime: '09:00', weekdays: [0, 5, 6], countryCode: null, }, ]; } describe('T23 NFC 场景路由规则 (nfc_rule)', () => { describe('1. 实体 NfcRuleEntity 表结构与列名', () => { const metadata = getMetadataArgsStorage(); it('应继承 comm/entity/base BaseEntity,表名为 nfc_rule', () => { const entity = new NfcRuleEntity(); expect(entity).toBeInstanceOf(BaseEntity); const table = metadata.tables.find(t => t.target === NfcRuleEntity); expect(table).toBeDefined(); expect(table?.name).toBe('nfc_rule'); }); it('实例应满足 NFCSceneRule 契约', () => { const rule = new NfcRuleEntity(); rule.id = 1; rule.tenantId = 10; rule.deviceId = 88; rule.name = '展会'; rule.sceneMode = 'business'; rule.targetCardId = 101; rule.enabled = true; rule.startTime = '09:00'; rule.endTime = '18:00'; rule.weekdays = [1, 2, 3, 4, 5]; rule.countryCode = 'AE'; const contract: NFCSceneRule = rule; expect(contract.sceneMode).toBe('business'); expect(contract.targetCardId).toBe(101); expect(contract.weekdays).toEqual([1, 2, 3, 4, 5]); }); it('属性必须映射为下划线列名', () => { const columns = metadata.columns.filter(c => c.target === NfcRuleEntity); const expected: Record = { deviceId: 'device_id', name: 'name', sceneMode: 'scene_mode', targetCardId: 'target_card_id', enabled: 'enabled', startTime: 'start_time', endTime: 'end_time', weekdays: 'weekdays', countryCode: 'country_code', }; for (const [prop, dbCol] of Object.entries(expected)) { const col = columns.find(c => c.propertyName === prop); expect(col).toBeDefined(); expect(col?.options.name || col?.propertyName).toBe(dbCol); } }); it('deviceId / startTime / endTime / weekdays / countryCode 必须可空', () => { const columns = metadata.columns.filter(c => c.target === NfcRuleEntity); for (const prop of [ 'deviceId', 'startTime', 'endTime', 'weekdays', 'countryCode', ] as const) { const col = columns.find(c => c.propertyName === prop); expect(col).toBeDefined(); expect(col?.options.nullable).toBe(true); } }); it('startTime / endTime 为 varchar,countryCode 长度为 8,weekdays 使用 transformerJson', () => { const columns = metadata.columns.filter(c => c.target === NfcRuleEntity); const startCol = columns.find(c => c.propertyName === 'startTime'); const endCol = columns.find(c => c.propertyName === 'endTime'); const countryCol = columns.find(c => c.propertyName === 'countryCode'); const weekdaysCol = columns.find(c => c.propertyName === 'weekdays'); expect(startCol?.options.type).toBe('varchar'); expect(endCol?.options.type).toBe('varchar'); expect(countryCol?.options.length).toBe(8); expect(weekdaysCol?.options.transformer).toBe(transformerJson); }); }); describe('2. Service resolveScene 时段 / 星期 / 优先级', () => { it('10:00 周一应命中 business 展会名片', async () => { const now = mondayAt10(); expect(now.getDay()).toBe(1); expect(now.getHours()).toBe(10); const service = createService(seedPrdRules()); const hit = await service.resolveScene({ deviceId: 88, tenantId: 10, now, }); expect(hit).toEqual({ targetCardId: 101, sceneMode: 'business', }); }); it('22:00 周六应命中 social 社交名片(含跨午夜)', async () => { const now = saturdayAt22(); expect(now.getDay()).toBe(6); expect(now.getHours()).toBe(22); const service = createService(seedPrdRules()); const hit = await service.resolveScene({ deviceId: 88, tenantId: 10, now, }); expect(hit).toEqual({ targetCardId: 202, sceneMode: 'social', }); }); it('设备级规则应覆盖同条件租户级规则', async () => { const service = createService([ ...seedPrdRules(), { id: 3, tenantId: 10, deviceId: 88, name: '设备专属社交', sceneMode: 'social', targetCardId: 303, enabled: true, startTime: '09:00', endTime: '18:00', weekdays: [1, 2, 3, 4, 5], countryCode: null, }, ]); const hit = await service.resolveScene({ deviceId: 88, tenantId: 10, now: mondayAt10(), }); expect(hit).toEqual({ targetCardId: 303, sceneMode: 'social', }); const otherDevice = await service.resolveScene({ deviceId: 99, tenantId: 10, now: mondayAt10(), }); expect(otherDevice).toEqual({ targetCardId: 101, sceneMode: 'business', }); }); it('应按国家 AE 路由且不区分大小写', async () => { const service = createService([ ...seedPrdRules(), { id: 4, tenantId: 10, deviceId: null, name: '阿联酋展会', sceneMode: 'business', targetCardId: 404, enabled: true, startTime: null, endTime: null, weekdays: null, countryCode: 'AE', }, ]); const ae = await service.resolveScene({ deviceId: 88, tenantId: 10, now: mondayAt10(), countryCode: 'ae', }); expect(ae).toEqual({ targetCardId: 404, sceneMode: 'business', }); const us = await service.resolveScene({ deviceId: 88, tenantId: 10, now: mondayAt10(), countryCode: 'US', }); expect(us).toEqual({ targetCardId: 101, sceneMode: 'business', }); }); it('禁用规则不命中', async () => { const service = createService([ { id: 5, tenantId: 10, deviceId: null, name: '已停用展会', sceneMode: 'business', targetCardId: 505, enabled: false, startTime: '09:00', endTime: '18:00', weekdays: [1, 2, 3, 4, 5], countryCode: null, }, ]); const hit = await service.resolveScene({ deviceId: 88, tenantId: 10, now: mondayAt10(), }); expect(hit).toBeNull(); }); it('resolveScene 应兼容 serviceApis 单对象入参', async () => { const service = createService(seedPrdRules()); const hit = await service.resolveScene({ deviceId: 88, tenantId: 10, now: mondayAt10(), countryCode: undefined, }); expect(hit?.sceneMode).toBe('business'); }); }); describe('3. 后台控制器 /admin/nfc/rule', () => { const controllerFile = path.join( __dirname, '../../../src/modules/nfc/controller/admin/rule.ts' ); let meta: CoolControllerMeta; let option: CurdOption; beforeAll(async () => { meta = await waitCoolMeta(AdminNfcRuleController); option = meta.curdOption as CurdOption; }); it('文件路径应符合 /admin/nfc/rule 自动路由约定', () => { expect(fs.existsSync(controllerFile)).toBe(true); expect(controllerFile.replace(/\\/g, '/')).toMatch( /modules\/nfc\/controller\/admin\/rule\.ts$/ ); }); it('路由前缀应为 /admin/nfc/rule', () => { expect(meta.prefix).toBe('/admin/nfc/rule'); }); it('控制器应继承 BaseController 并导出 adminNfcRuleCurdOption', () => { expect(AdminNfcRuleController.prototype).toBeInstanceOf(BaseController); expect(option.entity).toBe(NfcRuleEntity); expect(option.service).toBe(NfcRuleService); expect(adminNfcRuleCurdOption.entity).toBe(NfcRuleEntity); expect(adminNfcRuleCurdOption.service).toBe(NfcRuleService); }); it('装饰器 api 必须包含 add/delete/update/info/list/page', () => { expect(option.api).toEqual([ 'add', 'delete', 'update', 'info', 'list', 'page', ]); }); it('pageQueryOp.fieldEq 应支持 deviceId/enabled/sceneMode/countryCode', () => { const pageQueryOp = option.pageQueryOp as { fieldEq?: string[]; }; expect(pageQueryOp.fieldEq).toEqual( expect.arrayContaining([ 'a.deviceId', 'a.enabled', 'a.sceneMode', 'a.countryCode', ]) ); }); it('控制器含 resolveScene', () => { const methods = serviceApiMethods(option.serviceApis); expect(methods).toContain('resolveScene'); }); }); });