package repo import ( "context" "testing" "time" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/lunar" "github.com/agentmail/gateway/internal/models" "github.com/google/uuid" ) func seedEvent(t *testing.T, e *models.CalendarEvent) *models.CalendarEvent { t.Helper() if e.Title == "" { e.Title = "测试事件" } if e.EventTime.IsZero() { e.EventTime = time.Now().Add(time.Hour) } out, err := CreateCalendarEvent(context.Background(), e) if err != nil { t.Fatalf("建事件: %v", err) } return out } func TestCalendarEventCRUD(t *testing.T) { setupTestDB(t) ctx := context.Background() at := time.Now().Add(2 * time.Hour).Truncate(time.Second) e := seedEvent(t, &models.CalendarEvent{ Title: "每日站会", Description: "同步进展", ReminderText: "日程提醒:{title}", AgentName: "dsh", ToAddress: "dsh@/home", EventTime: at, RemindBefore: 15, Recurrence: "daily", CreatedBy: "jianf", }) if e.EventID == "" { t.Fatal("建完事件必须有 event_id") } if e.Status != "active" { t.Errorf("新事件默认应为 active,得到 %q", e.Status) } got, err := GetCalendarEvent(ctx, e.EventID) if err != nil { t.Fatalf("读事件: %v", err) } if got.Title != "每日站会" || got.RemindBefore != 15 || got.Recurrence != "daily" { t.Errorf("读回的字段不符:%+v", got) } if !got.EventTime.Equal(at) { t.Errorf("event_time 读回错位:写 %v 读 %v", at, got.EventTime) } got.Title = "改名后的站会" got.Status = "paused" if err := UpdateCalendarEvent(ctx, e.EventID, got); err != nil { t.Fatalf("改事件: %v", err) } again, _ := GetCalendarEvent(ctx, e.EventID) if again.Title != "改名后的站会" || again.Status != "paused" { t.Errorf("改后没生效:%+v", again) } if err := DeleteCalendarEvent(ctx, e.EventID); err != nil { t.Fatalf("删事件: %v", err) } if _, err := GetCalendarEvent(ctx, e.EventID); err != ErrEventNotFound { t.Errorf("删掉后应报 ErrEventNotFound,得到 %v", err) } } func TestCalendarNotFoundIsTyped(t *testing.T) { setupTestDB(t) ctx := context.Background() // 不存在的 id 要给出可判定的错误,而不是 sql.ErrNoRows —— // handler 靠它区分 404 与 500。 if _, err := GetCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000"); err != ErrEventNotFound { t.Errorf("Get 应报 ErrEventNotFound,得到 %v", err) } if err := DeleteCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000"); err != ErrEventNotFound { t.Errorf("Delete 应报 ErrEventNotFound,得到 %v", err) } if err := UpdateCalendarEvent(ctx, "00000000-0000-0000-0000-000000000000", &models.CalendarEvent{Title: "x", EventTime: time.Now()}); err != ErrEventNotFound { t.Errorf("Update 应报 ErrEventNotFound,得到 %v", err) } } func TestDueEventsOnlyReturnsRipe(t *testing.T) { setupTestDB(t) ctx := context.Background() now := time.Now() // 已经该响的(事件时间在过去) ripe := seedEvent(t, &models.CalendarEvent{Title: "该响了", EventTime: now.Add(-time.Minute)}) // 提前 30 分钟提醒、事件在 20 分钟后 —— 提醒点已过 early := seedEvent(t, &models.CalendarEvent{ Title: "提前提醒已到", EventTime: now.Add(20 * time.Minute), RemindBefore: 30, }) // 还早(1 小时后,无提前提醒) future := seedEvent(t, &models.CalendarEvent{Title: "还早", EventTime: now.Add(time.Hour)}) // 已暂停的不该响 paused := seedEvent(t, &models.CalendarEvent{Title: "暂停的", EventTime: now.Add(-time.Minute)}) p, _ := GetCalendarEvent(ctx, paused.EventID) p.Status = "paused" if err := UpdateCalendarEvent(ctx, paused.EventID, p); err != nil { t.Fatalf("暂停: %v", err) } due, err := DueEvents(ctx) if err != nil { t.Fatalf("DueEvents: %v", err) } got := map[string]bool{} for _, e := range due { got[e.EventID] = true } if !got[ripe.EventID] { t.Error("到期事件没被取出") } if !got[early.EventID] { t.Error("remind_before 已过的事件没被取出") } if got[future.EventID] { t.Error("未到期事件被取出了") } if got[paused.EventID] { t.Error("已暂停的事件被取出了") } } func TestMarkEventFiredStopsRefiring(t *testing.T) { setupTestDB(t) ctx := context.Background() // 幂等的关键:标记后同一条不该再出现在 DueEvents 里, // 否则调度器每 30 秒把同一封提醒重发一遍。 e := seedEvent(t, &models.CalendarEvent{Title: "只该响一次", EventTime: time.Now().Add(-time.Minute)}) due, _ := DueEvents(ctx) if len(due) != 1 { t.Fatalf("标记前应有 1 条到期,得到 %d", len(due)) } if err := MarkEventFired(ctx, e.EventID); err != nil { t.Fatalf("标记: %v", err) } due, _ = DueEvents(ctx) for _, d := range due { if d.EventID == e.EventID { t.Error("已标记触发的事件仍出现在 DueEvents 里") } } } func TestAdvanceRecurrence(t *testing.T) { setupTestDB(t) ctx := context.Background() base := time.Now().Add(-time.Minute).Truncate(time.Second) t.Run("一次性事件不推进", func(t *testing.T) { e := seedEvent(t, &models.CalendarEvent{Title: "一次性", EventTime: base, Recurrence: "none"}) advanced, err := AdvanceRecurrence(ctx, e.EventID) if err != nil { t.Fatalf("推进: %v", err) } if advanced { t.Error("recurrence=none 不该推进") } }) for _, tc := range []struct { rule string want time.Time }{ {"daily", base.AddDate(0, 0, 1)}, {"weekly", base.AddDate(0, 0, 7)}, {"monthly", base.AddDate(0, 1, 0)}, } { t.Run(tc.rule+" 推进一个周期", func(t *testing.T) { e := seedEvent(t, &models.CalendarEvent{ Title: tc.rule, EventTime: base, Recurrence: tc.rule, }) advanced, err := AdvanceRecurrence(ctx, e.EventID) if err != nil { t.Fatalf("推进: %v", err) } if !advanced { t.Fatal("应该推进") } got, _ := GetCalendarEvent(ctx, e.EventID) if !got.EventTime.Equal(tc.want) { t.Errorf("下次时间应为 %v,得到 %v", tc.want, got.EventTime) } // 推进后 event_time 已在未来,且 last_fired_at 仍为旧值 → // 必须重新出现在 DueEvents 里等待下一轮(否则重复事件只响一次)。 if got.Status != "active" { t.Errorf("推进后应仍为 active,得到 %q", got.Status) } }) } t.Run("超过 recurrence_end 则取消", func(t *testing.T) { end := base.Add(12 * time.Hour) // 下一次(+1 天)会越过它 e := seedEvent(t, &models.CalendarEvent{ Title: "快结束了", EventTime: base, Recurrence: "daily", RecurrenceEnd: &end, }) advanced, err := AdvanceRecurrence(ctx, e.EventID) if err != nil { t.Fatalf("推进: %v", err) } if advanced { t.Error("越过 recurrence_end 时不该报告推进成功") } got, _ := GetCalendarEvent(ctx, e.EventID) if got.Status != "cancelled" { t.Errorf("越过结束时间应置为 cancelled,得到 %q", got.Status) } }) } func TestListCalendarEventsRange(t *testing.T) { setupTestDB(t) ctx := context.Background() now := time.Now() inRange := seedEvent(t, &models.CalendarEvent{Title: "范围内", EventTime: now.Add(time.Hour)}) seedEvent(t, &models.CalendarEvent{Title: "太远", EventTime: now.AddDate(0, 3, 0)}) events, err := ListCalendarEvents(ctx, now, now.Add(24*time.Hour), "active") if err != nil { t.Fatalf("列事件: %v", err) } if len(events) != 1 || events[0].EventID != inRange.EventID { t.Errorf("时间范围过滤不对,得到 %d 条", len(events)) } } func TestCalendarAttachments(t *testing.T) { setupTestDB(t) ctx := context.Background() e := seedEvent(t, &models.CalendarEvent{Title: "带附件"}) if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{ EventID: e.EventID, Filename: "报表.xlsx", SHA256: "abc", SizeBytes: 2048, }); err != nil { t.Fatalf("加附件: %v", err) } atts, err := ListCalendarAttachments(ctx, e.EventID) if err != nil { t.Fatalf("列附件: %v", err) } if len(atts) != 1 || atts[0].Filename != "报表.xlsx" { t.Fatalf("附件读回不符:%+v", atts) } if atts[0].AttachmentID == "" { t.Error("附件必须有 attachment_id —— 没有它模型无法在 send_mail 里引用") } // 事件没有附件时返回空而不是报错 other := seedEvent(t, &models.CalendarEvent{Title: "没附件"}) if atts, err := ListCalendarAttachments(ctx, other.EventID); err != nil || len(atts) != 0 { t.Errorf("无附件事件应返回空列表,得到 %d 条 err=%v", len(atts), err) } } func TestDeleteCalendarAttachment(t *testing.T) { setupTestDB(t) ctx := context.Background() e := seedEvent(t, &models.CalendarEvent{Title: "要删附件"}) for _, name := range []string{"甲.pdf", "乙.pdf"} { if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{ EventID: e.EventID, Filename: name, SHA256: "sum-" + name, SizeBytes: 10, }); err != nil { t.Fatalf("加附件 %s: %v", name, err) } } atts, _ := ListCalendarAttachments(ctx, e.EventID) if len(atts) != 2 { t.Fatalf("准备阶段应有 2 个附件,得到 %d", len(atts)) } ok, err := DeleteCalendarAttachment(ctx, atts[0].AttachmentID) if err != nil { t.Fatalf("删附件: %v", err) } if !ok { t.Error("删掉存在的附件应返回 true") } left, _ := ListCalendarAttachments(ctx, e.EventID) if len(left) != 1 { t.Fatalf("删一个后应剩 1 个,得到 %d", len(left)) } if left[0].AttachmentID == atts[0].AttachmentID { t.Error("删错了对象") } // 不存在的 id 返回 false 而不是报错 —— 调用方据此回 404 而非 500 ok, err = DeleteCalendarAttachment(ctx, "00000000-0000-0000-0000-000000000000") if err != nil { t.Errorf("删不存在的附件不该报错,得到 %v", err) } if ok { t.Error("删不存在的附件应返回 false") } } // 事件附件必须能复制成邮件附件。 // // 少了这一步,附件只存在于日历侧:UI 里看得见、提醒按时发出、 // 而 Agent 收到的那封信附件清单是空的 —— 两张表互不相通。 func TestAttachCalendarFilesToMail(t *testing.T) { setupTestDB(t) ctx := context.Background() e := seedEvent(t, &models.CalendarEvent{Title: "带附件的提醒"}) if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{ EventID: e.EventID, Filename: "周报.md", SHA256: "deadbeef", SizeBytes: 512, }); err != nil { t.Fatalf("加附件: %v", err) } // sha256 为空的脏数据必须被跳过:挂上去只会得到一个下载必然 404 的附件 if err := AddCalendarAttachment(ctx, &models.CalendarAttachment{ EventID: e.EventID, Filename: "没内容.bin", SHA256: "", SizeBytes: 0, }); err != nil { t.Fatalf("加空附件: %v", err) } seedAgentForAttach(t, "pi") sessionID := seedSessionForAttach(t, "pi") mailID, err := CreateMail(ctx, sessionID, nil, "calendar", "", "pi", "", "日程提醒:带附件的提醒", "正文", nil) if err != nil { t.Fatalf("建邮件: %v", err) } n, err := AttachCalendarFilesToMail(ctx, e.EventID, mailID, "calendar") if err != nil { t.Fatalf("挂附件: %v", err) } if n != 1 { t.Fatalf("应只挂 1 个(空 sha256 那条跳过),得到 %d", n) } mailAtts, err := ListAttachmentsFor(ctx, mailID) if err != nil { t.Fatalf("列邮件附件: %v", err) } if len(mailAtts) != 1 { t.Fatalf("邮件上应有 1 个附件,得到 %d", len(mailAtts)) } if mailAtts[0].Filename != "周报.md" || mailAtts[0].SHA256 != "deadbeef" { t.Errorf("附件内容不符:%+v", mailAtts[0]) } // 内容寻址:复制不产生新的 sha256,指向同一份磁盘文件 if mailAtts[0].Uploader != "calendar" { t.Errorf("uploader 应是 calendar,得到 %q", mailAtts[0].Uploader) } // 没有附件的事件挂 0 个且不报错 empty := seedEvent(t, &models.CalendarEvent{Title: "无附件"}) if n, err := AttachCalendarFilesToMail(ctx, empty.EventID, mailID, "calendar"); err != nil || n != 0 { t.Errorf("无附件事件应挂 0 个,得到 %d err=%v", n, err) } } func seedAgentForAttach(t *testing.T, name string) { t.Helper() if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO agents (agent_name, secret, platform) VALUES ($1, 'x', 'test')`, name); err != nil { t.Fatalf("seed agent: %v", err) } } func seedSessionForAttach(t *testing.T, agentName string) uuid.UUID { t.Helper() id := uuid.New() // 列名是 from_agent 而不是 agent_name(后者是 agents 表的主键名) if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO sessions (session_id, from_agent, subject, session_alias, status) VALUES ($1, $2, '日程提醒', 'cal-test', 'active')`, id, agentName); err != nil { t.Fatalf("seed session: %v", err) } return id } // 落在 60 秒 lookahead 窗口内的**未来**事件,标记后不得再次到期。 // // 生产实测的重发现场:一条 event_time=12:53:17 的事件在 // 12:52:30 / 12:53:00 / 12:53:06 / 12:53:36 各发了一封相同提醒。 // 根因是去重判据写成 `last_fired_at < event_time` —— 触发时刻(now) // 本来就早于 event_time,条件恒真,于是每个 tick 重发一次, // 直到 event_time 真正过去才自己停下。 // // 改成按 occurrence 相等(fired_for = 当时的 event_time)才精确: // AdvanceRecurrence 改了 event_time 就该再触发,没改就永不重发。 func TestFiredEventInLookaheadWindowDoesNotRefire(t *testing.T) { setupTestDB(t) ctx := context.Background() // 47 秒后 —— 在 lookahead 窗口内,所以第一次扫描就会入选 e := seedEvent(t, &models.CalendarEvent{ Title: "窗口内的未来事件", EventTime: time.Now().Add(47 * time.Second), }) due, err := DueEvents(ctx) if err != nil { t.Fatalf("首次扫描: %v", err) } if len(due) != 1 { t.Fatalf("lookahead 应让它提前入选,得到 %d 条", len(due)) } if err := MarkEventFired(ctx, e.EventID); err != nil { t.Fatalf("标记: %v", err) } // 模拟后续几个 tick for i := 0; i < 3; i++ { due, err = DueEvents(ctx) if err != nil { t.Fatalf("第 %d 次重扫: %v", i+2, err) } for _, d := range due { if d.EventID == e.EventID { t.Fatalf("第 %d 次扫描仍判定到期 —— 提醒会被重发", i+2) } } } } // 重复事件推进 event_time 之后必须重新到期: // 按 occurrence 去重的另一半,漏了它就变成「每个重复事件只响一次」。 func TestRecurringEventRefiresAfterAdvance(t *testing.T) { setupTestDB(t) ctx := context.Background() e := seedEvent(t, &models.CalendarEvent{ Title: "每天都要响", EventTime: time.Now().Add(-time.Minute), Recurrence: "daily", }) if err := MarkEventFired(ctx, e.EventID); err != nil { t.Fatalf("标记: %v", err) } due, _ := DueEvents(ctx) for _, d := range due { if d.EventID == e.EventID { t.Fatal("标记后不该立刻再次到期") } } // 推进到下一次(+1 天)后,把时间挪到过去模拟「第二天到了」 if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil { t.Fatalf("推进重复: %v", err) } if _, err := db.DB.ExecContext(ctx, `UPDATE calendar_events SET event_time = ? WHERE event_id = ?`, time.Now().Add(-30*time.Second), e.EventID); err != nil { t.Fatalf("模拟次日: %v", err) } due, _ = DueEvents(ctx) found := false for _, d := range due { if d.EventID == e.EventID { found = true } } if !found { t.Error("event_time 推进后应重新到期,否则重复事件只响一次") } } // ─── 重复规则推进(NextOccurrence 是纯函数,不碰数据库)─── func TestNextOccurrenceSolar(t *testing.T) { base := time.Date(2026, 9, 3, 9, 30, 0, 0, time.Local) cases := []struct { rule string want string }{ {models.RecurDaily, "2026-09-04"}, {models.RecurWeekly, "2026-09-10"}, {models.RecurMonthly, "2026-10-03"}, } for _, c := range cases { got, err := NextOccurrence(c.rule, base) if err != nil { t.Errorf("%s: %v", c.rule, err) continue } if got.Format("2006-01-02") != c.want { t.Errorf("%s: 得到 %s,期望 %s", c.rule, got.Format("2006-01-02"), c.want) } // 时钟必须原样保留 if got.Hour() != 9 || got.Minute() != 30 { t.Errorf("%s: 时钟被改动 %v", c.rule, got) } } // none 与未知值都返回零值 + nil error for _, r := range []string{models.RecurNone, "", "每隔一个蓝月亮"} { got, err := NextOccurrence(r, base) if err != nil || !got.IsZero() { t.Errorf("%q 应返回零值无错,得到 %v err=%v", r, got, err) } } } // time.AddDate 的溢出对「每月同一日」是错的:3 月 31 日 +1 月 = 5 月 1 日。 // 一次溢出会永久改变规则 —— 31 日的事件在 2 月变成 3 月 3 日, // 然后从此每月 3 日提醒。 func TestNextOccurrenceMonthlyClampsMonthEnd(t *testing.T) { cases := []struct { from string want string why string }{ {"2026-01-31", "2026-02-28", "1月31日 +1月 → 2月末(2026 非闰年)"}, {"2026-03-31", "2026-04-30", "3月31日 +1月 → 4月30日"}, {"2026-05-31", "2026-06-30", "5月31日 +1月 → 6月30日"}, {"2028-01-31", "2028-02-29", "闰年 2 月有 29 天"}, {"2026-01-15", "2026-02-15", "月中日期不受影响"}, } for _, c := range cases { from, _ := time.ParseInLocation("2006-01-02", c.from, time.Local) got, err := NextOccurrence(models.RecurMonthly, from) if err != nil { t.Errorf("%s: %v", c.why, err) continue } if got.Format("2006-01-02") != c.want { t.Errorf("%s: 得到 %s,期望 %s", c.why, got.Format("2006-01-02"), c.want) } } } // 农历月推进:公历间隔在 29~30 天之间浮动,不是固定值。 // 这正是不能用 AddDate 的原因。 func TestNextOccurrenceLunarMonthly(t *testing.T) { // 2026-09-03 = 农历七月廿二 cur := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local) gaps := map[int]bool{} for i := 0; i < 6; i++ { next, err := NextOccurrence(models.RecurLunarMonthly, cur) if err != nil { t.Fatalf("第 %d 次推进: %v", i+1, err) } if !next.After(cur) { t.Fatalf("第 %d 次推进没有前进:%v → %v", i+1, cur, next) } gap := int(next.Sub(cur).Hours() / 24) gaps[gap] = true // 农历同一日:连续推进后农历「日」应保持 if d := lunar.FromSolar(next); d.Day != 22 { t.Errorf("第 %d 次推进后农历日变成 %d(期望 22):%s", i+1, d.Day, d.String()) } cur = next } // 间隔必须出现过多种值,证明不是固定天数 if len(gaps) < 2 { t.Errorf("六次农历月推进的公历间隔只有 %v —— 疑似退化成固定天数", gaps) } for g := range gaps { if g < 28 || g > 31 { t.Errorf("农历月间隔 %d 天不合理", g) } } } // 农历年推进:公历日期每年漂移。用公历 yearly 会固定在同一天, // 与「过农历生日/祭日」的期望不符 —— 这是农历规则存在的理由。 func TestNextOccurrenceLunarYearly(t *testing.T) { cur := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local) seen := map[string]bool{} for i := 0; i < 5; i++ { next, err := NextOccurrence(models.RecurLunarYearly, cur) if err != nil { t.Fatalf("第 %d 次: %v", i+1, err) } if !next.After(cur) { t.Fatalf("第 %d 次没有前进:%v → %v", i+1, cur, next) } // 农历月日应保持 d := lunar.FromSolar(next) if d.Month != 7 || d.Day != 22 { t.Errorf("第 %d 次推进后农历变成 %d-%d(期望 7-22)", i+1, d.Month, d.Day) } seen[next.Format("01-02")] = true cur = next } if len(seen) < 3 { t.Errorf("五年公历月日只有 %d 种 —— 农历年重复应漂移", len(seen)) } } // 农历规则经过数据库这一轮也要正确(AdvanceRecurrence 里调 NextOccurrence)。 func TestAdvanceRecurrenceLunar(t *testing.T) { setupTestDB(t) ctx := context.Background() start := time.Date(2026, 9, 3, 9, 0, 0, 0, time.Local) e := seedEvent(t, &models.CalendarEvent{ Title: "农历每月十五(这里用廿二)", EventTime: start, Recurrence: models.RecurLunarMonthly, }) advanced, err := AdvanceRecurrence(ctx, e.EventID) if err != nil { t.Fatalf("推进: %v", err) } if !advanced { t.Fatal("农历重复应能推进") } after, err := GetCalendarEvent(ctx, e.EventID) if err != nil { t.Fatalf("读回: %v", err) } if !after.EventTime.After(start) { t.Errorf("event_time 未前进:%v", after.EventTime) } // 农历日保持 if d := lunar.FromSolar(after.EventTime); d.Day != 22 { t.Errorf("农历日变成 %d,期望 22(%s)", d.Day, d.String()) } // 公历间隔应在一个农历月内 gap := int(after.EventTime.Sub(start).Hours() / 24) if gap < 28 || gap > 31 { t.Errorf("间隔 %d 天不像一个农历月", gap) } } // ─── 多收件人 ─── func TestRecipientsRoundtrip(t *testing.T) { setupTestDB(t) ctx := context.Background() e := seedEvent(t, &models.CalendarEvent{ Title: "三个 Agent 各自汇报", Recipients: []string{"pi@/home/program/agentmail", "dsh", "opencode@/tmp"}, DeliveryMode: models.DeliverSeparate, }) got, err := GetCalendarEvent(ctx, e.EventID) if err != nil { t.Fatalf("读回: %v", err) } if len(got.Recipients) != 3 { t.Fatalf("收件人应有 3 个,得到 %d:%v", len(got.Recipients), got.Recipients) } if got.Recipients[0] != "pi@/home/program/agentmail" { t.Errorf("顺序或内容不符:%v", got.Recipients) } if got.EffectiveDeliveryMode() != models.DeliverSeparate { t.Errorf("投递模式 = %q", got.EffectiveDeliveryMode()) } } // 空收件人列表必须序列化成 [](而不是 null):Go 的 nil slice 会变 null, // 前端 .map 直接崩。 func TestRecipientsNeverNull(t *testing.T) { setupTestDB(t) ctx := context.Background() e := seedEvent(t, &models.CalendarEvent{Title: "没写收件人"}) got, err := GetCalendarEvent(ctx, e.EventID) if err != nil { t.Fatalf("读回: %v", err) } if got.Recipients == nil { t.Error("Recipients 为 nil —— 会序列化成 null 让前端崩") } if len(got.Recipients) != 0 { t.Errorf("应是空数组,得到 %v", got.Recipients) } } // 旧数据(只有 agent_name / to_address)必须继续工作 —— 历史事件不迁移。 func TestEffectiveRecipientsFallbackChain(t *testing.T) { cases := []struct { name string e models.CalendarEvent want []string }{ { "Recipients 优先", models.CalendarEvent{Recipients: []string{"a", "b"}, ToAddress: "c", AgentName: "d"}, []string{"a", "b"}, }, { "退回 to_address", models.CalendarEvent{ToAddress: "pi@/tmp.alias", AgentName: "pi"}, []string{"pi@/tmp.alias"}, }, { "再退回 agent_name", models.CalendarEvent{AgentName: "dsh"}, []string{"dsh"}, }, { "全空给 nil", models.CalendarEvent{}, nil, }, { "Recipients 里全是空白时继续退回", models.CalendarEvent{Recipients: []string{"", " "}, AgentName: "pi"}, []string{"pi"}, }, } for _, c := range cases { got := c.e.EffectiveRecipients() if len(got) != len(c.want) { t.Errorf("%s: 得到 %v,期望 %v", c.name, got, c.want) continue } for i := range got { if got[i] != c.want[i] { t.Errorf("%s: 第 %d 项 %q,期望 %q", c.name, i, got[i], c.want[i]) } } } } // 未知投递模式按 separate 处理:它的失败模式更轻。 // together 用错会让本该独立判断的 Agent 互相看到回复而趋同,事后无法分离。 func TestEffectiveDeliveryModeDefaultsToSeparate(t *testing.T) { for _, in := range []string{"", "separate", "垃圾值", "SEPARATE"} { e := models.CalendarEvent{DeliveryMode: in} if got := e.EffectiveDeliveryMode(); got != models.DeliverSeparate { t.Errorf("DeliveryMode=%q → %q,期望 separate", in, got) } } e := models.CalendarEvent{DeliveryMode: models.DeliverTogether} if e.EffectiveDeliveryMode() != models.DeliverTogether { t.Error("together 应被保留") } } // 公历每年:2 月 29 日在平年必须夹到 2 月 28,不能溢出成 3 月 1 日。 // 闰日生日的约定是「平年过 2 月 28」。 func TestNextOccurrenceYearlyClampsLeapDay(t *testing.T) { cases := []struct { from string want string why string }{ {"2028-02-29", "2029-02-28", "闰日 +1 年 → 平年 2 月 28"}, {"2026-03-15", "2027-03-15", "普通日期不受影响"}, {"2027-02-28", "2028-02-28", "平年 2/28 → 闰年仍是 2/28(不跳到 29)"}, } for _, c := range cases { from, _ := time.ParseInLocation("2006-01-02", c.from, time.Local) got, err := NextOccurrence(models.RecurYearly, from) if err != nil { t.Errorf("%s: %v", c.why, err) continue } if got.Format("2006-01-02") != c.want { t.Errorf("%s: 得到 %s,期望 %s", c.why, got.Format("2006-01-02"), c.want) } } } // 过期的重复事件必须一次推到未来,不能每轮补发一封。 // // 实测的 bug:AdvanceRecurrence 只推进一步 —— 一条 100 天前设的每日事件, // 每轮扫描都判定「已过期该触发」→ 发一封 → event_time 只前进一天 → // 下一轮又过期。30 轮扫描触发 30 次,而调度周期是 30 秒, // 人会收到一串垃圾提醒,连发 100 封才追上今天。 func TestStaleRecurringEventDoesNotFlood(t *testing.T) { setupTestDB(t) ctx := context.Background() e := seedEvent(t, &models.CalendarEvent{ Title: "很久以前设的每日提醒", EventTime: time.Now().AddDate(0, 0, -100), Recurrence: models.RecurDaily, }) fires := 0 // 模拟调度器连续跑 30 轮(生产上就是 15 分钟) for i := 0; i < 30; i++ { due, err := DueEvents(ctx) if err != nil { t.Fatalf("第 %d 轮扫描: %v", i+1, err) } hit := false for _, d := range due { if d.EventID == e.EventID { hit = true } } if !hit { break } fires++ if err := MarkEventFired(ctx, e.EventID); err != nil { t.Fatalf("标记: %v", err) } if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil { t.Fatalf("推进: %v", err) } } if fires != 1 { t.Errorf("过期的每日重复事件触发了 %d 次,应只触发 1 次", fires) } after, err := GetCalendarEvent(ctx, e.EventID) if err != nil { t.Fatalf("读回: %v", err) } if !after.EventTime.After(time.Now()) { t.Errorf("推进后 event_time 仍在过去:%v", after.EventTime) } // 只跳到「刚过现在」的那一次,不是跳到很远的将来 if after.EventTime.After(time.Now().AddDate(0, 0, 2)) { t.Errorf("推得太远了:%v", after.EventTime) } } // SQLite 把时间按 UTC 读回,但农历必须按用户的本地公历日计算。 // 这个固定用例钉住凌晨跨 UTC 日期边界:本地 09-12 07:00 入库后会变成 // UTC 09-11 23:00;若 AdvanceRecurrence 不先转回 Local,农历日会少一天。 func TestAdvanceRecurrenceLunarUsesLocalCalendarDay(t *testing.T) { setupTestDB(t) ctx := context.Background() localTime := time.Date(2025, 9, 12, 7, 0, 0, 0, time.Local) originalLunar := lunar.FromSolar(localTime) e := seedEvent(t, &models.CalendarEvent{ Title: "凌晨创建的农历提醒", EventTime: localTime, Recurrence: models.RecurLunarMonthly, }) // 先证明测试真的跨了日期边界;否则它无法捕获这个 bug。 var scanned time.Time if err := db.DB.QueryRowContext(ctx, `SELECT event_time FROM calendar_events WHERE event_id = ?`, e.EventID).Scan(&scanned); err != nil { t.Fatalf("读数据库时间: %v", err) } if scanned.Day() == localTime.Day() { t.Fatalf("测试前提不成立:数据库时间 %v 与本地时间 %v 没有跨日", scanned, localTime) } if got := lunar.FromSolar(scanned).Day; got == originalLunar.Day { t.Fatalf("测试前提不成立:直接按 UTC 字段换算没有产生日偏移(仍为 %d)", got) } if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil { t.Fatalf("推进: %v", err) } after, err := GetCalendarEvent(ctx, e.EventID) if err != nil { t.Fatalf("读回: %v", err) } if got := lunar.FromSolar(after.EventTime.In(time.Local)).Day; got != originalLunar.Day { t.Errorf("农历日从 %d 变成 %d(推进后 %v)", originalLunar.Day, got, after.EventTime) } } // 农历规则的过期事件同样不能刷屏。 func TestStaleLunarRecurringDoesNotFlood(t *testing.T) { setupTestDB(t) ctx := context.Background() e := seedEvent(t, &models.CalendarEvent{ Title: "去年设的农历每月提醒", EventTime: time.Now().AddDate(-1, 0, 0), Recurrence: models.RecurLunarMonthly, }) if _, err := AdvanceRecurrence(ctx, e.EventID); err != nil { t.Fatalf("推进: %v", err) } after, _ := GetCalendarEvent(ctx, e.EventID) if !after.EventTime.After(time.Now()) { t.Errorf("一年前的农历事件推进后仍在过去:%v", after.EventTime) } // EventTime 从数据库读回是 UTC;农历必须按用户看到的本地公历日比较。 if d := lunar.FromSolar(after.EventTime.In(time.Local)); d.Day != lunar.FromSolar(e.EventTime).Day { t.Errorf("农历日从 %d 变成 %d", lunar.FromSolar(e.EventTime).Day, d.Day) } } // 越过 recurrence_end 时必须置 cancelled 而不是留在 active。 // // 留着的表现是一条僵尸事件:DueEvents 每轮都捞到它(event_time 在过去), // 但 fired_for 已等于 event_time 所以又不触发 —— 永远排在到期列表里不动。 func TestAdvanceCancelsAfterRecurrenceEnd(t *testing.T) { setupTestDB(t) ctx := context.Background() end := time.Now().AddDate(0, 0, -1) // 昨天就该停 e := seedEvent(t, &models.CalendarEvent{ Title: "已到期的每日重复", EventTime: time.Now().AddDate(0, 0, -5), Recurrence: models.RecurDaily, RecurrenceEnd: &end, }) advanced, err := AdvanceRecurrence(ctx, e.EventID) if err != nil { t.Fatalf("推进: %v", err) } if advanced { t.Error("已过 recurrence_end 不该报告推进成功") } after, err := GetCalendarEvent(ctx, e.EventID) if err != nil { t.Fatalf("读回: %v", err) } if after.Status != "cancelled" { t.Errorf("状态应是 cancelled,得到 %q —— 留在 active 会变僵尸事件", after.Status) } // 且不该再出现在到期列表里 due, _ := DueEvents(ctx) for _, d := range due { if d.EventID == e.EventID { t.Error("已 cancelled 的事件仍出现在 DueEvents") } } } // advanceToFuture 是纯函数,单独测三个终止条件。 func TestAdvanceToFuture(t *testing.T) { now := time.Date(2026, 9, 3, 12, 0, 0, 0, time.Local) t.Run("跨过 now 就停", func(t *testing.T) { from := now.AddDate(0, 0, -100) got, err := advanceToFuture(models.RecurDaily, from, now, nil) if err != nil { t.Fatalf("推进: %v", err) } if !got.After(now) { t.Errorf("结果 %v 不在 now 之后", got) } // 恰好是越过 now 的第一次,不是更远 if got.After(now.AddDate(0, 0, 1)) { t.Errorf("推过头了:%v", got) } }) t.Run("越过 recurrenceEnd 给零值", func(t *testing.T) { end := now.AddDate(0, 0, -1) got, err := advanceToFuture(models.RecurDaily, now.AddDate(0, 0, -5), now, &end) if err != nil { t.Fatalf("不该报错:%v", err) } if !got.IsZero() { t.Errorf("应给零值,得到 %v", got) } }) t.Run("不重复给零值无错", func(t *testing.T) { got, err := advanceToFuture(models.RecurNone, now, now, nil) if err != nil || !got.IsZero() { t.Errorf("得到 %v err=%v", got, err) } }) t.Run("未来的事件原地推一步", func(t *testing.T) { from := now.AddDate(0, 0, 5) got, err := advanceToFuture(models.RecurDaily, from, now, nil) if err != nil { t.Fatalf("推进: %v", err) } // from 已在未来,第一次推进就该返回 if !got.Equal(from.AddDate(0, 0, 1)) { t.Errorf("得到 %v,期望 %v", got, from.AddDate(0, 0, 1)) } }) // 上限是防御性的:农历路径依赖外部库,一旦某年给出反直觉结果, // 没有上限就是个死循环 goroutine,而它跑在调度器里 —— 整个提醒系统一起卡住 t.Run("十年前的每日事件也能在上限内追上", func(t *testing.T) { got, err := advanceToFuture(models.RecurDaily, now.AddDate(-10, 0, 0), now, nil) if err != nil { t.Fatalf("十年(约 3650 步)应在 %d 上限内:%v", maxAdvanceSteps, err) } if !got.After(now) { t.Errorf("结果 %v 不在 now 之后", got) } }) }