文件
OmniCard/backend/test/modules/card/bento.test.ts

429 行
14 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

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 网格块不存在'
);
});
});
});