Files
excalicloud/internal/mcp/mcp_test.go
T

211 lines
8.5 KiB
Go

package mcp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/vblogio/excalidraw-go/internal/storage"
)
func newServer(t *testing.T) http.Handler {
t.Helper()
st, err := storage.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
return &Server{
Spaces: storage.NewSpaces(st),
User: func(*http.Request) string { return "a@x.fr" },
BaseURL: func(*http.Request) string { return "https://ex.test" },
MaxBody: 1 << 20,
Version: "test",
}
}
func rpc(t *testing.T, h http.Handler, method string, params any) map[string]any {
t.Helper()
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("POST", "/mcp", strings.NewReader(string(b))))
var res map[string]any
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &res) != nil {
t.Fatalf("%s : %d %s", method, rec.Code, rec.Body)
}
return res
}
// tool appelle un outil et renvoie son résultat structuré (ou son erreur).
func callTool(t *testing.T, h http.Handler, name string, args any) (map[string]any, string) {
t.Helper()
res := rpc(t, h, "tools/call", map[string]any{"name": name, "arguments": args})
r, _ := res["result"].(map[string]any)
if r == nil {
t.Fatalf("%s : %v", name, res)
}
if r["isError"] == true {
return nil, r["content"].([]any)[0].(map[string]any)["text"].(string)
}
return r["structuredContent"].(map[string]any), ""
}
func TestProtocol(t *testing.T) {
h := newServer(t)
res := rpc(t, h, "initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{}})
r := res["result"].(map[string]any)
if r["protocolVersion"] != "2025-06-18" || r["capabilities"].(map[string]any)["tools"] == nil {
t.Fatalf("initialize = %v", r)
}
if v := rpc(t, h, "initialize", map[string]any{"protocolVersion": "1999-01-01"})["result"].(map[string]any)["protocolVersion"]; v != protocolVersions[0] {
t.Fatalf("version inconnue → %v", v)
}
tl := rpc(t, h, "tools/list", nil)["result"].(map[string]any)["tools"].([]any)
if len(tl) != len(handlers) {
t.Fatalf("%d outils", len(tl))
}
if e := rpc(t, h, "nope", nil)["error"].(map[string]any); e["code"].(float64) != errMethod {
t.Fatalf("méthode inconnue : %v", e)
}
// Notification : 202 sans corps ; GET : 405.
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`)))
if rec.Code != 202 || rec.Body.Len() != 0 {
t.Fatalf("notification : %d", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/mcp", nil))
if rec.Code != 405 {
t.Fatalf("GET : %d", rec.Code)
}
}
func TestWriteReadListSearch(t *testing.T) {
h := newServer(t)
els := []any{
map[string]any{"id": "web", "type": "rectangle", "x": 0, "y": 0, "width": 200, "height": 80, "label": map[string]any{"text": "Web T1"}},
map[string]any{"id": "db", "type": "ellipse", "x": 400, "y": 0, "width": 160, "height": 80, "label": map[string]any{"text": "Base"}},
map[string]any{"id": "link", "type": "arrow", "start": map[string]any{"id": "web"}, "end": map[string]any{"id": "db"}, "label": map[string]any{"text": "TCP 5432"}},
map[string]any{"type": "text", "x": 0, "y": 200, "text": "Segment overlay 172.27.207.0/24"},
}
out, errMsg := callTool(t, h, "write_diagram", map[string]any{"path": "réseau/app trois tiers", "elements": els})
if errMsg != "" || out["path"] != "réseau/app trois tiers.excalidraw" || out["created"] != true {
t.Fatalf("write = %v %s", out, errMsg)
}
if out["url"] != "https://ex.test/#/r%C3%A9seau/app%20trois%20tiers.excalidraw" {
t.Fatalf("url = %v", out["url"])
}
if _, errMsg := callTool(t, h, "write_diagram", map[string]any{"path": "réseau/app trois tiers.excalidraw", "elements": []any{}}); !strings.Contains(errMsg, "existe déjà") {
t.Fatalf("écrasement sans overwrite : %q", errMsg)
}
got, _ := callTool(t, h, "read_diagram", map[string]any{"path": "réseau/app trois tiers.excalidraw"})
byID := map[string]map[string]any{}
for _, e := range got["elements"].([]any) {
m := e.(map[string]any)
byID[m["id"].(string)] = m
}
if len(byID) != 7 {
t.Fatalf("%d éléments", len(byID))
}
lbl := byID["web-label"]
if lbl["containerId"] != "web" || lbl["text"] != "Web T1" || lbl["verticalAlign"] != "middle" {
t.Fatalf("label = %v", lbl)
}
arrow := byID["link"]
if arrow["startBinding"].(map[string]any)["elementId"] != "web" || arrow["endBinding"].(map[string]any)["elementId"] != "db" {
t.Fatalf("liaisons = %v", arrow)
}
// La flèche part du bord droit du rectangle et arrive au bord gauche de l'ellipse.
pts := arrow["points"].([]any)
if x := arrow["x"].(float64); x != 204 || pts[1].([]any)[0].(float64) != 400-4-204 {
t.Fatalf("géométrie = x %v points %v", x, pts)
}
bound := byID["db"]["boundElements"].([]any)
if len(bound) != 2 {
t.Fatalf("boundElements de db = %v", bound)
}
for _, k := range []string{"seed", "version", "strokeColor", "roughness"} {
if _, ok := byID["web"][k]; !ok {
t.Errorf("champ %s non complété", k)
}
}
l, _ := callTool(t, h, "list_diagrams", map[string]any{"recursive": true})
paths := []string{}
for _, e := range l["entries"].([]any) {
paths = append(paths, e.(map[string]any)["path"].(string))
}
if strings.Join(paths, ",") != ".favoris,.perso,.poubelle,réseau,réseau/app trois tiers.excalidraw" {
t.Fatalf("liste = %v", paths)
}
s, _ := callTool(t, h, "search_diagrams", map[string]any{"query": "172.27.207"})
if r := s["results"].([]any); len(r) != 1 || !strings.Contains(r[0].(map[string]any)["snippet"].(string), "overlay") {
t.Fatalf("recherche = %v", s)
}
}
func TestOverwriteKeepsFilesAndPerso(t *testing.T) {
h := newServer(t)
img := map[string]any{"id": "img", "type": "image", "fileId": "f1", "x": 0, "y": 0, "width": 10, "height": 10}
files := map[string]any{"f1": map[string]any{"id": "f1", "mimeType": "image/png", "dataURL": "data:image/png;base64,AA"}}
if _, e := callTool(t, h, "write_diagram", map[string]any{"path": ".perso/logo", "elements": []any{img}, "files": files}); e != "" {
t.Fatal(e)
}
r, _ := callTool(t, h, "read_diagram", map[string]any{"path": ".perso/logo.excalidraw"})
if r["files"] != nil || r["filesOmitted"].(float64) != 1 {
t.Fatalf("files non omis : %v", r)
}
// Réécriture sans files : l'image référencée est conservée.
els := append(r["elements"].([]any), map[string]any{"type": "text", "text": "ajout"})
out, e := callTool(t, h, "write_diagram", map[string]any{"path": ".perso/logo.excalidraw", "elements": els, "overwrite": true})
if e != "" || out["created"] != false {
t.Fatalf("overwrite : %v %s", out, e)
}
r, _ = callTool(t, h, "read_diagram", map[string]any{"path": ".perso/logo.excalidraw", "include_files": true})
if r["files"].(map[string]any)["f1"] == nil || len(r["elements"].([]any)) != 2 {
t.Fatalf("après overwrite : %v", r)
}
if f, _ := callTool(t, h, "create_folder", map[string]any{"path": ".perso/a/b"}); f["created"] != true {
t.Fatalf("dossier : %v", f)
}
if f, _ := callTool(t, h, "create_folder", map[string]any{"path": ".perso/a/b"}); f["created"] != false {
t.Fatalf("dossier existant : %v", f)
}
}
func TestToolErrors(t *testing.T) {
h := newServer(t)
cases := []struct {
name string
args any
want string
}{
{"read_diagram", map[string]any{"path": "absent.excalidraw"}, "introuvable"},
{"read_diagram", map[string]any{"path": "x", "bogus": 1}, "argument invalide"},
{"write_diagram", map[string]any{"path": "x", "elements": []any{map[string]any{"x": 1}}}, "type manquant"},
{"write_diagram", map[string]any{"path": "x", "elements": []any{map[string]any{"type": "arrow", "start": map[string]any{"id": "nope"}}}}, "inconnu"},
{"write_diagram", map[string]any{"path": ".users/x", "elements": []any{}}, "chemin"},
{"search_diagrams", map[string]any{}, "requis"},
}
for _, c := range cases {
if _, e := callTool(t, h, c.name, c.args); !strings.Contains(strings.ToLower(e), c.want) {
t.Errorf("%s %v : %q, attendu %q", c.name, c.args, e, c.want)
}
}
}
func TestFitLabel(t *testing.T) {
els, err := normalize([]any{map[string]any{"id": "d", "type": "diamond", "x": 0.0, "y": 0.0, "width": 100.0, "height": 100.0,
"label": map[string]any{"text": "NSX Edge cluster"}}})
if err != nil {
t.Fatal(err)
}
d := els[0].(map[string]any)
w, x := d["width"].(float64), d["x"].(float64)
if w <= 100 || x+w/2 != 50 || d["y"].(float64)+d["height"].(float64)/2 != 50 {
t.Fatalf("losange non agrandi autour de son centre : %v", d)
}
}