429 行
14 KiB
TypeScript
429 行
14 KiB
TypeScript
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<CardBentoEntity>[] = []) {
|
||
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>): 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<CardBentoEntity[]> {
|
||
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<CardBentoEntity | null> {
|
||
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<CardBentoEntity> | Array<Partial<CardBentoEntity>>
|
||
): Promise<any> {
|
||
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<void> {
|
||
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 网格块不存在'
|
||
);
|
||
});
|
||
});
|
||
});
|