package handler import ( "fmt" "strings" "testing" "time" "github.com/agentmail/gateway/internal/models" ) // parseICS 是导入的唯一入口,解析错了不会报错 —— 事件只是安静地不出现, // 或者出现在错误的时间。这些测试锁住 iCal 的形态约定。 func TestParseICSBasicEvent(t *testing.T) { ics := "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "BEGIN:VEVENT\r\n" + "SUMMARY:发布评审\r\n" + "DESCRIPTION:看 llmsproxy 的部署脚本\r\n" + "DTSTART:20260903T063000Z\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Fatalf("期望 1 个事件,得到 %d", len(events)) } e := events[0] if e.Title != "发布评审" { t.Errorf("Title = %q,期望 发布评审", e.Title) } if e.Description != "看 llmsproxy 的部署脚本" { t.Errorf("Description = %q", e.Description) } if !e.EventTime.Equal(time.Date(2026, 9, 3, 6, 30, 0, 0, time.UTC)) { t.Errorf("EventTime = %v,期望 2026-09-03T06:30:00Z", e.EventTime) } if e.Recurrence != "none" { t.Errorf("Recurrence = %q,期望 none", e.Recurrence) } } func TestParseICSMultipleEvents(t *testing.T) { var sb strings.Builder sb.WriteString("BEGIN:VCALENDAR\r\n") for i, title := range []string{"甲", "乙", "丙"} { sb.WriteString("BEGIN:VEVENT\r\n") sb.WriteString("SUMMARY:" + title + "\r\n") sb.WriteString("DTSTART:2026090" + string(rune('1'+i)) + "T020000Z\r\n") sb.WriteString("END:VEVENT\r\n") } sb.WriteString("END:VCALENDAR\r\n") events := parseICS([]byte(sb.String())) if len(events) != 3 { t.Fatalf("期望 3 个事件,得到 %d", len(events)) } for i, want := range []string{"甲", "乙", "丙"} { if events[i].Title != want { t.Errorf("第 %d 个 Title = %q,期望 %q", i, events[i].Title, want) } } } // 没有 DTSTART 的 VEVENT 必须被丢弃:让它进库会得到一个 zero time 事件, // 调度器认为它「早就该触发了」,于是立刻发一封莫名其妙的提醒。 func TestParseICSDropsEventWithoutStart(t *testing.T) { ics := "BEGIN:VCALENDAR\r\n" + "BEGIN:VEVENT\r\n" + "SUMMARY:没有时间\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" if events := parseICS([]byte(ics)); len(events) != 0 { t.Fatalf("无 DTSTART 的事件应被丢弃,却得到 %d 个", len(events)) } } func TestParseICSRecurrence(t *testing.T) { cases := []struct { rrule string want string }{ {"FREQ=DAILY", "daily"}, {"FREQ=WEEKLY;BYDAY=MO", "weekly"}, {"FREQ=MONTHLY;BYMONTHDAY=1", "monthly"}, {"FREQ=YEARLY", "yearly"}, // 不支持的频率退回 none 而不是乱猜:把 HOURLY 当 daily // 会让提醒少发 23 次且没有任何报错。 {"FREQ=HOURLY", "none"}, {"FREQ=SECONDLY", "none"}, } for _, c := range cases { ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" + "RRULE:" + c.rrule + "\r\nEND:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Fatalf("%s: 期望 1 个事件", c.rrule) } if events[0].Recurrence != c.want { t.Errorf("%s: Recurrence = %q,期望 %q", c.rrule, events[0].Recurrence, c.want) } } } func TestParseICSTriggerToRemindBefore(t *testing.T) { ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" + "TRIGGER:-PT30M\r\nEND:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Fatal("期望 1 个事件") } if events[0].RemindBefore != 30 { t.Errorf("RemindBefore = %d,期望 30", events[0].RemindBefore) } } // DTSTART 有三种合法形态,都得认。只认 UTC 那种会让本地时间的 .ics // 整份导入失败(每个事件都缺 DTSTART → 全被丢弃 → 「导入 0 个」且无提示)。 func TestParseICSDateFormats(t *testing.T) { cases := []struct { name string value string }{ {"UTC", "20260903T063000Z"}, {"本地时间", "20260903T143000"}, {"仅日期", "20260903"}, } for _, c := range cases { ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:" + c.value + "\r\nEND:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Errorf("%s (%s): 期望 1 个事件,得到 %d", c.name, c.value, len(events)) continue } if events[0].EventTime.IsZero() { t.Errorf("%s (%s): EventTime 为零值", c.name, c.value) } } } // DTSTART;TZID=Asia/Shanghai:... 这种带参数的键必须归一化到 DTSTART, // 否则 switch 落空 → 无 EventTime → 事件被丢。 func TestParseICSStripsKeyParameters(t *testing.T) { ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\n" + "DTSTART;TZID=Asia/Shanghai:20260903T143000\r\nEND:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Fatalf("带 TZID 参数的 DTSTART 应被识别,得到 %d 个事件", len(events)) } if events[0].EventTime.IsZero() { t.Error("EventTime 为零值") } } func TestParseICSEscapedNewlines(t *testing.T) { ics := "BEGIN:VEVENT\r\nSUMMARY:第一行\\n第二行\r\n" + "DTSTART:20260903T020000Z\r\nEND:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Fatal("期望 1 个事件") } if !strings.Contains(events[0].Title, "\n") { t.Errorf("转义的 \\n 应还原成真换行,得到 %q", events[0].Title) } } func TestParseICSEmptyAndGarbage(t *testing.T) { for _, in := range []string{"", "不是 ics", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"} { if events := parseICS([]byte(in)); len(events) != 0 { t.Errorf("输入 %q 应给 0 个事件,得到 %d", in, len(events)) } } } // LF 换行(非 CRLF)的 .ics 也要能解析:很多工具导出的是 LF。 func TestParseICSAcceptsLFLineEndings(t *testing.T) { ics := "BEGIN:VCALENDAR\nBEGIN:VEVENT\nSUMMARY:LF 换行\n" + "DTSTART:20260903T020000Z\nEND:VEVENT\nEND:VCALENDAR\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Fatalf("LF 换行应能解析,得到 %d 个事件", len(events)) } if events[0].Title != "LF 换行" { t.Errorf("Title = %q", events[0].Title) } } // TRIGGER duration 解析。 // // 原实现是 fmt.Sscanf(value, "-PT%dM", &mins),只认一种形态;导出端又写的是 // `-P15M`(T 之前的 M 在 iCal 里是**月**)—— 于是自己导出的文件自己都读不回来。 func TestParseTriggerMinutes(t *testing.T) { cases := []struct { in string want int ok bool }{ {"-PT30M", 30, true}, {"-PT1H", 60, true}, {"-PT1H30M", 90, true}, {"-P1D", 1440, true}, {"-P1DT2H", 1560, true}, {"-P1W", 10080, true}, {"-pt45m", 45, true}, // 大小写不敏感 // T 之前的 M 是月,映射不到分钟数,忽略比乱换算好 {"-P3M", 0, false}, // 正号 = 事件之后提醒,remind_before 表达不了 {"PT30M", 0, false}, // 零时长与垃圾输入 {"-PT0M", 0, false}, {"", 0, false}, {"垃圾", 0, false}, {"-P", 0, false}, } for _, c := range cases { got, ok := parseTriggerMinutes(c.in) if ok != c.ok || got != c.want { t.Errorf("parseTriggerMinutes(%q) = (%d, %v),期望 (%d, %v)", c.in, got, ok, c.want, c.ok) } } } // 导出写的 TRIGGER 必须能被自己的导入解析回同一个分钟数。 // 这条往返曾经是断的:导出 -P15M、导入找 -PT%dM。 func TestTriggerRoundtrip(t *testing.T) { for _, mins := range []int{5, 15, 30, 60, 120, 1440} { // 导出端的写法(与 ExportCalendarICS 里那行一致) trigger := fmt.Sprintf("-PT%dM", mins) got, ok := parseTriggerMinutes(trigger) if !ok { t.Errorf("%d 分钟导出成 %q 后无法解析", mins, trigger) continue } if got != mins { t.Errorf("%d 分钟往返后变成 %d(trigger=%q)", mins, got, trigger) } } } // 整份 .ics 的往返:TRIGGER 经过 parseICS 后落到 RemindBefore 上。 func TestParseICSTriggerVariants(t *testing.T) { cases := []struct { trigger string want int }{ {"-PT15M", 15}, {"-PT2H", 120}, {"-P1D", 1440}, } for _, c := range cases { ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" + "TRIGGER:" + c.trigger + "\r\nEND:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Errorf("%s: 期望 1 个事件", c.trigger) continue } if events[0].RemindBefore != c.want { t.Errorf("%s: RemindBefore = %d,期望 %d", c.trigger, events[0].RemindBefore, c.want) } } } // 默认提醒模板必须是**变量形式**,不能把当时的时间烤成字面值。 // // 重复事件上这个区别是致命的:AdvanceRecurrence 只推进 event_time, // reminder_text 保持不动 —— 字面值会让「每天 9 点」的提醒从第二天起 // 永远写着第一天的日期,且不报任何错。 // // 同时锁住「与前端 DEFAULT_TEMPLATE 逐字一致」: // client/electron/src/components/CalendarEventEditor.tsx 用它作预览, // 两边不同会让人看到的预览与 Agent 实收的正文不是一回事。 func TestDefaultReminderTemplateUsesVariables(t *testing.T) { for _, v := range []string{"{title}", "{time}", "{description}"} { if !strings.Contains(defaultReminderTemplate, v) { t.Errorf("默认模板缺变量 %s:%q", v, defaultReminderTemplate) } } // 前端那份的字面内容(保持同步) const frontend = "日程提醒:{title}\n时间:{time}\n{description}" if defaultReminderTemplate != frontend { t.Errorf("后端默认模板与前端 DEFAULT_TEMPLATE 不一致:\n后端 %q\n前端 %q", defaultReminderTemplate, frontend) } // 不该含任何形如年份的字面数字 —— 那是「把值烤进模板」的迹象 for _, digit := range []string{"2026", "20:", ":00"} { if strings.Contains(defaultReminderTemplate, digit) { t.Errorf("默认模板含字面时间片段 %q:%q", digit, defaultReminderTemplate) } } } // ─── 农历与多收件人的 iCal 往返 ─── // 农历规则 RFC 5545 表达不了。折中方案:X- 扩展属性记精确规则 + // 降级成最接近的公历 RRULE。别的客户端至少能看到一个大致对的重复, // 导回本系统时 X- 属性还原精确规则。 func TestParseICSLunarRecurrenceExtension(t *testing.T) { cases := []struct { name string body string want string }{ { "X- 属性覆盖 RRULE 近似值", "RRULE:FREQ=MONTHLY\r\nX-AGENTMAIL-RECURRENCE:lunar_monthly\r\n", "lunar_monthly", }, { "农历年", "RRULE:FREQ=YEARLY\r\nX-AGENTMAIL-RECURRENCE:lunar_yearly\r\n", "lunar_yearly", }, { "X- 在 RRULE 之前也生效(顺序无关)", "X-AGENTMAIL-RECURRENCE:lunar_monthly\r\nRRULE:FREQ=MONTHLY\r\n", "lunar_monthly", }, { "非法 X- 值被忽略,保留 RRULE 的近似值", "RRULE:FREQ=MONTHLY\r\nX-AGENTMAIL-RECURRENCE:lunar_montly\r\n", "monthly", }, { "键名小写也认(RFC 5545 §3.1 大小写不敏感)", "x-agentmail-recurrence:lunar_yearly\r\n", "lunar_yearly", }, } for _, c := range cases { ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" + c.body + "END:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Errorf("%s: 期望 1 个事件,得到 %d", c.name, len(events)) continue } if events[0].Recurrence != c.want { t.Errorf("%s: Recurrence = %q,期望 %q", c.name, events[0].Recurrence, c.want) } } } func TestParseICSRecipientsExtension(t *testing.T) { ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" + "X-AGENTMAIL-RECIPIENTS:pi@/home/x,dsh,opencode@/tmp.alias\r\n" + "X-AGENTMAIL-DELIVERY:together\r\nEND:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Fatalf("期望 1 个事件,得到 %d", len(events)) } e := events[0] if len(e.Recipients) != 3 { t.Fatalf("收件人应有 3 个,得到 %d:%v", len(e.Recipients), e.Recipients) } if e.Recipients[0] != "pi@/home/x" || e.Recipients[2] != "opencode@/tmp.alias" { t.Errorf("收件人内容或顺序不符:%v", e.Recipients) } if e.DeliveryMode != "together" { t.Errorf("DeliveryMode = %q,期望 together", e.DeliveryMode) } } // 未知投递模式必须被忽略(留空 → EffectiveDeliveryMode 给 separate), // 而不是原样写进库里。 func TestParseICSRejectsBadDeliveryMode(t *testing.T) { ics := "BEGIN:VEVENT\r\nSUMMARY:x\r\nDTSTART:20260903T020000Z\r\n" + "X-AGENTMAIL-DELIVERY:随便写的\r\nEND:VEVENT\r\n" events := parseICS([]byte(ics)) if len(events) != 1 { t.Fatal("期望 1 个事件") } if events[0].DeliveryMode != "" { t.Errorf("非法投递模式应被忽略,得到 %q", events[0].DeliveryMode) } if events[0].EffectiveDeliveryMode() != models.DeliverSeparate { t.Error("兜底应是 separate") } } func TestValidRecurrence(t *testing.T) { for _, ok := range []string{"none", "daily", "weekly", "monthly", "yearly", "lunar_monthly", "lunar_yearly"} { if !validRecurrence(ok) { t.Errorf("%q 应合法", ok) } } // 拼错必须被拒而不是静默当 none —— 后者会让每月提醒只响一次且无报错 for _, bad := range []string{"", "lunar_montly", "LUNAR_MONTHLY", "每月", "lunar_weekly"} { if validRecurrence(bad) { t.Errorf("%q 应非法", bad) } } } func TestNormalizeRecipients(t *testing.T) { got, bad := normalizeRecipients([]string{" pi ", "", "dsh", "pi", " "}) if bad != "" { t.Fatalf("不该报错,得到 %q", bad) } // 去空白 + 去重,保留首次出现的顺序 if len(got) != 2 || got[0] != "pi" || got[1] != "dsh" { t.Errorf("清洗结果 %v,期望 [pi dsh]", got) } // 去重是必要的:together 模式下同一 Agent 既主收又抄送会收到两条 SSE if dup, _ := normalizeRecipients([]string{"pi@/x", "pi@/x"}); len(dup) != 1 { t.Errorf("重复地址应去重,得到 %v", dup) } // 非法地址回报具体是哪一个 if _, bad := normalizeRecipients([]string{"pi", "@@@bad@@@"}); bad == "" { t.Error("非法地址应被报出") } // nil / 空输入给空数组而不是 nil(避免序列化成 null) if out, _ := normalizeRecipients(nil); out == nil { t.Error("nil 输入应给空数组") } }