Files
Agent-System-Backend/internal/ratelimit/ratelimit_test.go
T
weichen 5cd67be982
ci / vet-and-test (pull_request) Successful in 41s
Close request atomicity: locked message append, card versions, atomic rate limit, recovery holds (U3 slice 1)
Why: the approved plan U3 (KTD3/KTD7/KTD10 plus minimal recovery isolation) requires concurrent submits to not silently overwrite, unknown-side-effect runs to never auto-resume, and the shared rate limiter to degrade conservatively instead of failing open.

Evidence: full backend suite green twice-relevant paths against real PostgreSQL 18.6 and Redis (isolated instance) and on SQLite; new tests - card_version_test (concurrent single winner), recovery_hold_test + recovery_holds_test (skip/release/fail-closed CRUD), ratelimit atomic-window and degraded-fallback tests.

Impact: AppendMessageWithRequest now allocates seq under the conversation advisory lock in one transaction; activity_cards gain version (v19) with SaveCardVersioned/GetCardVersion/CardVersion and cardsvc user mutations (status/pin/action/resolve-by-ref) return 409-mapped conflicts while system mirrors keep blind writes; recovery_holds (v20) with admin list/release endpoints and fail-closed resume skip; Redis limiter uses one Lua INCR+PEXPIRE script and falls back to a per-instance limit with a Degraded counter.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U3 KTD3/KTD7/KTD10; R3/R6/R7/R8.
2026-10-11 04:37:50 +00:00

105 lines
2.9 KiB
Go

package ratelimit
import (
"context"
"os"
"testing"
"time"
)
func TestMemoryWindow(t *testing.T) {
m := NewMemory()
ctx := context.Background()
for i := 1; i <= 3; i++ {
if !m.Allow(ctx, "o", 3, time.Minute) {
t.Fatalf("request %d should pass", i)
}
}
if m.Allow(ctx, "o", 3, time.Minute) {
t.Fatal("4th request must be limited")
}
// 其他租户互不影响。
if !m.Allow(ctx, "p", 3, time.Minute) {
t.Fatal("other tenant must pass")
}
// 窗口滚动后恢复。
m.now = func() time.Time { return time.Now().Add(2 * time.Minute) }
if !m.Allow(ctx, "o", 3, time.Minute) {
t.Fatal("new window must reset")
}
// limit<=0 = 不限。
if !m.Allow(ctx, "x", 0, time.Minute) {
t.Fatal("limit 0 means unlimited")
}
}
// Redis 共享档(环境门控:AGENT_REDIS_TEST_ADDR,本机原生实例)。
func TestRedisWindow(t *testing.T) {
addr := os.Getenv("AGENT_REDIS_TEST_ADDR")
if addr == "" {
t.Skip("AGENT_REDIS_TEST_ADDR not set")
}
r := NewRedis(addr, "", 15) // 独立 db 避免污染
defer r.Close()
if err := r.Ping(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
ctx := context.Background()
key := "test-" + time.Now().Format("150405.000000000")
for i := 1; i <= 2; i++ {
if !r.Allow(ctx, key, 2, time.Minute) {
t.Fatalf("request %d should pass", i)
}
}
if r.Allow(ctx, key, 2, time.Minute) {
t.Fatal("3rd request must be limited")
}
}
// Lua 原子窗口(U3,KTD10):计数与设窗单脚本完成,键必然带 TTL——
// 窗口翻转后重新放行(原子性回归:旧两步实现窗口语义相同,此处锚定
// 「INCR 与 PEXPIRE 不可分」的行为面)。
func TestRedisWindowAtomicScript(t *testing.T) {
addr := os.Getenv("AGENT_REDIS_TEST_ADDR")
if addr == "" {
t.Skip("AGENT_REDIS_TEST_ADDR not set")
}
r := NewRedis(addr, "", 15)
defer r.Close()
ctx := context.Background()
key := "atomic-" + time.Now().Format("150405.000000000")
window := 300 * time.Millisecond
for i := 1; i <= 3; i++ {
if !r.Allow(ctx, key, 3, window) {
t.Fatalf("request %d should pass", i)
}
}
if r.Allow(ctx, key, 3, window) {
t.Fatal("4th request must be limited")
}
time.Sleep(window + 50*time.Millisecond)
if !r.Allow(ctx, key, 3, window) {
t.Fatal("new window must allow again")
}
}
// Redis 故障降级(KTD10):不可达时不再无界放行——按实例保守限流;
// Degraded 计数供观测告警。
func TestRedisDegradedFallback(t *testing.T) {
r := NewRedis("127.0.0.1:1", "", 15) // 不可达端口
defer r.Close()
ctx := context.Background()
key := "degraded-" + time.Now().Format("150405.000000000")
for i := 1; i <= 2; i++ {
if !r.Allow(ctx, key, 2, time.Minute) {
t.Fatalf("degraded request %d should pass (local limit 2)", i)
}
}
if r.Allow(ctx, key, 2, time.Minute) {
t.Fatal("degraded 3rd request must be limited locally")
}
if r.Degraded() == 0 {
t.Fatal("degradation must be observable")
}
}