linkwarden_test.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package linkwarden
  4. import (
  5. "encoding/json"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "miniflux.app/v2/internal/config"
  12. "miniflux.app/v2/internal/model"
  13. )
  14. func TestCreateBookmark(t *testing.T) {
  15. configureIntegrationAllowPrivateNetworksOption(t)
  16. tests := []struct {
  17. name string
  18. baseURL string
  19. apiKey string
  20. collectionID *int64
  21. entryURL string
  22. entryTitle string
  23. serverResponse func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64)
  24. wantErr bool
  25. errContains string
  26. }{
  27. {
  28. name: "successful bookmark creation without collection",
  29. baseURL: "",
  30. apiKey: "test-api-key",
  31. collectionID: nil,
  32. entryURL: "https://example.com",
  33. entryTitle: "Test Article",
  34. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
  35. // Verify authorization header
  36. auth := r.Header.Get("Authorization")
  37. if auth != "Bearer test-api-key" {
  38. t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
  39. }
  40. // Verify content type
  41. contentType := r.Header.Get("Content-Type")
  42. if contentType != "application/json" {
  43. t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
  44. }
  45. // Parse and verify request
  46. body, _ := io.ReadAll(r.Body)
  47. var req map[string]any
  48. if err := json.Unmarshal(body, &req); err != nil {
  49. t.Errorf("Failed to parse request body: %v", err)
  50. }
  51. // Verify URL
  52. if reqURL := req["url"]; reqURL != "https://example.com" {
  53. t.Errorf("Expected URL 'https://example.com', got %v", reqURL)
  54. }
  55. // Verify title/name
  56. if reqName := req["name"]; reqName != "Test Article" {
  57. t.Errorf("Expected name 'Test Article', got %v", reqName)
  58. }
  59. // Verify collection is not present when nil
  60. if _, ok := req["collection"]; ok {
  61. t.Error("Expected collection field to be omitted when collectionId is nil")
  62. }
  63. // Return success response
  64. w.WriteHeader(http.StatusOK)
  65. json.NewEncoder(w).Encode(map[string]any{
  66. "id": "123",
  67. "url": "https://example.com",
  68. "name": "Test Article",
  69. })
  70. },
  71. wantErr: false,
  72. },
  73. {
  74. name: "successful bookmark creation with collection",
  75. baseURL: "",
  76. apiKey: "test-api-key",
  77. collectionID: model.OptionalNumber(int64(42)),
  78. entryURL: "https://example.com/article",
  79. entryTitle: "Test Article With Collection",
  80. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionID *int64) {
  81. // Verify authorization header
  82. auth := r.Header.Get("Authorization")
  83. if auth != "Bearer test-api-key" {
  84. t.Errorf("Expected Authorization header 'Bearer test-api-key', got %s", auth)
  85. }
  86. // Parse and verify request
  87. body, _ := io.ReadAll(r.Body)
  88. var req map[string]any
  89. if err := json.Unmarshal(body, &req); err != nil {
  90. t.Errorf("Failed to parse request body: %v", err)
  91. }
  92. // Verify URL
  93. if reqURL := req["url"]; reqURL != "https://example.com/article" {
  94. t.Errorf("Expected URL 'https://example.com/article', got %v", reqURL)
  95. }
  96. // Verify title/name
  97. if reqName := req["name"]; reqName != "Test Article With Collection" {
  98. t.Errorf("Expected name 'Test Article With Collection', got %v", reqName)
  99. }
  100. // Verify collection is present and correct
  101. if collection, ok := req["collection"]; ok {
  102. collectionMap, ok := collection.(map[string]any)
  103. if !ok {
  104. t.Error("Expected collection to be a map")
  105. }
  106. if collectionID, ok := collectionMap["id"]; ok {
  107. // JSON numbers are float64
  108. if collectionIDFloat, ok := collectionID.(float64); !ok || int64(collectionIDFloat) != 42 {
  109. t.Errorf("Expected collection id 42, got %v", collectionID)
  110. }
  111. } else {
  112. t.Error("Expected collection to have 'id' field")
  113. }
  114. } else {
  115. t.Error("Expected collection field to be present when collectionId is set")
  116. }
  117. // Return success response
  118. w.WriteHeader(http.StatusOK)
  119. json.NewEncoder(w).Encode(map[string]any{
  120. "id": "124",
  121. "url": "https://example.com/article",
  122. "name": "Test Article With Collection",
  123. })
  124. },
  125. wantErr: false,
  126. },
  127. {
  128. name: "missing API key",
  129. baseURL: "",
  130. apiKey: "",
  131. collectionID: nil,
  132. entryURL: "https://example.com",
  133. entryTitle: "Test",
  134. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
  135. // Should not be called
  136. t.Error("Server should not be called when API key is missing")
  137. },
  138. wantErr: true,
  139. errContains: "missing base URL or API key",
  140. },
  141. {
  142. name: "server error",
  143. baseURL: "",
  144. apiKey: "test-api-key",
  145. collectionID: nil,
  146. entryURL: "https://example.com",
  147. entryTitle: "Test",
  148. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
  149. w.WriteHeader(http.StatusInternalServerError)
  150. w.Write([]byte(`{"error": "Internal server error"}`))
  151. },
  152. wantErr: true,
  153. errContains: "unable to create link: status=500",
  154. },
  155. {
  156. name: "bad request with null collection id error",
  157. baseURL: "",
  158. apiKey: "test-api-key",
  159. collectionID: nil,
  160. entryURL: "https://example.com",
  161. entryTitle: "Test",
  162. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
  163. w.WriteHeader(http.StatusBadRequest)
  164. w.Write([]byte(`{"response":"Error: Expected number, received null [collection, id]"}`))
  165. },
  166. wantErr: true,
  167. errContains: "unable to create link: status=400",
  168. },
  169. {
  170. name: "unauthorized",
  171. baseURL: "",
  172. apiKey: "invalid-key",
  173. collectionID: nil,
  174. entryURL: "https://example.com",
  175. entryTitle: "Test",
  176. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
  177. w.WriteHeader(http.StatusUnauthorized)
  178. w.Write([]byte(`{"error": "Unauthorized"}`))
  179. },
  180. wantErr: true,
  181. errContains: "unable to create link: status=401",
  182. },
  183. {
  184. name: "invalid base URL",
  185. baseURL: ":",
  186. apiKey: "test-api-key",
  187. collectionID: nil,
  188. entryURL: "https://example.com",
  189. entryTitle: "Test",
  190. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
  191. // Should not be called
  192. t.Error("Server should not be called when base URL is invalid")
  193. },
  194. wantErr: true,
  195. errContains: "invalid API endpoint",
  196. },
  197. {
  198. name: "missing base URL",
  199. baseURL: "",
  200. apiKey: "",
  201. collectionID: nil,
  202. entryURL: "https://example.com",
  203. entryTitle: "Test",
  204. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
  205. // Should not be called
  206. t.Error("Server should not be called when base URL is missing")
  207. },
  208. wantErr: true,
  209. errContains: "missing base URL or API key",
  210. },
  211. {
  212. name: "network connection error",
  213. baseURL: "http://localhost:1", // Invalid port that should fail to connect
  214. apiKey: "test-api-key",
  215. collectionID: nil,
  216. entryURL: "https://example.com",
  217. entryTitle: "Test",
  218. serverResponse: func(w http.ResponseWriter, r *http.Request, t *testing.T, collectionId *int64) {
  219. // Should not be called due to connection failure
  220. t.Error("Server should not be called when connection fails")
  221. },
  222. wantErr: true,
  223. errContains: "unable to send request",
  224. },
  225. }
  226. for _, tt := range tests {
  227. t.Run(tt.name, func(t *testing.T) {
  228. // Create test server only if we have a valid apiKey and don't have a custom baseURL for error testing
  229. var server *httptest.Server
  230. if tt.apiKey != "" && tt.baseURL != ":" && tt.baseURL != "http://localhost:1" {
  231. server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  232. tt.serverResponse(w, r, t, tt.collectionID)
  233. }))
  234. defer server.Close()
  235. }
  236. // Use test server URL if baseURL is empty and we have a server
  237. baseURL := tt.baseURL
  238. if baseURL == "" && server != nil {
  239. baseURL = server.URL
  240. }
  241. // Create client
  242. client := NewClient(baseURL, tt.apiKey, tt.collectionID)
  243. // Call CreateBookmark
  244. err := client.CreateBookmark(tt.entryURL, tt.entryTitle)
  245. // Check error
  246. if tt.wantErr {
  247. if err == nil {
  248. t.Error("Expected error, got nil")
  249. } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
  250. t.Errorf("Expected error to contain '%s', got '%s'", tt.errContains, err.Error())
  251. }
  252. } else {
  253. if err != nil {
  254. t.Errorf("Expected no error, got %v", err)
  255. }
  256. }
  257. })
  258. }
  259. }
  260. func TestNewClient(t *testing.T) {
  261. tests := []struct {
  262. name string
  263. baseURL string
  264. apiKey string
  265. collectionID *int64
  266. }{
  267. {
  268. name: "client without collection",
  269. baseURL: "https://linkwarden.example.com",
  270. apiKey: "test-key",
  271. collectionID: nil,
  272. },
  273. {
  274. name: "client with collection",
  275. baseURL: "https://linkwarden.example.com",
  276. apiKey: "test-key",
  277. collectionID: model.OptionalNumber(int64(123)),
  278. },
  279. }
  280. for _, tt := range tests {
  281. t.Run(tt.name, func(t *testing.T) {
  282. client := NewClient(tt.baseURL, tt.apiKey, tt.collectionID)
  283. if client.baseURL != tt.baseURL {
  284. t.Errorf("Expected baseURL %s, got %s", tt.baseURL, client.baseURL)
  285. }
  286. if client.apiKey != tt.apiKey {
  287. t.Errorf("Expected apiKey %s, got %s", tt.apiKey, client.apiKey)
  288. }
  289. if tt.collectionID == nil {
  290. if client.collectionID != nil {
  291. t.Errorf("Expected collectionId to be nil, got %v", *client.collectionID)
  292. }
  293. } else {
  294. if client.collectionID == nil {
  295. t.Error("Expected collectionId to be set, got nil")
  296. } else if *client.collectionID != *tt.collectionID {
  297. t.Errorf("Expected collectionId %d, got %d", *tt.collectionID, *client.collectionID)
  298. }
  299. }
  300. })
  301. }
  302. }
  303. func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
  304. t.Helper()
  305. t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
  306. configParser := config.NewConfigParser()
  307. parsedOptions, err := configParser.ParseEnvironmentVariables()
  308. if err != nil {
  309. t.Fatalf("Unable to configure test options: %v", err)
  310. }
  311. previousOptions := config.Opts
  312. config.Opts = parsedOptions
  313. t.Cleanup(func() {
  314. config.Opts = previousOptions
  315. })
  316. }