三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
`from_name = "calendar"` 刻意既非人类名也非 Agent 名 —— 用创建者的名字
会让 Agent 以为人在实时找它,而人此刻可能在睡觉,模型据此判断
「要不要马上追问」,来源写错会让它问一个不在线的人。
日历提醒不扣会话预算:预算的语义是「这件事值得模型自主发多少封信」,
而提醒是人预先设定的定时任务,不是模型的自主行为。
**多收件人两种投递模式,都要**:
- separate(默认)= 各发一封、落各自会话、互相看不到
- together = 首个为主收件人、其余进 cc_list、共享一条线索
「让三个 Agent 各自独立汇报」与「让 pi 主办、dsh 知情」是完全不同的任务
形态。默认 separate 因为失败模式更轻:together 用错会让本该独立判断的
Agent 互相看到回复而趋同,那种上下文污染事后无法分离。
抄送方也推 SSE。漏了这步的后果很隐蔽:cc_list 里有他们、查收件箱看得见,
但没有任何事件推给他们 —— 插件不会唤起会话,Agent 到下次补拉才发现。
recipients 存**原始地址串**而非结构化:session 位的 new/别名三态该在
触发那一刻解析,存结构化会让「.new」这种一次性语义在建事件时就被固化,
而重复事件每次触发都该重新决定落到哪条会话。
---
修掉的七个真问题:
**1. 默认提醒模板把时间烤成字面值。**
原来 Sprintf 出含字面时间的正文存进 reminder_text。对重复事件是错的:
AdvanceRecurrence 只推进 event_time,模板不动 —— 「每天 9 点」的提醒
从第二天起永远写着第一天的日期,且不报任何错。改为存变量形式。
**2. `{time}` 渲染成 UTC。**
DSN 带 _timezone=UTC,读回的 EventTime 是 UTC。直接 Format 会把人在
+0800 输入的 14:30 写成 06:30,而前端预览用本地时间 —— 两边差 8 小时
且都不报错。
**3. 同一提醒每 tick 重发一次(生产实测 4 封)。**
DueEvents 有 60 秒 lookahead(周期 30 秒,不提前看会迟到)。去重判据
原本是 `last_fired_at < event_time` —— 触发时刻本来就早于落在窗口内的
event_time,条件恒真。实测一条 12:53:17 的事件在 12:52:30 / 12:53:00 /
12:53:06 / 12:53:36 各发一封。新增 fired_for 列记录**已触发的
occurrence**,判据改为 `fired_for <> event_time`。
**4. 过期重复事件刷屏。**
AdvanceRecurrence 只推一步:一条 100 天前设的每日事件每轮都判定过期 →
发一封 → 只前进一天 → 下轮又过期。实测 30 轮触发 30 次,而周期是
30 秒。改成 advanceToFuture 一路推到越过当前时刻;跳过的 occurrence
不补发(三个月前那次站会提醒现在发出去毫无意义,只会淹掉该看的那封)。
带 maxAdvanceSteps=4000 上限:农历路径依赖外部库,没有上限就是个死循环
goroutine,而它跑在调度器里 —— 整个提醒系统会一起卡住。
**5. 越过 recurrence_end 不置 cancelled。**
留在 active 会变僵尸事件:DueEvents 每轮都捞到它(event_time 在过去),
但 fired_for 已等于 event_time 所以又不触发。
**6. 附件从未落盘。**
`data := make([]byte, header.Size); file.Read(data)` 两处错:单次 Read
不保证填满缓冲(大文件必然短读,sha256 算的是半截内容),而且文件内容
压根没写进 blob 存储。结果是附件「上传成功」、清单里看得见、
发提醒时取不到任何字节。改走 Blobs.Put。
**7. iCal TRIGGER 往返是断的。**
导出写 `-P15M`、导入找 `-PT%dM`,自己导出的文件自己都读不回来。更糟的是
`-P15M` 在任何合规客户端里都是「提前 **15 个月**」—— iCal duration 的 M
在 T 之前是月、之后才是分钟。而且用 maxInt(RemindBefore,15) 兜底,把用户
明确设的「到点提醒」(0) 悄悄改成提前 15 分钟。
---
其他修正:
- **PG schema 整块缺失日历两张表** —— DATABASE_URL 非空时所有 /calendar/*
在 relation does not exist 上 500,而 SQLite 下一切正常,问题只在切外部库
时才暴露
- DeleteCalendarAttachment 曾返回 501,让人「删整个事件来清附件」
- 导出忽略 from/to;导入只接受 multipart(命令行调用者收到含糊的
「Missing file field」)
- 上传附件不校验事件存在 —— 会攒孤儿记录,而 ON DELETE CASCADE 清不掉
它们(SQLite 的 foreign_keys 默认关)
- 农历规则 RRULE 表达不了,走 X-AGENTMAIL-RECURRENCE 扩展属性 + 公历近似
兜底。**RRULE 分支不能覆盖已读到的农历值** —— X- 出现在 RRULE 之前时
无条件赋值会把 lunar_monthly 打回 monthly,往返一圈农历规则悄悄退化
- 抽出 calendarCols 常量:原先四处手抄同一串列名,加一列漏改任何一处
不会编译报错,只会运行时列错位(ListSessionsFor 上真的发生过)
测试:repo 20+ 例(到期判定/幂等/lookahead 不重发/过期不刷屏/农历推进/
多收件人/兜底链)、handler 20 例 iCal、scheduler 7 例模板渲染。
生产端到端验证并清理了数据。
424 lines
14 KiB
Go
424 lines
14 KiB
Go
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 逐字一致」:
|
||
// web/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 输入应给空数组")
|
||
}
|
||
}
|