readeck_test.go 8.1 KB

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