feat(backend): 实现公开短链路由与秒级微官网详情 API (Task 08)

这个提交包含在:
wikiboss
2026-09-20 17:30:44 +08:00
父节点 2c3906b634
当前提交 7e0d9ee11b
共有 12 个文件被更改,包括 1935 次插入 和 1 次删除

查看文件

@@ -0,0 +1,428 @@
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 网格块不存在'
);
});
});
});

查看文件

@@ -0,0 +1,166 @@
import { getMetadataArgsStorage } from 'typeorm';
import { BaseEntity, transformerTime } from '../../../src/comm/entity/base';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CardProfile, CardStatus } from '../../../src/comm/types/omni';
describe('后端统一实体基类与名片实体 (Entity & BaseEntity)', () => {
const metadata = getMetadataArgsStorage();
const matchEntity = (target: any) =>
target === BaseEntity || target === CardInfoEntity || (target && target.prototype instanceof BaseEntity);
describe('1. 通用实体基类 (comm/entity/base.ts)', () => {
it('BaseEntity 应定义标准字段: id, tenantId, createTime, updateTime, deletedAt', () => {
const columns = metadata.columns.filter(c => matchEntity(c.target));
const columnNames = columns.map(c => c.propertyName);
expect(columnNames).toContain('id');
expect(columnNames).toContain('tenantId');
expect(columnNames).toContain('createTime');
expect(columnNames).toContain('updateTime');
expect(columnNames).toContain('deletedAt');
});
it('基类字段的数据库列名应严格遵循下划线命名规范', () => {
const columns = metadata.columns.filter(c => matchEntity(c.target));
const tenantIdCol = columns.find(c => c.propertyName === 'tenantId');
expect(tenantIdCol?.options.name).toBe('tenant_id');
const createTimeCol = columns.find(c => c.propertyName === 'createTime');
expect(createTimeCol?.options.name).toBe('create_time');
const updateTimeCol = columns.find(c => c.propertyName === 'updateTime');
expect(updateTimeCol?.options.name).toBe('update_time');
const deletedAtCol = columns.find(c => c.propertyName === 'deletedAt');
expect(deletedAtCol?.options.name).toBe('deleted_at');
});
it('deletedAt 必须被标记为软删除列或具备软删除特性', () => {
const deleteDateCols = metadata.columns.filter(
c => matchEntity(c.target) && c.propertyName === 'deletedAt'
);
expect(deleteDateCols.length).toBeGreaterThan(0);
expect(deleteDateCols[0].mode).toBe('deleteDate');
});
it('基类索引应正确声明在 tenantId 上以保障多租户隔离查询性能', () => {
const indexes = metadata.indices.filter(i => matchEntity(i.target));
const tenantIndex = indexes.find(i => {
if (Array.isArray(i.columns)) {
return i.columns.includes('tenantId') || i.columns.includes('tenant_id');
}
if (typeof i.columns === 'function') {
return (i.columns as any)({ tenantId: true });
}
return false;
});
expect(tenantIndex).toBeDefined();
});
it('transformerTime 应保证时间格式化正确', () => {
const formatted = transformerTime.to('2026-09-20 12:00:00');
expect(formatted).toBe('2026-09-20 12:00:00');
const currentFormatted = transformerTime.to(undefined);
expect(currentFormatted).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
});
describe('2. 名片实体 (modules/card/entity/info.ts)', () => {
it('实体表名必须为 card_info', () => {
const table = metadata.tables.find(t => t.target === CardInfoEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('card_info');
});
it('应完整包含 CardProfile 契约与扩展字段,且全部使用下划线数据库列名', () => {
const columns = metadata.columns.filter(c => c.target === CardInfoEntity);
const expectedColumnMappings: Record<string, string> = {
userId: 'user_id',
name: 'name',
title: 'title',
company: 'company',
slogan: 'slogan',
avatarUrl: 'avatar_url',
phone: 'phone',
email: 'email',
whatsapp: 'whatsapp',
linkedin: 'linkedin',
timezone: 'timezone',
audioPronunciationUrl: 'audio_pronunciation_url',
status: 'status',
themeCode: 'theme_code',
slug: 'slug',
};
for (const [prop, dbCol] of Object.entries(expectedColumnMappings)) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.name || col?.propertyName).toBe(dbCol);
}
});
it('slug 字段必须设置唯一索引 (unique: true) 用于短链与 NFC 极速路由', () => {
const indexes = metadata.indices.filter(i => i.target === CardInfoEntity);
const slugIndex = indexes.find(i => {
const matchProp = Array.isArray(i.columns) && (i.columns.includes('slug') || (i.columns as any[])[0] === 'slug');
return matchProp && i.unique === true;
});
expect(slugIndex).toBeDefined();
expect(slugIndex?.unique).toBe(true);
});
it('userId 必须设置索引以优化个人名片列表查询', () => {
const indexes = metadata.indices.filter(i => i.target === CardInfoEntity);
const userIndex = indexes.find(i => {
if (Array.isArray(i.columns)) {
return i.columns.includes('userId') || i.columns.includes('user_id');
}
return false;
});
expect(userIndex).toBeDefined();
});
it('默认值设置应合理且符合规范 (status=\'draft\', themeCode=\'corporate_navy\', timezone=\'UTC+8\')', () => {
const columns = metadata.columns.filter(c => c.target === CardInfoEntity);
const statusCol = columns.find(c => c.propertyName === 'status');
expect(statusCol?.options.default).toBe('draft');
const themeCol = columns.find(c => c.propertyName === 'themeCode');
expect(themeCol?.options.default).toBe('corporate_navy');
const tzCol = columns.find(c => c.propertyName === 'timezone');
expect(tzCol?.options.default).toBe('UTC+8');
});
it('CardInfoEntity 实例应能赋值并完全满足 CardProfile 契约接口', () => {
const card = new CardInfoEntity();
card.id = 1;
card.tenantId = 100;
card.userId = 10;
card.name = 'Alex Zhang';
card.title = 'Global BD Director';
card.company = 'OmniCard Global Ltd.';
card.slogan = 'Connecting Global Business Seamlessly';
card.avatarUrl = 'https://cdn.omnicard.ai/avatar/alex.jpg';
card.phone = '+86 13800138000';
card.email = 'alex@omnicard.ai';
card.whatsapp = '+8613800138000';
card.linkedin = 'https://linkedin.com/in/alex-zhang';
card.timezone = 'UTC+8';
card.audioPronunciationUrl = 'https://cdn.omnicard.ai/audio/alex.mp3';
card.status = 'published';
card.themeCode = 'tech_dark';
card.slug = 'alex-zhang-bd';
// 类型断言与赋值检查
const profile: CardProfile = card;
expect(profile.name).toBe('Alex Zhang');
expect(profile.status).toBe('published');
expect(profile.themeCode).toBe('tech_dark');
expect(card.slug).toBe('alex-zhang-bd');
});
});
});

查看文件

@@ -0,0 +1,347 @@
import { CardPortalService } from '../../../src/modules/card/service/portal';
import { OpenCardPortalController } from '../../../src/modules/card/controller/open/portal';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CardBentoEntity } from '../../../src/modules/card/entity/bento';
import { CoolCommException } from '@cool-midway/core';
describe('Task 08: 后端公开短链路由与秒级微官网详情 API (Card Portal & Open Router)', () => {
let cardPortalService: CardPortalService;
let portalController: OpenCardPortalController;
// 模拟名片数据仓储
const mockCardData: CardInfoEntity[] = [
{
id: 101,
tenantId: 1,
slug: 'alex-zheng',
name: 'Alex Zheng',
title: 'VP of Global Expansion',
company: 'OmniCard Global Inc.',
slogan: 'Empowering Global Business Connections',
avatarUrl: 'https://cdn.omnicard.ai/avatars/alex.png',
phone: '+86-13800138000',
email: 'alex@omnicard.ai',
whatsapp: '+8613800138000',
linkedin: 'https://linkedin.com/in/alex-zheng',
timezone: 'Asia/Shanghai',
audioPronunciationUrl: 'https://cdn.omnicard.ai/audio/alex.mp3',
status: 'published',
themeCode: 'corporate_navy',
// 敏感内部数据
userId: 9999,
remark: '重点出海客户核心高管,年费 VIP 专属',
internalConfig: '{"rateLimit": 1000, "debug": true, "internalTenantSecret": "sk_live_xyz"}',
createTime: new Date(),
updateTime: new Date(),
} as CardInfoEntity,
{
id: 102,
tenantId: 1,
slug: 'draft-user',
name: 'Draft User',
title: 'Engineer',
company: 'OmniCard',
status: 'draft', // 未发布草稿
userId: 8888,
remark: '草稿名片,禁止外网访问',
createTime: new Date(),
updateTime: new Date(),
} as CardInfoEntity,
{
id: 103,
tenantId: 1,
slug: 'archived-user',
name: 'Archived User',
title: 'Advisor',
company: 'OmniCard',
status: 'archived', // 归档下线
userId: 7777,
remark: '离职已下线',
createTime: new Date(),
updateTime: new Date(),
} as CardInfoEntity,
];
// 模拟 Bento 组件块数据
const mockBentoData: CardBentoEntity[] = [
{
id: 1,
cardId: 101,
type: 'article',
colSpan: 1,
rowSpan: 1,
sortOrder: 3,
title: '出海 SaaS 落地指南',
content: '深度解读全球数字名片与微官网搭建...',
status: 1, // 有效
internalNotes: '文章需由法务团队二次审核', // 敏感
} as CardBentoEntity,
{
id: 2,
cardId: 101,
type: 'hero',
colSpan: 2,
rowSpan: 1,
sortOrder: 1,
title: '关于我',
content: '10 年全球出海商务拓展经验',
status: 1, // 有效
internalNotes: '置顶 Hero 块',
} as CardBentoEntity,
{
id: 3,
cardId: 101,
type: 'case',
colSpan: 1,
rowSpan: 1,
sortOrder: 2,
title: '成功案例:NFC 智能会展',
content: '帮数百家品牌在 GITEX 达成合作',
status: 1, // 有效
internalNotes: '含未公开客户名称',
} as CardBentoEntity,
{
id: 4,
cardId: 101,
type: 'form',
colSpan: 2,
rowSpan: 1,
sortOrder: 4,
title: '内部调查问卷 (草稿/已禁用)',
status: 0, // 禁用/草稿,严禁返回
internalNotes: '已下线的测试表单',
} as CardBentoEntity,
];
let cardFindOneSpy: jest.Mock;
let bentoFindSpy: jest.Mock;
beforeEach(() => {
// 构造具备监控能力的 Mock Repository
cardFindOneSpy = jest.fn().mockImplementation(async options => {
const where = options?.where;
if (!where) return null;
if (Array.isArray(where)) {
for (const w of where) {
const matched = mockCardData.find(c => {
if (w.id !== undefined && c.id === Number(w.id)) return true;
if (w.slug !== undefined && c.slug === String(w.slug)) return true;
return false;
});
if (matched) return { ...matched };
}
return null;
}
return (
mockCardData.find(c => {
if (where.id !== undefined && c.id === Number(where.id)) return true;
if (where.slug !== undefined && c.slug === String(where.slug)) return true;
return false;
}) ?? null
);
});
bentoFindSpy = jest.fn().mockImplementation(async options => {
const cardId = options?.where?.cardId;
const status = options?.where?.status;
return mockBentoData
.filter(b => b.cardId === cardId && (status === undefined || b.status === status))
.map(b => ({ ...b }));
});
const mockCardRepo: any = {
findOne: cardFindOneSpy,
};
const mockBentoRepo: any = {
find: bentoFindSpy,
};
CardPortalService.clearAllCache();
cardPortalService = new CardPortalService();
cardPortalService.cardInfoEntity = mockCardRepo;
cardPortalService.cardBentoEntity = mockBentoRepo;
portalController = new OpenCardPortalController();
portalController.cardPortalService = cardPortalService;
});
describe('1. 核心查询与聚合逻辑 (Detail by ID & Slug)', () => {
it('应根据名片 ID 正确获取已发布名片详情并自动聚合有效 Bento 块列表', async () => {
const detail = await cardPortalService.getPortalDetail(101);
expect(detail).toBeDefined();
expect(detail.id).toBe(101);
expect(detail.name).toBe('Alex Zheng');
expect(detail.slug).toBe('alex-zheng');
expect(detail.company).toBe('OmniCard Global Inc.');
expect(detail.status).toBe('published');
// 验证 Bento 列表聚合
expect(detail.bentoBlocks).toBeDefined();
expect(detail.bentoBlocks.length).toBe(3); // 剔除了 status = 0 的块
// 验证排序:必须严格按照 sortOrder 升序排列 (1, 2, 3)
expect(detail.bentoBlocks[0].sortOrder).toBe(1);
expect(detail.bentoBlocks[0].type).toBe('hero');
expect(detail.bentoBlocks[1].sortOrder).toBe(2);
expect(detail.bentoBlocks[1].type).toBe('case');
expect(detail.bentoBlocks[2].sortOrder).toBe(3);
expect(detail.bentoBlocks[2].type).toBe('article');
});
it('应根据自定义短链 (Slug) 正确查询名片与微官网数据', async () => {
const detail = await cardPortalService.getPortalDetail('alex-zheng');
expect(detail).toBeDefined();
expect(detail.slug).toBe('alex-zheng');
expect(detail.id).toBe(101);
expect(detail.title).toBe('VP of Global Expansion');
expect(detail.bentoBlocks.length).toBe(3);
});
it('数字字符串类型的 ID 也应兼容处理', async () => {
const detail = await cardPortalService.getPortalDetail('101');
expect(detail.id).toBe(101);
expect(detail.name).toBe('Alex Zheng');
});
});
describe('2. 敏感信息脱敏与资产过滤安全性 (Data Privacy & Security)', () => {
it('必须严格脱敏员工账号 userId、后台备注 remark、租户内部配置 internalConfig 等敏感字段', async () => {
const detail: any = await cardPortalService.getPortalDetail(101);
// 验证名片主表敏感信息已完全剔除
expect(detail.userId).toBeUndefined();
expect(detail.remark).toBeUndefined();
expect(detail.internalConfig).toBeUndefined();
expect(detail.createTime).toBeUndefined();
expect(detail.updateTime).toBeUndefined();
// 验证 Bento 组件块敏感信息已完全剔除
for (const block of detail.bentoBlocks) {
expect((block as any).internalNotes).toBeUndefined();
expect((block as any).status).toBeUndefined(); // status 内部状态无需向公网访客暴露
}
});
it('仅返回对外公开展示的商务形象资产与必要交互字段', async () => {
const detail = await cardPortalService.getPortalDetail(101);
expect(detail).toHaveProperty('id');
expect(detail).toHaveProperty('slug');
expect(detail).toHaveProperty('name');
expect(detail).toHaveProperty('title');
expect(detail).toHaveProperty('company');
expect(detail).toHaveProperty('slogan');
expect(detail).toHaveProperty('avatarUrl');
expect(detail).toHaveProperty('phone');
expect(detail).toHaveProperty('email');
expect(detail).toHaveProperty('whatsapp');
expect(detail).toHaveProperty('linkedin');
expect(detail).toHaveProperty('timezone');
expect(detail).toHaveProperty('audioPronunciationUrl');
expect(detail).toHaveProperty('themeCode');
expect(detail).toHaveProperty('status');
expect(detail).toHaveProperty('bentoBlocks');
});
});
describe('3. 名片状态拦截与防泄露 (Status Interception & 404)', () => {
it('查询草稿状态 (draft) 名片时应拒绝并抛出 404 异常', async () => {
await expect(cardPortalService.getPortalDetail(102)).rejects.toThrow(CoolCommException);
await expect(cardPortalService.getPortalDetail('draft-user')).rejects.toThrow(
/未发布或已下线/
);
});
it('查询已归档下线 (archived) 名片时应拒绝并抛出 404 异常', async () => {
await expect(cardPortalService.getPortalDetail(103)).rejects.toThrow(CoolCommException);
await expect(cardPortalService.getPortalDetail('archived-user')).rejects.toThrow(
/未发布或已下线/
);
});
it('查询不存在的名片 ID 或 Slug 时应抛出 404 异常', async () => {
await expect(cardPortalService.getPortalDetail(99999)).rejects.toThrow(CoolCommException);
await expect(cardPortalService.getPortalDetail('not-exist-slug')).rejects.toThrow(
/不存在或已下线/
);
});
it('缺少名片标识参数时应抛出 400 参数异常', async () => {
await expect(cardPortalService.getPortalDetail('')).rejects.toThrow(CoolCommException);
await expect(cardPortalService.getPortalDetail(null as any)).rejects.toThrow(/缺少名片标识/);
});
});
describe('4. 高并发秒开缓存支持 (< 500ms Caching Optimization)', () => {
it('相同 ID 或 Slug 的多次连续请求应直接命中缓存,无需重复扫描底层存储', async () => {
// 首次查询:触发存储查询
const detail1 = await cardPortalService.getPortalDetail('alex-zheng');
expect(detail1).toBeDefined();
expect(cardFindOneSpy).toHaveBeenCalledTimes(1);
expect(bentoFindSpy).toHaveBeenCalledTimes(1);
// 第二次查询:命中缓存,调用次数保持为 1
const detail2 = await cardPortalService.getPortalDetail('alex-zheng');
expect(detail2).toEqual(detail1);
expect(cardFindOneSpy).toHaveBeenCalledTimes(1);
expect(bentoFindSpy).toHaveBeenCalledTimes(1);
// 通过 ID 查询同一名片也能命中 ID 关联缓存
const detailById = await cardPortalService.getPortalDetail(101);
expect(detailById.id).toBe(101);
expect(cardFindOneSpy).toHaveBeenCalledTimes(1);
// 数据中附带毫秒级缓存时间戳
expect(detail1.cachedAt).toBeDefined();
expect(typeof detail1.cachedAt).toBe('number');
});
it('当名片发布或编辑后,调用 clearPortalCache 应正确失效缓存以重新加载', async () => {
await cardPortalService.getPortalDetail(101);
expect(cardFindOneSpy).toHaveBeenCalledTimes(1);
// 主动清除缓存
await cardPortalService.clearPortalCache(101);
// 再次查询:重新穿透到底层查询
await cardPortalService.getPortalDetail(101);
expect(cardFindOneSpy).toHaveBeenCalledTimes(2);
expect(bentoFindSpy).toHaveBeenCalledTimes(2);
});
});
describe('5. 控制器路由与出参规范 (OpenCardPortalController)', () => {
it('GET /open/card/portal/detail 应支持 query 入参并返回统一 success 响应', async () => {
const resById = await portalController.detail(101, undefined, undefined);
expect(resById.code).toBe(1000);
expect(resById.message).toBe('success');
expect((resById as any).data.id).toBe(101);
expect((resById as any).data.name).toBe('Alex Zheng');
const resBySlug = await portalController.detail(undefined, 'alex-zheng', undefined);
expect(resBySlug.code).toBe(1000);
expect((resBySlug as any).data.slug).toBe('alex-zheng');
const resBySlugOrId = await portalController.detail(undefined, undefined, 'alex-zheng');
expect(resBySlugOrId.code).toBe(1000);
expect((resBySlugOrId as any).data.slug).toBe('alex-zheng');
});
it('GET /open/card/portal/:slugOrId 路径路由应正确转发并返回详情', async () => {
const res = await portalController.getBySlugOrId('alex-zheng');
expect(res.code).toBe(1000);
expect(res.message).toBe('success');
expect((res as any).data.slug).toBe('alex-zheng');
expect((res as any).data.bentoBlocks.length).toBe(3);
});
it('未传递有效参数时控制器应友好响应错误', async () => {
const res = await portalController.detail(undefined, undefined, undefined);
expect(res.code).not.toBe(1000);
});
});
});