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 名应一律放行") } } }