import * as fs from 'fs'; import * as path from 'path'; import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core'; import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core'; import { AdminCardBentoController } from '../../../src/modules/card/controller/admin/bento'; import { CardBentoEntity } from '../../../src/modules/card/entity/bento'; import { CardBentoService } from '../../../src/modules/card/service/bento'; import type { BentoBlockType, BentoColSpan, BentoRowSpan, } 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 元数据未就绪`); } function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] { return apis.map(api => (typeof api === 'string' ? api : api.method)); } interface BentoFindOptions { where?: Partial>; order?: Partial>; } /** * 内存 Repository,覆盖 getBlocksByCardId / batchSaveLayout / toggleStatus 所需能力 */ class MemoryBentoRepo { private data: CardBentoEntity[] = []; 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): CardBentoEntity { const entity = new CardBentoEntity(); Object.assign(entity, { id: item.id || this.currentId++, createTime: item.createTime || new Date(), updateTime: new Date(), status: item.status !== undefined ? item.status : 1, colSpan: item.colSpan !== undefined ? item.colSpan : 1, rowSpan: item.rowSpan !== undefined ? item.rowSpan : 1, sortOrder: item.sortOrder !== undefined ? item.sortOrder : 0, ...item, }); return entity; } async find(options?: BentoFindOptions): 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 CardBentoEntity)); } 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 CardBentoEntity) : null; } async save( entityOrEntities: Partial | Array> ): Promise { if (Array.isArray(entityOrEntities)) { const savedList: CardBentoEntity[] = []; for (const item of entityOrEntities) { savedList.push((await this.save(item)) as CardBentoEntity); } 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 CardBentoEntity; return { ...this.data[index] } as CardBentoEntity; } } const newEntity = this.createEntity(item); this.data.push(newEntity); return { ...newEntity } as CardBentoEntity; } async delete( condition: number | string | number[] | Partial> ): Promise<{ affected?: number }> { const initialLen = this.data.length; if (typeof condition === 'number' || typeof condition === 'string') { this.data = this.data.filter(d => d.id !== Number(condition)); } else if (Array.isArray(condition)) { const idSet = new Set(condition.map(id => Number(id))); this.data = this.data.filter(d => !idSet.has(Number(d.id))); } else { this.data = this.data.filter(item => { const keys = Object.keys(condition) as Array; const matched = keys.some(key => { const expected = condition[key]; return expected !== undefined && item[key] === expected; }); return !matched; }); } return { affected: initialLen - this.data.length }; } } function createService( seed: Array> ): CardBentoService { const service = new CardBentoService(); service.cardBentoEntity = new MemoryBentoRepo(seed) as unknown as CardBentoService['cardBentoEntity']; return service; } function seedBlocks(): Array> { return [ { id: 1, cardId: 100, type: 'hero', colSpan: 2, rowSpan: 1, sortOrder: 1, title: '个人简介与身份卡', content: '全栈出海专家', status: 1, }, { id: 2, cardId: 100, type: 'social', colSpan: 1, rowSpan: 1, sortOrder: 3, title: '社交媒体矩阵', content: 'WhatsApp & LinkedIn', status: 1, }, { id: 3, cardId: 100, type: 'case', colSpan: 2, rowSpan: 2, sortOrder: 2, title: '新能源出海案例', content: '中东客户成功交付', status: 0, }, { id: 4, cardId: 200, type: 'hero', colSpan: 2, rowSpan: 1, sortOrder: 1, title: '其他名片块', status: 1, }, ]; } describe('后台 Bento 画布 CRUD (Admin Card Bento CoolController)', () => { const controllerFile = path.join( __dirname, '../../../src/modules/card/controller/admin/bento.ts' ); describe('1. 控制器装饰器与路由约定', () => { let meta: CoolControllerMeta; let option: CurdOption; beforeAll(async () => { meta = await waitCoolMeta(AdminCardBentoController); option = meta.curdOption as CurdOption; }); it('文件路径应符合 /admin/card/bento 自动路由约定', () => { expect(fs.existsSync(controllerFile)).toBe(true); expect(controllerFile.replace(/\\/g, '/')).toMatch( /modules\/card\/controller\/admin\/bento\.ts$/ ); }); it('路由前缀应为 /admin/card/bento', () => { expect(meta.prefix).toBe('/admin/card/bento'); }); it('控制器应继承 BaseController', () => { expect(AdminCardBentoController.prototype).toBeInstanceOf(BaseController); }); it('装饰器 api 必须包含 add/delete/update/info/list/page', () => { expect(option.api).toEqual([ 'add', 'delete', 'update', 'info', 'list', 'page', ]); }); it('应绑定 Bento 实体与 Bento 服务', () => { expect(option.entity).toBe(CardBentoEntity); expect(option.service).toBe(CardBentoService); }); it('pageQueryOp 应支持 title/content 模糊检索', () => { const pageQueryOp = option.pageQueryOp as { keyWordLikeFields?: string[]; fieldEq?: string[]; addOrderBy?: Record; }; expect(pageQueryOp.keyWordLikeFields).toEqual(['a.title', 'a.content']); }); it('pageQueryOp 应支持 cardId/type/status 精确筛选,并按 sortOrder 升序', () => { const pageQueryOp = option.pageQueryOp as { keyWordLikeFields?: string[]; fieldEq?: string[]; addOrderBy?: Record; }; expect(pageQueryOp.fieldEq).toEqual(['a.cardId', 'a.type', 'a.status']); expect(pageQueryOp.addOrderBy).toEqual({ sortOrder: 'asc' }); }); it('应将 getBlocksByCardId / batchSaveLayout / toggleStatus 注册为 serviceApis', () => { const methods = serviceApiMethods(option.serviceApis); expect(methods).toEqual([ 'getBlocksByCardId', 'batchSaveLayout', 'toggleStatus', ]); const summaries = (option.serviceApis || []) .filter((api): api is { method: string; summary: string } => typeof api !== 'string' ) .map(api => ({ method: api.method, summary: api.summary })); expect(summaries).toEqual([ { method: 'getBlocksByCardId', summary: '按名片获取有序 Bento' }, { method: 'batchSaveLayout', summary: '批量同步画布排版' }, { method: 'toggleStatus', summary: '切换块启用状态' }, ]); }); }); describe('2. batchSaveLayout 旧双参行为不回归', () => { it('应支持全量布局保存:更新已有块、新增新块、并自动清理废弃块', async () => { const service = createService(seedBlocks()); const newLayout = [ { id: 2, type: 'social' as BentoBlockType, colSpan: 1 as BentoColSpan, rowSpan: 1 as BentoRowSpan, title: '更新后的社交媒体', }, { type: 'video' as BentoBlockType, colSpan: 2 as BentoColSpan, rowSpan: 2 as BentoRowSpan, title: '企业出海宣传片', mediaUrl: 'https://cdn.omnicard.ai/video/intro.mp4', }, ]; const saved = await service.batchSaveLayout(100, newLayout); expect(saved).toHaveLength(2); expect(saved[0].sortOrder).toBe(0); expect(saved[0].id).toBe(2); expect(saved[0].title).toBe('更新后的社交媒体'); expect(saved[1].sortOrder).toBe(1); expect(saved[1].type).toBe('video'); expect(saved[1].mediaUrl).toBe( 'https://cdn.omnicard.ai/video/intro.mp4' ); const remaining100 = await service.getBlocksByCardId(100); expect(remaining100.map(b => b.id)).toEqual([2, saved[1].id]); expect(remaining100.map(b => b.id)).not.toContain(1); expect(remaining100.map(b => b.id)).not.toContain(3); const otherCardBlocks = await service.getBlocksByCardId(200); expect(otherCardBlocks).toHaveLength(1); expect(otherCardBlocks[0].id).toBe(4); }); it('名片 ID 为空时应拒绝保存', async () => { const service = createService(seedBlocks()); await expect(service.batchSaveLayout(0, [])).rejects.toThrow( CoolCommException ); await expect(service.batchSaveLayout(0, [])).rejects.toThrow( '名片 ID 不能为空' ); }); }); describe('3. serviceApis POST body 单参数对象转调', () => { it('getBlocksByCardId 接收 { cardId, status } 时应转调已有双参签名', async () => { const service = createService(seedBlocks()); const enabled = await service.getBlocksByCardId({ cardId: 100, status: 1, }); expect(enabled.map(b => b.id)).toEqual([1, 2]); const ordered = await service.getBlocksByCardId({ cardId: 100 }); expect(ordered.map(b => b.id)).toEqual([1, 3, 2]); }); it('batchSaveLayout 接收 { cardId, blocks } 时应转调已有双参签名', async () => { const service = createService(seedBlocks()); const saved = await service.batchSaveLayout({ cardId: 100, blocks: [ { id: 2, type: 'social', colSpan: 1, rowSpan: 1, title: '对象体同步', }, ], }); expect(saved).toHaveLength(1); expect(saved[0].id).toBe(2); expect(saved[0].sortOrder).toBe(0); expect(saved[0].title).toBe('对象体同步'); const remaining = await service.getBlocksByCardId(100); expect(remaining).toHaveLength(1); expect(remaining[0].id).toBe(2); const other = await service.getBlocksByCardId(200); expect(other).toHaveLength(1); expect(other[0].id).toBe(4); }); it('toggleStatus 接收 { id, targetStatus } 时应转调已有双参签名', async () => { const service = createService(seedBlocks()); const flipped = await service.toggleStatus({ id: 1 }); expect(flipped.status).toBe(0); const explicit = await service.toggleStatus({ id: 1, targetStatus: 1 }); expect(explicit.status).toBe(1); const dual = await service.toggleStatus(1, 0); expect(dual.status).toBe(0); await expect(service.toggleStatus({ id: 99999 })).rejects.toThrow( 'Bento 网格块不存在' ); }); }); });