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.
460 lines
13 KiB
Go
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
|
|
}
|