348 行
13 KiB
TypeScript
348 行
13 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|