package mcp import ( "sync" "testing" "time" ) // mockNow 提供可控时间源,测试 TTL 过期无需 sleep. type mockNow struct { mu sync.Mutex t time.Time } func newMockNow(base time.Time) *mockNow { return &mockNow{t: base} } func (m *mockNow) Now() time.Time { m.mu.Lock() defer m.mu.Unlock() return m.t } func (m *mockNow) Advance(d time.Duration) { m.mu.Lock() defer m.mu.Unlock() m.t = m.t.Add(d) } // makeContent 构造简单的 ResourceContent 切片 func makeContent(text string) []ResourceContent { return []ResourceContent{{URI: "test://x", Text: text}} } // TestResourceCache_GetSet 基本读写 func TestResourceCache_GetSet(t *testing.T) { c := NewResourceCache(time.Minute) content := makeContent("hello") c.Set("srv1", "file:///config.json", content) got, ok := c.Get("srv1", "file:///config.json") if !ok { t.Fatal("Get should hit after Set") } if len(got) != 1 || got[0].Text != "hello" { t.Errorf("unexpected content: %+v", got) } } // TestResourceCache_Miss 未命中返回 false func TestResourceCache_Miss(t *testing.T) { c := NewResourceCache(time.Minute) _, ok := c.Get("srv1", "file:///nonexistent") if ok { t.Error("Get on empty cache should return false") } } // TestResourceCache_TTLExpiry TTL 过期后视为 miss func TestResourceCache_TTLExpiry(t *testing.T) { clk := newMockNow(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) c := NewResourceCache(5 * time.Minute) c.now = clk.Now // 注入假时间 c.Set("srv", "res://doc", makeContent("v1")) // 未过期:命中 _, ok := c.Get("srv", "res://doc") if !ok { t.Error("should hit before TTL expires") } // 推进时间超过 TTL clk.Advance(6 * time.Minute) _, ok = c.Get("srv", "res://doc") if ok { t.Error("should miss after TTL expires") } } // TestResourceCache_TTLZeroNeverExpires ttl=0 永不过期 func TestResourceCache_TTLZeroNeverExpires(t *testing.T) { clk := newMockNow(time.Now()) c := NewResourceCache(0) c.now = clk.Now c.Set("srv", "res://forever", makeContent("static")) // 推进 100 年 clk.Advance(100 * 365 * 24 * time.Hour) _, ok := c.Get("srv", "res://forever") if !ok { t.Error("ttl=0 should never expire") } } // TestResourceCache_Invalidate 手动失效 func TestResourceCache_Invalidate(t *testing.T) { c := NewResourceCache(time.Minute) c.Set("srv", "res://doc", makeContent("content")) c.Invalidate("srv", "res://doc") _, ok := c.Get("srv", "res://doc") if ok { t.Error("Get after Invalidate should miss") } } // TestResourceCache_InvalidateServer 批量失效某服务器 func TestResourceCache_InvalidateServer(t *testing.T) { c := NewResourceCache(time.Minute) // srv1 的资源 c.Set("srv1", "res://a", makeContent("a")) c.Set("srv1", "res://b", makeContent("b")) // srv2 的资源(不应受影响) c.Set("srv2", "res://c", makeContent("c")) c.InvalidateServer("srv1") if _, ok := c.Get("srv1", "res://a"); ok { t.Error("srv1 res://a should be invalidated") } if _, ok := c.Get("srv1", "res://b"); ok { t.Error("srv1 res://b should be invalidated") } // srv2 不受影响 if _, ok := c.Get("srv2", "res://c"); !ok { t.Error("srv2 res://c should NOT be affected by InvalidateServer('srv1')") } } // TestResourceCache_Clear 全量清空 func TestResourceCache_Clear(t *testing.T) { c := NewResourceCache(time.Minute) c.Set("srv1", "a", makeContent("a")) c.Set("srv2", "b", makeContent("b")) c.Clear() if c.Len() != 0 { t.Errorf("Len after Clear = %d, want 0", c.Len()) } } // TestResourceCache_Len 条目计数 func TestResourceCache_Len(t *testing.T) { c := NewResourceCache(time.Minute) if c.Len() != 0 { t.Errorf("initial Len = %d", c.Len()) } c.Set("s", "u1", makeContent("x")) c.Set("s", "u2", makeContent("y")) if c.Len() != 2 { t.Errorf("Len = %d, want 2", c.Len()) } } // TestResourceCache_SetOverwrite 覆盖写更新内容和过期时间 func TestResourceCache_SetOverwrite(t *testing.T) { clk := newMockNow(time.Now()) c := NewResourceCache(5 * time.Minute) c.now = clk.Now c.Set("s", "u", makeContent("v1")) // 推进 4 分钟(尚未过期) clk.Advance(4 * time.Minute) // 覆盖写(刷新时间) c.Set("s", "u", makeContent("v2")) // 再推进 3 分钟(若以第一次 Set 时间算,应已过期;以第二次 Set 算,未过期) clk.Advance(3 * time.Minute) got, ok := c.Get("s", "u") if !ok { t.Error("should hit because Set refreshed fetchedAt") } if got[0].Text != "v2" { t.Errorf("content = %q, want v2", got[0].Text) } } // TestResourceCache_ConcurrentSafe 并发读写不 panic/race func TestResourceCache_ConcurrentSafe(t *testing.T) { c := NewResourceCache(time.Millisecond * 10) var wg sync.WaitGroup const goroutines = 100 for i := 0; i < goroutines; i++ { wg.Add(1) go func(i int) { defer wg.Done() srv := "srv1" uri := "res://doc" switch i % 4 { case 0: c.Set(srv, uri, makeContent("data")) case 1: c.Get(srv, uri) case 2: c.Invalidate(srv, uri) case 3: c.InvalidateServer(srv) } }(i) } wg.Wait() // 不 panic 即通过 } // TestResourceCache_InvalidateServerPrefix 前缀匹配只影响目标服务器 func TestResourceCache_InvalidateServerPrefix(t *testing.T) { c := NewResourceCache(time.Minute) // "db" 和 "db2" 是不同的服务器("db2" 不是 "db" 的子集) c.Set("db", "res://table", makeContent("rows")) c.Set("db2", "res://schema", makeContent("schema")) c.InvalidateServer("db") if _, ok := c.Get("db", "res://table"); ok { t.Error("db res://table should be invalidated") } // "db2:" 前缀不匹配 "db:",应保留 if _, ok := c.Get("db2", "res://schema"); !ok { t.Error("db2 res://schema should NOT be affected by InvalidateServer('db')") } } // TestResourceCache_LRUEviction 容量满时驱逐最久未访问的条目 func TestResourceCache_LRUEviction(t *testing.T) { // 使用一个极小容量来验证 LRU 驱逐逻辑,绕过全局常量. // 构造 maxResourceCacheEntries 条目后再插入新条目,验证 Back 被驱逐. // 此处用辅助函数直接操作内部以绕过常量:改用 3 个条目 + 手动缩容不现实, // 因此通过填满 maxResourceCacheEntries 来测试. // - 实际测试:填入 maxResourceCacheEntries 条目,访问其中一个(刷新热度), // 再插入新条目,验证被访问的条目留存,最老的未访问条目被驱逐. c := NewResourceCache(time.Hour) // 填满缓存 const cap = maxResourceCacheEntries for i := 0; i < cap; i++ { c.Set("s", uriN(i), makeContent("v")) } if c.Len() != cap { t.Fatalf("Len = %d, want %d", c.Len(), cap) } // 访问第 0 个条目,使其成为最近访问(不会被驱逐) if _, ok := c.Get("s", uriN(0)); !ok { t.Fatal("entry 0 should exist before eviction test") } // 插入第 cap 个新条目,触发驱逐 // 此时 LRU Back 应为 uriN(1)(uriN(0) 已被 Get 移到 Front) c.Set("s", uriN(cap), makeContent("new")) if c.Len() != cap { t.Errorf("Len after eviction = %d, want %d", c.Len(), cap) } // 新条目必须存在 if _, ok := c.Get("s", uriN(cap)); !ok { t.Error("newly inserted entry should exist after eviction") } // uriN(0) 因为被 Get 访问过,应留存 if _, ok := c.Get("s", uriN(0)); !ok { t.Error("recently accessed entry 0 should NOT be evicted") } // uriN(1) 是最久未访问的,应被驱逐 if _, ok := c.Get("s", uriN(1)); ok { t.Error("LRU entry (uri 1) should have been evicted") } } // TestResourceCache_LRUGetUpdatesOrder Get 命中更新 LRU 顺序 func TestResourceCache_LRUGetUpdatesOrder(t *testing.T) { c := NewResourceCache(time.Hour) // 写入 A,B 两个条目(A 先写,顺序:B=Front, A=Back) c.Set("s", "res://a", makeContent("a")) c.Set("s", "res://b", makeContent("b")) // 访问 A,使 A 成为 Front(B 降为 Back) if _, ok := c.Get("s", "res://a"); !ok { t.Fatal("res://a should exist") } // 填满剩余容量 - 2 个已用位置,再插入 1 个条目触发驱逐 // 此处为了不填满 1000 个,改用内部方式: // 直接测试:再写入 C,此时容量 = 3(未满),不触发驱逐,验证顺序正确性留给 LRUEviction. // 改验证:A 存在,B 存在(容量未满,均应存在) c.Set("s", "res://c", makeContent("c")) if _, ok := c.Get("s", "res://a"); !ok { t.Error("res://a should still exist (was accessed)") } if _, ok := c.Get("s", "res://b"); !ok { t.Error("res://b should still exist (capacity not full)") } } // uriN 生成第 n 个测试 URI func uriN(n int) string { return "res://item-" + itoa(n) } // itoa 手写整数转字符串,避免 import fmt/strconv func itoa(n int) string { if n == 0 { return "0" } buf := make([]byte, 0, 10) for n > 0 { buf = append([]byte{byte('0' + n%10)}, buf...) n /= 10 } return string(buf) }