client_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. package wikijs
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. )
  13. func newTestClient(t *testing.T, handler http.HandlerFunc) *Client {
  14. t.Helper()
  15. srv := httptest.NewServer(handler)
  16. t.Cleanup(srv.Close)
  17. client, err := New(srv.URL, "test-key", WithUserAgent("wikijs-mcp/test"))
  18. if err != nil {
  19. t.Fatalf("New() error = %v", err)
  20. }
  21. return client
  22. }
  23. func decodeRequest(t *testing.T, r *http.Request) graphqlRequest {
  24. t.Helper()
  25. var req graphqlRequest
  26. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  27. t.Errorf("decode request: %v", err)
  28. }
  29. return req
  30. }
  31. func writeJSON(t *testing.T, w http.ResponseWriter, status int, body string) {
  32. t.Helper()
  33. w.Header().Set("Content-Type", "application/json")
  34. w.WriteHeader(status)
  35. if _, err := w.Write([]byte(body)); err != nil {
  36. t.Errorf("write response: %v", err)
  37. }
  38. }
  39. func TestNewNormalizesURL(t *testing.T) {
  40. tests := []struct {
  41. base string
  42. wantBase string
  43. wantEndpoint string
  44. }{
  45. {base: "https://wiki.example.com", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"},
  46. {base: "https://wiki.example.com/", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"},
  47. {base: "https://wiki.example.com/graphql", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"},
  48. {base: "https://wiki.example.com/graphql/", wantBase: "https://wiki.example.com", wantEndpoint: "https://wiki.example.com/graphql"},
  49. {base: "https://wiki.example.com/wiki", wantBase: "https://wiki.example.com/wiki", wantEndpoint: "https://wiki.example.com/wiki/graphql"},
  50. }
  51. for _, tt := range tests {
  52. t.Run(tt.base, func(t *testing.T) {
  53. c, err := New(tt.base, "key")
  54. if err != nil {
  55. t.Fatalf("New() error = %v", err)
  56. }
  57. if c.baseURL != tt.wantBase {
  58. t.Errorf("baseURL = %q, want %q", c.baseURL, tt.wantBase)
  59. }
  60. if c.endpoint != tt.wantEndpoint {
  61. t.Errorf("endpoint = %q, want %q", c.endpoint, tt.wantEndpoint)
  62. }
  63. })
  64. }
  65. }
  66. func TestNewRejectsInvalidURL(t *testing.T) {
  67. for _, raw := range []string{"", "wiki.example.com", "ftp://wiki.example.com", "/relative", "https://wiki.example.com/?a=1", "https://wiki.example.com/#x"} {
  68. if _, err := New(raw, "key"); err == nil {
  69. t.Errorf("New(%q) error = nil, want error", raw)
  70. }
  71. }
  72. }
  73. func TestPageByID(t *testing.T) {
  74. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  75. if got, want := r.Header.Get("Authorization"), "Bearer test-key"; got != want {
  76. t.Errorf("Authorization = %q, want %q", got, want)
  77. }
  78. if got, want := r.Header.Get("User-Agent"), "wikijs-mcp/test"; got != want {
  79. t.Errorf("User-Agent = %q, want %q", got, want)
  80. }
  81. if got, want := r.Header.Get("Content-Type"), "application/json"; got != want {
  82. t.Errorf("Content-Type = %q, want %q", got, want)
  83. }
  84. req := decodeRequest(t, r)
  85. if !strings.Contains(req.Query, "single(id: $id)") {
  86. t.Errorf("query does not contain single query: %s", req.Query)
  87. }
  88. if got := req.Variables["id"]; got != float64(42) {
  89. t.Errorf("variables[id] = %v, want 42", got)
  90. }
  91. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":{
  92. "id":42,"path":"/dev/go","title":"Go","description":"desc",
  93. "tags":[{"tag":"go","title":"Go"}],"content":"body",
  94. "createdAt":"2025-01-01T00:00:00Z","updatedAt":"2025-01-02T00:00:00Z",
  95. "locale":"de"}}}}`)
  96. })
  97. page, err := client.PageByID(context.Background(), 42)
  98. if err != nil {
  99. t.Fatalf("PageByID() error = %v", err)
  100. }
  101. if page == nil {
  102. t.Fatal("PageByID() = nil, want page")
  103. }
  104. if page.ID != 42 || page.Title != "Go" {
  105. t.Errorf("page = %+v", page)
  106. }
  107. if got, want := page.URL, client.baseURL+"/de/dev/go"; got != want {
  108. t.Errorf("URL = %q, want %q", got, want)
  109. }
  110. if len(page.Tags) != 1 || page.Tags[0].Tag != "go" {
  111. t.Errorf("Tags = %+v", page.Tags)
  112. }
  113. }
  114. func TestPageByIDEnglishHasNoLocalePrefix(t *testing.T) {
  115. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  116. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":{
  117. "id":1,"path":"/start","title":"Start","description":"",
  118. "tags":null,"content":"","createdAt":"","updatedAt":"","locale":"en"}}}}`)
  119. })
  120. page, err := client.PageByID(context.Background(), 1)
  121. if err != nil {
  122. t.Fatalf("PageByID() error = %v", err)
  123. }
  124. if got, want := page.URL, client.baseURL+"/start"; got != want {
  125. t.Errorf("URL = %q, want %q", got, want)
  126. }
  127. if page.Tags == nil {
  128. t.Error("Tags = nil, want empty slice")
  129. }
  130. }
  131. func TestPageByIDNotFound(t *testing.T) {
  132. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  133. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"single":null}}}`)
  134. })
  135. page, err := client.PageByID(context.Background(), 99)
  136. if err != nil {
  137. t.Fatalf("PageByID() error = %v", err)
  138. }
  139. if page != nil {
  140. t.Errorf("PageByID() = %+v, want nil", page)
  141. }
  142. }
  143. func TestPageByPath(t *testing.T) {
  144. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  145. req := decodeRequest(t, r)
  146. if !strings.Contains(req.Query, "singleByPath") {
  147. t.Errorf("query = %s", req.Query)
  148. }
  149. if got := req.Variables["path"]; got != "/start" {
  150. t.Errorf("variables[path] = %v", got)
  151. }
  152. if got := req.Variables["locale"]; got != "de-DE" {
  153. t.Errorf("variables[locale] = %v", got)
  154. }
  155. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"singleByPath":{
  156. "id":7,"path":"/start","title":"Start","description":"desc",
  157. "tags":[],"content":"body","createdAt":"c","updatedAt":"u","locale":"de-DE"}}}}`)
  158. })
  159. page, err := client.PageByPath(context.Background(), "/start", "de-DE")
  160. if err != nil {
  161. t.Fatalf("PageByPath() error = %v", err)
  162. }
  163. if got, want := page.URL, client.baseURL+"/de-DE/start"; got != want {
  164. t.Errorf("URL = %q, want %q", got, want)
  165. }
  166. }
  167. func TestSearchPages(t *testing.T) {
  168. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  169. req := decodeRequest(t, r)
  170. if got := req.Variables["query"]; got != "mcp" {
  171. t.Errorf("variables[query] = %v", got)
  172. }
  173. if got := req.Variables["path"]; got != "/dev" {
  174. t.Errorf("variables[path] = %v", got)
  175. }
  176. if _, ok := req.Variables["locale"]; ok {
  177. t.Errorf("variables[locale] = %v, want omitted", req.Variables["locale"])
  178. }
  179. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"search":{
  180. "results":[
  181. {"title":"A","description":"a","path":"/a","locale":"en"},
  182. null,
  183. {"title":"B","description":"b","path":"/b","locale":"de"}
  184. ],
  185. "suggestions":["s1"],"totalHits":3}}}}`)
  186. })
  187. res, err := client.SearchPages(context.Background(), "mcp", "/dev", "")
  188. if err != nil {
  189. t.Fatalf("SearchPages() error = %v", err)
  190. }
  191. if got, want := res.TotalHits, 3; got != want {
  192. t.Errorf("TotalHits = %d, want %d", got, want)
  193. }
  194. if len(res.Results) != 2 {
  195. t.Fatalf("Results = %d, want 2", len(res.Results))
  196. }
  197. if got, want := res.Results[0].URL, client.baseURL+"/a"; got != want {
  198. t.Errorf("URL = %q, want %q", got, want)
  199. }
  200. if got, want := res.Results[1].URL, client.baseURL+"/de/b"; got != want {
  201. t.Errorf("URL = %q, want %q", got, want)
  202. }
  203. if len(res.Suggestions) != 1 || res.Suggestions[0] != "s1" {
  204. t.Errorf("Suggestions = %v", res.Suggestions)
  205. }
  206. }
  207. func TestSearchPagesEmpty(t *testing.T) {
  208. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  209. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"search":null}}}`)
  210. })
  211. res, err := client.SearchPages(context.Background(), "nothing", "", "")
  212. if err != nil {
  213. t.Fatalf("SearchPages() error = %v", err)
  214. }
  215. if res.Results == nil || res.Suggestions == nil {
  216. t.Errorf("empty slices must not be nil: %+v", res)
  217. }
  218. if res.TotalHits != 0 {
  219. t.Errorf("TotalHits = %d, want 0", res.TotalHits)
  220. }
  221. }
  222. func TestListPages(t *testing.T) {
  223. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  224. req := decodeRequest(t, r)
  225. if got := req.Variables["limit"]; got != float64(10) {
  226. t.Errorf("variables[limit] = %v", got)
  227. }
  228. if got := req.Variables["locale"]; got != "de" {
  229. t.Errorf("variables[locale] = %v", got)
  230. }
  231. tags, ok := req.Variables["tags"].([]any)
  232. if !ok || len(tags) != 2 || tags[0] != "go" {
  233. t.Errorf("variables[tags] = %v", req.Variables["tags"])
  234. }
  235. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"list":[
  236. {"id":1,"path":"/a","locale":"de","title":"A","description":"a",
  237. "createdAt":"c","updatedAt":"u","tags":["go"]},
  238. null,
  239. {"id":2,"path":"/b","locale":"en","title":"B","description":"b",
  240. "createdAt":"c","updatedAt":"u","tags":null}
  241. ]}}}`)
  242. })
  243. limit := 10
  244. pages, err := client.ListPages(context.Background(), ListOptions{
  245. Limit: &limit,
  246. Locale: "de",
  247. Tags: []string{"go", "mcp"},
  248. })
  249. if err != nil {
  250. t.Fatalf("ListPages() error = %v", err)
  251. }
  252. if len(pages) != 2 {
  253. t.Fatalf("ListPages() returned %d pages, want 2", len(pages))
  254. }
  255. if got, want := pages[0].URL, client.baseURL+"/de/a"; got != want {
  256. t.Errorf("URL = %q, want %q", got, want)
  257. }
  258. if pages[1].Tags == nil {
  259. t.Error("Tags = nil, want empty slice")
  260. }
  261. }
  262. func TestListPagesOmitsEmptyFilters(t *testing.T) {
  263. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  264. req := decodeRequest(t, r)
  265. for _, key := range []string{"limit", "locale", "tags"} {
  266. if _, ok := req.Variables[key]; ok {
  267. t.Errorf("variables[%s] present, want omitted", key)
  268. }
  269. }
  270. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"list":[]}}}`)
  271. })
  272. if _, err := client.ListPages(context.Background(), ListOptions{}); err != nil {
  273. t.Fatalf("ListPages() error = %v", err)
  274. }
  275. }
  276. func TestCreatePage(t *testing.T) {
  277. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  278. req := decodeRequest(t, r)
  279. if !strings.Contains(req.Query, "mutation CreatePage") {
  280. t.Errorf("query = %s", req.Query)
  281. }
  282. if got := req.Variables["tags"].([]any); len(got) != 1 || got[0] != "go" {
  283. t.Errorf("variables[tags] = %v", req.Variables["tags"])
  284. }
  285. if got := req.Variables["isPublished"]; got != true {
  286. t.Errorf("variables[isPublished] = %v", got)
  287. }
  288. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"create":{"responseResult":{
  289. "succeeded":true,"errorCode":0,"slug":"new-page","message":"Page created"}}}}}`)
  290. })
  291. res, err := client.CreatePage(context.Background(), CreatePageInput{
  292. Title: "New",
  293. Content: "body",
  294. Description: "desc",
  295. Path: "new-page",
  296. Editor: "markdown",
  297. IsPublished: true,
  298. Locale: "en",
  299. Tags: []string{"go"},
  300. })
  301. if err != nil {
  302. t.Fatalf("CreatePage() error = %v", err)
  303. }
  304. if !res.Succeeded || res.Message != "Page created" || res.Slug != "new-page" {
  305. t.Errorf("result = %+v", res)
  306. }
  307. }
  308. func TestUpdatePageFailureIsReturned(t *testing.T) {
  309. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  310. writeJSON(t, w, http.StatusOK, `{"data":{"pages":{"update":{"responseResult":{
  311. "succeeded":false,"errorCode":6001,"slug":"","message":"Page not found"}}}}}`)
  312. })
  313. res, err := client.UpdatePage(context.Background(), UpdatePageInput{ID: 1})
  314. if err != nil {
  315. t.Fatalf("UpdatePage() error = %v", err)
  316. }
  317. if res.Succeeded {
  318. t.Error("Succeeded = true, want false")
  319. }
  320. if res.ErrorCode != 6001 || res.Message != "Page not found" {
  321. t.Errorf("result = %+v", res)
  322. }
  323. }
  324. func TestGraphQLErrors(t *testing.T) {
  325. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  326. writeJSON(t, w, http.StatusOK, `{"errors":[{"message":"Unauthorized"},{"message":"Forbidden"}]}`)
  327. })
  328. _, err := client.PageByID(context.Background(), 1)
  329. if err == nil {
  330. t.Fatal("PageByID() error = nil, want error")
  331. }
  332. var apiErr *APIError
  333. if !errors.As(err, &apiErr) {
  334. t.Fatalf("error type = %T, want *APIError", err)
  335. }
  336. if got := apiErr.Error(); !strings.Contains(got, "Unauthorized") || !strings.Contains(got, "Forbidden") {
  337. t.Errorf("error = %q", got)
  338. }
  339. }
  340. func TestHTTPError(t *testing.T) {
  341. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  342. http.Error(w, "server exploded", http.StatusInternalServerError)
  343. })
  344. _, err := client.PageByID(context.Background(), 1)
  345. if err == nil {
  346. t.Fatal("PageByID() error = nil, want error")
  347. }
  348. var apiErr *APIError
  349. if !errors.As(err, &apiErr) {
  350. t.Fatalf("error type = %T, want *APIError", err)
  351. }
  352. if apiErr.Status != http.StatusInternalServerError {
  353. t.Errorf("Status = %d, want 500", apiErr.Status)
  354. }
  355. if !strings.Contains(err.Error(), "server exploded") {
  356. t.Errorf("error = %q", err)
  357. }
  358. }
  359. func TestMissingData(t *testing.T) {
  360. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  361. writeJSON(t, w, http.StatusOK, `{"data":null}`)
  362. })
  363. _, err := client.PageByID(context.Background(), 1)
  364. if err == nil {
  365. t.Fatal("PageByID() error = nil, want error")
  366. }
  367. }
  368. func TestContextCancellation(t *testing.T) {
  369. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  370. t.Error("handler must not be called")
  371. })
  372. ctx, cancel := context.WithCancel(context.Background())
  373. cancel()
  374. if _, err := client.PageByID(ctx, 1); err == nil {
  375. t.Fatal("PageByID() error = nil, want context error")
  376. }
  377. }
  378. func TestListAssetFoldersRoot(t *testing.T) {
  379. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  380. req := decodeRequest(t, r)
  381. if !strings.Contains(req.Query, "folders(parentFolderId") {
  382. t.Errorf("query = %s", req.Query)
  383. }
  384. if got := req.Variables["parentFolderId"]; got != float64(0) {
  385. t.Errorf("variables[parentFolderId] = %v, want 0 (root)", got)
  386. }
  387. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[
  388. {"id":5,"slug":"images","name":"Images"},
  389. {"id":6,"slug":"docs","name":"Docs"}
  390. ]}}}`)
  391. })
  392. folders, err := client.ListAssetFolders(context.Background(), "")
  393. if err != nil {
  394. t.Fatalf("ListAssetFolders() error = %v", err)
  395. }
  396. if len(folders) != 2 {
  397. t.Fatalf("folders = %d, want 2", len(folders))
  398. }
  399. if got, want := folders[0].Path, "images"; got != want {
  400. t.Errorf("Path = %q, want %q", got, want)
  401. }
  402. }
  403. func TestResolveAssetFolderNested(t *testing.T) {
  404. var calls []map[string]any
  405. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  406. req := decodeRequest(t, r)
  407. calls = append(calls, req.Variables)
  408. switch len(calls) {
  409. case 1:
  410. if got := req.Variables["parentFolderId"]; got != float64(0) {
  411. t.Errorf("call 1 parentFolderId = %v, want 0", got)
  412. }
  413. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":5,"slug":"images"}]}}}`)
  414. case 2:
  415. if got := req.Variables["parentFolderId"]; got != float64(5) {
  416. t.Errorf("call 2 parentFolderId = %v, want 5", got)
  417. }
  418. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":9,"slug":"screens"}]}}}`)
  419. case 3:
  420. if got := req.Variables["folderId"]; got != float64(9) {
  421. t.Errorf("call 3 folderId = %v, want 9", got)
  422. }
  423. if got := req.Variables["kind"]; got != "ALL" {
  424. t.Errorf("call 3 kind = %v, want ALL", got)
  425. }
  426. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"list":[
  427. {"id":1,"filename":"shot.png","ext":"png","kind":"IMAGE","mime":"image/png",
  428. "fileSize":12,"createdAt":"c","updatedAt":"u",
  429. "folder":{"id":9,"slug":"screens","name":"Screens"}}
  430. ]}}}`)
  431. default:
  432. t.Errorf("unexpected call %d", len(calls))
  433. }
  434. })
  435. assets, err := client.ListAssets(context.Background(), "images/screens")
  436. if err != nil {
  437. t.Fatalf("ListAssets() error = %v", err)
  438. }
  439. if len(assets) != 1 {
  440. t.Fatalf("assets = %d, want 1", len(assets))
  441. }
  442. if got, want := assets[0].Folder.Path, "images/screens"; got != want {
  443. t.Errorf("Folder.Path = %q, want %q", got, want)
  444. }
  445. if got, want := assets[0].URL, client.baseURL+"/images/screens/shot.png"; got != want {
  446. t.Errorf("URL = %q, want %q", got, want)
  447. }
  448. }
  449. func TestListAssetsRootOmitsFolderResolution(t *testing.T) {
  450. requests := 0
  451. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  452. requests++
  453. req := decodeRequest(t, r)
  454. if got := req.Variables["folderId"]; got != float64(0) {
  455. t.Errorf("folderId = %v, want 0", got)
  456. }
  457. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"list":[
  458. {"id":2,"filename":"logo.png","ext":"png","kind":"IMAGE","mime":"image/png",
  459. "fileSize":9,"createdAt":"c","updatedAt":"u","folder":null}
  460. ]}}}`)
  461. })
  462. assets, err := client.ListAssets(context.Background(), "")
  463. if err != nil {
  464. t.Fatalf("ListAssets() error = %v", err)
  465. }
  466. if requests != 1 {
  467. t.Errorf("requests = %d, want 1", requests)
  468. }
  469. if got, want := assets[0].URL, client.baseURL+"/logo.png"; got != want {
  470. t.Errorf("URL = %q, want %q", got, want)
  471. }
  472. if assets[0].Folder == nil || assets[0].Folder.ID != 0 {
  473. t.Errorf("Folder = %+v, want root folder", assets[0].Folder)
  474. }
  475. }
  476. func TestResolveAssetFolderNotFound(t *testing.T) {
  477. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  478. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[]}}}`)
  479. })
  480. if _, err := client.ListAssets(context.Background(), "nope"); err == nil {
  481. t.Fatal("ListAssets() error = nil, want error")
  482. } else if !strings.Contains(err.Error(), "not found") {
  483. t.Errorf("error = %q", err)
  484. }
  485. }
  486. func TestCreateAssetFolder(t *testing.T) {
  487. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  488. req := decodeRequest(t, r)
  489. if !strings.Contains(req.Query, "mutation CreateAssetFolder") {
  490. t.Errorf("query = %s", req.Query)
  491. }
  492. if got := req.Variables["parentFolderId"]; got != float64(0) {
  493. t.Errorf("parentFolderId = %v, want 0", got)
  494. }
  495. if got, want := req.Variables["slug"], "vacation"; got != want {
  496. t.Errorf("slug = %v, want %q", got, want)
  497. }
  498. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"createFolder":{"responseResult":{
  499. "succeeded":true,"errorCode":0,"slug":"vacation","message":"ok"}}}}}`)
  500. })
  501. res, err := client.CreateAssetFolder(context.Background(), "", "vacation", "Vacation 2025")
  502. if err != nil {
  503. t.Fatalf("CreateAssetFolder() error = %v", err)
  504. }
  505. if !res.Succeeded {
  506. t.Errorf("result = %+v", res)
  507. }
  508. }
  509. func TestCreateAssetFolderFailure(t *testing.T) {
  510. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  511. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"createFolder":{"responseResult":{
  512. "succeeded":false,"errorCode":2002,"slug":"","message":"AssetFolderExists"}}}}}`)
  513. })
  514. res, err := client.CreateAssetFolder(context.Background(), "", "vacation", "Vacation")
  515. if err != nil {
  516. t.Fatalf("CreateAssetFolder() error = %v", err)
  517. }
  518. if res.Succeeded || res.ErrorCode != 2002 {
  519. t.Errorf("result = %+v", res)
  520. }
  521. }
  522. func TestUploadAssetRoot(t *testing.T) {
  523. var meta, file string
  524. var filename, contentType, auth string
  525. var contents []byte
  526. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  527. // Best-effort site config probe arrives before the multipart upload.
  528. if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
  529. writeJSON(t, w, http.StatusOK, `{"data":{"site":{"config":{"uploadMaxFileSize":5242880}}}}`)
  530. return
  531. }
  532. auth = r.Header.Get("Authorization")
  533. mr, err := r.MultipartReader()
  534. if err != nil {
  535. t.Fatalf("multipart reader: %v", err)
  536. }
  537. for i := 0; ; i++ {
  538. part, err := mr.NextPart()
  539. if err == io.EOF {
  540. break
  541. }
  542. if err != nil {
  543. t.Fatalf("part %d: %v", i, err)
  544. }
  545. if got := part.FormName(); got != "mediaUpload" {
  546. t.Errorf("part %d FormName() = %q, want mediaUpload", i, got)
  547. }
  548. data, err := io.ReadAll(part)
  549. if err != nil {
  550. t.Fatalf("read part %d: %v", i, err)
  551. }
  552. if part.FileName() == "" {
  553. meta = string(data)
  554. } else {
  555. file = string(data)
  556. filename = part.FileName()
  557. contentType = part.Header.Get("Content-Type")
  558. contents = data
  559. }
  560. }
  561. _, _ = w.Write([]byte("ok"))
  562. })
  563. res, uploaded, err := client.UploadAsset(context.Background(), "", "Report 2025; Final.pdf", []byte("payload"), "")
  564. if err != nil {
  565. t.Fatalf("UploadAsset() error = %v", err)
  566. }
  567. if auth != "Bearer test-key" {
  568. t.Errorf("Authorization = %q", auth)
  569. }
  570. if meta != `{"folderId":0}` {
  571. t.Errorf("metadata = %q, want folderId 0", meta)
  572. }
  573. if file != "payload" || string(contents) != "payload" {
  574. t.Errorf("file part = %q", file)
  575. }
  576. if filename != "report_2025_final.pdf" {
  577. t.Errorf("filename = %q, want sanitized", filename)
  578. }
  579. if contentType != "application/pdf" {
  580. t.Errorf("Content-Type = %q, want guessed application/pdf", contentType)
  581. }
  582. if !res.Succeeded {
  583. t.Errorf("result = %+v", res)
  584. }
  585. if got, want := uploaded.Filename, filename; got != want {
  586. t.Errorf("uploaded.Filename = %q, want %q", got, want)
  587. }
  588. if got, want := uploaded.URL, client.baseURL+"/report_2025_final.pdf"; got != want {
  589. t.Errorf("uploaded.URL = %q, want %q", got, want)
  590. }
  591. }
  592. func TestUploadAssetNestedFolder(t *testing.T) {
  593. var calls []map[string]any
  594. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  595. if strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") {
  596. _, _ = w.Write([]byte("ok"))
  597. return
  598. }
  599. req := decodeRequest(t, r)
  600. if strings.Contains(req.Query, "query SiteConfig") {
  601. writeJSON(t, w, http.StatusOK, `{"data":{"site":{"config":{"uploadMaxFileSize":5242880}}}}`)
  602. return
  603. }
  604. calls = append(calls, req.Variables)
  605. if len(calls) == 1 {
  606. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":5,"slug":"images"}]}}}`)
  607. return
  608. }
  609. writeJSON(t, w, http.StatusOK, `{"data":{"assets":{"folders":[{"id":9,"slug":"screens"}]}}}`)
  610. })
  611. res, uploaded, err := client.UploadAsset(
  612. context.Background(), "images/screens", "shot.png", []byte("png"), "image/png",
  613. )
  614. if err != nil {
  615. t.Fatalf("UploadAsset() error = %v", err)
  616. }
  617. if len(calls) != 2 {
  618. t.Errorf("folder calls = %d, want 2", len(calls))
  619. }
  620. if !res.Succeeded {
  621. t.Errorf("result = %+v", res)
  622. }
  623. if got, want := uploaded.URL, client.baseURL+"/images/screens/shot.png"; got != want {
  624. t.Errorf("uploaded.URL = %q, want %q", got, want)
  625. }
  626. }
  627. func TestUploadAssetForbidden(t *testing.T) {
  628. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  629. w.Header().Set("Content-Type", "application/json")
  630. w.WriteHeader(http.StatusForbidden)
  631. fmt.Fprint(w, `{"succeeded":false,"message":"You are not authorized to upload files."}`)
  632. })
  633. res, _, err := client.UploadAsset(context.Background(), "", "x.png", []byte("png"), "")
  634. if err != nil {
  635. t.Fatalf("UploadAsset() error = %v", err)
  636. }
  637. if res.Succeeded {
  638. t.Error("Succeeded = true, want false")
  639. }
  640. if res.Message != "You are not authorized to upload files." {
  641. t.Errorf("Message = %q", res.Message)
  642. }
  643. }
  644. func TestUploadAssetUnexpectedResponse(t *testing.T) {
  645. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  646. _, _ = w.Write([]byte("<html>proxy error</html>"))
  647. })
  648. if _, _, err := client.UploadAsset(context.Background(), "", "x.png", []byte("png"), ""); err == nil {
  649. t.Fatal("UploadAsset() error = nil, want error")
  650. }
  651. }
  652. func TestUploadAssetInvalidFilename(t *testing.T) {
  653. client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
  654. t.Error("handler must not be called")
  655. })
  656. if _, _, err := client.UploadAsset(context.Background(), "", " ", []byte("png"), ""); err == nil {
  657. t.Fatal("UploadAsset() error = nil, want error")
  658. }
  659. }
  660. func TestSanitizeAssetFilename(t *testing.T) {
  661. tests := []struct{ in, want string }{
  662. {in: "Report 2025; Final.pdf", want: "report_2025_final.pdf"},
  663. {in: "a b.png", want: "a_b.png"},
  664. {in: " A/B\\C.png ", want: "abc.png"},
  665. {in: "a,;#b.txt", want: "a_b.txt"},
  666. {in: "images/shot.png", want: "imagesshot.png"},
  667. }
  668. for _, tt := range tests {
  669. if got := sanitizeAssetFilename(tt.in); got != tt.want {
  670. t.Errorf("sanitizeAssetFilename(%q) = %q, want %q", tt.in, got, tt.want)
  671. }
  672. }
  673. }