在这之前 /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 各条边界)。
216 lines
6.4 KiB
Go
216 lines
6.4 KiB
Go
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 得到空数组而不是 nil(nil 序列化成 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 名应一律放行")
|
||
}
|
||
}
|
||
}
|