Files
MailUI4Agents/server/internal/repo/calendar_test.go
JianFeeeee dc7bf57ceb fix(calendar): 农历提醒按本地公历日推进,修正凌晨跨 UTC 日期错一天
# 现象

全量服务端测试稳定失败:

    --- FAIL: TestStaleLunarRecurringDoesNotFlood
        calendar_test.go:869: 农历日从 21 变成 20

不是随机失败,也不是测试写错 —— 是产品逻辑的真实缺陷。

# 根因

SQLite 的 DSN 带 `_timezone=UTC`(为了让 `expires_at > NOW()` 这类字符串比较
同一时间轴,见 db.sqliteDSN 的注释)。因此从库里 Scan 出来的 `event_time` 是
UTC 时刻的表示。

对公历重复规则,这无关紧要 —— `AddDate` 操作的是同一时刻的另一种表示。
但**农历换算直接读取 Year/Month/Day**:

    本地 2025-09-12 07:00 (+0800) → 存库 → 读出 UTC 2025-09-11 23:00
    农历(本地) = 七月廿一        → 农历(UTC 字段) = 七月二十   ← 少一天

后果:在本地时间 0:00–8:00(+0800)创建的农历提醒,之后每次推进都按前一天
计算,日期永久偏一天;而且只有等到下一次该提醒时才暴露,没有任何报错。

# 修法

在 `AdvanceRecurrence` 里,仅对两条农历规则把 event_time 转回 `time.Local`
再交给 `NextOccurrence`。

只转农历规则而不是无条件转:公历规则不需要,且 UTC 与 Local 表示同一时刻,
`AddDate` 在两者上结果相同 —— 无条件转会掩盖「DSN 时间是 UTC」这个事实,
让后来者更难判断该在哪一层做时区处理。

# 测试

新增 `TestAdvanceRecurrenceLunarUsesLocalCalendarDay`,用**固定日期**
(2025-09-12 07:00 本地)而不是 `time.Now()`,因此任何时刻跑都稳定;
并且它先断言测试前提成立:

  - 库里读回的时刻确实与输入跨了不同公历日
  - 直接按 UTC 字段做农历换算确实会得到不同的农历日

前提不成立就直接 Fatal —— 否则这个用例可能在某个时区/时段下变成永远通过的
空壳(那正是它要防的那类假绿)。

# 验证

- 新用例与原有的两条农历用例 ×10 连跑全绿(`-count=10`)
- `go vet ./...` 干净;`go test ./... -count=1` 全量通过
- 修复前该用例 5/5 失败,修复后 10/10 通过
2026-09-12 08:01:59 +08:00

1013 lines
32 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 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())
}
}
// 空收件人列表必须序列化成 [](而不是 nullGo 的 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)
}
}
}
// 过期的重复事件必须一次推到未来,不能每轮补发一封。
//
// 实测的 bugAdvanceRecurrence 只推进一步 —— 一条 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)
}
})
}