Files
MailUI4Agents/server/internal/repo/calendar_test.go
JianFeeeee 6757644756 fix(判据): 农历推进的起点必须在未来 —— 否则 advanceToFuture 跳过过期月份必假红
## 现象

部署被测试闸拦下(这正是纪律该做的事):`TestAdvanceRecurrenceLunar` 报
「间隔 58 天不像一个农历月」。已确认与本次 SSE 改动无关
(把我的改动全部 stash 后它同样红)。

## 根因(实测复算,不是推测)

`AdvanceRecurrence` 走 `advanceToFuture`,职责是「推进到**未来**」:
已经过去的农历月会被跳过(`if cur.After(now) { return }` 那个循环)。

原起点写死 `2026-09-03`,而今天已是 10-02 ⇒ 那个农历月(10-02)已过去
⇒ 循环再推一个月。探针实测:

    起点 2026-09-03 ⇒ 落点 2026-10-31 间隔 58 天  ← 旧起点,今天跑必红
    起点 2026-10-03 ⇒ 落点 2026-11-01 间隔 29 天  ← 明天,正确

农历库本身是对的:直接调 `AddMonths(1).ToSolar()` 得到的落点恰好 29 天。
所以**不是代码缺陷,是判据的期望依赖了「今天离起点不到一个月」**这个
随日期漂移的前提。

## 修法

起点改为「明天」起算(不写死具体日期 ⇒ 明年跑也成立),
农历日的期望也跟着起点走。

★ 不用 `time.Now().AddDate(0,1,0)` 那种相对写法:农历月 29/30 天不定,
  起点落在月末时下一个同农历日可能被夹(commit 3459605 记的同族假红)。
  明天起算同时满足「确保在未来」与「落点就是下一个农历月」。

## ★★ 修判据时差点削弱了它(变异验证抓出来的)

把起点改成明天后判据绿了,但我立刻做变异验证:

    变异:advanceToFuture 不跳过已过期月份 ⇒ TestAdvanceRecurrenceLunar **全绿**

因为起点在未来,第一个落点本来就在未来,循环与单步没有区别 ——
**我修好了假红,却顺手删掉了「跳过过期」这个真行为的判别力。**

补了一格:用**已过期 70 天**的起点单独钉它,落点必须在未来,
否则「每次扫描都重复触发同一封提醒」。变异重测 ⇒ 红 1 格。

这是同一天内第二次判据自身缺陷(第一次是 SSE 那格只查文本不查控制流)。
**改判据后必须变异验证判别力还在**,否则就是用改测试掩盖问题。
2026-10-02 10:57:59 +08:00

1084 lines
35 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()
// ★ 2026-10-02:起点必须是**未来**的,否则这条判据必红(同族第三次)。
//
// `AdvanceRecurrence` 走的是 `advanceToFuture`,它的职责是「推进到**未来**」:
// 已经过去的农历月会被跳过(`if cur.After(now) { return }` 那个循环)。
//
// 原起点写死 `2026-09-03`,而今天已是 10-02 ⇒ 那个农历月(10-02)已过去
// ⇒ 循环再推一个月 ⇒ 落点 10-31,间隔 **58 天**,与「一个农历月」的断言矛盾。
//
// 实测(本次修复时跑的探针):
// 起点 2026-09-03 ⇒ 落点 2026-10-31 间隔 58 天 ← 旧起点,今天跑必红
// 起点 2026-10-03 ⇒ 落点 2026-11-01 间隔 29 天 ← 明天,正确
//
// 为什么不能用 `time.Now().AddDate(0,1,0)` 那种「相对现在」写法:
// 农历月 29/30 天不定,起点落在月末时下一个同农历日可能被夹
// (见 commit 3459605 记的同族问题:那是**夹到短月**导致的另一种假红)。
// 这里要的是「起点确保在未来,且落点就是下一个农历月」——
// 明天起算就同时满足两者,且不依赖具体日期。
start := time.Now().AddDate(0, 0, 1).Truncate(time.Hour).Add(9 * time.Hour)
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)
}
// 农历日保持(起点是明天,那个日就是起点的农历日)
wantLunarDay := lunar.FromSolar(start).Day
if d := lunar.FromSolar(after.EventTime); d.Day != wantLunarDay {
t.Errorf("农历日变成 %d,期望 %d(%s)", d.Day, wantLunarDay, d.String())
}
// 公历间隔应在一个农历月内
gap := int(after.EventTime.Sub(start).Hours() / 24)
if gap < 28 || gap > 31 {
t.Errorf("间隔 %d 天不像一个农历月", gap)
}
/*
★ 补这一格(2026-10-02):起点改成「明天」之后,判据的**判别力**下降了。
实测:把 `advanceToFuture` 改成「不跳过已过期的农历月」,
`TestAdvanceRecurrenceLunar` 依然**全绿** —— 因为起点在未来,
第一个落点本来就在未来,循环与单步没有区别。
而「跳过过期」是**真行为**:`AdvanceRecurrence` 存在的意义就是
把过期事件推到将来(否则每次扫描都会重复触发同一封信)。
下面这格用一个**已过期**的起点单独钉它。
*/
past := time.Now().AddDate(0, 0, -70)
e2 := seedEvent(t, &models.CalendarEvent{
Title: "过期起点:必须被推到未来",
EventTime: past,
Recurrence: models.RecurLunarMonthly,
})
if _, err := AdvanceRecurrence(ctx, e2.EventID); err != nil {
t.Fatalf("过期起点推进: %v", err)
}
after2, err := GetCalendarEvent(ctx, e2.EventID)
if err != nil {
t.Fatalf("读回: %v", err)
}
if !after2.EventTime.After(time.Now()) {
t.Errorf("★ 起点已过期时,落点 %s 仍不在未来 —— "+
"advanceToFuture 必须跳过已过去的农历月,否则每次扫描都重复触发同一封提醒",
after2.EventTime.Format(time.RFC3339))
}
}
// ─── 多收件人 ───
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;农历必须按用户看到的本地公历日比较。
//
// ★ 断言必须**按目标月的天数**来判(2026-09-21 修)。
//
// 原先写的是 `d.Day != 原日` —— 即「农历日必须原样保持」。那条断言**要求一个
// 可能不存在的日期**:农历月有 29 天也有 30 天,若推进落到一个 29 天的月份,
// 「30 日」根本不存在,夹到 29 是**唯一正确**的行为(`ToSolar` 的 clamped 就是为此)。
//
// 实测(2026-09-21):起点 `now-1y` = 农历七月三十,推进落到**八月廿九**
// (2026 年农历八月只有 **29** 天)⇒ 正确结果就是 29,而旧断言要求 30 ⇒ 必红。
// 于是这条测试会在某些日子**必然失败**,与代码对不对无关(日期相关的假红)。
//
// 修法:期望值取 `min(原日, 目标月天数)` —— 既保留了"短月允许夹取",
// 又**没有放松**真正的约束(落到长月却少一天,仍然红)。
want := lunar.FromSolar(e.EventTime).Day
got := lunar.FromSolar(after.EventTime.In(time.Local))
days, err := lunar.DaysInMonth(got.Year, got.Month)
if err != nil {
t.Fatalf("读目标月天数(农历 %d年%d月): %v", got.Year, got.Month, err)
}
if want > days {
want = days // 目标月是短月 ⇒ 夹到月末是唯一正确的落点
}
if got.Day != want {
t.Errorf("农历日从 %d 变成 %d(目标月 %d 天,期望 %d)",
lunar.FromSolar(e.EventTime).Day, got.Day, days, want)
}
}
// 越过 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)
}
})
}