feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
这个提交包含在:
@@ -0,0 +1,90 @@
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { AdminCardInfoController } from '../../../src/modules/card/controller/admin/info';
|
||||
import { CardInfoService } from '../../../src/modules/card/service/info';
|
||||
|
||||
/**
|
||||
* R35:生产环境禁止 POST /admin/card/info/seed 写入演示名片
|
||||
*/
|
||||
describe('AdminCardInfoController seed 生产守卫', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
});
|
||||
|
||||
function createController(env: {
|
||||
nodeEnv?: string;
|
||||
midwayEnv?: string;
|
||||
}): {
|
||||
controller: AdminCardInfoController;
|
||||
seedMockCards: jest.Mock;
|
||||
} {
|
||||
if (env.nodeEnv !== undefined) {
|
||||
process.env.NODE_ENV = env.nodeEnv;
|
||||
}
|
||||
const seedMockCards = jest.fn().mockResolvedValue(6);
|
||||
const controller = new AdminCardInfoController();
|
||||
controller.cardInfoService = {
|
||||
seedMockCards,
|
||||
} as unknown as CardInfoService;
|
||||
controller.ok = jest.fn((data: unknown) => ({
|
||||
code: 1000,
|
||||
data,
|
||||
})) as never;
|
||||
if (env.midwayEnv !== undefined) {
|
||||
(controller as { app?: { getEnv: () => string } }).app = {
|
||||
getEnv: () => env.midwayEnv as string,
|
||||
};
|
||||
}
|
||||
return { controller, seedMockCards };
|
||||
}
|
||||
|
||||
it('NODE_ENV=production 时应抛明确错误且不调用 seedMockCards', async () => {
|
||||
const { controller, seedMockCards } = createController({
|
||||
nodeEnv: 'production',
|
||||
});
|
||||
|
||||
await expect(controller.seed()).rejects.toThrow(CoolCommException);
|
||||
await expect(controller.seed()).rejects.toThrow(
|
||||
/生产|production|禁止|不可用|不允许/i
|
||||
);
|
||||
expect(seedMockCards).not.toHaveBeenCalled();
|
||||
expect(controller.ok).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Midway getEnv()=prod 时应拒绝且不播种', async () => {
|
||||
const { controller, seedMockCards } = createController({
|
||||
nodeEnv: 'local',
|
||||
midwayEnv: 'prod',
|
||||
});
|
||||
|
||||
await expect(controller.seed()).rejects.toThrow(CoolCommException);
|
||||
expect(seedMockCards).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('非生产环境应调用 seedMockCards 并正常返回', async () => {
|
||||
const { controller, seedMockCards } = createController({
|
||||
nodeEnv: 'local',
|
||||
midwayEnv: 'local',
|
||||
});
|
||||
|
||||
const result = await controller.seed();
|
||||
|
||||
expect(seedMockCards).toHaveBeenCalledTimes(1);
|
||||
expect(controller.ok).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
code: 1000,
|
||||
data: expect.objectContaining({ count: 6 }),
|
||||
});
|
||||
});
|
||||
|
||||
it('unittest 环境允许播种(本地测试)', async () => {
|
||||
const { controller, seedMockCards } = createController({
|
||||
nodeEnv: 'unittest',
|
||||
midwayEnv: 'unittest',
|
||||
});
|
||||
|
||||
await controller.seed();
|
||||
expect(seedMockCards).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,69 @@ describe('Point 62 (P1): 展会名片双向互换与 CRM 互加好友验证', ()
|
||||
const outcome = await service.confirmExchange(invite.id, 402);
|
||||
expect(outcome.status).toBe('ACCEPTED');
|
||||
expect(outcome.isDualSynced).toBe(true);
|
||||
expect(invite.persisted).toBe(false);
|
||||
expect(outcome.persisted).toBe(false);
|
||||
});
|
||||
|
||||
it('仅内存 Map 保存时,创建与确认结果均应带 persisted:false', async () => {
|
||||
// 无仓库注入:不得假装已入库
|
||||
expect((service as any).exchangeEntity).toBeUndefined();
|
||||
|
||||
const invite = await service.initiateExchange({
|
||||
senderCardId: 501,
|
||||
recipientCardId: 602,
|
||||
});
|
||||
expect(invite.persisted).toBe(false);
|
||||
|
||||
const outcome = await service.confirmExchange(invite.id, 602);
|
||||
expect(outcome.status).toBe('ACCEPTED');
|
||||
expect(outcome.persisted).toBe(false);
|
||||
});
|
||||
|
||||
it('有仓库且写入成功时,创建与确认结果均应带 persisted:true', async () => {
|
||||
let nextId = 1001;
|
||||
const store = new Map<number, any>();
|
||||
(service as any).exchangeEntity = {
|
||||
save: async (row: any) => {
|
||||
if (!row.id) {
|
||||
row.id = nextId++;
|
||||
}
|
||||
store.set(Number(row.id), { ...row });
|
||||
return row;
|
||||
},
|
||||
findOne: async ({ where }: { where: { id?: number } }) => {
|
||||
if (where?.id == null) return null;
|
||||
const hit = store.get(Number(where.id));
|
||||
return hit ? { ...hit } : null;
|
||||
},
|
||||
};
|
||||
|
||||
const invite = await service.initiateExchange({
|
||||
senderCardId: 701,
|
||||
recipientCardId: 802,
|
||||
});
|
||||
expect(invite.persisted).toBe(true);
|
||||
expect(invite.id).toBe('1001');
|
||||
|
||||
const outcome = await service.confirmExchange(invite.id, 802);
|
||||
expect(outcome.status).toBe('ACCEPTED');
|
||||
expect(outcome.persisted).toBe(true);
|
||||
});
|
||||
|
||||
it('找不到邀约时应报错,不得伪造成功互换', async () => {
|
||||
await expect(
|
||||
service.confirmExchange('xchg_missing_nowhere', 402)
|
||||
).rejects.toThrow(/未找到该名片互换邀约/);
|
||||
});
|
||||
|
||||
it('有仓库但库中与内存均无此邀约时,确认应失败而非造一条成功互换', async () => {
|
||||
(service as any).exchangeEntity = {
|
||||
findOne: async () => null,
|
||||
save: async (row: any) => row,
|
||||
};
|
||||
await expect(service.confirmExchange('99999', 402)).rejects.toThrow(
|
||||
/未找到该名片互换邀约/
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -343,6 +343,41 @@ describe('Task 08: 后端公开短链路由与秒级微官网详情 API (Card Po
|
||||
const res = await portalController.detail(undefined, undefined, undefined);
|
||||
expect(res.code).not.toBe(1000);
|
||||
});
|
||||
|
||||
describe('GET /open/card/portal/mock-cards (ALLOW_PORTAL_MOCK)', () => {
|
||||
const prevAllow = process.env.ALLOW_PORTAL_MOCK;
|
||||
|
||||
afterEach(() => {
|
||||
if (prevAllow === undefined) {
|
||||
delete process.env.ALLOW_PORTAL_MOCK;
|
||||
} else {
|
||||
process.env.ALLOW_PORTAL_MOCK = prevAllow;
|
||||
}
|
||||
});
|
||||
|
||||
it('默认(未开启 ALLOW_PORTAL_MOCK)应返回空数组,不含演示姓名', async () => {
|
||||
delete process.env.ALLOW_PORTAL_MOCK;
|
||||
const res = await portalController.mockCards();
|
||||
expect(res.code).toBe(1000);
|
||||
expect(Array.isArray((res as any).data)).toBe(true);
|
||||
expect((res as any).data).toEqual([]);
|
||||
const payload = JSON.stringify((res as any).data);
|
||||
expect(payload).not.toContain('Alex Zheng');
|
||||
expect(payload).not.toContain('Marcus Weber');
|
||||
});
|
||||
|
||||
it('ALLOW_PORTAL_MOCK=1 时才返回原演示名片清单', async () => {
|
||||
process.env.ALLOW_PORTAL_MOCK = '1';
|
||||
const res = await portalController.mockCards();
|
||||
expect(res.code).toBe(1000);
|
||||
const list = (res as any).data;
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list.length).toBeGreaterThanOrEqual(6);
|
||||
expect(list[0].slug).toBe('alex-zheng');
|
||||
expect(list[0].name).toBe('Alex Zheng');
|
||||
expect(list[0].bentoBlocksCount).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('6. suggestedLocale 浅拷贝且不污染缓存 (T18)', () => {
|
||||
|
||||
@@ -166,16 +166,18 @@ describe('Point 29-A: SEO & OpenGraph 社交预览元标签注入引擎', () =>
|
||||
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');
|
||||
// 无真实头像/封面时不得填假 CDN / 占位图
|
||||
expect(metaNull['og:image']).toBe('');
|
||||
expect(metaNull['twitter:image']).toBe('');
|
||||
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');
|
||||
expect(metaUndefined['og:image']).toBe('');
|
||||
expect(metaUndefined['twitter:image']).toBe('');
|
||||
});
|
||||
|
||||
it('当 template 不含标准 <head> 标签时,renderHtmlWithOgMeta 能够安全兜底注入', () => {
|
||||
@@ -185,4 +187,112 @@ describe('Point 29-A: SEO & OpenGraph 社交预览元标签注入引擎', () =>
|
||||
expect(result).toContain('<div>Raw Body Content</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('6. 无真实图片时 og:image / twitter:image 必须为空(禁止假图)', () => {
|
||||
it('generateOgMeta:无 avatarUrl 时 image 为空,不得回退到 default-card-og / og-default', () => {
|
||||
const og = seoService.generateOgMeta(
|
||||
{
|
||||
id: 1,
|
||||
name: 'No Avatar',
|
||||
title: 'Engineer',
|
||||
company: 'Omni',
|
||||
status: 'active' as any,
|
||||
bentoBlocks: [],
|
||||
},
|
||||
'https://omnicard.ai'
|
||||
);
|
||||
expect(og.image).toBe('');
|
||||
expect(og.image).not.toMatch(/og-default|default-card-og|placeholder|example\.com|unsplash|picsum/i);
|
||||
});
|
||||
|
||||
it('generateOgMeta:仅接受真实 http(s) 头像,相对路径与非 http 协议视为无图', () => {
|
||||
const relative = seoService.generateOgMeta({
|
||||
id: 2,
|
||||
name: 'Rel',
|
||||
title: 'T',
|
||||
company: 'C',
|
||||
avatarUrl: '/static/avatar.png',
|
||||
status: 'active' as any,
|
||||
bentoBlocks: [],
|
||||
});
|
||||
expect(relative.image).toBe('');
|
||||
|
||||
const blank = seoService.generateOgMeta({
|
||||
id: 2,
|
||||
name: 'Blank',
|
||||
title: 'T',
|
||||
company: 'C',
|
||||
avatarUrl: ' ',
|
||||
status: 'active' as any,
|
||||
bentoBlocks: [],
|
||||
});
|
||||
expect(blank.image).toBe('');
|
||||
|
||||
const dataUri = seoService.generateOgMeta({
|
||||
id: 2,
|
||||
name: 'Data',
|
||||
title: 'T',
|
||||
company: 'C',
|
||||
avatarUrl: 'data:image/png;base64,AAAA',
|
||||
status: 'active' as any,
|
||||
bentoBlocks: [],
|
||||
});
|
||||
expect(dataUri.image).toBe('');
|
||||
|
||||
const real = seoService.generateOgMeta({
|
||||
id: 3,
|
||||
name: 'Https',
|
||||
title: 'T',
|
||||
company: 'C',
|
||||
avatarUrl: 'https://cdn.omnicard.ai/avatars/real.jpg',
|
||||
status: 'active' as any,
|
||||
bentoBlocks: [],
|
||||
});
|
||||
expect(real.image).toBe('https://cdn.omnicard.ai/avatars/real.jpg');
|
||||
});
|
||||
|
||||
it('generateOpenGraphMeta:无头像时 og:image / twitter:image 为空,忽略假 defaultImage 占位', () => {
|
||||
const meta = seoService.generateOpenGraphMeta(
|
||||
{ name: 'Empty Pic', title: 'CTO', company: 'X' },
|
||||
{
|
||||
baseUrl: 'https://omnicard.ai',
|
||||
defaultImage: 'https://omnicard.ai/assets/og-default.png',
|
||||
}
|
||||
);
|
||||
expect(meta['og:image']).toBe('');
|
||||
expect(meta['twitter:image']).toBe('');
|
||||
expect(JSON.stringify(meta)).not.toMatch(
|
||||
/og-default|default-card-og|placeholder|picsum|unsplash/i
|
||||
);
|
||||
});
|
||||
|
||||
it('renderHtmlWithOgMeta:无真实图片时不输出 og:image / twitter:image 标签', () => {
|
||||
const template = `<!DOCTYPE html><html><head><title>t</title></head><body></body></html>`;
|
||||
const html = seoService.renderHtmlWithOgMeta(
|
||||
template,
|
||||
{ name: 'No Image Card' },
|
||||
{ baseUrl: 'https://omnicard.ai' }
|
||||
);
|
||||
expect(html).not.toContain('property="og:image"');
|
||||
expect(html).not.toContain('name="twitter:image"');
|
||||
expect(html).not.toMatch(/og-default|default-card-og/i);
|
||||
});
|
||||
|
||||
it('renderCardHtml:无头像时 og:image / twitter:image content 为空', () => {
|
||||
const html = seoService.renderCardHtml(
|
||||
{
|
||||
id: 9,
|
||||
name: 'Blank',
|
||||
title: 'Dev',
|
||||
company: 'ACME',
|
||||
status: 'active' as any,
|
||||
bentoBlocks: [],
|
||||
},
|
||||
{ baseUrl: 'https://omnicard.ai' }
|
||||
);
|
||||
expect(html).toContain('<meta property="og:image" content="" />');
|
||||
expect(html).toContain('<meta name="twitter:image" content="" />');
|
||||
expect(html).not.toMatch(/og-default|default-card-og|placeholder\.com|example\.com\/.*\.(png|jpe?g)/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CoolCommException } from '@cool-midway/core';
|
||||
import { CardInfoEntity } from '../../../src/modules/card/entity/info';
|
||||
import { CardInfoService } from '../../../src/modules/card/service/info';
|
||||
import { AdminCardInfoController } from '../../../src/modules/card/controller/admin/info';
|
||||
|
||||
/**
|
||||
* 内存 Repository,仅覆盖 findOneBy
|
||||
*/
|
||||
class MemoryCardInfoRepo {
|
||||
private data: CardInfoEntity[] = [];
|
||||
|
||||
constructor(initialData: Array<Partial<CardInfoEntity>> = []) {
|
||||
this.data = initialData.map(item => {
|
||||
const entity = new CardInfoEntity();
|
||||
Object.assign(entity, item);
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
async findOneBy(
|
||||
where: Partial<Pick<CardInfoEntity, 'id'>>
|
||||
): Promise<CardInfoEntity | null> {
|
||||
const found = this.data.find(item => {
|
||||
if (where.id === undefined) {
|
||||
return false;
|
||||
}
|
||||
return Number(item.id) === Number(where.id);
|
||||
});
|
||||
return found ? ({ ...found } as CardInfoEntity) : null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('后台名片 vCard 导出 API', () => {
|
||||
const controllerFile = path.join(
|
||||
__dirname,
|
||||
'../../../src/modules/card/controller/admin/info.ts'
|
||||
);
|
||||
const serviceFile = path.join(
|
||||
__dirname,
|
||||
'../../../src/modules/card/service/info.ts'
|
||||
);
|
||||
|
||||
describe('1. 路由与方法约定', () => {
|
||||
it('admin info 控制器应声明 /vcard 导出接口', () => {
|
||||
expect(fs.existsSync(controllerFile)).toBe(true);
|
||||
const content = fs.readFileSync(controllerFile, 'utf-8');
|
||||
expect(content).toMatch(/@Post\(\s*['"]\/vcard['"]/);
|
||||
expect(content).toMatch(/exportVCard|exportVcard/);
|
||||
});
|
||||
|
||||
it('CardInfoService 应提供 exportVCard 方法', () => {
|
||||
expect(fs.existsSync(serviceFile)).toBe(true);
|
||||
const content = fs.readFileSync(serviceFile, 'utf-8');
|
||||
expect(content).toContain('exportVCard');
|
||||
expect(content).toContain('generateVCard');
|
||||
expect(content).not.toContain("FN:假");
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. exportVCard 业务', () => {
|
||||
let service: CardInfoService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CardInfoService();
|
||||
service.cardInfoEntity = new MemoryCardInfoRepo([
|
||||
{
|
||||
id: 42,
|
||||
name: 'Alex Morgan',
|
||||
title: 'CTO',
|
||||
company: 'OmniCard AI',
|
||||
phone: '+86-13800138000',
|
||||
email: 'alex@omnicard.ai',
|
||||
slug: 'alex-morgan',
|
||||
status: 'published',
|
||||
},
|
||||
]) as unknown as CardInfoService['cardInfoEntity'];
|
||||
});
|
||||
|
||||
it('名片不存在时应抛出 CoolCommException,不得返回写死姓名', async () => {
|
||||
await expect(service.exportVCard(99999)).rejects.toThrow(CoolCommException);
|
||||
await expect(service.exportVCard(99999)).rejects.toThrow(/不存在|not found/i);
|
||||
});
|
||||
|
||||
it('名片存在时返回的 vcf 必须含 BEGIN:VCARD 且包含真实姓名', async () => {
|
||||
const vcf = await service.exportVCard(42);
|
||||
expect(vcf).toContain('BEGIN:VCARD');
|
||||
expect(vcf).toContain('END:VCARD');
|
||||
expect(vcf).toContain('FN:Alex Morgan');
|
||||
expect(vcf).not.toContain('假姓名');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 控制器委派', () => {
|
||||
it('vcard 接口应按 id 调用 service.exportVCard 并 ok 返回', async () => {
|
||||
const controller = new AdminCardInfoController();
|
||||
const exportVCard = jest.fn().mockResolvedValue(
|
||||
'BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Alex Morgan\r\nEND:VCARD\r\n'
|
||||
);
|
||||
controller.cardInfoService = {
|
||||
exportVCard,
|
||||
} as unknown as CardInfoService;
|
||||
controller.ok = jest.fn((data: unknown) => ({ code: 1000, data })) as never;
|
||||
|
||||
const result = await controller.vcard({ id: 42 });
|
||||
|
||||
expect(exportVCard).toHaveBeenCalledWith(42);
|
||||
expect(controller.ok).toHaveBeenCalled();
|
||||
const okArg = (controller.ok as jest.Mock).mock.calls[0][0] as {
|
||||
vcard?: string;
|
||||
filename?: string;
|
||||
};
|
||||
expect(okArg.vcard).toContain('BEGIN:VCARD');
|
||||
expect(okArg.filename).toMatch(/\.vcf$/);
|
||||
expect(result).toEqual({
|
||||
code: 1000,
|
||||
data: okArg,
|
||||
});
|
||||
});
|
||||
|
||||
it('缺少 id 时应抛 CoolCommException', async () => {
|
||||
const controller = new AdminCardInfoController();
|
||||
controller.cardInfoService = {
|
||||
exportVCard: jest.fn(),
|
||||
} as unknown as CardInfoService;
|
||||
|
||||
await expect(controller.vcard({})).rejects.toThrow(CoolCommException);
|
||||
await expect(controller.vcard({ id: 0 })).rejects.toThrow(CoolCommException);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户