Files
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00

460 lines
13 KiB
Go

package discovery
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// newTestMCPServer returns an httptest.Server that handles both market registry
// and MCP JSON-RPC endpoints. marketOK controls whether /cli/discovery/apis/cedar
// succeeds, and mcpOK controls whether initialize+tools/list succeed.
func newTestMCPServer(t *testing.T, marketOK, mcpOK bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/cli/discovery/apis/cedar", func(w http.ResponseWriter, r *http.Request) {
if !marketOK {
http.Error(w, "market unavailable", http.StatusInternalServerError)
return
}
// Return one server whose MCP endpoint is this same test server.
resp := map[string]any{
"metadata": map[string]any{"count": 1},
"servers": []map[string]any{{
"server": map[string]any{
"name": "test-server",
"description": "Test server",
"remotes": []map[string]any{{"type": "streamable-http", "url": "PLACEHOLDER"}},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{"mcpId": 0, "status": "active"},
"com.dingtalk.mcp.registry/cli": map[string]any{"id": "test", "command": "test", "description": "Test CLI"},
},
}},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) {
if !mcpOK {
http.Error(w, "mcp unavailable", http.StatusInternalServerError)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
method, _ := req["method"].(string)
id := req["id"]
switch method {
case "initialize":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": "test", "version": "1.0"},
},
})
case "notifications/initialized":
// Notification — no response needed, but respond 200 with empty.
w.WriteHeader(http.StatusOK)
case "tools/list":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"result": map[string]any{
"tools": []map[string]any{{
"name": "search",
"description": "Search documents",
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
}},
},
})
default:
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": id,
"error": map[string]any{"code": -32601, "message": "method not found"},
})
}
})
return httptest.NewServer(mux)
}
func newTestService(t *testing.T, baseURL string, mcpServer *httptest.Server) *Service {
t.Helper()
cacheDir := t.TempDir()
return &Service{
MarketClient: market.NewClient(baseURL, mcpServer.Client()),
Transport: transport.NewClient(mcpServer.Client()),
Cache: cache.NewStore(cacheDir),
Tenant: "test-tenant",
AuthIdentity: "test-identity",
}
}
func TestDiscoverServers_LiveSuccess(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() error = %v", err)
}
if len(servers) == 0 {
t.Fatal("DiscoverServers() returned no servers")
}
if servers[0].Source != "live_market" {
t.Fatalf("Source = %q, want live_market", servers[0].Source)
}
}
func TestDiscoverServers_FallbackToCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
// Populate cache with a live fetch first.
_, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("initial DiscoverServers() error = %v", err)
}
// Now point market at a broken URL so the next fetch fails.
svc.MarketClient = market.NewClient("http://127.0.0.1:1/broken", nil)
servers, err := svc.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() with cache fallback error = %v", err)
}
if len(servers) == 0 {
t.Fatal("DiscoverServers() returned no servers from cache")
}
if !servers[0].Degraded {
t.Fatal("cached servers should be marked Degraded")
}
}
func TestDiscoverServers_NoMarketNoCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, false, false)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
_, err := svc.DiscoverServers(context.Background())
if err == nil {
t.Fatal("DiscoverServers() error = nil, want error when both market and cache fail")
}
}
func TestDiscoverServerRuntime_LiveSuccess(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "test-key",
Endpoint: srv.URL + "/mcp",
}
result, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("DiscoverServerRuntime() error = %v", err)
}
if result.Source != "live_runtime" {
t.Fatalf("Source = %q, want live_runtime", result.Source)
}
if result.Degraded {
t.Fatal("live result should not be degraded")
}
if len(result.Tools) == 0 {
t.Fatal("expected at least one tool")
}
if result.Tools[0].Name != "search" {
t.Fatalf("tool name = %q, want search", result.Tools[0].Name)
}
if result.NegotiatedProtocolVersion != "2025-03-26" {
t.Fatalf("protocol = %q, want 2025-03-26", result.NegotiatedProtocolVersion)
}
}
func TestDiscoverServerRuntime_FallbackToCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "cache-test",
Endpoint: srv.URL + "/mcp",
}
// First call populates cache.
_, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("initial DiscoverServerRuntime() error = %v", err)
}
// Now use a broken MCP endpoint so initialize fails.
server.Endpoint = "http://127.0.0.1:1/broken"
result, err := svc.DiscoverServerRuntime(context.Background(), server)
if err != nil {
t.Fatalf("DiscoverServerRuntime() with cache fallback error = %v", err)
}
if !result.Degraded {
t.Fatal("cached result should be degraded")
}
if len(result.Tools) == 0 {
t.Fatal("cached tools should be present")
}
}
func TestDiscoverServerRuntime_NoCacheNoLive(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, false)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
server := market.ServerDescriptor{
Key: "fail-key",
Endpoint: srv.URL + "/mcp",
}
_, err := svc.DiscoverServerRuntime(context.Background(), server)
if err == nil {
t.Fatal("DiscoverServerRuntime() error = nil, want error")
}
// Error should contain server key for debugging.
if got := err.Error(); !contains(got, "fail-key") {
t.Fatalf("error %q should contain server key 'fail-key'", got)
}
}
func TestDiscoverServerRuntime_ContextCanceled(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately.
server := market.ServerDescriptor{
Key: "cancel-key",
Endpoint: srv.URL + "/mcp",
}
_, err := svc.DiscoverServerRuntime(ctx, server)
if err == nil {
t.Fatal("DiscoverServerRuntime() error = nil, want context canceled")
}
}
func TestDiscoverAllRuntime_PartialFailure(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers := []market.ServerDescriptor{
{Key: "good", Endpoint: srv.URL + "/mcp"},
{Key: "bad", Endpoint: "http://127.0.0.1:1/broken"},
}
results, failures := svc.DiscoverAllRuntime(context.Background(), servers)
if len(results) != 1 {
t.Fatalf("results count = %d, want 1", len(results))
}
if results[0].Server.Key != "good" {
t.Fatalf("successful server key = %q, want good", results[0].Server.Key)
}
if len(failures) != 1 {
t.Fatalf("failures count = %d, want 1", len(failures))
}
if failures[0].ServerKey != "bad" {
t.Fatalf("failed server key = %q, want bad", failures[0].ServerKey)
}
}
func TestDiscoverAllRuntime_SkipsCLISkippedServersWithoutWritingCache(t *testing.T) {
t.Parallel()
srv := newTestMCPServer(t, true, true)
defer srv.Close()
svc := newTestService(t, srv.URL, srv)
servers := []market.ServerDescriptor{
{
Key: "active",
Endpoint: srv.URL + "/mcp",
CLI: market.CLIOverlay{ID: "active", Command: "active"},
},
{
Key: "skipped",
Endpoint: srv.URL + "/mcp",
CLI: market.CLIOverlay{ID: "legacy", Command: "legacy", Skip: true},
},
}
results, failures := svc.DiscoverAllRuntime(context.Background(), servers)
if len(failures) != 0 {
t.Fatalf("failures count = %d, want 0", len(failures))
}
if len(results) != 1 {
t.Fatalf("results count = %d, want 1", len(results))
}
if results[0].Server.Key != "active" {
t.Fatalf("successful server key = %q, want active", results[0].Server.Key)
}
if _, _, err := svc.Cache.LoadTools("test-tenant/test-identity", "skipped"); err == nil {
t.Fatal("LoadTools(skipped) error = nil, want skipped service to avoid cache writes")
}
}
func TestCacheDetailSnapshotWritesServerKeyAndCLIID(t *testing.T) {
t.Parallel()
store := cache.NewStore(t.TempDir())
server := market.ServerDescriptor{
Key: "server-key",
CLI: market.CLIOverlay{ID: "server-id"},
}
cacheDetailSnapshot(store, "test-tenant/test-identity", server, 42, market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{{ToolName: "search", ToolTitle: "Search"}},
},
})
if _, _, err := store.LoadDetail("test-tenant/test-identity", "server-key"); err != nil {
t.Fatalf("LoadDetail(server-key) error = %v", err)
}
if _, _, err := store.LoadDetail("test-tenant/test-identity", "server-id"); err != nil {
t.Fatalf("LoadDetail(server-id) error = %v", err)
}
}
func TestMergeRuntimeToolsWithDetail(t *testing.T) {
t.Parallel()
tools := []transport.ToolDescriptor{
{Name: "search", Description: "original desc"},
{Name: "create", Description: "create desc"},
}
detail := market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{
{ToolName: "search", ToolTitle: "Search Title", ToolDesc: "Updated desc", IsSensitive: true},
},
},
}
merged := mergeRuntimeToolsWithDetail(tools, detail)
if len(merged) != 2 {
t.Fatalf("merged count = %d, want 2", len(merged))
}
// "search" should be updated from detail.
if merged[0].Title != "Search Title" {
t.Fatalf("search title = %q, want 'Search Title'", merged[0].Title)
}
if merged[0].Description != "Updated desc" {
t.Fatalf("search description = %q, want 'Updated desc'", merged[0].Description)
}
if !merged[0].Sensitive {
t.Fatal("search should be marked sensitive")
}
// "create" should remain unchanged.
if merged[1].Description != "create desc" {
t.Fatalf("create description = %q, want original", merged[1].Description)
}
}
func TestMergeRuntimeToolsWithDetail_EmptyDetail(t *testing.T) {
t.Parallel()
tools := []transport.ToolDescriptor{{Name: "t1"}}
empty := market.DetailResponse{Success: false}
result := mergeRuntimeToolsWithDetail(tools, empty)
if len(result) != 1 || result[0].Name != "t1" {
t.Fatal("empty detail should return original tools unchanged")
}
}
func TestParseDetailSchema(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantNil bool
}{
{"valid object", `{"type":"object"}`, false},
{"empty string", "", true},
{"invalid json", "not-json", true},
{"json array", `[1,2,3]`, true},
{"whitespace only", " ", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseDetailSchema(tt.input)
if tt.wantNil && result != nil {
t.Fatalf("parseDetailSchema(%q) = %v, want nil", tt.input, result)
}
if !tt.wantNil && result == nil {
t.Fatalf("parseDetailSchema(%q) = nil, want non-nil", tt.input)
}
})
}
}
func TestPartition(t *testing.T) {
t.Parallel()
svc := &Service{Tenant: "corp1", AuthIdentity: "user1"}
got := svc.partition()
if got != "corp1/user1" {
t.Fatalf("partition() = %q, want corp1/user1", got)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && searchString(s, substr)))
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}