import { CardBentoEntity } from '../../../src/modules/card/entity/bento'; import { CardBentoService } from '../../../src/modules/card/service/bento'; import { BentoBlockType, BentoColSpan, BentoRowSpan, } from '../../../src/comm/types/omni'; /** * 模拟 TypeORM Repository,支持在无需真实数据库连接的环境下进行高保真单元测试 */ class MockCardBentoRepository { private data: CardBentoEntity[] = []; private currentId = 1; constructor(initialData: Partial[] = []) { 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?: any): Promise { let result = [...this.data]; if (options?.where) { const where = options.where; result = result.filter(item => { for (const key of Object.keys(where)) { if (where[key] !== undefined && item[key] !== where[key]) { return false; } } return true; }); } if (options?.order) { const orderKeys = Object.keys(options.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 CardBentoEntity)); } async findOneBy(where: any): Promise { const found = this.data.find(item => { for (const key of Object.keys(where)) { if (where[key] !== undefined && item[key] !== where[key]) { return false; } } return true; }); 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)); } 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] }; } } const newEntity = this.createEntity(item); this.data.push(newEntity); return { ...newEntity }; } async delete(condition: any): 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 if (condition && typeof condition === 'object') { this.data = this.data.filter(item => { for (const key of Object.keys(condition)) { if (condition[key] !== undefined && item[key] === condition[key]) { return false; } } return true; }); } return { affected: initialLen - this.data.length }; } async update(criteria: any, partialEntity: any): Promise { const id = typeof criteria === 'object' ? criteria.id : criteria; const target = this.data.find(d => d.id === Number(id)); if (target) { Object.assign(target, partialEntity, { updateTime: new Date() }); } } // 辅助获取内部数据快照进行校验 getSnapshot(): CardBentoEntity[] { return [...this.data]; } } describe('后端微官网模块 (modules/card) - Bento Grid 便当盒组件树服务 TDD 测试', () => { let service: CardBentoService; let mockRepo: MockCardBentoRepository; beforeEach(() => { mockRepo = new MockCardBentoRepository([ { 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, }, ]); service = new CardBentoService(); // 注入 mock repo service.cardBentoEntity = mockRepo as any; }); describe('1. 实体与契约类型约束 (Entity & Contract Verification)', () => { it('应正确创建 CardBentoEntity 实例且具有默认属性与字段', () => { const entity = new CardBentoEntity(); entity.cardId = 101; entity.type = 'article'; entity.colSpan = 1; entity.rowSpan = 1; entity.sortOrder = 0; entity.title = '技术洞察'; entity.status = 1; expect(entity.cardId).toBe(101); expect(entity.type).toBe('article'); expect(entity.colSpan).toBe(1); expect(entity.rowSpan).toBe(1); expect(entity.sortOrder).toBe(0); expect(entity.status).toBe(1); }); it('所有 BentoBlockType 支持的核心类型必须有效', () => { const validTypes: BentoBlockType[] = [ 'hero', 'case', 'article', 'social', 'video', 'form', ]; validTypes.forEach(t => { const entity = new CardBentoEntity(); entity.type = t; expect(entity.type).toBe(t); }); }); }); describe('2. 获取有序 Bento 网格块列表 (getBlocksByCardId)', () => { it('按 cardId 获取时应严格按照 sortOrder 升序排列', async () => { const blocks = await service.getBlocksByCardId(100); expect(blocks).toHaveLength(3); expect(blocks[0].id).toBe(1); // sortOrder: 1 expect(blocks[1].id).toBe(3); // sortOrder: 2 expect(blocks[2].id).toBe(2); // sortOrder: 3 }); it('支持按 status 进行状态过滤(例如仅获取已启用的组件)', async () => { const enabledBlocks = await service.getBlocksByCardId(100, { status: 1, }); expect(enabledBlocks).toHaveLength(2); expect(enabledBlocks.map(b => b.id)).toEqual([1, 2]); const disabledBlocks = await service.getBlocksByCardId(100, { status: 0, }); expect(disabledBlocks).toHaveLength(1); expect(disabledBlocks[0].id).toBe(3); }); it('查询不存在的名片时应安全返回空数组', async () => { const blocks = await service.getBlocksByCardId(9999); expect(blocks).toEqual([]); }); }); describe('3. 批量保存与更新 Bento 网格布局 (batchSaveLayout)', () => { it('应支持全量布局保存:更新已有块、新增新块、并自动清理废弃块', async () => { const newLayout = [ { id: 2, // 已有块,更新排序 type: 'social' as BentoBlockType, colSpan: 1 as BentoColSpan, rowSpan: 1 as BentoRowSpan, title: '更新后的社交媒体', }, { // 新块,无 ID 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); // 检查自动按数组下标重新编号 sortOrder 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'); // 验证名片 100 下未在本次提交中的旧块(id: 1 与 id: 3)已被清理 const remaining100 = await service.getBlocksByCardId(100); expect(remaining100).toHaveLength(2); const remainingIds = remaining100.map(b => b.id); expect(remainingIds).toContain(2); expect(remainingIds).not.toContain(1); expect(remainingIds).not.toContain(3); // 其他名片 (cardId: 200) 的数据不受任何影响 const otherCardBlocks = await service.getBlocksByCardId(200); expect(otherCardBlocks).toHaveLength(1); expect(otherCardBlocks[0].id).toBe(4); }); it('栅格尺寸必须被严格约束在 colSpan 1~2, rowSpan 1~2', async () => { const abnormalLayout = [ { type: 'hero' as BentoBlockType, colSpan: 5 as any, // 超出上限 rowSpan: 0 as any, // 低于下限 title: '边界测试1', }, { type: 'case' as BentoBlockType, colSpan: -1 as any, // 低于下限 rowSpan: 10 as any, // 超出上限 title: '边界测试2', }, ]; const saved = await service.batchSaveLayout(100, abnormalLayout); expect(saved[0].colSpan).toBe(2); expect(saved[0].rowSpan).toBe(1); expect(saved[1].colSpan).toBe(1); expect(saved[1].rowSpan).toBe(2); }); it('应正确处理 metaJson(支持字符串自动解析为对象与直接对象赋值)', async () => { const layoutWithMeta = [ { type: 'form' as BentoBlockType, colSpan: 2 as BentoColSpan, rowSpan: 1 as BentoRowSpan, title: '商务留资表单', metaJson: JSON.stringify({ fields: ['name', 'email', 'country', 'requirement'], buttonText: 'Get Quote', }) as any, }, { type: 'social' as BentoBlockType, colSpan: 1 as BentoColSpan, rowSpan: 1 as BentoRowSpan, title: '社媒', metaJson: { platforms: ['whatsapp', 'wechat'], }, }, ]; const saved = await service.batchSaveLayout(100, layoutWithMeta); expect(typeof saved[0].metaJson).toBe('object'); expect((saved[0].metaJson as any).buttonText).toBe('Get Quote'); expect((saved[1].metaJson as any).platforms).toEqual(['whatsapp', 'wechat']); }); }); describe('4. 单块增删改查与状态切换 (Single Block Operations & Toggle)', () => { it('getBlockById 能根据主键返回对应块或 null', async () => { const block = await service.getBlockById(1); expect(block).not.toBeNull(); expect(block?.id).toBe(1); expect(block?.title).toBe('个人简介与身份卡'); const nonExistent = await service.getBlockById(99999); expect(nonExistent).toBeNull(); }); it('saveBlock 能够单独新增或更新单个块', async () => { const updated = await service.saveBlock({ id: 1, title: '全新身份标签', colSpan: 2, }); expect(updated.title).toBe('全新身份标签'); const created = await service.saveBlock({ cardId: 100, type: 'article', title: '全球商务白皮书', colSpan: 1, rowSpan: 1, }); expect(created.id).toBeDefined(); expect(created.title).toBe('全球商务白皮书'); }); it('deleteBlock 应能成功删除单个块', async () => { await service.deleteBlock(1); const block = await service.getBlockById(1); expect(block).toBeNull(); }); it('deleteBlocksByCardId 应能彻底清空指定名片的所有 Bento 块', async () => { await service.deleteBlocksByCardId(100); const blocks100 = await service.getBlocksByCardId(100); expect(blocks100).toHaveLength(0); // 其他名片不受影响 const blocks200 = await service.getBlocksByCardId(200); expect(blocks200).toHaveLength(1); }); it('toggleStatus 应支持显式传参设置状态,也支持无参时在 0 和 1 间翻转', async () => { // 块 1 初始 status = 1 const toggled1 = await service.toggleStatus(1); expect(toggled1.status).toBe(0); const toggled2 = await service.toggleStatus(1); expect(toggled2.status).toBe(1); // 显式指定状态 const explicit = await service.toggleStatus(1, 0); expect(explicit.status).toBe(0); // 不存在的块应抛出友好错误 await expect(service.toggleStatus(99999)).rejects.toThrow( 'Bento 网格块不存在' ); }); }); });