Files
dingtalk-workspace-cli/internal/app/legacy_cache_test.go
T
2026-03-28 22:21:20 +08:00

744 lines
23 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// marketListResponse builds a minimal valid FetchServers JSON response.
// The server has a ToolOverride so BuildDynamicCommands emits a command.
func marketListResponse(cliID string) map[string]any {
return map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
map[string]any{
"server": map[string]any{
"name": "Test Server",
"description": "desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/test/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active", "isLatest": true,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": cliID,
"command": cliID,
"toolOverrides": map[string]any{
"test_tool": map[string]any{
"cliName": "test",
"flags": map[string]any{},
},
},
},
},
},
},
}
}
type testCLIServerSpec struct {
id string
command string
tool string
cliName string
}
func marketListResponseForSpecs(specs ...testCLIServerSpec) map[string]any {
servers := make([]any, 0, len(specs))
for _, spec := range specs {
servers = append(servers, map[string]any{
"server": map[string]any{
"name": spec.command,
"description": spec.command + " desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/" + spec.command + "/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active", "isLatest": true,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": spec.id,
"command": spec.command,
"toolOverrides": map[string]any{
spec.tool: map[string]any{
"cliName": spec.cliName,
"flags": map[string]any{},
},
},
},
},
})
}
return map[string]any{
"metadata": map[string]any{"count": len(servers), "nextCursor": ""},
"servers": servers,
}
}
// minimalCLIServer returns a ServerDescriptor with ToolOverrides so
// BuildDynamicCommands will emit at least one cobra command.
func minimalCLIServer(id, endpoint string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: id + "-key",
DisplayName: id,
Endpoint: endpoint,
Source: "market",
CLI: market.CLIOverlay{
ID: id,
Command: id,
ToolOverrides: map[string]market.CLIToolOverride{
"test_tool": {CLIName: "test"},
},
},
HasCLIMeta: true,
}
}
// TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork verifies that when a
// fresh registry cache exists, no network request is made.
//
// This test uses an isolated DWS_CACHE_DIR + discoveryBaseURLOverride so that:
// - useCache=true (DWS_CATALOG_FIXTURE is "")
// - The test server records any incoming request; it should NOT be hit when cache is fresh.
func TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(marketListResponse("test-fresh"))
}))
defer srv.Close()
// Isolated cache dir with a FRESH snapshot.
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC(), // fresh
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
// Point discovery to the test server. Since cache is fresh and
// useCache=true (CATALOG_FIXTURE is ""), the network should not be needed.
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if got := requestCount.Load(); got != 0 {
t.Errorf("network request count = %d, want 0 (fresh cache should be used)", got)
}
if len(cmds) == 0 {
t.Errorf("loadDynamicCommands() returned 0 commands, want >0 from fresh cache")
}
}
// TestLoadDynamicCommandsUsesStaleCacheOnStartup verifies that when the
// registry cache is stale, startup still returns commands from the cache
// instead of blocking on a synchronous market refresh.
func TestLoadDynamicCommandsUsesStaleCacheOnStartup(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
}))
defer srv.Close()
// Isolated cache dir with a STALE snapshot.
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // older than RegistryTTL=24h
Servers: []market.ServerDescriptor{minimalCLIServer("stale", "https://mcp.dingtalk.com/stale/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if len(cmds) == 0 {
t.Fatalf("loadDynamicCommands() = 0 commands, want >0 from stale cache")
}
if got := requestCount.Load(); got != 0 {
t.Errorf("startup network request count = %d, want 0 (stale cache should not block startup)", got)
}
}
// TestLoadDynamicCommandsCacheUpdatedAfterFetch verifies the cache is persisted
// after a successful network fetch (useCache=true, isolated cache dir).
func TestLoadDynamicCommandsCacheUpdatedAfterFetch(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(marketListResponse("fresh-server"))
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
SetDiscoveryBaseURL(srv.URL) // stale/empty cache → network
t.Cleanup(func() { SetDiscoveryBaseURL("") })
_ = loadDynamicCommands(context.Background(), nil)
snapshot, freshness, err := store.LoadRegistry("default/default")
if err != nil {
t.Fatalf("LoadRegistry() after fetch error = %v", err)
}
if freshness != cache.FreshnessFresh {
t.Errorf("cache freshness = %s, want fresh", freshness)
}
if len(snapshot.Servers) == 0 {
t.Errorf("cache servers = 0, want >0 after network fetch")
}
}
// TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError verifies that
// when the market API is unavailable but a stale cache exists, the CLI
// still generates commands from the stale data (offline degradation).
func TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // stale
Servers: []market.ServerDescriptor{minimalCLIServer("degraded", "https://mcp.dingtalk.com/degraded/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if len(cmds) == 0 {
t.Errorf("loadDynamicCommands() = 0 commands, want >0 (stale fallback on network error)")
}
}
func TestLoadDynamicCommandsRefreshesRegistryCacheInBackgroundAfterAgedStart(t *testing.T) {
// Skip: async revalidation is disabled when discoveryBaseURLOverride is set.
// This test requires background refresh which only runs in production mode.
t.Skip("async revalidation disabled in test mode")
t.Setenv(cli.CatalogFixtureEnv, "")
var phase atomic.Int32
phase.Store(1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload := marketListResponseForSpecs(testCLIServerSpec{
id: "doc",
command: "doc",
tool: "create_document",
cliName: "create-document",
})
if phase.Load() == 2 {
payload = marketListResponseForSpecs(
testCLIServerSpec{
id: "doc",
command: "doc",
tool: "archive_document",
cliName: "archive-document",
},
testCLIServerSpec{
id: "drive",
command: "drive",
tool: "list_files",
cliName: "list-files",
},
)
}
_ = json.NewEncoder(w).Encode(payload)
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
snapshot, _, err := store.LoadRegistry("default/default")
if err != nil {
t.Fatalf("LoadRegistry() error = %v", err)
}
snapshot.SavedAt = time.Now().UTC().Add(-2 * time.Hour)
if err := store.SaveRegistry("default/default", snapshot); err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
phase.Store(2)
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
refreshed, _, err := store.LoadRegistry("default/default")
if err == nil && len(refreshed.Servers) == 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"archive-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
}
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
var phase atomic.Int32
docDetailCalls := new(atomic.Int32)
driveDetailCalls := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis":
payload := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
},
}
if phase.Load() == 1 {
payload["servers"] = []any{
registryServerEnvelope("doc", "doc", "2026-03-25T10:00:00Z", 1001, "archive_document", "archive-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
}
}
_ = json.NewEncoder(w).Encode(payload)
case r.URL.Path == "/mcp/market/detail":
switch r.URL.Query().Get("mcpId") {
case "1001":
docDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1001, "archive_document", "Archive Document", "archive desc"))
case "1002":
driveDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
default:
http.Error(w, "unknown mcpId", http.StatusNotFound)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after startup = %d, want 0", got)
}
phase.Store(1)
docDetailCalls.Store(0)
driveDetailCalls.Store(0)
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-2*time.Hour))
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after aged startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after aged startup = %d, want 0", got)
}
}
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryTTLExpires(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
docDetailCalls := new(atomic.Int32)
driveDetailCalls := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis":
_ = json.NewEncoder(w).Encode(map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
},
})
case r.URL.Path == "/mcp/market/detail":
switch r.URL.Query().Get("mcpId") {
case "1001":
docDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1001, "create_document", "Create Document", "create desc"))
case "1002":
driveDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
default:
http.Error(w, "unknown mcpId", http.StatusNotFound)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after startup = %d, want 0", got)
}
docDetailCalls.Store(0)
driveDetailCalls.Store(0)
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-25*time.Hour))
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after registry TTL expiry = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after registry TTL expiry = %d, want 0", got)
}
}
func TestLoadDynamicCommandsUsesStaleCacheWithoutBlockingRegistryRefresh(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_ = json.NewEncoder(w).Encode(marketListResponseForSpecs(testCLIServerSpec{
id: "doc",
command: "doc",
tool: "archive_document",
cliName: "archive-document",
}))
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour),
Servers: []market.ServerDescriptor{
{
Key: "doc-key",
DisplayName: "doc",
Endpoint: "https://mcp.dingtalk.com/doc/v1",
Source: "market",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"create_document": {CLIName: "create-document"},
},
},
HasCLIMeta: true,
},
},
}); err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
start := time.Now()
cmds := loadDynamicCommands(context.Background(), nil)
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
t.Fatalf("loadDynamicCommands() took %v, want stale cache startup under 200ms", elapsed)
}
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
}
// TestFetchDetailsByServerIDRunsConcurrently verifies that detail fetches are
// concurrent, not serial. Uses MCPID path to avoid the localhost SSRF guard.
func TestFetchDetailsByServerIDRunsConcurrently(t *testing.T) {
const numServers = 4
const delay = 50 * time.Millisecond
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(delay)
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"mcpId": 1, "name": "test", "description": "test",
"tools": []any{
map[string]any{"toolName": "test_tool", "toolTitle": "Test Tool", "toolDesc": "desc"},
},
},
})
}))
defer srv.Close()
servers := make([]market.ServerDescriptor, numServers)
for i := range servers {
servers[i] = market.ServerDescriptor{
DetailLocator: market.DetailLocator{MCPID: i + 1},
CLI: market.CLIOverlay{ID: "test-server-" + string(rune('a'+i))},
HasCLIMeta: true,
}
}
start := time.Now()
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, cache.NewStore(t.TempDir()), false)
elapsed := time.Since(start)
serialBound := time.Duration(numServers) * delay
if elapsed >= serialBound {
t.Errorf("elapsed %v >= serial bound %v: requests appear serial, want concurrent", elapsed, serialBound)
}
if len(result) == 0 {
t.Errorf("fetchDetailsByServerID() = empty map, want results")
}
}
func assertDynamicCommandChildren(t *testing.T, cmds []*cobra.Command, name string, want []string) {
t.Helper()
for _, cmd := range cmds {
if cmd.Name() != name {
continue
}
got := make([]string, 0)
for _, child := range cmd.Commands() {
if child.Name() == "help" {
continue
}
got = append(got, child.Name())
}
sort.Strings(got)
sortedWant := append([]string(nil), want...)
sort.Strings(sortedWant)
if len(got) != len(sortedWant) {
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
}
for idx := range got {
if got[idx] != sortedWant[idx] {
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
}
}
return
}
t.Fatalf("command %q not found", name)
}
func registryServerEnvelope(id, command, updatedAt string, mcpID int, toolName, cliName string) map[string]any {
return map[string]any{
"server": map[string]any{
"name": command,
"description": command + " desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/" + command + "/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active",
"isLatest": true,
"updatedAt": updatedAt,
"publishedAt": updatedAt,
"mcpId": mcpID,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": id,
"command": command,
"toolOverrides": map[string]any{
toolName: map[string]any{
"cliName": cliName,
"flags": map[string]any{},
},
},
},
},
}
}
func detailResponse(mcpID int, toolName, title, desc string) map[string]any {
return map[string]any{
"success": true,
"result": map[string]any{
"mcpId": mcpID,
"name": title,
"description": desc,
"tools": []any{
map[string]any{
"toolName": toolName,
"toolTitle": title,
"toolDesc": desc,
"toolRequest": `{"type":"object"}`,
"toolResponse": `{"type":"object"}`,
"actionVersion": "v1",
},
},
},
}
}
func ageCacheSnapshotsOnDisk(t *testing.T, root string, savedAt time.Time) {
t.Helper()
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".json") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return nil
}
if _, ok := payload["saved_at"]; !ok {
return nil
}
payload["saved_at"] = savedAt.Format(time.RFC3339Nano)
rewritten, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, rewritten, 0o644)
})
if walkErr != nil {
t.Fatalf("ageCacheSnapshotsOnDisk() error = %v", walkErr)
}
}
// TestFetchDetailsByServerIDUsesCacheOnHit verifies that a fresh detail cache
// entry prevents any network request.
func TestFetchDetailsByServerIDUsesCacheOnHit(t *testing.T) {
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"tools": []any{}}})
}))
defer srv.Close()
store := cache.NewStore(t.TempDir())
cachedTools := []market.DetailTool{{ToolName: "cached_tool", ToolTitle: "Cached", ToolDesc: "from cache"}}
cachedJSON, _ := json.Marshal(map[string]any{"tools": cachedTools})
err := store.SaveDetail("default/default", "test-server", cache.DetailSnapshot{
SavedAt: time.Now().UTC(),
MCPID: 42,
Payload: cachedJSON,
})
if err != nil {
t.Fatalf("SaveDetail() error = %v", err)
}
servers := []market.ServerDescriptor{
{DetailLocator: market.DetailLocator{MCPID: 42}, CLI: market.CLIOverlay{ID: "test-server"}, HasCLIMeta: true},
}
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, store, false)
if got := requestCount.Load(); got != 0 {
t.Errorf("network request count = %d, want 0 (fresh detail cache should be used)", got)
}
if len(result) == 0 {
t.Errorf("fetchDetailsByServerID() returned empty map, want cached tools")
}
}