Files
MailUI4Agents/gateway/internal/repo/agent_calendar_test.go
JianFeeeee 2ae4df0e98 feat(agent-calendar): Agent 侧日历端点(可写,只能动自己建的)
在这之前 /calendar/* 全挂在 UserAuth 后面,Agent 密钥一律 401。
于是「明天九点提醒我看 CI」只能靠插件进程里的 setTimeout —— 进程一重启
定时器就消失,那条提醒静默不见且无处留痕。放进 Gateway 后由数据库与
调度器保证:插件重启、Agent 换机器、甚至换平台都不影响。

更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条
「明天交周报」的提醒。这件事模型自己做不到 —— 它没法让另一个进程在未来
某刻醒来。

三处收紧:

**1. 只看/只改自己建的。**
别人的日程里可能有它无权知道的会议与地址。不存在与不属于我都回 **404**
而非 403 —— 后者会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。

**2. 不能设给人类。**
理由是投递通道不对等。Agent 之间的提醒是任务信号:收到就干活、干完回信。
发给人的提醒是打扰 —— 进收件箱、触发未读徽标,而人**无法回信让它停下**
(提醒是日历实体不是对话),只能去 WebUI 里找出那条事件删掉。
一个 Agent 建条「每 10 分钟提醒 jianf 检查进度」的代价远大于收益
(它其实可以直接 send_mail)。

**3. 速率 + 总量双闸。**
速率(20 次/小时,独立桶)压住「短时间暴建」,压不住「每小时建 19 条、
连建一周」—— 而日历事件是**长效**的,一条每日重复提醒会一直发下去。
攒下 300 条之后即使停止建新的,每天仍有 300 封提醒涌出来。
所以加 maxActiveEventsPerAgent=50,并在列表响应里回传 active_limit:
模型看到 42/50 就知道该清理,只在撞墙时才报错等于让它一直蒙在鼓里。

其他设计点:
- **PUT 是部分更新**(人类端点是整体替换)。调用方是模型 —— 要求它每次
  回传全部字段,漏一个就把提醒正文或收件人清空,而那种破坏没有任何报错。
  全部字段用 *T,nil = 没传 = 保持原值。
- 一次性事件设在过去拦掉(会立刻触发,几乎总是时区或年份写错);
  重复事件不拦 —— 「每天 9 点」从昨天开始是合理写法。
- 收件人省略时默认给自己:最常见的用法,每次要求写出自己的名字只会让
  模型忘记然后拿到 400。

顺带:两个 handler 文件各写了一份手写 itoa(models_scope 那份还漏了负数),
统一成 strconv.Itoa。

测试 repo 9 例:创建者过滤、收件人≠创建者不返回、总量计数、
速率桶与会话桶独立、按 Agent 隔离、失败归还名额。
生产实测 11 项全过(含 403/400/404/429 各条边界)。
2026-09-04 06:28:33 +08:00

216 lines
6.4 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/models"
)
// Agent 只能看到自己建的日程。别人的日程里可能有它无权知道的会议与地址。
func TestListCalendarEventsCreatedBy(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
mine := seedEvent(t, &models.CalendarEvent{
Title: "pi 自己建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi",
})
seedEvent(t, &models.CalendarEvent{
Title: "dsh 建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "dsh",
})
seedEvent(t, &models.CalendarEvent{
Title: "人建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "jianf",
})
from := time.Now().Add(-time.Hour)
to := time.Now().AddDate(0, 1, 0)
got, err := ListCalendarEventsCreatedBy(ctx, "pi", from, to, "active")
if err != nil {
t.Fatalf("列出: %v", err)
}
if len(got) != 1 {
t.Fatalf("pi 应只看到 1 条,得到 %d", len(got))
}
if got[0].EventID != mine.EventID {
t.Errorf("看到了别人的事件:%s", got[0].Title)
}
// 没建过任何事件的 Agent 得到空数组而不是 nilnil 序列化成 null 前端会崩)
empty, err := ListCalendarEventsCreatedBy(ctx, "opencode", from, to, "active")
if err != nil {
t.Fatalf("列出: %v", err)
}
if empty == nil {
t.Error("应返回空数组而不是 nil")
}
if len(empty) != 0 {
t.Errorf("应为空,得到 %d 条", len(empty))
}
}
// 「发给我但不是我建的」同样不返回:那些事件的编辑权不属于我,
// 列出来只会让模型试图改它然后拿到 404。
func TestListCalendarEventsCreatedByIgnoresRecipient(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedEvent(t, &models.CalendarEvent{
Title: "dsh 建的、发给 pi",
EventTime: time.Now().Add(time.Hour),
CreatedBy: "dsh",
Recipients: []string{"pi"},
})
got, err := ListCalendarEventsCreatedBy(ctx, "pi",
time.Now().Add(-time.Hour), time.Now().AddDate(0, 1, 0), "active")
if err != nil {
t.Fatalf("列出: %v", err)
}
if len(got) != 0 {
t.Errorf("收件人不等于创建者,不该出现在列表里(得到 %d 条)", len(got))
}
}
func TestListCalendarEventsCreatedByStatusFilter(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedEvent(t, &models.CalendarEvent{
Title: "生效中", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "active",
})
seedEvent(t, &models.CalendarEvent{
Title: "已暂停", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "paused",
})
from := time.Now().Add(-time.Hour)
to := time.Now().AddDate(0, 1, 0)
if got, _ := ListCalendarEventsCreatedBy(ctx, "pi", from, to, "active"); len(got) != 1 {
t.Errorf("active 过滤应给 1 条,得到 %d", len(got))
}
// 空串 = 不过滤handler 里 status=all 映射成空串)
if got, _ := ListCalendarEventsCreatedBy(ctx, "pi", from, to, ""); len(got) != 2 {
t.Errorf("不过滤应给 2 条,得到 %d", len(got))
}
}
// 总量上限的依据。速率限制压不住「每小时建 19 条连建一周」,
// 而日历事件是长效的 —— 攒下来的每条都持续产生提醒。
func TestCountActiveEventsBy(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
seedEvent(t, &models.CalendarEvent{
Title: "生效", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "active",
})
}
// cancelled 与 paused 不该计入「生效中」
seedEvent(t, &models.CalendarEvent{
Title: "取消了", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "cancelled",
})
seedEvent(t, &models.CalendarEvent{
Title: "暂停了", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "paused",
})
seedEvent(t, &models.CalendarEvent{
Title: "别人的", EventTime: time.Now().Add(time.Hour), CreatedBy: "dsh", Status: "active",
})
n, err := CountActiveEventsBy(ctx, "pi")
if err != nil {
t.Fatalf("计数: %v", err)
}
if n != 3 {
t.Errorf("pi 的生效事件应为 3得到 %d", n)
}
if n, _ := CountActiveEventsBy(ctx, "从来没建过"); n != 0 {
t.Errorf("没建过应为 0得到 %d", n)
}
}
// ─── 速率限制 ───
func TestAllowAgentCalendarEvent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
limit := CalendarRateLimit()
for i := 0; i < limit; i++ {
ok, _ := AllowAgentCalendarEvent(ctx, "pi")
if !ok {
t.Fatalf("第 %d 次(上限 %d就被拒了", i+1, limit)
}
}
ok, retry := AllowAgentCalendarEvent(ctx, "pi")
if ok {
t.Error("超过上限应被拒")
}
if retry <= 0 {
t.Errorf("被拒时应给出 retryAfter得到 %d", retry)
}
}
// 日历桶与新建会话桶必须独立:建满 20 条日程不该连带堵住新建会话。
func TestCalendarRateBucketIsSeparateFromSession(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for i := 0; i < CalendarRateLimit(); i++ {
AllowAgentCalendarEvent(ctx, "pi")
}
if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); ok {
t.Fatal("准备阶段:日历桶应已满")
}
// 新建会话桶应完全不受影响
if ok, _ := AllowNewSession(ctx, "pi"); !ok {
t.Error("日历桶满不该堵住新建会话 —— 两个桶必须独立")
}
}
// 不同 Agent 的桶互不干扰。
func TestCalendarRateBucketPerAgent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for i := 0; i < CalendarRateLimit(); i++ {
AllowAgentCalendarEvent(ctx, "pi")
}
if ok, _ := AllowAgentCalendarEvent(ctx, "dsh"); !ok {
t.Error("pi 建满不该影响 dsh")
}
}
// 创建失败要归还名额:那次创建实际没有发生。
func TestReleaseAgentCalendarEvent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for i := 0; i < CalendarRateLimit(); i++ {
AllowAgentCalendarEvent(ctx, "pi")
}
if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); ok {
t.Fatal("准备阶段:应已满")
}
// 归还一个(模拟刚才那次被拒之前的失败创建)
ReleaseAgentCalendarEvent(ctx, "pi")
if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); !ok {
t.Error("归还名额后应能再建一条")
}
}
// 空 Agent 名放行且不记账:这条路径只在鉴权已经失败时才可能走到,
// 记账会污染桶bucket 名变成 "calendar:")。
func TestCalendarRateEmptyAgentPassesThrough(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
for i := 0; i < CalendarRateLimit()+5; i++ {
if ok, _ := AllowAgentCalendarEvent(ctx, ""); !ok {
t.Fatal("空 Agent 名应一律放行")
}
}
}