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

348 行
13 KiB
TypeScript

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

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

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);
});
});
});