文件
OmniCard/backend/test/modules/card/calendar.test.ts

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