package server import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "net/http/httptest" "slices" "strings" "sync" "testing" "gogs.netdome.biz/paul/go-wikijs-mcp/internal/wikijs" "github.com/modelcontextprotocol/go-sdk/mcp" ) type capturedRequest struct { Query string `json:"query"` Variables map[string]any `json:"variables"` } type fakeWiki struct { t *testing.T mu sync.Mutex requests []capturedRequest } func (f *fakeWiki) handle(w http.ResponseWriter, r *http.Request) { f.t.Helper() if got, want := r.Header.Get("Authorization"), "Bearer test-key"; got != want { f.t.Errorf("Authorization = %q, want %q", got, want) } var req capturedRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { f.t.Errorf("decode request: %v", err) http.Error(w, "bad request", http.StatusBadRequest) return } f.mu.Lock() f.requests = append(f.requests, req) f.mu.Unlock() w.Header().Set("Content-Type", "application/json") switch { case strings.Contains(req.Query, "query SearchPages"): fmt.Fprint(w, `{"data":{"pages":{"search":{"results":[ {"title":"MCP","description":"protocol","path":"/mcp","locale":"en"} ],"suggestions":["mcp"],"totalHits":1}}}}`) case strings.Contains(req.Query, "query GetPageById"): id, _ := req.Variables["id"].(float64) if id == 404 { fmt.Fprint(w, `{"data":{"pages":{"single":null}}}`) return } fmt.Fprintf(w, `{"data":{"pages":{"single":{ "id":%d,"path":"/dev/go","title":"Go","description":"desc", "tags":[{"tag":"go","title":"Go"}],"content":"body", "createdAt":"c","updatedAt":"u","locale":"de"}}}}`, int(id)) case strings.Contains(req.Query, "query GetPageByPath"): path, _ := req.Variables["path"].(string) locale, _ := req.Variables["locale"].(string) fmt.Fprintf(w, `{"data":{"pages":{"singleByPath":{ "id":7,"path":%q,"title":"Start","description":"desc", "tags":[],"content":"body","createdAt":"c","updatedAt":"u","locale":%q}}}}`, path, locale) case strings.Contains(req.Query, "query GetAllPages"): fmt.Fprint(w, `{"data":{"pages":{"list":[ {"id":1,"path":"/a","locale":"de","title":"A","description":"a", "createdAt":"c","updatedAt":"u","tags":["go"]} ]}}}`) case strings.Contains(req.Query, "mutation CreatePage"): title, _ := req.Variables["title"].(string) if title == "fail" { fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{ "succeeded":false,"errorCode":6001,"slug":"","message":"Nope"}}}}}`) return } fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{ "succeeded":true,"errorCode":0,"slug":"new","message":"Page created"}}}}}`) case strings.Contains(req.Query, "mutation UpdatePage"): id, _ := req.Variables["id"].(float64) if id == 404 { fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{ "succeeded":false,"errorCode":6001,"slug":"","message":"Page not found"}}}}}`) return } fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{ "succeeded":true,"errorCode":0,"slug":"","message":"Page updated"}}}}}`) default: f.t.Errorf("unexpected query: %s", req.Query) http.Error(w, "unknown operation", http.StatusBadRequest) } } func (f *fakeWiki) lastContaining(t *testing.T, fragment string) capturedRequest { t.Helper() f.mu.Lock() defer f.mu.Unlock() for i := len(f.requests) - 1; i >= 0; i-- { if strings.Contains(f.requests[i].Query, fragment) { return f.requests[i] } } t.Fatalf("no request containing %q", fragment) return capturedRequest{} } func newTestSession(t *testing.T, enableEdit bool) (*mcp.ClientSession, *fakeWiki) { t.Helper() fake := &fakeWiki{t: t} wikiSrv := httptest.NewServer(http.HandlerFunc(fake.handle)) t.Cleanup(wikiSrv.Close) wikiClient, err := wikijs.New(wikiSrv.URL, "test-key") if err != nil { t.Fatalf("wikijs.New() error = %v", err) } srv := New(wikiClient, Options{ Version: "test", EnableEdit: enableEdit, Logger: slog.New(slog.DiscardHandler), }) st, ct := mcp.NewInMemoryTransports() ctx := context.Background() ss, err := srv.Connect(ctx, st, nil) if err != nil { t.Fatalf("server connect: %v", err) } t.Cleanup(func() { _ = ss.Close() }) client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil) cs, err := client.Connect(ctx, ct, nil) if err != nil { t.Fatalf("client connect: %v", err) } t.Cleanup(func() { _ = cs.Close() }) return cs, fake } func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult { t.Helper() res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) if err != nil { t.Fatalf("CallTool(%s) error = %v", name, err) } return res } func toolText(t *testing.T, res *mcp.CallToolResult) string { t.Helper() if len(res.Content) == 0 { t.Fatal("tool result has no content") } text, ok := res.Content[0].(*mcp.TextContent) if !ok { t.Fatalf("content type = %T, want *mcp.TextContent", res.Content[0]) } return text.Text } func TestToolListWithoutEdit(t *testing.T) { cs, _ := newTestSession(t, false) res, err := cs.ListTools(context.Background(), nil) if err != nil { t.Fatalf("ListTools() error = %v", err) } got := toolNames(res.Tools) want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages"} if !equalNames(got, want) { t.Errorf("tools = %v, want %v", got, want) } } func TestToolListWithEdit(t *testing.T) { cs, _ := newTestSession(t, true) res, err := cs.ListTools(context.Background(), nil) if err != nil { t.Fatalf("ListTools() error = %v", err) } got := toolNames(res.Tools) want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages", "create_page", "edit_page"} if !equalNames(got, want) { t.Errorf("tools = %v, want %v", got, want) } } func toolNames(tools []*mcp.Tool) []string { names := make([]string, len(tools)) for i, tool := range tools { names[i] = tool.Name } return names } func equalNames(got, want []string) bool { got = slices.Clone(got) want = slices.Clone(want) slices.Sort(got) slices.Sort(want) return slices.Equal(got, want) } func TestSearchPagesTool(t *testing.T) { cs, fake := newTestSession(t, false) res := callTool(t, cs, "search_pages", map[string]any{"query": "mcp"}) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } if res.StructuredContent == nil { t.Error("StructuredContent = nil") } var out wikijs.SearchResults if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil { t.Fatalf("decode result: %v", err) } if out.TotalHits != 1 || len(out.Results) != 1 { t.Fatalf("result = %+v", out) } if !strings.HasSuffix(out.Results[0].URL, "/mcp") { t.Errorf("URL = %q", out.Results[0].URL) } req := fake.lastContaining(t, "query SearchPages") if _, ok := req.Variables["path"]; ok { t.Errorf("path variable present: %v", req.Variables["path"]) } } func TestSearchPagesValidation(t *testing.T) { cs, _ := newTestSession(t, false) tests := []struct { name string args map[string]any want string }{ {name: "empty query", args: map[string]any{"query": " "}, want: "query must not be empty"}, {name: "bad locale", args: map[string]any{"query": "x", "locale": "english"}, want: "invalid locale"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { res := callTool(t, cs, "search_pages", tt.args) if !res.IsError { t.Fatal("IsError = false, want true") } if got := toolText(t, res); !strings.Contains(got, tt.want) { t.Errorf("text = %q, want substring %q", got, tt.want) } }) } } func TestGetPageByIDTool(t *testing.T) { cs, _ := newTestSession(t, false) res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 42}) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } var page wikijs.Page if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil { t.Fatalf("decode page: %v", err) } if page.ID != 42 || page.Title != "Go" { t.Errorf("page = %+v", page) } if !strings.HasSuffix(page.URL, "/de/dev/go") { t.Errorf("URL = %q", page.URL) } } func TestGetPageByIDNotFound(t *testing.T) { cs, _ := newTestSession(t, false) res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 404}) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } if got := strings.TrimSpace(toolText(t, res)); got != "null" { t.Errorf("text = %q, want null", got) } } func TestGetPageByIDInvalid(t *testing.T) { cs, _ := newTestSession(t, false) res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 0}) if !res.IsError { t.Fatal("IsError = false, want true") } if got := toolText(t, res); !strings.Contains(got, "positive") { t.Errorf("text = %q", got) } } func TestGetPageByPathTool(t *testing.T) { cs, _ := newTestSession(t, false) res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start", "locale": "de-DE"}) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } var page wikijs.Page if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil { t.Fatalf("decode page: %v", err) } if page.ID != 7 { t.Errorf("page = %+v", page) } if !strings.HasSuffix(page.URL, "/de-DE/start") { t.Errorf("URL = %q", page.URL) } } func TestGetPageByPathRequiresLocale(t *testing.T) { cs, _ := newTestSession(t, false) res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start"}) if !res.IsError { t.Fatal("IsError = false, want true") } } func TestListPagesTool(t *testing.T) { cs, fake := newTestSession(t, false) res := callTool(t, cs, "get_all_pages", map[string]any{ "limit": 10, "locale": "de", "tags": []string{"go"}, }) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } var pages []wikijs.PageListItem if err := json.Unmarshal([]byte(toolText(t, res)), &pages); err != nil { t.Fatalf("decode pages: %v", err) } if len(pages) != 1 || pages[0].ID != 1 { t.Errorf("pages = %+v", pages) } req := fake.lastContaining(t, "query GetAllPages") if got := req.Variables["limit"]; got != float64(10) { t.Errorf("variables[limit] = %v", got) } if got := req.Variables["locale"]; got != "de" { t.Errorf("variables[locale] = %v", got) } } func TestListPagesValidation(t *testing.T) { cs, _ := newTestSession(t, false) res := callTool(t, cs, "get_all_pages", map[string]any{"limit": 0}) if !res.IsError { t.Fatal("IsError = false, want true") } if got := toolText(t, res); !strings.Contains(got, "limit must be a positive integer") { t.Errorf("text = %q", got) } } func TestCreatePageDefaults(t *testing.T) { cs, fake := newTestSession(t, true) res := callTool(t, cs, "create_page", map[string]any{ "title": "New", "content": "body", "description": "desc", "path": "/new", }) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } var out mutationOutput if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil { t.Fatalf("decode output: %v", err) } if !out.Succeeded || out.Message != "Page created" { t.Errorf("output = %+v", out) } req := fake.lastContaining(t, "mutation CreatePage") want := map[string]any{ "editor": "markdown", "isPublished": true, "isPrivate": false, "locale": "en", } for key, value := range want { if got := req.Variables[key]; got != value { t.Errorf("variables[%s] = %v, want %v", key, got, value) } } tags, ok := req.Variables["tags"].([]any) if !ok || len(tags) != 0 { t.Errorf("variables[tags] = %v, want []", req.Variables["tags"]) } } func TestCreatePageFailure(t *testing.T) { cs, _ := newTestSession(t, true) res := callTool(t, cs, "create_page", map[string]any{ "title": "fail", "content": "body", "description": "desc", "path": "/new", }) if !res.IsError { t.Fatal("IsError = false, want true") } text := toolText(t, res) if !strings.Contains(text, "failed to create page") || !strings.Contains(text, "Nope") || !strings.Contains(text, "6001") { t.Errorf("text = %q", text) } } func TestEditPageDefaults(t *testing.T) { cs, fake := newTestSession(t, true) res := callTool(t, cs, "edit_page", map[string]any{ "id": 7, "title": "Updated", "content": "body", "description": "desc", "path": "/start", }) if res.IsError { t.Fatalf("IsError = true: %s", toolText(t, res)) } var out mutationOutput if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil { t.Fatalf("decode output: %v", err) } if !out.Succeeded || out.Message != "Page updated" { t.Errorf("output = %+v", out) } req := fake.lastContaining(t, "mutation UpdatePage") if got := req.Variables["id"]; got != float64(7) { t.Errorf("variables[id] = %v", got) } if got := req.Variables["editor"]; got != "markdown" { t.Errorf("variables[editor] = %v", got) } } func TestEditPageNotFound(t *testing.T) { cs, _ := newTestSession(t, true) res := callTool(t, cs, "edit_page", map[string]any{ "id": 404, "title": "Updated", "content": "body", "description": "desc", "path": "/start", }) if !res.IsError { t.Fatal("IsError = false, want true") } text := toolText(t, res) if !strings.Contains(text, "failed to update page") || !strings.Contains(text, "Page not found") { t.Errorf("text = %q", text) } } func TestEditToolsHiddenWithoutEditMode(t *testing.T) { cs, _ := newTestSession(t, false) _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ Name: "create_page", Arguments: map[string]any{"title": "x", "content": "", "description": "", "path": "/x"}, }) if err == nil { t.Fatal("CallTool(create_page) error = nil, want tool not found") } } func TestToolOutputIsJSON(t *testing.T) { cs, _ := newTestSession(t, false) res := callTool(t, cs, "get_all_pages", nil) var decoded any if err := json.Unmarshal([]byte(toolText(t, res)), &decoded); err != nil { t.Fatalf("output is not JSON: %v", err) } }