feat: 完成全系统30个功能点开发与E2E全链路验收 (8轮调度32个子代理闭环)
- 模块一: 出海案例作品集、博客动态发布、人名发音语音条、RTL阿语多语言切换 - 模块二: AI顾问人设配置、行业术语库润色、RAG切片沙盒调试、流式会话与意向留资卡片 - 模块三: 全球访客雷达大屏、多维加权意向算分、外部CRM对接推送、团队公海池流转 - 模块四: 移动端NFC安全挂失、NFC动态路由规则配置、动态防伪验签、展会制卡Zip批量导出 - 模块五: 组织架构与名片分配、移动端个人中心设置、离职交接与历史线索迁移、安全审计日志 - 模块六: Stripe国际信用卡订阅Webhook、独立二级域名与SSL检测、坐席配额预警、多币种发票 - 模块七: 纸质名片OCR解析入库、国际商务日程RFC 5545 ICS日历、微官网离线SWR预加载、展会实时排行榜大屏 - 模块八: 全球出海SEO OpenGraph元标签注入与社交爬虫探测、30点端到端全链路业务流集成测试套件
这个提交包含在:
127
backend/test/comm/geo-i18n.test.ts
普通文件
127
backend/test/comm/geo-i18n.test.ts
普通文件
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
parseAcceptLanguage,
|
||||
parseGeoLocale,
|
||||
resolveSuggestedLocale,
|
||||
} from '../../src/comm/i18n/geo';
|
||||
|
||||
describe('T18: Geo / 浏览器语言自动建议', () => {
|
||||
describe('parseAcceptLanguage', () => {
|
||||
it('应将 zh* 映射为 zh-CN', () => {
|
||||
expect(parseAcceptLanguage('zh-CN,zh;q=0.9,en;q=0.8')).toBe('zh-CN');
|
||||
expect(parseAcceptLanguage('zh')).toBe('zh-CN');
|
||||
expect(parseAcceptLanguage('zh-TW')).toBe('zh-CN');
|
||||
expect(parseAcceptLanguage('zh-Hans-CN')).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('应将 ar* 映射为 ar-SA', () => {
|
||||
expect(parseAcceptLanguage('ar-SA,ar;q=0.8')).toBe('ar-SA');
|
||||
expect(parseAcceptLanguage('ar')).toBe('ar-SA');
|
||||
expect(parseAcceptLanguage('ar-EG')).toBe('ar-SA');
|
||||
});
|
||||
|
||||
it('应将 en* 映射为 en-US', () => {
|
||||
expect(parseAcceptLanguage('en-US,en;q=0.9')).toBe('en-US');
|
||||
expect(parseAcceptLanguage('en')).toBe('en-US');
|
||||
expect(parseAcceptLanguage('en-GB')).toBe('en-US');
|
||||
});
|
||||
|
||||
it('未识别语言应回退 en-US', () => {
|
||||
expect(parseAcceptLanguage('fr-FR,fr;q=0.9')).toBe('en-US');
|
||||
expect(parseAcceptLanguage('ja,en;q=0.1')).toBe('en-US');
|
||||
expect(parseAcceptLanguage('')).toBe('en-US');
|
||||
expect(parseAcceptLanguage(undefined)).toBe('en-US');
|
||||
});
|
||||
|
||||
it('应按 q 值选择首选语言', () => {
|
||||
expect(parseAcceptLanguage('en;q=0.4,zh-CN;q=0.9')).toBe('zh-CN');
|
||||
expect(parseAcceptLanguage('ar;q=0.2,en-US;q=0.8')).toBe('en-US');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGeoLocale', () => {
|
||||
it('应将 CN/TW/HK 映射为 zh-CN', () => {
|
||||
expect(parseGeoLocale({ 'cf-ipcountry': 'CN' })).toBe('zh-CN');
|
||||
expect(parseGeoLocale({ 'x-country': 'TW' })).toBe('zh-CN');
|
||||
expect(parseGeoLocale({ 'CF-IPCountry': 'hk' })).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('应将 SA/AE/EG 映射为 ar-SA', () => {
|
||||
expect(parseGeoLocale({ 'cf-ipcountry': 'SA' })).toBe('ar-SA');
|
||||
expect(parseGeoLocale({ 'x-country': 'AE' })).toBe('ar-SA');
|
||||
expect(parseGeoLocale({ 'X-Country': 'eg' })).toBe('ar-SA');
|
||||
});
|
||||
|
||||
it('其余国家应回退 en-US', () => {
|
||||
expect(parseGeoLocale({ 'cf-ipcountry': 'US' })).toBe('en-US');
|
||||
expect(parseGeoLocale({ 'x-country': 'JP' })).toBe('en-US');
|
||||
expect(parseGeoLocale({})).toBe('en-US');
|
||||
expect(parseGeoLocale(undefined)).toBe('en-US');
|
||||
});
|
||||
|
||||
it('应优先使用 cf-ipcountry 再回退 x-country', () => {
|
||||
expect(
|
||||
parseGeoLocale({
|
||||
'cf-ipcountry': 'SA',
|
||||
'x-country': 'CN',
|
||||
})
|
||||
).toBe('ar-SA');
|
||||
expect(parseGeoLocale({ 'x-country': 'CN' })).toBe('zh-CN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSuggestedLocale 优先级', () => {
|
||||
it('query.lang 应优先于 Accept-Language 与 geo country', () => {
|
||||
expect(
|
||||
resolveSuggestedLocale({
|
||||
lang: 'ar',
|
||||
headers: {
|
||||
'accept-language': 'zh-CN,zh;q=0.9',
|
||||
'cf-ipcountry': 'US',
|
||||
},
|
||||
})
|
||||
).toBe('ar-SA');
|
||||
expect(
|
||||
resolveSuggestedLocale({
|
||||
lang: 'zh-TW',
|
||||
headers: {
|
||||
'accept-language': 'en-US',
|
||||
'x-country': 'SA',
|
||||
},
|
||||
})
|
||||
).toBe('zh-CN');
|
||||
});
|
||||
|
||||
it('无 query.lang 时应采用 Accept-Language', () => {
|
||||
expect(
|
||||
resolveSuggestedLocale({
|
||||
headers: {
|
||||
'accept-language': 'ar-EG,ar;q=0.8',
|
||||
'cf-ipcountry': 'US',
|
||||
},
|
||||
})
|
||||
).toBe('ar-SA');
|
||||
});
|
||||
|
||||
it('无 query.lang 与 Accept-Language 时应采用 geo country', () => {
|
||||
expect(
|
||||
resolveSuggestedLocale({
|
||||
headers: {
|
||||
'cf-ipcountry': 'HK',
|
||||
},
|
||||
})
|
||||
).toBe('zh-CN');
|
||||
expect(
|
||||
resolveSuggestedLocale({
|
||||
headers: {
|
||||
'x-country': 'EG',
|
||||
},
|
||||
})
|
||||
).toBe('ar-SA');
|
||||
});
|
||||
|
||||
it('全无信号时应回退 en-US', () => {
|
||||
expect(resolveSuggestedLocale({})).toBe('en-US');
|
||||
expect(resolveSuggestedLocale({ lang: ' ', headers: {} })).toBe('en-US');
|
||||
});
|
||||
});
|
||||
});
|
||||
183
backend/test/comm/qrcode.test.ts
普通文件
183
backend/test/comm/qrcode.test.ts
普通文件
@@ -0,0 +1,183 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CoolCommException, BaseController } from '@cool-midway/core';
|
||||
import { CardPortalService } from '../../src/modules/card/service/portal';
|
||||
import { CardQrcodeService } from '../../src/modules/card/service/qrcode';
|
||||
import { OpenCardQrcodeController } from '../../src/modules/card/controller/open/qrcode';
|
||||
import {
|
||||
buildCardPublicUrl,
|
||||
DEFAULT_CARD_PUBLIC_BASE,
|
||||
} from '../../src/comm/qrcode/qrcode';
|
||||
import { CardPortalDetail } from '../../src/comm/types/omni';
|
||||
|
||||
describe('T06: 动态短链二维码 PNG/SVG(公开免登录)', () => {
|
||||
const publishedPortal: CardPortalDetail = {
|
||||
id: 101,
|
||||
slug: 'alex-zheng',
|
||||
name: 'Alex Zheng',
|
||||
title: 'VP of Global Expansion',
|
||||
company: 'OmniCard Global Inc.',
|
||||
status: 'published',
|
||||
bentoBlocks: [],
|
||||
};
|
||||
|
||||
const draftPortal: CardPortalDetail = {
|
||||
id: 102,
|
||||
slug: 'draft-user',
|
||||
name: 'Draft User',
|
||||
title: 'Engineer',
|
||||
company: 'OmniCard',
|
||||
status: 'draft',
|
||||
bentoBlocks: [],
|
||||
};
|
||||
|
||||
let cardQrcodeService: CardQrcodeService;
|
||||
let getPortalDetail: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
getPortalDetail = jest.fn();
|
||||
cardQrcodeService = new CardQrcodeService();
|
||||
cardQrcodeService.cardPortalService = {
|
||||
getPortalDetail,
|
||||
} as unknown as CardPortalService;
|
||||
});
|
||||
|
||||
describe('1. 公开短链地址', () => {
|
||||
it('无环境变量时使用 https://card.omnicard.ai/{slug},slug 优先否则 id', () => {
|
||||
const prev = process.env.CARD_PUBLIC_BASE_URL;
|
||||
delete process.env.CARD_PUBLIC_BASE_URL;
|
||||
|
||||
expect(DEFAULT_CARD_PUBLIC_BASE).toBe('https://card.omnicard.ai');
|
||||
expect(buildCardPublicUrl('alex-zheng')).toBe(
|
||||
'https://card.omnicard.ai/alex-zheng'
|
||||
);
|
||||
expect(buildCardPublicUrl(101)).toBe('https://card.omnicard.ai/101');
|
||||
|
||||
if (prev === undefined) {
|
||||
delete process.env.CARD_PUBLIC_BASE_URL;
|
||||
} else {
|
||||
process.env.CARD_PUBLIC_BASE_URL = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 已发布名片生成二维码', () => {
|
||||
it('已发布名片能生成含 slug 的 SVG(包含 <svg)', async () => {
|
||||
getPortalDetail.mockResolvedValue(publishedPortal);
|
||||
|
||||
const result = await cardQrcodeService.generateSvg('alex-zheng');
|
||||
|
||||
expect(getPortalDetail).toHaveBeenCalledWith('alex-zheng');
|
||||
expect(result.url).toBe('https://card.omnicard.ai/alex-zheng');
|
||||
expect(result.url).toContain('alex-zheng');
|
||||
expect(result.svg).toContain('<svg');
|
||||
});
|
||||
|
||||
it('已发布名片能生成 PNG dataURL', async () => {
|
||||
getPortalDetail.mockResolvedValue(publishedPortal);
|
||||
|
||||
const result = await cardQrcodeService.generatePng('alex-zheng');
|
||||
|
||||
expect(result.url).toBe('https://card.omnicard.ai/alex-zheng');
|
||||
expect(result.png.startsWith('data:image/png;base64,')).toBe(true);
|
||||
});
|
||||
|
||||
it('无 slug 时二维码内容回退为名片 id', async () => {
|
||||
getPortalDetail.mockResolvedValue({
|
||||
...publishedPortal,
|
||||
slug: undefined,
|
||||
id: 101,
|
||||
});
|
||||
|
||||
const result = await cardQrcodeService.generateSvg(101);
|
||||
|
||||
expect(result.url).toBe('https://card.omnicard.ai/101');
|
||||
expect(result.svg).toContain('<svg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 发布校验与参数校验', () => {
|
||||
it('draft 名片抛错', async () => {
|
||||
getPortalDetail.mockImplementation(async () => {
|
||||
throw new CoolCommException('名片未发布或已下线', 404);
|
||||
});
|
||||
|
||||
await expect(cardQrcodeService.generateSvg('draft-user')).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
await expect(cardQrcodeService.generateSvg('draft-user')).rejects.toThrow(
|
||||
/未发布或已下线/
|
||||
);
|
||||
expect(getPortalDetail).toHaveBeenCalledWith('draft-user');
|
||||
|
||||
getPortalDetail.mockResolvedValue(draftPortal);
|
||||
await expect(cardQrcodeService.generatePng('draft-user')).rejects.toThrow(
|
||||
/未发布或已下线/
|
||||
);
|
||||
});
|
||||
|
||||
it('缺少标识抛错', async () => {
|
||||
await expect(cardQrcodeService.generateSvg('')).rejects.toThrow(
|
||||
CoolCommException
|
||||
);
|
||||
await expect(cardQrcodeService.generateSvg(' ')).rejects.toThrow(
|
||||
/缺少名片标识/
|
||||
);
|
||||
await expect(
|
||||
cardQrcodeService.generateSvg(undefined as unknown as string)
|
||||
).rejects.toThrow(/缺少名片标识/);
|
||||
expect(getPortalDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 公开控制器路由约定', () => {
|
||||
const controllerFile = path.join(
|
||||
__dirname,
|
||||
'../../src/modules/card/controller/open/qrcode.ts'
|
||||
);
|
||||
|
||||
it('文件路径应符合 /open/card/qrcode 自动路由约定', () => {
|
||||
expect(fs.existsSync(controllerFile)).toBe(true);
|
||||
expect(controllerFile.replace(/\\/g, '/')).toMatch(
|
||||
/modules\/card\/controller\/open\/qrcode\.ts$/
|
||||
);
|
||||
});
|
||||
|
||||
it('控制器应继承 BaseController 并忽略 Token', () => {
|
||||
expect(OpenCardQrcodeController.prototype).toBeInstanceOf(BaseController);
|
||||
const source = fs.readFileSync(controllerFile, 'utf8');
|
||||
expect(source).toContain('TagTypes.IGNORE_TOKEN');
|
||||
expect(source).toContain("@Get('/svg'");
|
||||
expect(source).toContain("@Get('/png'");
|
||||
});
|
||||
|
||||
it('GET /open/card/qrcode/svg 应返回 { data: { svg, url } }', async () => {
|
||||
getPortalDetail.mockResolvedValue(publishedPortal);
|
||||
const controller = new OpenCardQrcodeController();
|
||||
controller.cardQrcodeService = cardQrcodeService;
|
||||
|
||||
const res = (await controller.svg('alex-zheng')) as unknown as {
|
||||
code: number;
|
||||
data: { svg: string; url: string };
|
||||
};
|
||||
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.url).toContain('alex-zheng');
|
||||
expect(res.data.svg).toContain('<svg');
|
||||
});
|
||||
|
||||
it('GET /open/card/qrcode/png 应返回 PNG dataURL', async () => {
|
||||
getPortalDetail.mockResolvedValue(publishedPortal);
|
||||
const controller = new OpenCardQrcodeController();
|
||||
controller.cardQrcodeService = cardQrcodeService;
|
||||
|
||||
const res = (await controller.png('alex-zheng')) as unknown as {
|
||||
code: number;
|
||||
data: { png: string; url: string };
|
||||
};
|
||||
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.png.startsWith('data:image/png;base64,')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户