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 = {}; 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'); }); }); });