feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)

- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换
- 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片
- 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转
- 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出
- 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志
- 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票
- 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏
- 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
wikiboss
2026-09-20 23:47:47 +08:00
父节点 acd10ffb59
当前提交 eec259a3a7
共有 276 个文件被更改,包括 54906 次插入 和 145 次删除

查看文件

@@ -0,0 +1,430 @@
import * as fs from 'fs';
import * as path from 'path';
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core';
import { AdminCardBentoController } from '../../../src/modules/card/controller/admin/bento';
import { CardBentoEntity } from '../../../src/modules/card/entity/bento';
import { CardBentoService } from '../../../src/modules/card/service/bento';
import type {
BentoBlockType,
BentoColSpan,
BentoRowSpan,
} from '../../../src/comm/types/omni';
/**
* CoolController 元数据(脚本路径解析为异步)
*/
interface CoolControllerMeta {
prefix?: string;
curdOption?: CurdOption;
}
/**
* 等待 CoolController 将 CRUD 配置写入类元数据
*/
async function waitCoolMeta(
target: new (...args: never[]) => unknown
): Promise<CoolControllerMeta> {
const started = Date.now();
while (Date.now() - started < 2000) {
const meta = getClassMetadata(CONTROLLER_KEY, target) as
| CoolControllerMeta
| undefined;
if (meta?.curdOption) {
return meta;
}
await new Promise<void>(resolve => setImmediate(resolve));
}
throw new Error(`${target.name} 的 CoolController 元数据未就绪`);
}
function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] {
return apis.map(api => (typeof api === 'string' ? api : api.method));
}
interface BentoFindOptions {
where?: Partial<Pick<CardBentoEntity, 'id' | 'cardId' | 'status'>>;
order?: Partial<Record<'id' | 'sortOrder', 'ASC' | 'DESC' | 'asc' | 'desc'>>;
}
/**
* 内存 Repository,覆盖 getBlocksByCardId / batchSaveLayout / toggleStatus 所需能力
*/
class MemoryBentoRepo {
private data: CardBentoEntity[] = [];
private currentId = 1;
constructor(initialData: Array<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?: BentoFindOptions): Promise<CardBentoEntity[]> {
let result = [...this.data];
if (options?.where) {
const where = options.where;
result = result.filter(item => {
const keys = Object.keys(where) as Array<keyof typeof where>;
return keys.every(key => {
const expected = where[key];
return expected === undefined || item[key] === expected;
});
});
}
if (options?.order) {
const orderKeys = Object.keys(options.order) as Array<
keyof NonNullable<BentoFindOptions['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: Partial<Pick<CardBentoEntity, 'id' | 'cardId' | 'status'>>
): Promise<CardBentoEntity | null> {
const found = this.data.find(item => {
const keys = Object.keys(where) as Array<keyof typeof where>;
return keys.every(key => {
const expected = where[key];
return expected === undefined || item[key] === expected;
});
});
return found ? ({ ...found } as CardBentoEntity) : null;
}
async save(
entityOrEntities: Partial<CardBentoEntity> | Array<Partial<CardBentoEntity>>
): Promise<CardBentoEntity | CardBentoEntity[]> {
if (Array.isArray(entityOrEntities)) {
const savedList: CardBentoEntity[] = [];
for (const item of entityOrEntities) {
savedList.push((await this.save(item)) as CardBentoEntity);
}
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] } as CardBentoEntity;
}
}
const newEntity = this.createEntity(item);
this.data.push(newEntity);
return { ...newEntity } as CardBentoEntity;
}
async delete(
condition: number | string | number[] | Partial<Pick<CardBentoEntity, 'id' | 'cardId'>>
): 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 {
this.data = this.data.filter(item => {
const keys = Object.keys(condition) as Array<keyof typeof condition>;
const matched = keys.some(key => {
const expected = condition[key];
return expected !== undefined && item[key] === expected;
});
return !matched;
});
}
return { affected: initialLen - this.data.length };
}
}
function createService(
seed: Array<Partial<CardBentoEntity>>
): CardBentoService {
const service = new CardBentoService();
service.cardBentoEntity =
new MemoryBentoRepo(seed) as unknown as CardBentoService['cardBentoEntity'];
return service;
}
function seedBlocks(): Array<Partial<CardBentoEntity>> {
return [
{
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,
},
];
}
describe('后台 Bento 画布 CRUD (Admin Card Bento CoolController)', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/bento.ts'
);
describe('1. 控制器装饰器与路由约定', () => {
let meta: CoolControllerMeta;
let option: CurdOption;
beforeAll(async () => {
meta = await waitCoolMeta(AdminCardBentoController);
option = meta.curdOption as CurdOption;
});
it('文件路径应符合 /admin/card/bento 自动路由约定', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
expect(controllerFile.replace(/\\/g, '/')).toMatch(
/modules\/card\/controller\/admin\/bento\.ts$/
);
});
it('路由前缀应为 /admin/card/bento', () => {
expect(meta.prefix).toBe('/admin/card/bento');
});
it('控制器应继承 BaseController', () => {
expect(AdminCardBentoController.prototype).toBeInstanceOf(BaseController);
});
it('装饰器 api 必须包含 add/delete/update/info/list/page', () => {
expect(option.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
});
it('应绑定 Bento 实体与 Bento 服务', () => {
expect(option.entity).toBe(CardBentoEntity);
expect(option.service).toBe(CardBentoService);
});
it('pageQueryOp 应支持 title/content 模糊检索', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.keyWordLikeFields).toEqual(['a.title', 'a.content']);
});
it('pageQueryOp 应支持 cardId/type/status 精确筛选,并按 sortOrder 升序', () => {
const pageQueryOp = option.pageQueryOp as {
keyWordLikeFields?: string[];
fieldEq?: string[];
addOrderBy?: Record<string, string>;
};
expect(pageQueryOp.fieldEq).toEqual(['a.cardId', 'a.type', 'a.status']);
expect(pageQueryOp.addOrderBy).toEqual({ sortOrder: 'asc' });
});
it('应将 getBlocksByCardId / batchSaveLayout / toggleStatus 注册为 serviceApis', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toEqual([
'getBlocksByCardId',
'batchSaveLayout',
'toggleStatus',
]);
const summaries = (option.serviceApis || [])
.filter((api): api is { method: string; summary: string } =>
typeof api !== 'string'
)
.map(api => ({ method: api.method, summary: api.summary }));
expect(summaries).toEqual([
{ method: 'getBlocksByCardId', summary: '按名片获取有序 Bento' },
{ method: 'batchSaveLayout', summary: '批量同步画布排版' },
{ method: 'toggleStatus', summary: '切换块启用状态' },
]);
});
});
describe('2. batchSaveLayout 旧双参行为不回归', () => {
it('应支持全量布局保存:更新已有块、新增新块、并自动清理废弃块', async () => {
const service = createService(seedBlocks());
const newLayout = [
{
id: 2,
type: 'social' as BentoBlockType,
colSpan: 1 as BentoColSpan,
rowSpan: 1 as BentoRowSpan,
title: '更新后的社交媒体',
},
{
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);
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'
);
const remaining100 = await service.getBlocksByCardId(100);
expect(remaining100.map(b => b.id)).toEqual([2, saved[1].id]);
expect(remaining100.map(b => b.id)).not.toContain(1);
expect(remaining100.map(b => b.id)).not.toContain(3);
const otherCardBlocks = await service.getBlocksByCardId(200);
expect(otherCardBlocks).toHaveLength(1);
expect(otherCardBlocks[0].id).toBe(4);
});
it('名片 ID 为空时应拒绝保存', async () => {
const service = createService(seedBlocks());
await expect(service.batchSaveLayout(0, [])).rejects.toThrow(
CoolCommException
);
await expect(service.batchSaveLayout(0, [])).rejects.toThrow(
'名片 ID 不能为空'
);
});
});
describe('3. serviceApis POST body 单参数对象转调', () => {
it('getBlocksByCardId 接收 { cardId, status } 时应转调已有双参签名', async () => {
const service = createService(seedBlocks());
const enabled = await service.getBlocksByCardId({
cardId: 100,
status: 1,
});
expect(enabled.map(b => b.id)).toEqual([1, 2]);
const ordered = await service.getBlocksByCardId({ cardId: 100 });
expect(ordered.map(b => b.id)).toEqual([1, 3, 2]);
});
it('batchSaveLayout 接收 { cardId, blocks } 时应转调已有双参签名', async () => {
const service = createService(seedBlocks());
const saved = await service.batchSaveLayout({
cardId: 100,
blocks: [
{
id: 2,
type: 'social',
colSpan: 1,
rowSpan: 1,
title: '对象体同步',
},
],
});
expect(saved).toHaveLength(1);
expect(saved[0].id).toBe(2);
expect(saved[0].sortOrder).toBe(0);
expect(saved[0].title).toBe('对象体同步');
const remaining = await service.getBlocksByCardId(100);
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(2);
const other = await service.getBlocksByCardId(200);
expect(other).toHaveLength(1);
expect(other[0].id).toBe(4);
});
it('toggleStatus 接收 { id, targetStatus } 时应转调已有双参签名', async () => {
const service = createService(seedBlocks());
const flipped = await service.toggleStatus({ id: 1 });
expect(flipped.status).toBe(0);
const explicit = await service.toggleStatus({ id: 1, targetStatus: 1 });
expect(explicit.status).toBe(1);
const dual = await service.toggleStatus(1, 0);
expect(dual.status).toBe(0);
await expect(service.toggleStatus({ id: 99999 })).rejects.toThrow(
'Bento 网格块不存在'
);
});
});
});

查看文件

@@ -0,0 +1,97 @@
import * as fs from 'fs';
import * as path from 'path';
import { BaseController } from '@cool-midway/core';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CardPortalService } from '../../../src/modules/card/service/portal';
import {
AdminCardInfoController,
adminCardInfoCurdOption,
} from '../../../src/modules/card/controller/admin/info';
import { CardInfoService } from '../../../src/modules/card/service/info';
describe('后台名片 CRUD (Admin Card Info CoolController)', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/info.ts'
);
describe('1. 控制器装饰器与路由约定', () => {
it('文件路径应符合 /admin/card/info 自动路由约定', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
expect(controllerFile.replace(/\\/g, '/')).toMatch(
/modules\/card\/controller\/admin\/info\.ts$/
);
});
it('控制器应继承 BaseController 并挂载 CoolController CRUD', () => {
expect(AdminCardInfoController.prototype).toBeInstanceOf(BaseController);
expect(adminCardInfoCurdOption.entity).toBe(CardInfoEntity);
expect(adminCardInfoCurdOption.service).toBe(CardInfoService);
});
it('装饰器 api 必须包含 add/delete/update/info/list/page', () => {
expect(adminCardInfoCurdOption.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
});
it('infoIgnoreProperty 必须包含 remark 与 internalConfig', () => {
expect(adminCardInfoCurdOption.infoIgnoreProperty).toEqual(
expect.arrayContaining(['remark', 'internalConfig'])
);
});
it('pageQueryOp 应支持 name 模糊搜索与 status 精确筛选', () => {
const pageQueryOp = adminCardInfoCurdOption.pageQueryOp;
expect(pageQueryOp.keyWordLikeFields).toEqual(
expect.arrayContaining(['a.name'])
);
expect(pageQueryOp.fieldEq).toEqual(expect.arrayContaining(['a.status']));
expect(pageQueryOp.addOrderBy).toEqual({ updateTime: 'desc' });
});
it('insertParam 应从后台登录态写入 userId', () => {
expect(
adminCardInfoCurdOption.insertParam({ admin: { userId: 42 } })
).toEqual({ userId: 42 });
expect(adminCardInfoCurdOption.insertParam({})).toEqual({
userId: undefined,
});
});
});
describe('2. modifyAfter 发布后清缓存', () => {
let service: CardInfoService;
let clearPortalCache: jest.Mock;
beforeEach(() => {
clearPortalCache = jest.fn().mockResolvedValue(undefined);
service = new CardInfoService();
service.cardPortalService = {
clearPortalCache,
} as unknown as CardPortalService;
});
it('add 时应按 id 或 slug 调用 clearPortalCache', async () => {
await service.modifyAfter({ id: 101, slug: 'alex-zheng' }, 'add');
expect(clearPortalCache).toHaveBeenCalledWith(101);
expect(clearPortalCache).toHaveBeenCalledWith('alex-zheng');
});
it('update 时应清缓存', async () => {
await service.modifyAfter({ id: 101 }, 'update');
expect(clearPortalCache).toHaveBeenCalledWith(101);
});
it('delete 时应按 id 列表清缓存', async () => {
await service.modifyAfter([101, 102], 'delete');
expect(clearPortalCache).toHaveBeenCalledWith(101);
expect(clearPortalCache).toHaveBeenCalledWith(102);
});
});
});

查看文件

@@ -0,0 +1,319 @@
import {
CardCalendarService,
generateIcsEvent,
formatIcsDate,
escapeIcsText,
unescapeIcsText,
foldIcsLine,
parseIcsEvent,
MeetingEventOptions,
} from '../../../src/modules/card/service/calendar';
import { OpenCardPortalController } from '../../../src/modules/card/controller/open/portal';
describe('Point 26: 国际商务日程预约排期与 ICS 日历订阅 (RFC 5545)', () => {
let calendarService: CardCalendarService;
let portalController: OpenCardPortalController;
beforeEach(() => {
calendarService = new CardCalendarService();
portalController = new OpenCardPortalController();
portalController.cardCalendarService = calendarService;
});
describe('1. 文本特殊字符转义规范 (RFC 5545 Section 3.3.11)', () => {
it('应正确转义反斜杠、分号、逗号和换行符', () => {
const rawText = 'Project Alpha, Phase 1; Notes: C:\\Files\nNext Line\r\nThird Line';
const escaped = escapeIcsText(rawText);
// 反斜杠转义为 \\
expect(escaped).toContain('C:\\\\Files');
// 逗号转义为 \,
expect(escaped).toContain('Alpha\\,');
// 分号转义为 \;
expect(escaped).toContain('Phase 1\\;');
// 换行转义为 \n
expect(escaped).toContain('\\nNext Line\\nThird Line');
// 不能包含真实的 CRLF
expect(escaped).not.toContain('\r');
expect(escaped).not.toContain('\n');
});
it('应正确反转义已转义的 ICS 文本', () => {
const escaped = 'Hello\\, World\\; Path: C:\\\\Docs\\nLine 2';
const unescaped = unescapeIcsText(escaped);
expect(unescaped).toBe('Hello, World; Path: C:\\Docs\nLine 2');
});
it('处理空字符串、null 或 undefined 时应安全返回空字符串', () => {
expect(escapeIcsText('')).toBe('');
expect(escapeIcsText(null as unknown as string)).toBe('');
expect(escapeIcsText(undefined as unknown as string)).toBe('');
expect(unescapeIcsText('')).toBe('');
expect(unescapeIcsText(null as unknown as string)).toBe('');
expect(unescapeIcsText(undefined as unknown as string)).toBe('');
});
});
describe('2. UTC 日期时间戳格式化规范 (RFC 5545 Section 3.3.5)', () => {
it('应将 Date 对象精准格式化为 YYYYMMDDTHHmmssZ 格式', () => {
// 2026-09-20 12:00:00 UTC
const date = new Date(Date.UTC(2026, 8, 20, 12, 0, 0));
const formatted = formatIcsDate(date);
expect(formatted).toBe('20260920T120000Z');
});
it('应支持 ISO 8601 字符串和时间戳毫秒数值输入', () => {
const isoStr = '2026-09-20T12:30:45.000Z';
expect(formatIcsDate(isoStr)).toBe('20260920T123045Z');
const timestamp = Date.UTC(2026, 11, 31, 23, 59, 59);
expect(formatIcsDate(timestamp)).toBe('20261231T235959Z');
});
it('月份与日期个位数时应补零对其', () => {
const date = new Date(Date.UTC(2026, 0, 5, 8, 5, 9));
expect(formatIcsDate(date)).toBe('20260105T080509Z');
});
it('非法日期参数输入时应抛出错误异常', () => {
expect(() => formatIcsDate('invalid-date')).toThrow();
});
});
describe('3. RFC 5545 iCalendar 事件生成 (generateIcsEvent)', () => {
const mockMeeting: MeetingEventOptions = {
uid: 'omnicard-meet-20260920-001@omnicard.ai',
summary: 'OmniCard AI 商务拓展战略会谈 (Alex & Partner)',
description: '讨论海外微官网落地方案, NFC 实体卡发行与访客雷达 CRM 对接。\n请准时参会。',
location: 'Zoom 会议室 (ID: 888-999-000), 新加坡滨海湾金融中心',
startTime: new Date(Date.UTC(2026, 8, 20, 14, 0, 0)),
endTime: new Date(Date.UTC(2026, 8, 20, 15, 0, 0)),
organizer: {
name: 'Alex Zheng',
email: 'alex@omnicard.ai',
},
url: 'https://omnicard.ai/p/alex-zheng',
dtStamp: new Date(Date.UTC(2026, 8, 20, 10, 0, 0)),
};
it('生成的 ICS 必须严格使用 CRLF (\\r\\n) 换行且末尾终止', () => {
const ics = generateIcsEvent(mockMeeting);
expect(ics).toContain('\r\n');
// 不能包含孤立的 \n (即前面不是 \r 的换行)
const bareNewlineRegex = /[^\r]\n/;
expect(bareNewlineRegex.test(ics)).toBe(false);
// 末尾必须以 \r\n 结尾
expect(ics.endsWith('\r\n')).toBe(true);
});
it('必须包含 RFC 5545 核心必备组件与属性 (BEGIN/END, VCALENDAR, VEVENT, UID, DTSTAMP, etc.)', () => {
const ics = generateIcsEvent(mockMeeting);
expect(ics).toContain('BEGIN:VCALENDAR\r\n');
expect(ics).toContain('VERSION:2.0\r\n');
expect(ics).toContain('PRODID:');
expect(ics).toContain('CALSCALE:GREGORIAN\r\n');
expect(ics).toContain('BEGIN:VEVENT\r\n');
expect(ics).toContain('UID:omnicard-meet-20260920-001@omnicard.ai\r\n');
expect(ics).toContain('DTSTAMP:20260920T100000Z\r\n');
expect(ics).toContain('DTSTART:20260920T140000Z\r\n');
expect(ics).toContain('DTEND:20260920T150000Z\r\n');
expect(ics).toContain('SUMMARY:OmniCard AI 商务拓展战略会谈 (Alex & Partner)\r\n');
expect(ics).toContain('DESCRIPTION:讨论海外微官网落地方案\\, NFC 实体卡发行与访客雷达 CRM 对接。\\n请准时参会。\r\n');
expect(ics).toContain('LOCATION:Zoom 会议室 (ID: 888-999-000)\\, 新加坡滨海湾金融中心\r\n');
expect(ics).toContain('ORGANIZER;CN=Alex Zheng:mailto:alex@omnicard.ai\r\n');
expect(ics).toContain('URL:https://omnicard.ai/p/alex-zheng\r\n');
expect(ics).toContain('END:VEVENT\r\n');
expect(ics).toContain('END:VCALENDAR\r\n');
});
it('ORGANIZER 支持纯邮箱字符串与对象入参', () => {
const meetingWithStringOrg: MeetingEventOptions = {
...mockMeeting,
organizer: 'alex@omnicard.ai',
};
const ics1 = generateIcsEvent(meetingWithStringOrg);
expect(ics1).toContain('ORGANIZER:mailto:alex@omnicard.ai\r\n');
const meetingWithMailtoOrg: MeetingEventOptions = {
...mockMeeting,
organizer: 'mailto:service@omnicard.ai',
};
const ics2 = generateIcsEvent(meetingWithMailtoOrg);
expect(ics2).toContain('ORGANIZER:mailto:service@omnicard.ai\r\n');
});
it('缺省可选字段时应具备完备容错与安全默认值 (UID, DTSTAMP, ORGANIZER, DESCRIPTION)', () => {
const minimalMeeting: MeetingEventOptions = {
summary: '快速商务连线',
startTime: '2026-09-20T16:00:00Z',
endTime: '2026-09-20T16:30:00Z',
};
const ics = generateIcsEvent(minimalMeeting);
expect(ics).toContain('BEGIN:VCALENDAR\r\n');
expect(ics).toContain('BEGIN:VEVENT\r\n');
expect(ics).toContain('SUMMARY:快速商务连线\r\n');
expect(ics).toContain('DTSTART:20260920T160000Z\r\n');
expect(ics).toContain('DTEND:20260920T163000Z\r\n');
expect(ics).toMatch(/UID:.+@omnicard\.ai\r\n/);
expect(ics).toMatch(/DTSTAMP:\d{8}T\d{6}Z\r\n/);
expect(ics).toContain('ORGANIZER:');
expect(ics).toContain('END:VEVENT\r\n');
expect(ics).toContain('END:VCALENDAR\r\n');
});
it('CardCalendarService 服务实例方法应与独立函数功能一致', () => {
const icsFromService = calendarService.generateIcsEvent(mockMeeting);
const icsFromFn = generateIcsEvent(mockMeeting);
expect(icsFromService).toBe(icsFromFn);
});
});
describe('4. 控制器接口集成测试 (GET /open/card/portal/meeting-ics)', () => {
it('控制器应成功导出 ICS 并正确设置 HTTP 响应头', async () => {
const mockHeaders: Record<string, string> = {};
let responseBody: unknown = null;
const mockCtx = {
set(key: string, value: string) {
mockHeaders[key.toLowerCase()] = value;
},
set body(val: unknown) {
responseBody = val;
},
get body() {
return responseBody;
},
};
portalController.ctx = mockCtx;
const result = await portalController.meetingIcs({
summary: '全球合作伙伴出海研讨会',
startTime: '2026-09-22T09:00:00Z',
endTime: '2026-09-22T10:00:00Z',
description: '探讨 2026 年品牌海外本土化与数字名片生态布局',
location: '线上 Google Meet',
organizerName: 'Alex Zheng',
organizerEmail: 'alex@omnicard.ai',
});
// 验证 HTTP 头与文件名
expect(mockHeaders['content-type']).toBe('text/calendar; charset=utf-8');
expect(mockHeaders['content-disposition']).toContain('attachment');
expect(mockHeaders['content-disposition']).toContain('.ics');
// 验证响应内容为合法 ICS
const icsOutput = (responseBody as string) || (result as string);
expect(typeof icsOutput).toBe('string');
expect(icsOutput).toContain('BEGIN:VCALENDAR');
expect(icsOutput).toContain('SUMMARY:全球合作伙伴出海研讨会');
expect(icsOutput).toContain('DTSTART:20260922T090000Z');
expect(icsOutput).toContain('DTEND:20260922T100000Z');
expect(icsOutput).toContain('ORGANIZER;CN=Alex Zheng:mailto:alex@omnicard.ai');
});
it('缺少必要参数时应返回友好失败提示', async () => {
const res = await portalController.meetingIcs({
// 缺少 startTime 和 endTime
summary: '未排期的意向',
});
expect(typeof res).toBe('object');
expect((res as any).code).not.toBe(1000);
});
it('控制器应支持直接使用独立参数传参调用', async () => {
const result = await portalController.meetingIcs(
'新加坡全球金融科技展会谈',
undefined,
'2026-11-15T02:00:00Z',
'2026-11-15T03:00:00Z',
'展台 B08 深入对接',
'Sands Expo Singapore'
);
expect(typeof result).toBe('string');
expect(result).toContain('SUMMARY:新加坡全球金融科技展会谈');
expect(result).toContain('DTSTART:20261115T020000Z');
expect(result).toContain('DTEND:20261115T030000Z');
expect(result).toContain('LOCATION:Sands Expo Singapore');
});
});
describe('5. RFC 5545 长行折叠规范 (foldIcsLine)', () => {
it('短于 75 字符的行应保持原样不变', () => {
const line = 'SUMMARY:Quick Sync';
expect(foldIcsLine(line, 75)).toBe(line);
});
it('超过 75 字符的长行应在 75 字符处折叠并插入 CRLF + 空格', () => {
const longText = 'DESCRIPTION:' + 'A'.repeat(100);
const folded = foldIcsLine(longText, 75);
expect(folded).toContain('\r\n ');
const parts = folded.split('\r\n ');
expect(parts[0].length).toBe(75);
expect(parts[1].length).toBe(37); // 112 - 75 = 37
});
});
describe('6. 结构化解析 (parseIcsEvent)', () => {
it('应正确从 ICS 字符串反向提取会议核心属性', () => {
const meeting: MeetingEventOptions = {
uid: 'roundtrip-test-001@omnicard.ai',
summary: '出海战略合作洽谈',
description: '讨论微官网与海外本地化支付通道',
location: '线上 Google Meet',
startTime: '2026-09-25T08:00:00Z',
endTime: '2026-09-25T09:00:00Z',
organizer: {
name: 'Alex Zheng',
email: 'alex@omnicard.ai',
},
status: 'CONFIRMED',
};
const ics = generateIcsEvent(meeting);
const parsed = parseIcsEvent(ics);
expect(parsed.uid).toBe('roundtrip-test-001@omnicard.ai');
expect(parsed.summary).toBe('出海战略合作洽谈');
expect(parsed.description).toBe('讨论微官网与海外本地化支付通道');
expect(parsed.location).toBe('线上 Google Meet');
expect(parsed.status).toBe('CONFIRMED');
expect(typeof parsed.organizer).toBe('object');
expect((parsed.organizer as any).email).toBe('alex@omnicard.ai');
expect((parsed.organizer as any).name).toBe('Alex Zheng');
});
it('解析空输入时应返回空对象', () => {
expect(parseIcsEvent('')).toEqual({});
});
});
describe('7. 多参会人与扩展属性支持 (ATTENDEE & STATUS)', () => {
it('应支持多个参会人及 RSVP 设置', () => {
const meetingWithAttendees: MeetingEventOptions = {
summary: '多方商务评审会',
startTime: '2026-09-20T10:00:00Z',
endTime: '2026-09-20T11:00:00Z',
attendees: [
{ name: 'Sarah Partner', email: 'sarah@global.com', rsvp: true },
{ email: 'observer@global.com', rsvp: false },
],
status: 'CONFIRMED',
};
const ics = generateIcsEvent(meetingWithAttendees);
expect(ics).toContain('ATTENDEE;CN=Sarah Partner;RSVP=TRUE:mailto:sarah@global.com\r\n');
expect(ics).toContain('ATTENDEE:mailto:observer@global.com\r\n');
expect(ics).toContain('STATUS:CONFIRMED\r\n');
});
});
});

查看文件

@@ -0,0 +1,205 @@
import * as fs from 'fs';
import * as path from 'path';
import { BaseController } from '@cool-midway/core';
import { CardCasesEntity } from '../../../src/modules/card/entity/cases';
import { CardCasesService } from '../../../src/modules/card/service/cases';
import {
AdminCardCasesController,
adminCardCasesCurdOption,
} from '../../../src/modules/card/controller/admin/cases';
describe('后台出海案例作品集 (Admin Card Cases CRUD)', () => {
const entityFile = path.join(
__dirname,
'../../../src/modules/card/entity/cases.ts'
);
const serviceFile = path.join(
__dirname,
'../../../src/modules/card/service/cases.ts'
);
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/cases.ts'
);
describe('1. 契约文件与自动路由约定', () => {
it('文件路径应符合 /admin/card/cases 路由与模块约定', () => {
expect(fs.existsSync(entityFile)).toBe(true);
expect(fs.existsSync(serviceFile)).toBe(true);
expect(fs.existsSync(controllerFile)).toBe(true);
});
it('控制器应继承 BaseController 并挂载 CoolController CRUD', () => {
expect(AdminCardCasesController.prototype).toBeInstanceOf(BaseController);
expect(adminCardCasesCurdOption.entity).toBe(CardCasesEntity);
expect(adminCardCasesCurdOption.service).toBe(CardCasesService);
});
it('装饰器 api 必须包含 add/delete/update/info/list/page', () => {
expect(adminCardCasesCurdOption.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
});
it('pageQueryOp 应支持 a.cardId 精确筛选与 title/client/metrics 模糊搜索', () => {
const pageQueryOp = adminCardCasesCurdOption.pageQueryOp;
expect(pageQueryOp.fieldEq).toEqual(expect.arrayContaining(['a.cardId']));
expect(pageQueryOp.keyWordLikeFields).toEqual(
expect.arrayContaining(['a.title', 'a.client', 'a.metrics'])
);
expect(pageQueryOp.addOrderBy).toMatchObject({ sortOrder: 'asc' });
});
});
describe('2. 实体字段契约 (CardCasesEntity)', () => {
it('实体实例应支持出海案例必需契约字段', () => {
const item = new CardCasesEntity();
item.cardId = 1001;
item.title = '智能电动滑板车欧美 DTC 出海';
item.client = 'VoltRide Global';
item.duration = '2024.01 - 2024.06';
item.metrics = '+320% ROI, 45,000+ 留资量';
item.coverImage = 'https://cdn.example.com/cases/voltride.webp';
item.pdfUrl = 'https://cdn.example.com/cases/voltride-deck.pdf';
item.description = '品牌从 0 到 1 进军北美市场独立站及全渠道获客';
item.sortOrder = 1;
expect(item.cardId).toBe(1001);
expect(item.title).toBe('智能电动滑板车欧美 DTC 出海');
expect(item.client).toBe('VoltRide Global');
expect(item.duration).toBe('2024.01 - 2024.06');
expect(item.metrics).toBe('+320% ROI, 45,000+ 留资量');
expect(item.coverImage).toBe('https://cdn.example.com/cases/voltride.webp');
expect(item.pdfUrl).toBe('https://cdn.example.com/cases/voltride-deck.pdf');
expect(item.description).toBe('品牌从 0 到 1 进军北美市场独立站及全渠道获客');
expect(item.sortOrder).toBe(1);
});
});
describe('3. 服务层按 cardId 筛选与 CRUD 逻辑 (CardCasesService)', () => {
let service: CardCasesService;
let mockList: CardCasesEntity[] = [];
beforeEach(() => {
mockList = [
Object.assign(new CardCasesEntity(), {
id: 1,
cardId: 101,
title: '跨境智能硬件众筹出海',
client: 'TechNova',
duration: '3个月',
metrics: '+280% 众筹目标达成',
coverImage: 'https://cdn.example.com/case1.png',
pdfUrl: 'https://cdn.example.com/case1.pdf',
sortOrder: 2,
}),
Object.assign(new CardCasesEntity(), {
id: 2,
cardId: 101,
title: '东南亚 TikTok 跨境直播获客',
client: 'FashionGo',
duration: '6个月',
metrics: '千万级曝光, +150% GMV',
coverImage: 'https://cdn.example.com/case2.png',
pdfUrl: 'https://cdn.example.com/case2.pdf',
sortOrder: 1,
}),
Object.assign(new CardCasesEntity(), {
id: 3,
cardId: 102,
title: 'B2B 重工机械中东拓客',
client: 'HeavyMach',
duration: '12个月',
metrics: '$5M+ 意向大单',
coverImage: 'https://cdn.example.com/case3.png',
pdfUrl: 'https://cdn.example.com/case3.pdf',
sortOrder: 1,
}),
];
service = new CardCasesService();
service.cardCasesEntity = {
find: jest.fn().mockImplementation(async (options?: { where?: { cardId?: number }; order?: Record<string, string> }) => {
let res = [...mockList];
if (options?.where?.cardId !== undefined) {
res = res.filter(item => Number(item.cardId) === Number(options.where.cardId));
}
if (options?.order?.sortOrder === 'ASC') {
res.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
return res;
}),
findOneBy: jest.fn().mockImplementation(async (where: { id: number }) => {
return mockList.find(item => item.id === where.id) || null;
}),
save: jest.fn().mockImplementation(async (entity: Partial<CardCasesEntity>) => {
if (entity.id) {
const index = mockList.findIndex(item => item.id === entity.id);
if (index >= 0) {
mockList[index] = Object.assign(mockList[index], entity);
return mockList[index];
}
}
const created = Object.assign(new CardCasesEntity(), entity, {
id: mockList.length + 1,
});
mockList.push(created);
return created;
}),
delete: jest.fn().mockImplementation(async (id: number) => {
mockList = mockList.filter(item => item.id !== id);
return { affected: 1 };
}),
} as unknown as typeof service.cardCasesEntity;
});
it('getCasesByCardId 应根据名片 cardId 筛选并按 sortOrder 升序排列', async () => {
const cases = await service.getCasesByCardId(101);
expect(cases).toHaveLength(2);
expect(cases[0].title).toBe('东南亚 TikTok 跨境直播获客');
expect(cases[0].sortOrder).toBe(1);
expect(cases[1].title).toBe('跨境智能硬件众筹出海');
expect(cases[1].sortOrder).toBe(2);
});
it('getCasesByCardId 传入无效 cardId 时应返回空数组', async () => {
const cases = await service.getCasesByCardId(0);
expect(cases).toEqual([]);
});
it('创建案例并能查询出最新案例', async () => {
const created = await service.cardCasesEntity.save({
cardId: 101,
title: '拉美电商本土化品牌出海',
client: 'LatamDirect',
duration: '4个月',
metrics: '+90% 复购率',
coverImage: 'https://cdn.example.com/case4.png',
pdfUrl: 'https://cdn.example.com/case4.pdf',
sortOrder: 3,
});
expect(created.id).toBeDefined();
expect(created.title).toBe('拉美电商本土化品牌出海');
const found = await service.cardCasesEntity.findOneBy({ id: created.id });
expect(found).not.toBeNull();
expect(found?.client).toBe('LatamDirect');
});
it('更新案例信息应成功', async () => {
await service.cardCasesEntity.save({
id: 1,
metrics: '+350% 众筹目标达成',
});
const updated = await service.cardCasesEntity.findOneBy({ id: 1 });
expect(updated?.metrics).toBe('+350% 众筹目标达成');
});
});
});

查看文件

@@ -87,6 +87,9 @@ describe('后端统一实体基类与名片实体 (Entity & BaseEntity)', () =>
email: 'email',
whatsapp: 'whatsapp',
linkedin: 'linkedin',
telegram: 'telegram',
twitter: 'twitter',
wechat: 'wechat',
timezone: 'timezone',
audioPronunciationUrl: 'audio_pronunciation_url',
status: 'status',
@@ -149,6 +152,9 @@ describe('后端统一实体基类与名片实体 (Entity & BaseEntity)', () =>
card.email = 'alex@omnicard.ai';
card.whatsapp = '+8613800138000';
card.linkedin = 'https://linkedin.com/in/alex-zhang';
card.telegram = '@alex_zhang';
card.twitter = 'https://x.com/alexzhang';
card.wechat = 'alex_wx';
card.timezone = 'UTC+8';
card.audioPronunciationUrl = 'https://cdn.omnicard.ai/audio/alex.mp3';
card.status = 'published';
@@ -160,6 +166,9 @@ describe('后端统一实体基类与名片实体 (Entity & BaseEntity)', () =>
expect(profile.name).toBe('Alex Zhang');
expect(profile.status).toBe('published');
expect(profile.themeCode).toBe('tech_dark');
expect(profile.telegram).toBe('@alex_zhang');
expect(profile.twitter).toBe('https://x.com/alexzhang');
expect(profile.wechat).toBe('alex_wx');
expect(card.slug).toBe('alex-zhang-bd');
});
});

查看文件

@@ -0,0 +1,409 @@
import * as fs from 'fs';
import * as path from 'path';
import { getMetadataArgsStorage } from 'typeorm';
import { CONTROLLER_KEY, getClassMetadata } from '@midwayjs/core';
import { BaseController, CoolCommException, CurdOption } from '@cool-midway/core';
import { BaseEntity } from '../../../src/comm/entity/base';
import { CardLocaleEntity } from '../../../src/modules/card/entity/locale';
import { CardLocaleService } from '../../../src/modules/card/service/locale';
import {
AdminCardLocaleController,
adminCardLocaleCurdOption,
} from '../../../src/modules/card/controller/admin/locale';
/**
* CoolController 元数据(脚本路径解析为异步)
*/
interface CoolControllerMeta {
prefix?: string;
curdOption?: CurdOption;
}
/**
* 等待 CoolController 将 CRUD 配置写入类元数据
*/
async function waitCoolMeta(
target: new (...args: never[]) => unknown
): Promise<CoolControllerMeta> {
const started = Date.now();
while (Date.now() - started < 2000) {
const meta = getClassMetadata(CONTROLLER_KEY, target) as
| CoolControllerMeta
| undefined;
if (meta?.curdOption) {
return meta;
}
await new Promise<void>(resolve => setImmediate(resolve));
}
throw new Error(`${target.name} 的 CoolController 元数据未就绪`);
}
function serviceApiMethods(apis: CurdOption['serviceApis'] = []): string[] {
return apis.map(api => (typeof api === 'string' ? api : api.method));
}
interface LocaleFindOptions {
where?: Partial<Pick<CardLocaleEntity, 'id' | 'cardId' | 'locale'>>;
order?: Partial<Record<'id' | 'locale', 'ASC' | 'DESC' | 'asc' | 'desc'>>;
}
/**
* 内存 Repository,覆盖 upsert / listByCardId / getByCardIdAndLocale
*/
class MemoryLocaleRepo {
private data: CardLocaleEntity[] = [];
private currentId = 1;
constructor(initialData: Array<Partial<CardLocaleEntity>> = []) {
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<CardLocaleEntity>): CardLocaleEntity {
const entity = new CardLocaleEntity();
Object.assign(entity, {
id: item.id || this.currentId++,
createTime: item.createTime || new Date(),
updateTime: new Date(),
...item,
});
return entity;
}
async find(options?: LocaleFindOptions): Promise<CardLocaleEntity[]> {
let result = [...this.data];
if (options?.where) {
const where = options.where;
result = result.filter(item => {
const keys = Object.keys(where) as Array<keyof typeof where>;
return keys.every(key => {
const expected = where[key];
return expected === undefined || item[key] === expected;
});
});
}
if (options?.order) {
const orderKeys = Object.keys(options.order) as Array<
keyof NonNullable<LocaleFindOptions['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 CardLocaleEntity));
}
async findOneBy(
where: Partial<Pick<CardLocaleEntity, 'id' | 'cardId' | 'locale'>>
): Promise<CardLocaleEntity | null> {
const found = this.data.find(item => {
const keys = Object.keys(where) as Array<keyof typeof where>;
return keys.every(key => {
const expected = where[key];
return expected === undefined || item[key] === expected;
});
});
return found ? ({ ...found } as CardLocaleEntity) : null;
}
async save(
entityOrEntities:
| Partial<CardLocaleEntity>
| Array<Partial<CardLocaleEntity>>
): Promise<CardLocaleEntity | CardLocaleEntity[]> {
if (Array.isArray(entityOrEntities)) {
const savedList: CardLocaleEntity[] = [];
for (const item of entityOrEntities) {
savedList.push((await this.save(item)) as CardLocaleEntity);
}
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 CardLocaleEntity;
return { ...this.data[index] } as CardLocaleEntity;
}
}
const newEntity = this.createEntity(item);
this.data.push(newEntity);
return { ...newEntity } as CardLocaleEntity;
}
getSnapshot(): CardLocaleEntity[] {
return [...this.data];
}
}
function createService(
seed: Array<Partial<CardLocaleEntity>> = []
): CardLocaleService {
const service = new CardLocaleService();
service.cardLocaleEntity = new MemoryLocaleRepo(
seed
) as unknown as CardLocaleService['cardLocaleEntity'];
return service;
}
function seedLocales(): Array<Partial<CardLocaleEntity>> {
return [
{
id: 1,
cardId: 100,
locale: 'zh-CN',
name: '张伟',
title: '全球商务总监',
company: 'OmniCard',
slogan: '连接全球商务',
},
{
id: 2,
cardId: 100,
locale: 'en-US',
name: 'Alex Zhang',
title: 'Global BD Director',
company: 'OmniCard',
slogan: 'Connecting Global Business',
},
{
id: 3,
cardId: 200,
locale: 'zh-CN',
name: '李娜',
title: '产品经理',
},
];
}
describe('T17 名片内容级多语言分支 (card_locale)', () => {
describe('1. 实体 CardLocaleEntity 表结构与索引', () => {
const metadata = getMetadataArgsStorage();
it('应继承 comm/entity/base BaseEntity,表名为 card_locale', () => {
const entity = new CardLocaleEntity();
expect(entity).toBeInstanceOf(BaseEntity);
const table = metadata.tables.find(t => t.target === CardLocaleEntity);
expect(table).toBeDefined();
expect(table?.name).toBe('card_locale');
});
it('cardId / locale 必须建索引,locale 为 varchar(16)', () => {
const columns = metadata.columns.filter(
c => c.target === CardLocaleEntity
);
const cardIdCol = columns.find(c => c.propertyName === 'cardId');
const localeCol = columns.find(c => c.propertyName === 'locale');
expect(cardIdCol?.options.name || cardIdCol?.propertyName).toBe(
'card_id'
);
expect(localeCol?.options.length).toBe(16);
const indexes = metadata.indices.filter(
i => i.target === CardLocaleEntity
);
const hasCardIdIndex = indexes.some(
i => Array.isArray(i.columns) && i.columns.includes('cardId')
);
const hasLocaleIndex = indexes.some(
i => Array.isArray(i.columns) && i.columns.includes('locale')
);
expect(hasCardIdIndex).toBe(true);
expect(hasLocaleIndex).toBe(true);
});
it('name / title / company / slogan 必须可空', () => {
const columns = metadata.columns.filter(
c => c.target === CardLocaleEntity
);
for (const prop of ['name', 'title', 'company', 'slogan'] as const) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.nullable).toBe(true);
}
});
});
describe('2. Service upsert / listByCardId / getByCardIdAndLocale', () => {
it('upsert 同卡同语言应覆盖而非新增重复行', async () => {
const service = createService(seedLocales());
const first = await service.upsert(100, 'ar-SA', {
name: 'أليكس تشانغ',
title: 'مدير تطوير الأعمال',
company: 'OmniCard',
slogan: 'ربط الأعمال العالمية',
});
expect(first.id).toBeDefined();
expect(first.cardId).toBe(100);
expect(first.locale).toBe('ar-SA');
expect(first.name).toBe('أليكس تشانغ');
const overwritten = await service.upsert(100, 'ar-SA', {
name: 'أليكس',
slogan: 'نسخة محدثة',
});
expect(overwritten.id).toBe(first.id);
expect(overwritten.name).toBe('أليكس');
expect(overwritten.slogan).toBe('نسخة محدثة');
expect(overwritten.title).toBe('مدير تطوير الأعمال');
expect(overwritten.company).toBe('OmniCard');
const listed = await service.listByCardId(100);
const arRows = listed.filter(row => row.locale === 'ar-SA');
expect(arRows).toHaveLength(1);
});
it('listByCardId 应按 cardId 返回多语种分支且不串卡', async () => {
const service = createService(seedLocales());
const list = await service.listByCardId(100);
expect(list).toHaveLength(2);
expect(list.map(row => row.locale).sort()).toEqual(['en-US', 'zh-CN']);
expect(list.every(row => row.cardId === 100)).toBe(true);
const other = await service.listByCardId(200);
expect(other).toHaveLength(1);
expect(other[0].name).toBe('李娜');
const empty = await service.listByCardId(9999);
expect(empty).toEqual([]);
});
it('getByCardIdAndLocale 应按名片与语言精确命中', async () => {
const service = createService(seedLocales());
const zh = await service.getByCardIdAndLocale(100, 'zh-CN');
expect(zh?.name).toBe('张伟');
expect(zh?.title).toBe('全球商务总监');
const missing = await service.getByCardIdAndLocale(100, 'fr-FR');
expect(missing).toBeNull();
});
it('cardId 或 locale 非法时应拒绝写入', async () => {
const service = createService();
await expect(
service.upsert(0, 'zh-CN', { name: '无效' })
).rejects.toThrow(CoolCommException);
await expect(
service.upsert(100, ' ', { name: '无效' })
).rejects.toThrow(CoolCommException);
});
});
describe('3. 后台控制器装饰器 /admin/card/locale', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/locale.ts'
);
let meta: CoolControllerMeta;
let option: CurdOption;
beforeAll(async () => {
meta = await waitCoolMeta(AdminCardLocaleController);
option = meta.curdOption as CurdOption;
});
it('文件路径应符合 /admin/card/locale 自动路由约定', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
expect(controllerFile.replace(/\\/g, '/')).toMatch(
/modules\/card\/controller\/admin\/locale\.ts$/
);
});
it('路由前缀应为 /admin/card/locale', () => {
expect(meta.prefix).toBe('/admin/card/locale');
});
it('控制器应继承 BaseController 并绑定实体与服务', () => {
expect(AdminCardLocaleController.prototype).toBeInstanceOf(
BaseController
);
expect(option.entity).toBe(CardLocaleEntity);
expect(option.service).toBe(CardLocaleService);
expect(adminCardLocaleCurdOption.entity).toBe(CardLocaleEntity);
expect(adminCardLocaleCurdOption.service).toBe(CardLocaleService);
});
it('装饰器 api 必须包含 add/delete/update/info/list/page', () => {
expect(option.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
expect(option.api).toContain('page');
});
it('pageQueryOp.fieldEq 应支持 cardId 与 locale 精确筛选', () => {
const pageQueryOp = option.pageQueryOp as {
fieldEq?: string[];
};
expect(pageQueryOp.fieldEq).toEqual(
expect.arrayContaining(['a.cardId', 'a.locale'])
);
});
it('应将 upsert / listByCardId / getByCardIdAndLocale 注册为 serviceApis', () => {
const methods = serviceApiMethods(option.serviceApis);
expect(methods).toEqual([
'upsert',
'listByCardId',
'getByCardIdAndLocale',
]);
});
});
describe('4. serviceApis POST body 单参数对象转调', () => {
it('upsert 接收 { cardId, locale, fields } 时应覆盖同卡同语言', async () => {
const service = createService(seedLocales());
const saved = await service.upsert({
cardId: 100,
locale: 'en-US',
fields: {
name: 'Alexander Zhang',
slogan: 'Updated slogan',
},
});
expect(saved.id).toBe(2);
expect(saved.name).toBe('Alexander Zhang');
expect(saved.slogan).toBe('Updated slogan');
expect(saved.title).toBe('Global BD Director');
const listed = await service.listByCardId({ cardId: 100 });
expect(listed.filter(row => row.locale === 'en-US')).toHaveLength(1);
});
it('getByCardIdAndLocale 接收 { cardId, locale } 时应转调已有双参签名', async () => {
const service = createService(seedLocales());
const found = await service.getByCardIdAndLocale({
cardId: 100,
locale: 'zh-CN',
});
expect(found?.id).toBe(1);
expect(found?.name).toBe('张伟');
});
});
});

查看文件

@@ -0,0 +1,372 @@
import { CardSeoService } from '../../../src/modules/card/service/seo';
import { CardPortalService } from '../../../src/modules/card/service/portal';
import {
OpenCardPortalController,
PortalRequestContext,
} from '../../../src/modules/card/controller/open/portal';
import { CardPortalDetail } from '../../../src/comm/types/omni';
describe('Point 29-B: 社交爬虫探测与微官网短链服务端渲染集成 (Social Crawler & SEO SSR)', () => {
let cardSeoService: CardSeoService;
let cardPortalService: CardPortalService;
let portalController: OpenCardPortalController;
// 模拟真实名片聚合资产
const mockAlexCardDetail: CardPortalDetail = {
id: 101,
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',
themeCode: 'corporate_navy',
status: 'published',
bentoBlocks: [
{
id: 1,
cardId: 101,
type: 'hero',
colSpan: 2,
rowSpan: 1,
sortOrder: 1,
title: 'About Alex',
content: '10+ years in international business expansion.',
},
],
cachedAt: Date.now(),
};
// 常用社交爬虫与普通用户 UA 常量
const USER_AGENTS = {
whatsapp:
'WhatsApp/2.21.12.21 A',
whatsappIos:
'WhatsApp/2.23.20.0 i',
linkedin:
'LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +http://www.linkedin.com)',
twitter:
'Twitterbot/1.0',
facebook:
'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
facebot:
'Facebot',
telegram:
'TelegramBot (like TwitterBot)',
slack:
'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)',
discord:
'Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)',
googlebot:
'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
safariMobile:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
chromeDesktop:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36',
};
beforeEach(() => {
cardSeoService = new CardSeoService();
cardPortalService = new CardPortalService();
interface CustomHttpError extends Error {
status?: number;
}
// Mock cardPortalService.getPortalDetail
jest
.spyOn(cardPortalService, 'getPortalDetail')
.mockImplementation(async (slugOrId: string | number) => {
if (slugOrId === 'alex-zheng' || slugOrId === 101 || slugOrId === '101') {
return { ...mockAlexCardDetail };
}
const error = new Error('名片不存在或已下线') as CustomHttpError;
error.status = 404;
throw error;
});
portalController = new OpenCardPortalController();
portalController.cardPortalService = cardPortalService;
portalController.cardSeoService = cardSeoService;
});
describe('1. 社交爬虫与机器人 User-Agent 精准探测识别', () => {
it('应准确识别 WhatsApp 爬虫 User-Agent', () => {
expect(cardSeoService.isCrawler(USER_AGENTS.whatsapp)).toBe(true);
expect(cardSeoService.isCrawler(USER_AGENTS.whatsappIos)).toBe(true);
expect(cardSeoService.detectCrawler(USER_AGENTS.whatsapp)).toBe('whatsapp');
});
it('应准确识别 LinkedInBot 爬虫 User-Agent', () => {
expect(cardSeoService.isCrawler(USER_AGENTS.linkedin)).toBe(true);
expect(cardSeoService.detectCrawler(USER_AGENTS.linkedin)).toBe('linkedin');
});
it('应准确识别 Twitterbot 爬虫 User-Agent', () => {
expect(cardSeoService.isCrawler(USER_AGENTS.twitter)).toBe(true);
expect(cardSeoService.detectCrawler(USER_AGENTS.twitter)).toBe('twitter');
});
it('应准确识别 facebookexternalhit 与 Facebot 爬虫 User-Agent', () => {
expect(cardSeoService.isCrawler(USER_AGENTS.facebook)).toBe(true);
expect(cardSeoService.isCrawler(USER_AGENTS.facebot)).toBe(true);
expect(cardSeoService.detectCrawler(USER_AGENTS.facebook)).toBe('facebook');
});
it('应准确识别 TelegramBot 爬虫 User-Agent', () => {
expect(cardSeoService.isCrawler(USER_AGENTS.telegram)).toBe(true);
expect(cardSeoService.detectCrawler(USER_AGENTS.telegram)).toBe('telegram');
});
it('应支持识别 Slackbot、Discordbot 及主流搜索引擎机器人', () => {
expect(cardSeoService.isCrawler(USER_AGENTS.slack)).toBe(true);
expect(cardSeoService.isCrawler(USER_AGENTS.discord)).toBe(true);
expect(cardSeoService.isCrawler(USER_AGENTS.googlebot)).toBe(true);
});
it('常规移动端与桌面端浏览器不应被判定为爬虫', () => {
expect(cardSeoService.isCrawler(USER_AGENTS.safariMobile)).toBe(false);
expect(cardSeoService.isCrawler(USER_AGENTS.chromeDesktop)).toBe(false);
expect(cardSeoService.detectCrawler(USER_AGENTS.chromeDesktop)).toBeNull();
});
it('对于空字符串或 undefined User-Agent 应安全降级返回 false', () => {
expect(cardSeoService.isCrawler('')).toBe(false);
expect(cardSeoService.isCrawler(undefined)).toBe(false);
expect(cardSeoService.detectCrawler('')).toBeNull();
expect(cardSeoService.detectCrawler(undefined)).toBeNull();
});
});
describe('2. OpenGraph 元数据提炼与微官网 HTML 模板服务端渲染', () => {
it('应正确从名片提取 og:title、og:image、og:description 等元数据', () => {
const ogMeta = cardSeoService.generateOgMeta(mockAlexCardDetail);
expect(ogMeta.title).toBe('Alex Zheng - VP of Global Expansion | OmniCard Global Inc.');
expect(ogMeta.image).toBe('https://cdn.omnicard.ai/avatars/alex.png');
expect(ogMeta.description).toBe('Empowering Global Business Connections');
expect(ogMeta.type).toBe('profile');
});
it('渲染输出的 HTML 必须包含完整的 OpenGraph、Twitter Cards 与 JSON-LD 标签', () => {
const html = cardSeoService.renderCardHtml(mockAlexCardDetail);
// HTML5 规范结构
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('<html');
expect(html).toContain('<head>');
expect(html).toContain('</head>');
expect(html).toContain('<body>');
expect(html).toContain('</body>');
// OpenGraph 协议标准断言
expect(html).toContain('<meta property="og:title" content="Alex Zheng - VP of Global Expansion | OmniCard Global Inc."');
expect(html).toContain('<meta property="og:image" content="https://cdn.omnicard.ai/avatars/alex.png"');
expect(html).toContain('<meta property="og:description" content="Empowering Global Business Connections"');
expect(html).toContain('<meta property="og:type" content="profile"');
expect(html).toContain('<meta property="og:url" content="https://omnicard.ai/p/alex-zheng"');
// Twitter Cards 断言
expect(html).toContain('<meta name="twitter:card" content="summary_large_image"');
expect(html).toContain('<meta name="twitter:title" content="Alex Zheng - VP of Global Expansion | OmniCard Global Inc."');
expect(html).toContain('<meta name="twitter:image" content="https://cdn.omnicard.ai/avatars/alex.png"');
// JSON-LD 结构化数据断言
expect(html).toContain('<script type="application/ld+json">');
expect(html).toContain('"@type":"Person"');
expect(html).toContain('"name":"Alex Zheng"');
expect(html).toContain('"jobTitle":"VP of Global Expansion"');
// 包含名片主要信息
expect(html).toContain('Alex Zheng');
expect(html).toContain('VP of Global Expansion');
expect(html).toContain('OmniCard Global Inc.');
});
it('处理特殊字符时必须严格 HTML 实体转义以防范 XSS 注入', () => {
const maliciousCard: CardPortalDetail = {
...mockAlexCardDetail,
name: 'Evil <script>alert("xss")</script>',
company: 'Hack & Co "Global"',
slogan: 'Test <img src=x onerror=alert(1)> & more',
};
const html = cardSeoService.renderCardHtml(maliciousCard);
expect(html).not.toContain('<script>alert("xss")</script>');
expect(html).toContain('Evil &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
expect(html).toContain('Hack &amp; Co &quot;Global&quot;');
});
it('生成 404 兜底 HTML 时应提示名片已下线或不存在', () => {
const notFoundHtml = cardSeoService.renderNotFoundHtml('名片不存在或已下线');
expect(notFoundHtml).toContain('<!DOCTYPE html>');
expect(notFoundHtml).toContain('名片不存在或已下线');
expect(notFoundHtml).toContain('<meta property="og:title" content="名片未找到 | OmniCard AI"');
});
});
describe('3. 控制器公开路由 GET /open/card/portal/preview-html/:slug 集成断言', () => {
const createMockCtx = (userAgent?: string): PortalRequestContext => {
const headerMap: Record<string, string> = {};
if (userAgent) {
headerMap['user-agent'] = userAgent;
}
return {
headers: headerMap,
set: jest.fn((k: string, v: string) => {
headerMap[k.toLowerCase()] = v;
}),
get: jest.fn((field: string) => headerMap[field.toLowerCase()]),
status: 200,
body: undefined,
};
};
it('模拟 WhatsApp 爬虫访问 preview-html 路由,返回 200、text/html 及包含 og:title 和 og:image', async () => {
const mockCtx = createMockCtx(USER_AGENTS.whatsapp);
portalController.ctx = mockCtx;
const html = await portalController.previewHtml('alex-zheng');
expect(typeof html).toBe('string');
expect(mockCtx.status).toBe(200);
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
expect(html).toContain('og:title');
expect(html).toContain('Alex Zheng - VP of Global Expansion | OmniCard Global Inc.');
expect(html).toContain('og:image');
expect(html).toContain('https://cdn.omnicard.ai/avatars/alex.png');
});
it('模拟 LinkedInBot 爬虫访问,断言返回 200 与 OpenGraph HTML', async () => {
const mockCtx = createMockCtx(USER_AGENTS.linkedin);
portalController.ctx = mockCtx;
const html = await portalController.previewHtml('alex-zheng');
expect(mockCtx.status).toBe(200);
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
expect(html).toContain('og:title');
expect(html).toContain('og:image');
expect(html).toContain('Alex Zheng');
});
it('模拟 Twitterbot 爬虫访问,断言返回 200 与 twitter:card 元数据', async () => {
const mockCtx = createMockCtx(USER_AGENTS.twitter);
portalController.ctx = mockCtx;
const html = await portalController.previewHtml('alex-zheng');
expect(mockCtx.status).toBe(200);
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
expect(html).toContain('twitter:card');
expect(html).toContain('twitter:image');
expect(html).toContain('https://cdn.omnicard.ai/avatars/alex.png');
});
it('模拟 facebookexternalhit 爬虫访问,断言返回 200', async () => {
const mockCtx = createMockCtx(USER_AGENTS.facebook);
portalController.ctx = mockCtx;
const html = await portalController.previewHtml('alex-zheng');
expect(mockCtx.status).toBe(200);
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
expect(html).toContain('og:title');
});
it('模拟 TelegramBot 爬虫访问,断言返回 200', async () => {
const mockCtx = createMockCtx(USER_AGENTS.telegram);
portalController.ctx = mockCtx;
const html = await portalController.previewHtml('alex-zheng');
expect(mockCtx.status).toBe(200);
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
expect(html).toContain('og:title');
expect(html).toContain('og:image');
});
it('普通桌面浏览器在 preview-html 路由下也能正常获取真实名片渲染预览', async () => {
const mockCtx = createMockCtx(USER_AGENTS.chromeDesktop);
portalController.ctx = mockCtx;
const html = await portalController.previewHtml('alex-zheng');
expect(mockCtx.status).toBe(200);
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
expect(html).toContain('Alex Zheng');
expect(html).toContain('OmniCard Global Inc.');
});
it('访问不存在的名片 slug 时应返回 404 状态码及兜底 HTML', async () => {
const mockCtx = createMockCtx(USER_AGENTS.whatsapp);
portalController.ctx = mockCtx;
const html = await portalController.previewHtml('non-existent-slug');
expect(mockCtx.status).toBe(404);
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
expect(html).toContain('名片不存在或已下线');
});
});
describe('4. 短链路由 GET /open/card/portal/:slugOrId 智能爬虫探测集成', () => {
it('当社交爬虫请求普通短链路由时,自动响应 SEO 预览 HTML 而非 JSON', async () => {
const headerMap: Record<string, string> = {
'user-agent': USER_AGENTS.whatsapp,
};
const mockCtx: PortalRequestContext = {
headers: headerMap,
set: jest.fn((k: string, v: string) => {
headerMap[k.toLowerCase()] = v;
}),
get: jest.fn((field: string) => headerMap[field.toLowerCase()]),
status: 200,
};
portalController.ctx = mockCtx;
const res = await portalController.getBySlugOrId('alex-zheng');
// 确认为 HTML 字符串响应,并设置了 Content-Type: text/html
expect(typeof res).toBe('string');
expect(res).toContain('og:title');
expect(res).toContain('Alex Zheng');
expect(mockCtx.set).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8');
expect(mockCtx.status).toBe(200);
});
it('普通移动端用户或 API 客户端访问短链路由时,保持标准 JSON API 响应不变', async () => {
const headerMap: Record<string, string> = {
'user-agent': USER_AGENTS.safariMobile,
};
const mockCtx: PortalRequestContext = {
headers: headerMap,
set: jest.fn(),
get: jest.fn((field: string) => headerMap[field.toLowerCase()]),
status: 200,
};
portalController.ctx = mockCtx;
const res = await portalController.getBySlugOrId('alex-zheng');
interface ApiOkResponse {
code: number;
message: string;
data: CardPortalDetail;
}
expect(typeof res).toBe('object');
const okRes = res as ApiOkResponse;
expect(okRes.code).toBe(1000);
expect(okRes.data.slug).toBe('alex-zheng');
});
});
});

查看文件

@@ -344,4 +344,33 @@ describe('Task 08: 后端公开短链路由与秒级微官网详情 API (Card Po
expect(res.code).not.toBe(1000);
});
});
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');
});
});
});

查看文件

@@ -0,0 +1,188 @@
import { CardSeoService, SeoOptions } from '../../../src/modules/card/service/seo';
import { CardProfile } from '../../../src/comm/types/omni';
describe('Point 29-A: SEO & OpenGraph 社交预览元标签注入引擎', () => {
let seoService: CardSeoService;
beforeEach(() => {
seoService = new CardSeoService();
});
const mockCard: Partial<CardProfile> = {
id: 101,
name: 'Sarah Connor',
title: 'Chief Technology Officer',
company: 'Omni Global Tech',
slogan: 'Building the next generation of AI-driven digital identities.',
avatarUrl: 'https://cdn.omnicard.ai/avatars/sarah.jpg',
slug: 'sarah-connor',
};
describe('1. generateOpenGraphMeta 标签生成规范', () => {
it('应为完整名片生成标准化的 OpenGraph 与 Twitter Card 标签集合', () => {
const options: SeoOptions = {
baseUrl: 'https://omnicard.ai',
locale: 'en_US',
siteName: 'OmniCard AI',
};
const meta = seoService.generateOpenGraphMeta(mockCard, options);
// OpenGraph 基础标准规范
expect(meta['og:title']).toBe('Sarah Connor - Chief Technology Officer | Omni Global Tech');
expect(meta['og:description']).toBe(
'Building the next generation of AI-driven digital identities.'
);
expect(meta['og:image']).toBe('https://cdn.omnicard.ai/avatars/sarah.jpg');
expect(meta['og:url']).toBe('https://omnicard.ai/c/sarah-connor');
expect(meta['og:type']).toBe('profile');
expect(meta['og:locale']).toBe('en_US');
expect(meta['og:site_name']).toBe('OmniCard AI');
// Twitter Card 协议
expect(meta['twitter:card']).toBe('summary_large_image');
expect(meta['twitter:title']).toBe('Sarah Connor - Chief Technology Officer | Omni Global Tech');
expect(meta['twitter:description']).toBe(
'Building the next generation of AI-driven digital identities.'
);
expect(meta['twitter:image']).toBe('https://cdn.omnicard.ai/avatars/sarah.jpg');
});
it('当提供 currentUrl 时,og:url 应优先采用 currentUrl', () => {
const meta = seoService.generateOpenGraphMeta(mockCard, {
currentUrl: 'https://custom.domain.com/profile/sarah',
});
expect(meta['og:url']).toBe('https://custom.domain.com/profile/sarah');
});
it('当缺少 slogan 时,og:description 应优雅拼接 title 与 company', () => {
const cardWithoutSlogan: Partial<CardProfile> = {
name: 'Alex Rivera',
title: 'Global Sales Director',
company: 'Nexa Global',
};
const meta = seoService.generateOpenGraphMeta(cardWithoutSlogan);
expect(meta['og:description']).toBe('Global Sales Director @ Nexa Global');
expect(meta['og:title']).toBe('Alex Rivera - Global Sales Director | Nexa Global');
});
});
describe('2. 多语言规范与 OpenGraph Locale 格式化', () => {
it('应将各类格式的多语言编码格式化为 RFC/OpenGraph 标准 ll_CC 格式', () => {
expect(seoService.formatOgLocale('zh-CN')).toBe('zh_CN');
expect(seoService.formatOgLocale('en-US')).toBe('en_US');
expect(seoService.formatOgLocale('ar-SA')).toBe('ar_SA');
expect(seoService.formatOgLocale('ja')).toBe('ja_JP');
expect(seoService.formatOgLocale('en')).toBe('en_US');
expect(seoService.formatOgLocale('zh')).toBe('zh_CN');
expect(seoService.formatOgLocale('ar')).toBe('ar_SA');
expect(seoService.formatOgLocale(undefined)).toBe('en_US');
});
});
describe('3. renderHtmlWithOgMeta 模板注入与 RTL 支持', () => {
const template = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>OmniCard</title>
</head>
<body>
<div id="app"></div>
</body>
</html>`;
it('应将 meta 标签与 title 安全注入到 <head> 中', () => {
const html = seoService.renderHtmlWithOgMeta(template, mockCard, {
baseUrl: 'https://omnicard.ai',
locale: 'en_US',
});
expect(html).toContain('<meta property="og:title" content="Sarah Connor - Chief Technology Officer | Omni Global Tech" />');
expect(html).toContain('<meta property="og:type" content="profile" />');
expect(html).toContain('<meta property="og:image" content="https://cdn.omnicard.ai/avatars/sarah.jpg" />');
expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />');
expect(html).toContain('<title>Sarah Connor - Chief Technology Officer | Omni Global Tech</title>');
expect(html).toContain('<html lang="en">');
});
it('当语言为阿拉伯语时,应自动为 <html> 标签注入 dir="rtl" 与 lang="ar"', () => {
const arCard: Partial<CardProfile> = {
name: 'طارق المنصور',
title: 'مدير تطوير الأعمال',
company: 'التقنية العالمية',
};
const html = seoService.renderHtmlWithOgMeta(template, arCard, {
locale: 'ar-SA',
});
expect(html).toContain('dir="rtl"');
expect(html).toContain('lang="ar"');
expect(html).toContain('<meta property="og:locale" content="ar_SA" />');
expect(html).toContain('طارق المنصور');
});
it('当显式传入 options.isRtl = true 时,也应注入 dir="rtl"', () => {
const html = seoService.renderHtmlWithOgMeta(template, mockCard, {
isRtl: true,
});
expect(html).toContain('dir="rtl"');
});
});
describe('4. 防 XSS 注入与特殊字符安全转义', () => {
it('当姓名、职位或描述包含恶意 HTML/JS 脚本或注入符号时,应进行严格转义', () => {
const xssCard: Partial<CardProfile> = {
name: '<script>alert("xss")</script>',
title: '"><img src=x onerror=alert(1)>',
company: 'Tech & "Solutions" <LLC>',
slogan: '\'OR 1=1 -- <iframe src="evil.com">',
};
const meta = seoService.generateOpenGraphMeta(xssCard);
expect(meta['og:title']).not.toContain('<script>');
expect(meta['og:title']).not.toContain('onerror=');
const template = '<html><head></head><body></body></html>';
const renderedHtml = seoService.renderHtmlWithOgMeta(template, xssCard);
// 验证特殊字符均被 escape,属性双引号不可跳脱
expect(renderedHtml).not.toContain('<script>alert');
expect(renderedHtml).not.toContain('<img src=x');
expect(renderedHtml).not.toContain('<iframe');
expect(renderedHtml).toContain('&quot;');
expect(renderedHtml).toContain('&lt;');
expect(renderedHtml).toContain('&gt;');
expect(renderedHtml).toContain('&amp;');
});
});
describe('5. 空值与边界安全兜底', () => {
it('当 card 为 null 或 undefined 时,应使用默认值兜底且绝不报错', () => {
const metaNull = seoService.generateOpenGraphMeta(null);
expect(metaNull['og:title']).toBe('OmniCard AI');
expect(metaNull['og:description']).toBe(
'OmniCard AI - Global Digital Business Card & Micro Portal'
);
expect(metaNull['og:image']).toBe('https://omnicard.ai/assets/og-default.png');
expect(metaNull['og:type']).toBe('profile');
expect(metaNull['twitter:card']).toBe('summary_large_image');
const metaUndefined = seoService.generateOpenGraphMeta(undefined, {
siteName: 'Custom Brand',
defaultImage: 'https://example.com/fallback.jpg',
});
expect(metaUndefined['og:title']).toBe('Custom Brand');
expect(metaUndefined['og:image']).toBe('https://example.com/fallback.jpg');
});
it('当 template 不含标准 <head> 标签时,renderHtmlWithOgMeta 能够安全兜底注入', () => {
const rawHtml = '<div>Raw Body Content</div>';
const result = seoService.renderHtmlWithOgMeta(rawHtml, mockCard);
expect(result).toContain('<meta property="og:title"');
expect(result).toContain('<div>Raw Body Content</div>');
});
});
});

查看文件

@@ -0,0 +1,183 @@
/**
* T16: 名片社媒快捷字段 telegram / twitter / wechat
*/
import { getMetadataArgsStorage } from 'typeorm';
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
import { CardPortalService } from '../../../src/modules/card/service/portal';
import { CardProfile, CardPortalDetail } from '../../../src/comm/types/omni';
describe('T16 社媒快捷字段 telegram / twitter / wechat', () => {
describe('1. 实体元数据列名下划线', () => {
it('telegram / twitter / wechat 应为可空 varchar(255),数据库列名下划线', () => {
const columns = getMetadataArgsStorage().columns.filter(
c => c.target === CardInfoEntity
);
const expected: Record<string, string> = {
telegram: 'telegram',
twitter: 'twitter',
wechat: 'wechat',
};
for (const [prop, dbCol] of Object.entries(expected)) {
const col = columns.find(c => c.propertyName === prop);
expect(col).toBeDefined();
expect(col?.options.name || col?.propertyName).toBe(dbCol);
expect(col?.options.length).toBe(255);
expect(col?.options.nullable).toBe(true);
}
});
it('CardProfile 与 CardPortalDetail 契约应包含三字段', () => {
const profile: CardProfile = {
id: 1,
tenantId: 1,
userId: 1,
name: 'Alex',
title: 'BD',
company: 'Omni',
status: 'published',
telegram: '@omni_ai',
twitter: 'https://x.com/omni',
wechat: 'omni_wx',
};
const detail: CardPortalDetail = {
id: 1,
name: 'Alex',
title: 'BD',
company: 'Omni',
status: 'published',
bentoBlocks: [],
telegram: '@omni_ai',
twitter: 'https://x.com/omni',
wechat: 'omni_wx',
};
expect(profile.telegram).toBe('@omni_ai');
expect(profile.twitter).toBe('https://x.com/omni');
expect(profile.wechat).toBe('omni_wx');
expect(detail.telegram).toBe('@omni_ai');
expect(detail.twitter).toBe('https://x.com/omni');
expect(detail.wechat).toBe('omni_wx');
});
});
describe('2. 门户公开详情映射', () => {
let service: CardPortalService;
beforeEach(() => {
CardPortalService.clearAllCache();
service = new CardPortalService();
service.cardInfoEntity = {
findOne: async () => ({
id: 201,
tenantId: 1,
slug: 'social-card',
name: 'Social User',
title: 'BD',
company: 'Omni',
status: 'published',
telegram: '@omni_ai',
twitter: 'https://x.com/omni',
wechat: 'omni_wx',
}),
} as unknown as CardPortalService['cardInfoEntity'];
service.cardBentoEntity = {
find: async () => [],
} as unknown as CardPortalService['cardBentoEntity'];
});
it('getPortalDetail 应带出 telegram / twitter / wechat', async () => {
const detail = await service.getPortalDetail(201);
expect(detail.telegram).toBe('@omni_ai');
expect(detail.twitter).toBe('https://x.com/omni');
expect(detail.wechat).toBe('omni_wx');
});
});
describe('3. usePortal 快捷动作新 type', () => {
let toastCalls: Array<{ title: string; icon?: string }>;
let clipboardText: string;
beforeEach(() => {
toastCalls = [];
clipboardText = '';
(global as { uni?: unknown }).uni = {
getStorageSync: () => '',
setStorageSync: () => undefined,
showToast: (options: { title: string; icon?: string }) => {
toastCalls.push(options);
},
makePhoneCall: () => undefined,
setClipboardData: (options: { data: string; success?: () => void }) => {
clipboardText = options.data;
options.success?.();
},
getLocale: () => 'zh-CN',
getSystemInfoSync: () => ({ language: 'zh-CN' }),
};
jest.resetModules();
});
afterEach(() => {
delete (global as { uni?: unknown }).uni;
});
it('telegram / twitter / wechat 新 type 不报错', () => {
const { triggerQuickAction } = require('../../../../mpuniapp/pages/portal/usePortal.uts') as {
triggerQuickAction: (type: string, value?: string) => boolean;
};
expect(() => triggerQuickAction('telegram', '@omni_ai')).not.toThrow();
expect(() => triggerQuickAction('twitter', 'https://x.com/omni')).not.toThrow();
expect(() => triggerQuickAction('wechat', 'omni_wx')).not.toThrow();
expect(triggerQuickAction('telegram', '@omni_ai')).toBe(true);
expect(triggerQuickAction('twitter', 'https://x.com/omni')).toBe(true);
expect(triggerQuickAction('wechat', 'omni_wx')).toBe(true);
});
it('telegram 不含 http 时补 https://t.me/ 前缀并复制', () => {
const { triggerQuickAction } = require('../../../../mpuniapp/pages/portal/usePortal.uts') as {
triggerQuickAction: (type: string, value?: string) => boolean;
};
const ok = triggerQuickAction('telegram', '@omni_ai');
expect(ok).toBe(true);
expect(clipboardText).toBe('https://t.me/omni_ai');
expect(toastCalls[0].title.length).toBeGreaterThan(0);
});
it('telegram 已含 http 时原样复制', () => {
const { triggerQuickAction } = require('../../../../mpuniapp/pages/portal/usePortal.uts') as {
triggerQuickAction: (type: string, value?: string) => boolean;
};
triggerQuickAction('telegram', 'https://t.me/omni_ai');
expect(clipboardText).toBe('https://t.me/omni_ai');
});
it('twitter 复制对应链接,wechat 只复制微信号', () => {
const { triggerQuickAction } = require('../../../../mpuniapp/pages/portal/usePortal.uts') as {
triggerQuickAction: (type: string, value?: string) => boolean;
};
triggerQuickAction('twitter', 'https://x.com/omni');
expect(clipboardText).toBe('https://x.com/omni');
triggerQuickAction('wechat', 'omni_wx');
expect(clipboardText).toBe('omni_wx');
});
it('无值时 toast 走 i18n 且不抛错', () => {
const { triggerQuickAction } = require('../../../../mpuniapp/pages/portal/usePortal.uts') as {
triggerQuickAction: (type: string, value?: string) => boolean;
};
expect(triggerQuickAction('telegram', '')).toBe(false);
expect(triggerQuickAction('twitter')).toBe(false);
expect(triggerQuickAction('wechat', ' ')).toBe(false);
expect(toastCalls.length).toBe(3);
expect(toastCalls[0].title).toContain('未设置');
});
});
});