# 现象
全量服务端测试稳定失败:
--- 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 通过
1013 lines
32 KiB
Go
1013 lines
32 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|