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); }); describe('GET /open/card/portal/mock-cards (ALLOW_PORTAL_MOCK)', () => { const prevAllow = process.env.ALLOW_PORTAL_MOCK; afterEach(() => { if (prevAllow === undefined) { delete process.env.ALLOW_PORTAL_MOCK; } else { process.env.ALLOW_PORTAL_MOCK = prevAllow; } }); it('默认(未开启 ALLOW_PORTAL_MOCK)应返回空数组,不含演示姓名', async () => { delete process.env.ALLOW_PORTAL_MOCK; const res = await portalController.mockCards(); expect(res.code).toBe(1000); expect(Array.isArray((res as any).data)).toBe(true); expect((res as any).data).toEqual([]); const payload = JSON.stringify((res as any).data); expect(payload).not.toContain('Alex Zheng'); expect(payload).not.toContain('Marcus Weber'); }); it('ALLOW_PORTAL_MOCK=1 时才返回原演示名片清单', async () => { process.env.ALLOW_PORTAL_MOCK = '1'; const res = await portalController.mockCards(); expect(res.code).toBe(1000); const list = (res as any).data; expect(Array.isArray(list)).toBe(true); expect(list.length).toBeGreaterThanOrEqual(6); expect(list[0].slug).toBe('alex-zheng'); expect(list[0].name).toBe('Alex Zheng'); expect(list[0].bentoBlocksCount).toBeGreaterThanOrEqual(5); }); }); }); describe('6. suggestedLocale 浅拷贝且不污染缓存 (T18)', () => { it('不同 suggestedLocale 应命中同一名片缓存,且互不覆盖', async () => { const zh = await cardPortalService.getPortalDetail(101, 'zh-CN'); expect(zh.suggestedLocale).toBe('zh-CN'); expect(cardFindOneSpy).toHaveBeenCalledTimes(1); const ar = await cardPortalService.getPortalDetail(101, 'ar-SA'); expect(ar.suggestedLocale).toBe('ar-SA'); expect(zh.suggestedLocale).toBe('zh-CN'); expect(cardFindOneSpy).toHaveBeenCalledTimes(1); expect(bentoFindSpy).toHaveBeenCalledTimes(1); }); it('控制器 query.lang 应优先于 Accept-Language 与 geo country', async () => { portalController.ctx = { headers: { 'accept-language': 'en-US', 'cf-ipcountry': 'US', }, }; const res = await portalController.detail(101, undefined, undefined, 'zh'); expect(res.code).toBe(1000); expect( (res as unknown as { data: { suggestedLocale?: string } }).data .suggestedLocale ).toBe('zh-CN'); }); }); });