tools_test.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  1. package server
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "net/http/httptest"
  9. "slices"
  10. "strings"
  11. "sync"
  12. "testing"
  13. "gogs.netdome.biz/paul/go-wikijs-mcp/internal/wikijs"
  14. "github.com/modelcontextprotocol/go-sdk/mcp"
  15. )
  16. type capturedRequest struct {
  17. Query string `json:"query"`
  18. Variables map[string]any `json:"variables"`
  19. }
  20. type fakeWiki struct {
  21. t *testing.T
  22. mu sync.Mutex
  23. requests []capturedRequest
  24. emptyList bool
  25. uploadCalled bool
  26. }
  27. func (f *fakeWiki) handle(w http.ResponseWriter, r *http.Request) {
  28. f.t.Helper()
  29. if got, want := r.Header.Get("Authorization"), "Bearer test-key"; got != want {
  30. f.t.Errorf("Authorization = %q, want %q", got, want)
  31. }
  32. // The upload endpoint is multipart, not GraphQL JSON.
  33. if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
  34. f.mu.Lock()
  35. f.uploadCalled = true
  36. f.mu.Unlock()
  37. _, _ = w.Write([]byte("ok"))
  38. return
  39. }
  40. var req capturedRequest
  41. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  42. f.t.Errorf("decode request: %v", err)
  43. http.Error(w, "bad request", http.StatusBadRequest)
  44. return
  45. }
  46. f.mu.Lock()
  47. f.requests = append(f.requests, req)
  48. f.mu.Unlock()
  49. w.Header().Set("Content-Type", "application/json")
  50. switch {
  51. case strings.Contains(req.Query, "query SearchPages"):
  52. fmt.Fprint(w, `{"data":{"pages":{"search":{"results":[
  53. {"title":"MCP","description":"protocol","path":"/mcp","locale":"en"}
  54. ],"suggestions":["mcp"],"totalHits":1}}}}`)
  55. case strings.Contains(req.Query, "query GetPageById"):
  56. id, _ := req.Variables["id"].(float64)
  57. if id == 404 {
  58. fmt.Fprint(w, `{"data":{"pages":{"single":null}}}`)
  59. return
  60. }
  61. fmt.Fprintf(w, `{"data":{"pages":{"single":{
  62. "id":%d,"path":"/dev/go","title":"Go","description":"desc",
  63. "tags":[{"tag":"go","title":"Go"}],"content":"body",
  64. "createdAt":"c","updatedAt":"u","locale":"de",
  65. "isPublished":true,"isPrivate":false}}}}`, int(id))
  66. case strings.Contains(req.Query, "query GetPageByPath"):
  67. path, _ := req.Variables["path"].(string)
  68. locale, _ := req.Variables["locale"].(string)
  69. fmt.Fprintf(w, `{"data":{"pages":{"singleByPath":{
  70. "id":7,"path":%q,"title":"Start","description":"desc",
  71. "tags":[],"content":"body","createdAt":"c","updatedAt":"u","locale":%q}}}}`, path, locale)
  72. case strings.Contains(req.Query, "query GetAllPages"):
  73. if f.emptyList {
  74. fmt.Fprint(w, `{"data":{"pages":{"list":[]}}}`)
  75. return
  76. }
  77. fmt.Fprint(w, `{"data":{"pages":{"list":[
  78. {"id":1,"path":"/a","locale":"de","title":"A","description":"a",
  79. "createdAt":"c","updatedAt":"u","tags":["go"]}
  80. ]}}}`)
  81. case strings.Contains(req.Query, "mutation CreatePage"):
  82. title, _ := req.Variables["title"].(string)
  83. if title == "fail" {
  84. fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{
  85. "succeeded":false,"errorCode":6001,"slug":"","message":"Nope"}}}}}`)
  86. return
  87. }
  88. fmt.Fprint(w, `{"data":{"pages":{"create":{"responseResult":{
  89. "succeeded":true,"errorCode":0,"slug":"new","message":"Page created"}}}}}`)
  90. case strings.Contains(req.Query, "mutation UpdatePage"):
  91. id, _ := req.Variables["id"].(float64)
  92. if id == 404 {
  93. fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
  94. "succeeded":false,"errorCode":6001,"slug":"","message":"Page not found"}}}}}`)
  95. return
  96. }
  97. fmt.Fprint(w, `{"data":{"pages":{"update":{"responseResult":{
  98. "succeeded":true,"errorCode":0,"slug":"","message":"Page updated"}}}}}`)
  99. case strings.Contains(req.Query, "query SiteConfig"):
  100. fmt.Fprint(w, `{"data":{"site":{"config":{"uploadMaxFileSize":5242880}}}}`)
  101. case strings.Contains(req.Query, "query ListAssetFolders"):
  102. parentID, _ := req.Variables["parentFolderId"].(float64)
  103. if parentID == 0 {
  104. fmt.Fprint(w, `{"data":{"assets":{"folders":[{"id":5,"slug":"images","name":"Images"}]}}}`)
  105. return
  106. }
  107. fmt.Fprint(w, `{"data":{"assets":{"folders":[{"id":9,"slug":"screens","name":"Screens"}]}}}`)
  108. case strings.Contains(req.Query, "query ListAssets"):
  109. fmt.Fprint(w, `{"data":{"assets":{"list":[
  110. {"id":1,"filename":"shot.png","ext":"png","kind":"IMAGE","mime":"image/png",
  111. "fileSize":12,"createdAt":"c","updatedAt":"u",
  112. "folder":{"id":9,"slug":"screens","name":"Screens"}},
  113. {"id":2,"filename":"manual.pdf","ext":"pdf","kind":"BINARY","mime":"application/pdf",
  114. "fileSize":2048,"createdAt":"c","updatedAt":"u",
  115. "folder":{"id":9,"slug":"screens","name":"Screens"}}
  116. ]}}}`)
  117. case strings.Contains(req.Query, "mutation CreateAssetFolder"):
  118. slug, _ := req.Variables["slug"].(string)
  119. if slug == "fail" {
  120. fmt.Fprint(w, `{"data":{"assets":{"createFolder":{"responseResult":{
  121. "succeeded":false,"errorCode":2002,"slug":"","message":"AssetFolderExists"}}}}}`)
  122. return
  123. }
  124. fmt.Fprint(w, `{"data":{"assets":{"createFolder":{"responseResult":{
  125. "succeeded":true,"errorCode":0,"slug":"new-folder","message":"Folder created"}}}}}`)
  126. default:
  127. f.t.Errorf("unexpected query: %s", req.Query)
  128. http.Error(w, "unknown operation", http.StatusBadRequest)
  129. }
  130. }
  131. func (f *fakeWiki) lastContaining(t *testing.T, fragment string) capturedRequest {
  132. t.Helper()
  133. f.mu.Lock()
  134. defer f.mu.Unlock()
  135. for i := len(f.requests) - 1; i >= 0; i-- {
  136. if strings.Contains(f.requests[i].Query, fragment) {
  137. return f.requests[i]
  138. }
  139. }
  140. t.Fatalf("no request containing %q", fragment)
  141. return capturedRequest{}
  142. }
  143. func newTestSession(t *testing.T, enableEdit bool) (*mcp.ClientSession, *fakeWiki) {
  144. t.Helper()
  145. fake := &fakeWiki{t: t}
  146. wikiSrv := httptest.NewServer(http.HandlerFunc(fake.handle))
  147. t.Cleanup(wikiSrv.Close)
  148. wikiClient, err := wikijs.New(wikiSrv.URL, "test-key")
  149. if err != nil {
  150. t.Fatalf("wikijs.New() error = %v", err)
  151. }
  152. srv := New(wikiClient, Options{
  153. Version: "test",
  154. EnableEdit: enableEdit,
  155. Logger: slog.New(slog.DiscardHandler),
  156. })
  157. st, ct := mcp.NewInMemoryTransports()
  158. ctx := context.Background()
  159. ss, err := srv.Connect(ctx, st, nil)
  160. if err != nil {
  161. t.Fatalf("server connect: %v", err)
  162. }
  163. t.Cleanup(func() { _ = ss.Close() })
  164. client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil)
  165. cs, err := client.Connect(ctx, ct, nil)
  166. if err != nil {
  167. t.Fatalf("client connect: %v", err)
  168. }
  169. t.Cleanup(func() { _ = cs.Close() })
  170. return cs, fake
  171. }
  172. func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult {
  173. t.Helper()
  174. res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
  175. if err != nil {
  176. t.Fatalf("CallTool(%s) error = %v", name, err)
  177. }
  178. return res
  179. }
  180. func toolText(t *testing.T, res *mcp.CallToolResult) string {
  181. t.Helper()
  182. if len(res.Content) == 0 {
  183. t.Fatal("tool result has no content")
  184. }
  185. text, ok := res.Content[0].(*mcp.TextContent)
  186. if !ok {
  187. t.Fatalf("content type = %T, want *mcp.TextContent", res.Content[0])
  188. }
  189. return text.Text
  190. }
  191. func TestToolListWithoutEdit(t *testing.T) {
  192. cs, _ := newTestSession(t, false)
  193. res, err := cs.ListTools(context.Background(), nil)
  194. if err != nil {
  195. t.Fatalf("ListTools() error = %v", err)
  196. }
  197. got := toolNames(res.Tools)
  198. want := []string{"search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages", "list_asset_folders", "list_assets"}
  199. if !equalNames(got, want) {
  200. t.Errorf("tools = %v, want %v", got, want)
  201. }
  202. }
  203. func TestToolListWithEdit(t *testing.T) {
  204. cs, _ := newTestSession(t, true)
  205. res, err := cs.ListTools(context.Background(), nil)
  206. if err != nil {
  207. t.Fatalf("ListTools() error = %v", err)
  208. }
  209. got := toolNames(res.Tools)
  210. want := []string{
  211. "search_pages", "get_page_by_id", "get_page_by_path", "get_all_pages",
  212. "list_asset_folders", "list_assets",
  213. "create_page", "edit_page", "upload_asset", "create_asset_folder",
  214. }
  215. if !equalNames(got, want) {
  216. t.Errorf("tools = %v, want %v", got, want)
  217. }
  218. }
  219. func toolNames(tools []*mcp.Tool) []string {
  220. names := make([]string, len(tools))
  221. for i, tool := range tools {
  222. names[i] = tool.Name
  223. }
  224. return names
  225. }
  226. func equalNames(got, want []string) bool {
  227. got = slices.Clone(got)
  228. want = slices.Clone(want)
  229. slices.Sort(got)
  230. slices.Sort(want)
  231. return slices.Equal(got, want)
  232. }
  233. func TestSearchPagesTool(t *testing.T) {
  234. cs, fake := newTestSession(t, false)
  235. res := callTool(t, cs, "search_pages", map[string]any{"query": "mcp"})
  236. if res.IsError {
  237. t.Fatalf("IsError = true: %s", toolText(t, res))
  238. }
  239. if res.StructuredContent == nil {
  240. t.Error("StructuredContent = nil")
  241. }
  242. var out wikijs.SearchResults
  243. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  244. t.Fatalf("decode result: %v", err)
  245. }
  246. if out.TotalHits != 1 || len(out.Results) != 1 {
  247. t.Fatalf("result = %+v", out)
  248. }
  249. if !strings.HasSuffix(out.Results[0].URL, "/mcp") {
  250. t.Errorf("URL = %q", out.Results[0].URL)
  251. }
  252. req := fake.lastContaining(t, "query SearchPages")
  253. if _, ok := req.Variables["path"]; ok {
  254. t.Errorf("path variable present: %v", req.Variables["path"])
  255. }
  256. }
  257. func TestSearchPagesValidation(t *testing.T) {
  258. cs, _ := newTestSession(t, false)
  259. tests := []struct {
  260. name string
  261. args map[string]any
  262. want string
  263. }{
  264. {name: "empty query", args: map[string]any{"query": " "}, want: "query must not be empty"},
  265. {name: "bad locale", args: map[string]any{"query": "x", "locale": "english"}, want: "invalid locale"},
  266. }
  267. for _, tt := range tests {
  268. t.Run(tt.name, func(t *testing.T) {
  269. res := callTool(t, cs, "search_pages", tt.args)
  270. if !res.IsError {
  271. t.Fatal("IsError = false, want true")
  272. }
  273. if got := toolText(t, res); !strings.Contains(got, tt.want) {
  274. t.Errorf("text = %q, want substring %q", got, tt.want)
  275. }
  276. })
  277. }
  278. }
  279. func TestGetPageByIDTool(t *testing.T) {
  280. cs, _ := newTestSession(t, false)
  281. res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 42})
  282. if res.IsError {
  283. t.Fatalf("IsError = true: %s", toolText(t, res))
  284. }
  285. var page wikijs.Page
  286. if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil {
  287. t.Fatalf("decode page: %v", err)
  288. }
  289. if page.ID != 42 || page.Title != "Go" {
  290. t.Errorf("page = %+v", page)
  291. }
  292. if !strings.HasSuffix(page.URL, "/de/dev/go") {
  293. t.Errorf("URL = %q", page.URL)
  294. }
  295. }
  296. func TestGetPageByIDNotFound(t *testing.T) {
  297. cs, _ := newTestSession(t, false)
  298. res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 404})
  299. if res.IsError {
  300. t.Fatalf("IsError = true: %s", toolText(t, res))
  301. }
  302. if got := strings.TrimSpace(toolText(t, res)); got != "null" {
  303. t.Errorf("text = %q, want null", got)
  304. }
  305. }
  306. func TestGetPageByIDInvalid(t *testing.T) {
  307. cs, _ := newTestSession(t, false)
  308. res := callTool(t, cs, "get_page_by_id", map[string]any{"id": 0})
  309. if !res.IsError {
  310. t.Fatal("IsError = false, want true")
  311. }
  312. if got := toolText(t, res); !strings.Contains(got, "positive") {
  313. t.Errorf("text = %q", got)
  314. }
  315. }
  316. func TestGetPageByPathTool(t *testing.T) {
  317. cs, _ := newTestSession(t, false)
  318. res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start", "locale": "de-DE"})
  319. if res.IsError {
  320. t.Fatalf("IsError = true: %s", toolText(t, res))
  321. }
  322. var page wikijs.Page
  323. if err := json.Unmarshal([]byte(toolText(t, res)), &page); err != nil {
  324. t.Fatalf("decode page: %v", err)
  325. }
  326. if page.ID != 7 {
  327. t.Errorf("page = %+v", page)
  328. }
  329. if !strings.HasSuffix(page.URL, "/de-DE/start") {
  330. t.Errorf("URL = %q", page.URL)
  331. }
  332. }
  333. func TestGetPageByPathRequiresLocale(t *testing.T) {
  334. cs, _ := newTestSession(t, false)
  335. res := callTool(t, cs, "get_page_by_path", map[string]any{"path": "/start"})
  336. if !res.IsError {
  337. t.Fatal("IsError = false, want true")
  338. }
  339. }
  340. func TestListPagesTool(t *testing.T) {
  341. cs, fake := newTestSession(t, false)
  342. res := callTool(t, cs, "get_all_pages", map[string]any{
  343. "limit": 10,
  344. "locale": "de",
  345. "tags": []string{"go"},
  346. })
  347. if res.IsError {
  348. t.Fatalf("IsError = true: %s", toolText(t, res))
  349. }
  350. var out struct {
  351. Pages []wikijs.PageListItem `json:"pages"`
  352. }
  353. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  354. t.Fatalf("decode pages: %v", err)
  355. }
  356. if len(out.Pages) != 1 || out.Pages[0].ID != 1 {
  357. t.Errorf("pages = %+v", out.Pages)
  358. }
  359. req := fake.lastContaining(t, "query GetAllPages")
  360. if got := req.Variables["limit"]; got != float64(10) {
  361. t.Errorf("variables[limit] = %v", got)
  362. }
  363. if got := req.Variables["locale"]; got != "de" {
  364. t.Errorf("variables[locale] = %v", got)
  365. }
  366. }
  367. func TestListPagesValidation(t *testing.T) {
  368. cs, _ := newTestSession(t, false)
  369. res := callTool(t, cs, "get_all_pages", map[string]any{"limit": 0})
  370. if !res.IsError {
  371. t.Fatal("IsError = false, want true")
  372. }
  373. if got := toolText(t, res); !strings.Contains(got, "limit must be a positive integer") {
  374. t.Errorf("text = %q", got)
  375. }
  376. }
  377. func TestCreatePageDefaults(t *testing.T) {
  378. cs, fake := newTestSession(t, true)
  379. res := callTool(t, cs, "create_page", map[string]any{
  380. "title": "New",
  381. "content": "body",
  382. "description": "desc",
  383. "path": "/new",
  384. })
  385. if res.IsError {
  386. t.Fatalf("IsError = true: %s", toolText(t, res))
  387. }
  388. var out mutationOutput
  389. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  390. t.Fatalf("decode output: %v", err)
  391. }
  392. if !out.Succeeded || out.Message != "Page created" {
  393. t.Errorf("output = %+v", out)
  394. }
  395. req := fake.lastContaining(t, "mutation CreatePage")
  396. want := map[string]any{
  397. "editor": "markdown",
  398. "isPublished": true,
  399. "isPrivate": false,
  400. "locale": "en",
  401. }
  402. for key, value := range want {
  403. if got := req.Variables[key]; got != value {
  404. t.Errorf("variables[%s] = %v, want %v", key, got, value)
  405. }
  406. }
  407. tags, ok := req.Variables["tags"].([]any)
  408. if !ok || len(tags) != 0 {
  409. t.Errorf("variables[tags] = %v, want []", req.Variables["tags"])
  410. }
  411. }
  412. func TestCreatePageFailure(t *testing.T) {
  413. cs, _ := newTestSession(t, true)
  414. res := callTool(t, cs, "create_page", map[string]any{
  415. "title": "fail",
  416. "content": "body",
  417. "description": "desc",
  418. "path": "/new",
  419. })
  420. if !res.IsError {
  421. t.Fatal("IsError = false, want true")
  422. }
  423. text := toolText(t, res)
  424. if !strings.Contains(text, "failed to create page") || !strings.Contains(text, "Nope") || !strings.Contains(text, "6001") {
  425. t.Errorf("text = %q", text)
  426. }
  427. }
  428. func TestEditPageDefaults(t *testing.T) {
  429. cs, fake := newTestSession(t, true)
  430. res := callTool(t, cs, "edit_page", map[string]any{
  431. "id": 7,
  432. "title": "Updated",
  433. "content": "body",
  434. "description": "desc",
  435. "path": "/start",
  436. })
  437. if res.IsError {
  438. t.Fatalf("IsError = true: %s", toolText(t, res))
  439. }
  440. var out mutationOutput
  441. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  442. t.Fatalf("decode output: %v", err)
  443. }
  444. if !out.Succeeded || out.Message != "Page updated" {
  445. t.Errorf("output = %+v", out)
  446. }
  447. req := fake.lastContaining(t, "mutation UpdatePage")
  448. if got := req.Variables["id"]; got != float64(7) {
  449. t.Errorf("variables[id] = %v", got)
  450. }
  451. if got := req.Variables["editor"]; got != "markdown" {
  452. t.Errorf("variables[editor] = %v", got)
  453. }
  454. }
  455. func TestEditPageNotFound(t *testing.T) {
  456. cs, _ := newTestSession(t, true)
  457. res := callTool(t, cs, "edit_page", map[string]any{
  458. "id": 404,
  459. "title": "Updated",
  460. "content": "body",
  461. "description": "desc",
  462. "path": "/start",
  463. })
  464. if !res.IsError {
  465. t.Fatal("IsError = false, want true")
  466. }
  467. text := toolText(t, res)
  468. if !strings.Contains(text, "failed to update page") || !strings.Contains(text, "Page not found") {
  469. t.Errorf("text = %q", text)
  470. }
  471. }
  472. func TestEditToolsHiddenWithoutEditMode(t *testing.T) {
  473. cs, _ := newTestSession(t, false)
  474. _, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
  475. Name: "create_page",
  476. Arguments: map[string]any{"title": "x", "content": "", "description": "", "path": "/x"},
  477. })
  478. if err == nil {
  479. t.Fatal("CallTool(create_page) error = nil, want tool not found")
  480. }
  481. }
  482. func TestToolOutputIsJSON(t *testing.T) {
  483. cs, _ := newTestSession(t, false)
  484. res := callTool(t, cs, "get_all_pages", nil)
  485. var decoded any
  486. if err := json.Unmarshal([]byte(toolText(t, res)), &decoded); err != nil {
  487. t.Fatalf("output is not JSON: %v", err)
  488. }
  489. }
  490. func TestUploadAssetTool(t *testing.T) {
  491. cs, fake := newTestSession(t, true)
  492. res := callTool(t, cs, "upload_asset", map[string]any{
  493. "filename": "notes.txt",
  494. "content": "hello",
  495. "folder": "images",
  496. "contentB64": "",
  497. })
  498. if res.IsError {
  499. t.Fatalf("IsError = true: %s", toolText(t, res))
  500. }
  501. var out uploadAssetOutput
  502. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  503. t.Fatalf("decode result: %v", err)
  504. }
  505. if !out.Succeeded {
  506. t.Error("Succeeded = false, want true")
  507. }
  508. fake.mu.Lock()
  509. uploaded := fake.uploadCalled
  510. fake.mu.Unlock()
  511. if !uploaded {
  512. t.Error("upload never reached the fake WikiJS server")
  513. }
  514. }
  515. func TestUploadAssetToolValidation(t *testing.T) {
  516. cs, _ := newTestSession(t, true)
  517. tests := []struct {
  518. name string
  519. args map[string]any
  520. want string
  521. }{
  522. {name: "no content", args: map[string]any{"filename": "a.txt", "content": "", "contentB64": ""}, want: "exactly one"},
  523. {name: "both contents", args: map[string]any{"filename": "a.txt", "content": "a", "contentB64": "YQ=="}, want: "exactly one"},
  524. {name: "bad folder", args: map[string]any{"filename": "a.txt", "content": "a", "folder": "im ages/"}, want: "invalid asset folder"},
  525. }
  526. for _, tc := range tests {
  527. t.Run(tc.name, func(t *testing.T) {
  528. res := callTool(t, cs, "upload_asset", tc.args)
  529. if !res.IsError {
  530. t.Fatalf("IsError = false, want error containing %q", tc.want)
  531. }
  532. if !strings.Contains(toolText(t, res), tc.want) {
  533. t.Errorf("error text %q does not contain %q", toolText(t, res), tc.want)
  534. }
  535. })
  536. }
  537. }
  538. func TestListAssetsTool(t *testing.T) {
  539. cs, fake := newTestSession(t, false)
  540. res := callTool(t, cs, "list_assets", map[string]any{"folder": "images/screens"})
  541. if res.IsError {
  542. t.Fatalf("IsError = true: %s", toolText(t, res))
  543. }
  544. var out listAssetsOutput
  545. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  546. t.Fatalf("decode result: %v", err)
  547. }
  548. if len(out.Assets) != 2 {
  549. t.Fatalf("assets = %d, want 2", len(out.Assets))
  550. }
  551. if !strings.HasSuffix(out.Assets[0].URL, "/images/screens/shot.png") {
  552. t.Errorf("URL = %q", out.Assets[0].URL)
  553. }
  554. if out.Assets[0].Markdown == "" {
  555. t.Error("Markdown = empty, want a markdown reference")
  556. }
  557. req := fake.lastContaining(t, "query ListAssets")
  558. if got := req.Variables["folderId"]; got != float64(9) {
  559. t.Errorf("folderId = %v, want 9 (resolved from images/screens)", got)
  560. }
  561. }
  562. func TestListAssetsToolKindFilter(t *testing.T) {
  563. cs, _ := newTestSession(t, false)
  564. res := callTool(t, cs, "list_assets", map[string]any{"folder": "images/screens", "kind": "IMAGE"})
  565. if res.IsError {
  566. t.Fatalf("IsError = true: %s", toolText(t, res))
  567. }
  568. var out listAssetsOutput
  569. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  570. t.Fatalf("decode result: %v", err)
  571. }
  572. if len(out.Assets) != 1 || out.Assets[0].Kind != "IMAGE" {
  573. t.Fatalf("assets = %+v, want only the IMAGE asset", out.Assets)
  574. }
  575. }
  576. func TestListAssetsToolInvalidKind(t *testing.T) {
  577. cs, _ := newTestSession(t, false)
  578. res := callTool(t, cs, "list_assets", map[string]any{"kind": "VIDEO"})
  579. if !res.IsError {
  580. t.Fatal("IsError = false, want error")
  581. }
  582. if !strings.Contains(toolText(t, res), "invalid kind") {
  583. t.Errorf("error = %q", toolText(t, res))
  584. }
  585. }
  586. func TestListAssetFoldersTool(t *testing.T) {
  587. cs, fake := newTestSession(t, false)
  588. res := callTool(t, cs, "list_asset_folders", map[string]any{"parentFolder": "images"})
  589. if res.IsError {
  590. t.Fatalf("IsError = true: %s", toolText(t, res))
  591. }
  592. var out listAssetFoldersOutput
  593. if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
  594. t.Fatalf("decode result: %v", err)
  595. }
  596. if len(out.Folders) != 1 || out.Folders[0].Slug != "screens" {
  597. t.Fatalf("folders = %+v", out.Folders)
  598. }
  599. if got, want := out.Folders[0].Path, "images/screens"; got != want {
  600. t.Errorf("Path = %q, want %q", got, want)
  601. }
  602. req := fake.lastContaining(t, "query ListAssetFolders")
  603. if got := req.Variables["parentFolderId"]; got != float64(5) {
  604. t.Errorf("parentFolderId = %v, want 5 (resolved from images)", got)
  605. }
  606. }
  607. func TestCreateAssetFolderTool(t *testing.T) {
  608. cs, fake := newTestSession(t, true)
  609. res := callTool(t, cs, "create_asset_folder", map[string]any{"name": "Vacation 2025"})
  610. if res.IsError {
  611. t.Fatalf("IsError = true: %s", toolText(t, res))
  612. }
  613. req := fake.lastContaining(t, "mutation CreateAssetFolder")
  614. if got, want := req.Variables["slug"], "vacation-2025"; got != want {
  615. t.Errorf("slug = %v, want %q", got, want)
  616. }
  617. }
  618. func TestCreateAssetFolderToolFailure(t *testing.T) {
  619. cs, _ := newTestSession(t, true)
  620. res := callTool(t, cs, "create_asset_folder", map[string]any{"name": "fail"})
  621. if !res.IsError {
  622. t.Fatal("IsError = false, want error")
  623. }
  624. if !strings.Contains(toolText(t, res), "AssetFolderExists") {
  625. t.Errorf("error = %q", toolText(t, res))
  626. }
  627. }
  628. func TestCreateAssetFolderToolValidation(t *testing.T) {
  629. cs, _ := newTestSession(t, true)
  630. tests := []struct {
  631. name string
  632. args map[string]any
  633. want string
  634. }{
  635. {name: "empty name", args: map[string]any{"name": " "}, want: "name must not be empty"},
  636. {name: "bad parent", args: map[string]any{"name": "x", "parentFolder": "im ages"}, want: "invalid asset folder"},
  637. }
  638. for _, tc := range tests {
  639. t.Run(tc.name, func(t *testing.T) {
  640. res := callTool(t, cs, "create_asset_folder", tc.args)
  641. if !res.IsError {
  642. t.Fatalf("IsError = false, want error containing %q", tc.want)
  643. }
  644. if !strings.Contains(toolText(t, res), tc.want) {
  645. t.Errorf("error text %q does not contain %q", toolText(t, res), tc.want)
  646. }
  647. })
  648. }
  649. }