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 { 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 元数据未就绪`); } interface DeviceFindOptions { where?: Partial< Pick >; order?: Partial< Record<'id' | 'updateTime' | 'uid', 'ASC' | 'DESC' | 'asc' | 'desc'> >; } /** * 内存 Repository,覆盖 add / page 所需能力 */ class MemoryDeviceRepo { private data: NfcDeviceEntity[] = []; 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): 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 { 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; }); }); } if (options?.order) { const orderKeys = Object.keys(options.order) as Array< keyof NonNullable >; 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> ): Promise { const found = this.data.find(item => { const keys = Object.keys(where) as Array; return keys.every(key => { const expected = where[key]; return expected === undefined || item[key] === expected; }); }); return found ? ({ ...found } as NfcDeviceEntity) : null; } async save( entityOrEntities: | Partial | Array> ): Promise { 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> = [] ): NfcDeviceService { const service = new NfcDeviceService(); service.nfcDeviceEntity = new MemoryDeviceRepo( seed ) as unknown as NfcDeviceService['nfcDeviceEntity']; return service; } function seedDevices(): Array> { 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 = { 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; }; 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' }); }); }); });