Files
dingtalk-workspace-cli/test/integration/extensions/extensions_test.go
T
2026-03-27 17:55:57 +08:00

380 lines
11 KiB
Go

package extensions_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
)
func TestDiscoveryPreservesCLIMetadataAndLifecycle(t *testing.T) {
t.Parallel()
srv := newRuntimeMarketServer(t, mockmcp.DefaultFixture())
defer srv.Close()
service := discovery.NewService(
market.NewClient(srv.URL, srv.Client()),
transport.NewClient(srv.Client()),
cache.NewStore(t.TempDir()),
)
servers, err := service.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() error = %v", err)
}
if len(servers) != 2 {
t.Fatalf("DiscoverServers() len = %d, want 2", len(servers))
}
doc := findServerByName(t, servers, "钉钉文档")
if !doc.HasCLIMeta {
t.Fatalf("doc server should expose CLI metadata")
}
if doc.CLI.ID != "doc" || doc.CLI.Command != "doc" {
t.Fatalf("doc CLI overlay = %#v, want doc command", doc.CLI)
}
if len(doc.CLI.Tools) != 2 {
t.Fatalf("doc CLI tools len = %d, want 2", len(doc.CLI.Tools))
}
if doc.DetailLocator.MCPID != 9629 {
t.Fatalf("doc detail locator mcpId = %d, want 9629", doc.DetailLocator.MCPID)
}
if doc.Lifecycle.DeprecatedBy != 0 {
t.Fatalf("doc lifecycle = %#v, want zero-value lifecycle", doc.Lifecycle)
}
legacy := findServerByName(t, servers, "钉钉文档(旧)")
if !legacy.HasCLIMeta {
t.Fatalf("legacy server should also expose CLI metadata")
}
if legacy.Lifecycle.DeprecatedBy != 9629 {
t.Fatalf("legacy lifecycle deprecatedBy = %d, want 9629", legacy.Lifecycle.DeprecatedBy)
}
if legacy.Lifecycle.MigrationURL == "" {
t.Fatalf("legacy lifecycle should carry migration URL")
}
}
func TestDiscoverySurfacesNewServerWithoutRuntimeChanges(t *testing.T) {
t.Parallel()
fixture := mockmcp.DefaultFixture()
fixture.Servers = append(fixture.Servers, mockmcp.ServerFixture{
Name: "钉钉会议",
Description: "会议能力通过市场元数据零代码出现",
SchemaURI: mockmcp.DefaultSchemaURI,
RemotePath: "/server/meeting",
Registry: market.RegistryMetadata{
IsLatest: true,
PublishedAt: "2026-03-19T00:00:00Z",
UpdatedAt: "2026-03-20T00:00:00Z",
Status: "active",
MCPID: 9901,
DetailURL: "",
Quality: market.QualityMetadata{
HighQuality: true,
Official: true,
DTBiz: true,
},
},
CLI: market.CLIOverlay{
ID: "meeting",
Command: "meeting",
Description: "会议管理",
Prefixes: []string{"meeting"},
Aliases: []string{"钉钉会议"},
Tools: []market.CLITool{
{
Name: "create_meeting",
CLIName: "create-meeting",
Title: "创建会议",
Description: "创建新的钉钉会议",
Category: "写入",
},
},
},
})
srv := newRuntimeMarketServer(t, fixture)
defer srv.Close()
service := discovery.NewService(
market.NewClient(srv.URL, srv.Client()),
transport.NewClient(srv.Client()),
cache.NewStore(t.TempDir()),
)
servers, err := service.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() error = %v", err)
}
meeting := findServerByName(t, servers, "钉钉会议")
if meeting.Endpoint == "" {
t.Fatalf("meeting server missing endpoint: %#v", meeting)
}
if !meeting.HasCLIMeta {
t.Fatalf("meeting server should expose CLI metadata")
}
if meeting.CLI.ID != "meeting" || meeting.CLI.Command != "meeting" {
t.Fatalf("meeting CLI overlay = %#v, want meeting command", meeting.CLI)
}
if meeting.Source != "live_market" {
t.Fatalf("meeting source = %q, want live_market", meeting.Source)
}
}
func TestDiscoverDetailFallsBackToCachedSnapshot(t *testing.T) {
t.Parallel()
fixture := mockmcp.DefaultFixture()
for idx := range fixture.Servers {
if fixture.Servers[idx].Name == "钉钉文档" {
fixture.Servers[idx].Detail = nil
}
}
srv := newRuntimeMarketServer(t, fixture)
defer srv.Close()
store := cache.NewStore(t.TempDir())
service := discovery.NewService(
market.NewClient(srv.URL, srv.Client()),
transport.NewClient(srv.Client()),
store,
)
servers, err := service.DiscoverServers(context.Background())
if err != nil {
t.Fatalf("DiscoverServers() error = %v", err)
}
doc := findServerByName(t, servers, "钉钉文档")
cached := market.DetailResponse{
Success: true,
Result: market.DetailResult{
MCPID: doc.DetailLocator.MCPID,
Name: doc.DisplayName,
Description: doc.Description,
Tools: []market.DetailTool{
{
ToolName: "search_documents",
ToolTitle: "搜索文档",
ToolDesc: "缓存回退文档搜索",
IsSensitive: false,
ToolRequest: `{"type":"object","properties":{"keyword":{"type":"string"}}}`,
ToolResponse: `{"type":"object"}`,
ActionVersion: "cached-version",
},
},
},
}
raw, err := json.Marshal(cached)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if err := store.SaveDetail("default/default", doc.Key, cache.DetailSnapshot{
MCPID: doc.DetailLocator.MCPID,
Payload: raw,
}); err != nil {
t.Fatalf("SaveDetail() error = %v", err)
}
detail, err := service.DiscoverDetail(context.Background(), doc)
if err != nil {
t.Fatalf("DiscoverDetail() error = %v", err)
}
if !detail.Success {
t.Fatalf("DiscoverDetail() success = false, want cached success")
}
if detail.Result.Tools[0].ActionVersion != "cached-version" {
t.Fatalf("DiscoverDetail() returned %q, want cached-version", detail.Result.Tools[0].ActionVersion)
}
}
func TestToolsCallUsesMockTransportPath(t *testing.T) {
t.Parallel()
srv := mockmcp.DefaultServer()
defer srv.Close()
client := transport.NewClient(srv.Client())
result, err := client.CallTool(context.Background(), srv.RemoteURL("/server/doc"), "create_document", map[string]any{
"title": "Quarterly Report",
})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if got := result.Content["documentId"]; got != "doc-123" {
t.Fatalf("CallTool() documentId = %#v, want doc-123", got)
}
}
func findServerByName(t *testing.T, servers []market.ServerDescriptor, name string) market.ServerDescriptor {
t.Helper()
for _, server := range servers {
if server.DisplayName == name {
return server
}
}
t.Fatalf("server %q not found in %#v", name, servers)
return market.ServerDescriptor{}
}
func newRuntimeMarketServer(t *testing.T, fixture mockmcp.Fixture) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
srv := httptest.NewServer(mux)
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
response := market.ListResponse{}
for _, server := range fixture.Servers {
response.Servers = append(response.Servers, market.ServerEnvelope{
Server: market.RegistryServer{
SchemaURI: server.SchemaURI,
Name: server.Name,
Description: server.Description,
Remotes: []market.RegistryRemote{
{Type: "streamable-http", URL: srv.URL + server.RemotePath},
},
},
Meta: market.EnvelopeMeta{
Registry: server.RegistryWithDetailURL(srv.URL + "/mcp/market/detail?mcpId=" + strconv.Itoa(server.Registry.MCPID)),
CLI: server.CLI,
},
})
}
response.Metadata.Count = len(response.Servers)
if err := json.NewEncoder(w).Encode(response); err != nil {
t.Fatalf("encode market response: %v", err)
}
})
mux.HandleFunc("/mcp/market/detail", func(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("mcpId")
mcpID, err := strconv.Atoi(raw)
if err != nil {
http.Error(w, "invalid mcpId", http.StatusBadRequest)
return
}
server, ok := fixture.ServerByMCPID(mcpID)
if !ok || server.Detail == nil {
http.Error(w, "detail fixture missing", http.StatusNotFound)
return
}
if err := json.NewEncoder(w).Encode(server.Detail.Response); err != nil {
t.Fatalf("encode detail response: %v", err)
}
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
server, ok := fixture.ServerByRemotePath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json-rpc request", http.StatusBadRequest)
return
}
method, _ := req["method"].(string)
switch method {
case "initialize":
protocolVersion := mockmcp.DefaultProtocolVer
capabilities := map[string]any{"tools": map[string]any{"listChanged": false}}
serverInfo := map[string]any{"name": server.Name, "version": "0.0.0"}
if server.MCP != nil {
if server.MCP.ProtocolVersion != "" {
protocolVersion = server.MCP.ProtocolVersion
}
if len(server.MCP.Capabilities) > 0 {
capabilities = server.MCP.Capabilities
}
if len(server.MCP.ServerInfo) > 0 {
serverInfo = server.MCP.ServerInfo
}
}
payload := map[string]any{
"protocolVersion": protocolVersion,
"capabilities": capabilities,
"serverInfo": serverInfo,
}
writeJSONRPC(w, req, payload, nil)
case "notifications/initialized":
w.WriteHeader(http.StatusNoContent)
case "tools/list":
tools := []any(nil)
if server.MCP != nil && len(server.MCP.Tools) > 0 {
tools = make([]any, 0, len(server.MCP.Tools))
for _, tool := range server.MCP.Tools {
tools = append(tools, tool)
}
}
writeJSONRPC(w, req, map[string]any{"tools": tools}, nil)
case "tools/call":
toolName := ""
if params, ok := req["params"].(map[string]any); ok {
if name, ok := params["name"].(string); ok {
toolName = name
}
}
call := mockmcp.ToolCallFixture{}
if server.MCP != nil && server.MCP.Calls != nil {
if fixtureCall, ok := server.MCP.Calls[toolName]; ok {
call = fixtureCall
}
}
if call.Error != nil {
writeJSONRPC(w, req, nil, call.Error)
return
}
if call.Status != 0 {
w.WriteHeader(call.Status)
}
writeJSONRPC(w, req, call.ResultOrDefault(toolName), nil)
default:
http.Error(w, "unexpected json-rpc method", http.StatusBadRequest)
}
})
return srv
}
func writeJSONRPC(w http.ResponseWriter, req map[string]any, result any, rpcErr *transport.RPCError) {
payload := map[string]any{
"jsonrpc": "2.0",
"id": requestID(req),
}
if rpcErr != nil {
payload["error"] = rpcErr
} else {
payload["result"] = result
}
_ = json.NewEncoder(w).Encode(payload)
}
func requestID(req map[string]any) any {
if id, ok := req["id"]; ok {
return id
}
return 0
}