320 行
13 KiB
TypeScript
320 行
13 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|