feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
@@ -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 <script>alert("xss")</script>');
|
||||
expect(html).toContain('Hack & Co "Global"');
|
||||
});
|
||||
|
||||
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('"');
|
||||
expect(renderedHtml).toContain('<');
|
||||
expect(renderedHtml).toContain('>');
|
||||
expect(renderedHtml).toContain('&');
|
||||
});
|
||||
});
|
||||
|
||||
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('未设置');
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户