Files
MailUI4Agents/server/internal/handler/ics_test.go

424 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 分钟往返后变成 %dtrigger=%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 输入应给空数组")
}
}