readeck_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package readeck
  4. import (
  5. "encoding/json"
  6. "io"
  7. "mime/multipart"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. )
  13. func TestCreateBookmark(t *testing.T) {
  14. entryURL := "https://example.com/article"
  15. entryTitle := "Example Title"
  16. entryContent := "<p>Some HTML content</p>"
  17. labels := "tag1,tag2"
  18. tests := []struct {
  19. name string
  20. onlyURL bool
  21. baseURL string
  22. apiKey string
  23. labels string
  24. entryURL string
  25. entryTitle string
  26. entryContent string
  27. serverResponse func(w http.ResponseWriter, r *http.Request)
  28. wantErr bool
  29. errContains string
  30. }{
  31. {
  32. name: "successful bookmark creation with only URL",
  33. onlyURL: true,
  34. labels: labels,
  35. entryURL: entryURL,
  36. entryTitle: entryTitle,
  37. entryContent: entryContent,
  38. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  39. if r.Method != http.MethodPost {
  40. t.Errorf("expected POST, got %s", r.Method)
  41. }
  42. if r.URL.Path != "/api/bookmarks/" {
  43. t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
  44. }
  45. if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
  46. t.Errorf("expected Authorization Bearer header, got %q", got)
  47. }
  48. if ct := r.Header.Get("Content-Type"); ct != "application/json" {
  49. t.Errorf("expected Content-Type application/json, got %s", ct)
  50. }
  51. body, _ := io.ReadAll(r.Body)
  52. var payload map[string]any
  53. if err := json.Unmarshal(body, &payload); err != nil {
  54. t.Fatalf("failed to parse JSON body: %v", err)
  55. }
  56. if u := payload["url"]; u != entryURL {
  57. t.Errorf("expected url %s, got %v", entryURL, u)
  58. }
  59. if title := payload["title"]; title != entryTitle {
  60. t.Errorf("expected title %s, got %v", entryTitle, title)
  61. }
  62. // Labels should be split into an array
  63. if raw := payload["labels"]; raw == nil {
  64. t.Errorf("expected labels to be set")
  65. } else if arr, ok := raw.([]any); ok {
  66. if len(arr) != 2 || arr[0] != "tag1" || arr[1] != "tag2" {
  67. t.Errorf("unexpected labels: %#v", arr)
  68. }
  69. } else {
  70. t.Errorf("labels should be an array, got %T", raw)
  71. }
  72. w.WriteHeader(http.StatusOK)
  73. },
  74. },
  75. {
  76. name: "successful bookmark creation with content (multipart)",
  77. onlyURL: false,
  78. labels: labels,
  79. entryURL: entryURL,
  80. entryTitle: entryTitle,
  81. entryContent: entryContent,
  82. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  83. if r.Method != http.MethodPost {
  84. t.Errorf("expected POST, got %s", r.Method)
  85. }
  86. if r.URL.Path != "/api/bookmarks/" {
  87. t.Errorf("expected path /api/bookmarks/, got %s", r.URL.Path)
  88. }
  89. if got := r.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") {
  90. t.Errorf("expected Authorization Bearer header, got %q", got)
  91. }
  92. ct := r.Header.Get("Content-Type")
  93. if !strings.HasPrefix(ct, "multipart/form-data;") {
  94. t.Errorf("expected multipart/form-data, got %s", ct)
  95. }
  96. boundaryIdx := strings.Index(ct, "boundary=")
  97. if boundaryIdx == -1 {
  98. t.Fatalf("missing multipart boundary in Content-Type: %s", ct)
  99. }
  100. boundary := ct[boundaryIdx+len("boundary="):]
  101. mr := multipart.NewReader(r.Body, boundary)
  102. seenLabels := []string{}
  103. var seenURL, seenTitle, seenFeature string
  104. var resourceHeader map[string]any
  105. var resourceBody string
  106. for {
  107. part, err := mr.NextPart()
  108. if err == io.EOF {
  109. break
  110. }
  111. if err != nil {
  112. t.Fatalf("reading multipart: %v", err)
  113. }
  114. name := part.FormName()
  115. data, _ := io.ReadAll(part)
  116. switch name {
  117. case "url":
  118. seenURL = string(data)
  119. case "title":
  120. seenTitle = string(data)
  121. case "feature_find_main":
  122. seenFeature = string(data)
  123. case "labels":
  124. seenLabels = append(seenLabels, string(data))
  125. case "resource":
  126. // First line is JSON header, then newline, then content
  127. all := string(data)
  128. idx := strings.IndexByte(all, '\n')
  129. if idx == -1 {
  130. t.Fatalf("resource content missing header separator")
  131. }
  132. headerJSON := all[:idx]
  133. resourceBody = all[idx+1:]
  134. if err := json.Unmarshal([]byte(headerJSON), &resourceHeader); err != nil {
  135. t.Fatalf("invalid resource header JSON: %v", err)
  136. }
  137. }
  138. }
  139. if seenURL != entryURL {
  140. t.Errorf("expected url %s, got %s", entryURL, seenURL)
  141. }
  142. if seenTitle != entryTitle {
  143. t.Errorf("expected title %s, got %s", entryTitle, seenTitle)
  144. }
  145. if seenFeature != "false" {
  146. t.Errorf("expected feature_find_main to be 'false', got %s", seenFeature)
  147. }
  148. if len(seenLabels) != 2 || seenLabels[0] != "tag1" || seenLabels[1] != "tag2" {
  149. t.Errorf("unexpected labels: %#v", seenLabels)
  150. }
  151. if resourceHeader == nil {
  152. t.Fatalf("missing resource header")
  153. }
  154. if hURL, _ := resourceHeader["url"].(string); hURL != entryURL {
  155. t.Errorf("expected resource header url %s, got %v", entryURL, hURL)
  156. }
  157. if headers, ok := resourceHeader["headers"].(map[string]any); ok {
  158. if ct, _ := headers["content-type"].(string); ct != "text/html; charset=utf-8" {
  159. t.Errorf("expected resource header content-type text/html; charset=utf-8, got %v", ct)
  160. }
  161. } else {
  162. t.Errorf("missing resource header 'headers' field")
  163. }
  164. if resourceBody != entryContent {
  165. t.Errorf("expected resource body %q, got %q", entryContent, resourceBody)
  166. }
  167. w.WriteHeader(http.StatusOK)
  168. },
  169. },
  170. {
  171. name: "error when server returns 400",
  172. onlyURL: true,
  173. labels: labels,
  174. entryURL: entryURL,
  175. entryTitle: entryTitle,
  176. entryContent: entryContent,
  177. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  178. w.WriteHeader(http.StatusBadRequest)
  179. },
  180. wantErr: true,
  181. errContains: "unable to create bookmark",
  182. },
  183. {
  184. name: "error when missing baseURL or apiKey",
  185. onlyURL: true,
  186. baseURL: "",
  187. apiKey: "",
  188. labels: labels,
  189. entryURL: entryURL,
  190. entryTitle: entryTitle,
  191. entryContent: entryContent,
  192. serverResponse: nil,
  193. wantErr: true,
  194. errContains: "missing base URL or API key",
  195. },
  196. }
  197. for _, tt := range tests {
  198. t.Run(tt.name, func(t *testing.T) {
  199. var serverURL string
  200. if tt.serverResponse != nil {
  201. srv := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
  202. defer srv.Close()
  203. serverURL = srv.URL
  204. }
  205. baseURL := tt.baseURL
  206. if baseURL == "" {
  207. baseURL = serverURL
  208. }
  209. apiKey := tt.apiKey
  210. if apiKey == "" {
  211. apiKey = "test-api-key"
  212. }
  213. client := NewClient(baseURL, apiKey, tt.labels, tt.onlyURL)
  214. err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
  215. if tt.wantErr {
  216. if err == nil {
  217. t.Fatalf("expected error, got none")
  218. }
  219. if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
  220. t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
  221. }
  222. } else if err != nil {
  223. t.Fatalf("unexpected error: %v", err)
  224. }
  225. })
  226. }
  227. }
  228. func TestNewClient(t *testing.T) {
  229. baseURL := "https://readeck.example.com"
  230. apiKey := "key"
  231. labels := "tag1,tag2"
  232. onlyURL := true
  233. c := NewClient(baseURL, apiKey, labels, onlyURL)
  234. if c.baseURL != baseURL {
  235. t.Errorf("expected baseURL %s, got %s", baseURL, c.baseURL)
  236. }
  237. if c.apiKey != apiKey {
  238. t.Errorf("expected apiKey %s, got %s", apiKey, c.apiKey)
  239. }
  240. if c.labels != labels {
  241. t.Errorf("expected labels %s, got %s", labels, c.labels)
  242. }
  243. if c.onlyURL != onlyURL {
  244. t.Errorf("expected onlyURL %v, got %v", onlyURL, c.onlyURL)
  245. }
  246. }