linktaco_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package linktaco
  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. )
  13. func TestCreateBookmark(t *testing.T) {
  14. configureIntegrationAllowPrivateNetworksOption(t)
  15. tests := []struct {
  16. name string
  17. apiToken string
  18. orgSlug string
  19. tags string
  20. visibility string
  21. entryURL string
  22. entryTitle string
  23. entryContent string
  24. serverResponse func(w http.ResponseWriter, r *http.Request)
  25. wantErr bool
  26. errContains string
  27. }{
  28. {
  29. name: "successful bookmark creation",
  30. apiToken: "test-token",
  31. orgSlug: "test-org",
  32. tags: "tag1, tag2",
  33. visibility: "PUBLIC",
  34. entryURL: "https://example.com",
  35. entryTitle: "Test Article",
  36. entryContent: "Test content",
  37. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  38. // Verify authorization header
  39. auth := r.Header.Get("Authorization")
  40. if auth != "Bearer test-token" {
  41. t.Errorf("Expected Authorization header 'Bearer test-token', got %s", auth)
  42. }
  43. // Verify content type
  44. contentType := r.Header.Get("Content-Type")
  45. if contentType != "application/json" {
  46. t.Errorf("Expected Content-Type 'application/json', got %s", contentType)
  47. }
  48. // Parse and verify request
  49. body, _ := io.ReadAll(r.Body)
  50. var req map[string]any
  51. if err := json.Unmarshal(body, &req); err != nil {
  52. t.Errorf("Failed to parse request body: %v", err)
  53. }
  54. // Verify mutation exists
  55. if _, ok := req["query"]; !ok {
  56. t.Error("Missing 'query' field in request")
  57. }
  58. // Return success response
  59. w.WriteHeader(http.StatusOK)
  60. json.NewEncoder(w).Encode(map[string]any{
  61. "data": map[string]any{
  62. "addLink": map[string]any{
  63. "id": "123",
  64. "url": "https://example.com",
  65. "title": "Test Article",
  66. },
  67. },
  68. })
  69. },
  70. wantErr: false,
  71. },
  72. {
  73. name: "missing API token",
  74. apiToken: "",
  75. orgSlug: "test-org",
  76. entryURL: "https://example.com",
  77. entryTitle: "Test",
  78. entryContent: "Content",
  79. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  80. // Should not be called
  81. t.Error("Server should not be called when API token is missing")
  82. },
  83. wantErr: true,
  84. errContains: "missing API token or organization slug",
  85. },
  86. {
  87. name: "missing organization slug",
  88. apiToken: "test-token",
  89. orgSlug: "",
  90. entryURL: "https://example.com",
  91. entryTitle: "Test",
  92. entryContent: "Content",
  93. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  94. // Should not be called
  95. t.Error("Server should not be called when org slug is missing")
  96. },
  97. wantErr: true,
  98. errContains: "missing API token or organization slug",
  99. },
  100. {
  101. name: "GraphQL error response",
  102. apiToken: "test-token",
  103. orgSlug: "test-org",
  104. entryURL: "https://example.com",
  105. entryTitle: "Test",
  106. entryContent: "Content",
  107. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  108. w.WriteHeader(http.StatusOK)
  109. json.NewEncoder(w).Encode(map[string]any{
  110. "errors": []any{
  111. map[string]any{
  112. "message": "Invalid input",
  113. },
  114. },
  115. })
  116. },
  117. wantErr: true,
  118. errContains: "Invalid input",
  119. },
  120. {
  121. name: "HTTP error status",
  122. apiToken: "test-token",
  123. orgSlug: "test-org",
  124. entryURL: "https://example.com",
  125. entryTitle: "Test",
  126. entryContent: "Content",
  127. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  128. w.WriteHeader(http.StatusUnauthorized)
  129. },
  130. wantErr: true,
  131. errContains: "status=401",
  132. },
  133. {
  134. name: "private visibility permission error",
  135. apiToken: "test-token",
  136. orgSlug: "test-org",
  137. visibility: "PRIVATE",
  138. entryURL: "https://example.com",
  139. entryTitle: "Test",
  140. entryContent: "Content",
  141. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  142. w.WriteHeader(http.StatusOK)
  143. json.NewEncoder(w).Encode(map[string]any{
  144. "errors": []any{
  145. map[string]any{
  146. "message": "PRIVATE visibility requires a paid LinkTaco account",
  147. },
  148. },
  149. })
  150. },
  151. wantErr: true,
  152. errContains: "PRIVATE visibility requires a paid LinkTaco account",
  153. },
  154. {
  155. name: "content truncation",
  156. apiToken: "test-token",
  157. orgSlug: "test-org",
  158. entryURL: "https://example.com",
  159. entryTitle: "Test",
  160. entryContent: strings.Repeat("a", 600), // Content longer than 500 chars
  161. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  162. body, _ := io.ReadAll(r.Body)
  163. var req map[string]any
  164. json.Unmarshal(body, &req)
  165. // Check that description was truncated
  166. variables := req["variables"].(map[string]any)
  167. input := variables["input"].(map[string]any)
  168. description := input["description"].(string)
  169. if len(description) != maxDescriptionLength {
  170. t.Errorf("Expected description length %d, got %d", maxDescriptionLength, len(description))
  171. }
  172. w.WriteHeader(http.StatusOK)
  173. json.NewEncoder(w).Encode(map[string]any{
  174. "data": map[string]any{
  175. "addLink": map[string]any{"id": "123"},
  176. },
  177. })
  178. },
  179. wantErr: false,
  180. },
  181. {
  182. name: "tag limiting",
  183. apiToken: "test-token",
  184. orgSlug: "test-org",
  185. tags: "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12",
  186. entryURL: "https://example.com",
  187. entryTitle: "Test",
  188. entryContent: "Content",
  189. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  190. body, _ := io.ReadAll(r.Body)
  191. var req map[string]any
  192. json.Unmarshal(body, &req)
  193. // Check that only 10 tags were sent
  194. variables := req["variables"].(map[string]any)
  195. input := variables["input"].(map[string]any)
  196. tags := input["tags"].(string)
  197. tagCount := len(strings.Split(tags, ","))
  198. if tagCount != maxTags {
  199. t.Errorf("Expected %d tags, got %d", maxTags, tagCount)
  200. }
  201. w.WriteHeader(http.StatusOK)
  202. json.NewEncoder(w).Encode(map[string]any{
  203. "data": map[string]any{
  204. "addLink": map[string]any{"id": "123"},
  205. },
  206. })
  207. },
  208. wantErr: false,
  209. },
  210. {
  211. name: "invalid JSON response",
  212. apiToken: "test-token",
  213. orgSlug: "test-org",
  214. entryURL: "https://example.com",
  215. entryTitle: "Test",
  216. entryContent: "Content",
  217. serverResponse: func(w http.ResponseWriter, r *http.Request) {
  218. w.WriteHeader(http.StatusOK)
  219. w.Write([]byte("invalid json"))
  220. },
  221. wantErr: true,
  222. errContains: "unable to decode response",
  223. },
  224. }
  225. for _, tt := range tests {
  226. t.Run(tt.name, func(t *testing.T) {
  227. // Create test server if we have a server response function
  228. var serverURL string
  229. if tt.serverResponse != nil {
  230. server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
  231. defer server.Close()
  232. serverURL = server.URL
  233. }
  234. // Create client with test server URL
  235. client := &Client{
  236. graphqlURL: serverURL,
  237. apiToken: tt.apiToken,
  238. orgSlug: tt.orgSlug,
  239. tags: tt.tags,
  240. visibility: tt.visibility,
  241. }
  242. // Call CreateBookmark
  243. err := client.CreateBookmark(tt.entryURL, tt.entryTitle, tt.entryContent)
  244. // Check error expectations
  245. if tt.wantErr {
  246. if err == nil {
  247. t.Errorf("Expected error but got none")
  248. } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) {
  249. t.Errorf("Expected error containing '%s', got '%s'", tt.errContains, err.Error())
  250. }
  251. } else {
  252. if err != nil {
  253. t.Errorf("Unexpected error: %v", err)
  254. }
  255. }
  256. })
  257. }
  258. }
  259. func TestNewClient(t *testing.T) {
  260. tests := []struct {
  261. name string
  262. apiToken string
  263. orgSlug string
  264. tags string
  265. visibility string
  266. expectedVisibility string
  267. }{
  268. {
  269. name: "with all parameters",
  270. apiToken: "token",
  271. orgSlug: "org",
  272. tags: "tag1,tag2",
  273. visibility: "PRIVATE",
  274. expectedVisibility: "PRIVATE",
  275. },
  276. {
  277. name: "empty visibility defaults to PUBLIC",
  278. apiToken: "token",
  279. orgSlug: "org",
  280. tags: "tag1",
  281. visibility: "",
  282. expectedVisibility: "PUBLIC",
  283. },
  284. }
  285. for _, tt := range tests {
  286. t.Run(tt.name, func(t *testing.T) {
  287. client := NewClient(tt.apiToken, tt.orgSlug, tt.tags, tt.visibility)
  288. if client.apiToken != tt.apiToken {
  289. t.Errorf("Expected apiToken %s, got %s", tt.apiToken, client.apiToken)
  290. }
  291. if client.orgSlug != tt.orgSlug {
  292. t.Errorf("Expected orgSlug %s, got %s", tt.orgSlug, client.orgSlug)
  293. }
  294. if client.tags != tt.tags {
  295. t.Errorf("Expected tags %s, got %s", tt.tags, client.tags)
  296. }
  297. if client.visibility != tt.expectedVisibility {
  298. t.Errorf("Expected visibility %s, got %s", tt.expectedVisibility, client.visibility)
  299. }
  300. if client.graphqlURL != defaultGraphQLURL {
  301. t.Errorf("Expected graphqlURL %s, got %s", defaultGraphQLURL, client.graphqlURL)
  302. }
  303. })
  304. }
  305. }
  306. func TestGraphQLMutation(t *testing.T) {
  307. configureIntegrationAllowPrivateNetworksOption(t)
  308. // Test that the GraphQL mutation is properly formatted
  309. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  310. body, _ := io.ReadAll(r.Body)
  311. var req map[string]any
  312. if err := json.Unmarshal(body, &req); err != nil {
  313. t.Fatalf("Failed to parse request: %v", err)
  314. }
  315. // Verify mutation structure
  316. query, ok := req["query"].(string)
  317. if !ok {
  318. t.Fatal("Missing query field")
  319. }
  320. // Check that mutation contains expected parts
  321. if !strings.Contains(query, "mutation AddLink") {
  322. t.Error("Mutation should contain 'mutation AddLink'")
  323. }
  324. if !strings.Contains(query, "$input: LinkInput!") {
  325. t.Error("Mutation should contain input parameter")
  326. }
  327. if !strings.Contains(query, "addLink(input: $input)") {
  328. t.Error("Mutation should contain addLink call")
  329. }
  330. // Verify variables structure
  331. variables, ok := req["variables"].(map[string]any)
  332. if !ok {
  333. t.Fatal("Missing variables field")
  334. }
  335. input, ok := variables["input"].(map[string]any)
  336. if !ok {
  337. t.Fatal("Missing input in variables")
  338. }
  339. // Check all required fields
  340. requiredFields := []string{"url", "title", "description", "orgSlug", "visibility", "unread", "starred", "archive", "tags"}
  341. for _, field := range requiredFields {
  342. if _, ok := input[field]; !ok {
  343. t.Errorf("Missing required field: %s", field)
  344. }
  345. }
  346. // Return success
  347. w.WriteHeader(http.StatusOK)
  348. json.NewEncoder(w).Encode(map[string]any{
  349. "data": map[string]any{
  350. "addLink": map[string]any{
  351. "id": "123",
  352. },
  353. },
  354. })
  355. }))
  356. defer server.Close()
  357. client := &Client{
  358. graphqlURL: server.URL,
  359. apiToken: "test-token",
  360. orgSlug: "test-org",
  361. tags: "test",
  362. visibility: "PUBLIC",
  363. }
  364. err := client.CreateBookmark("https://example.com", "Test Title", "Test Content")
  365. if err != nil {
  366. t.Errorf("Unexpected error: %v", err)
  367. }
  368. }
  369. func BenchmarkCreateBookmark(b *testing.B) {
  370. // Create a mock server that always returns success
  371. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  372. w.WriteHeader(http.StatusOK)
  373. json.NewEncoder(w).Encode(map[string]any{
  374. "data": map[string]any{
  375. "addLink": map[string]any{
  376. "id": "123",
  377. },
  378. },
  379. })
  380. }))
  381. defer server.Close()
  382. client := &Client{
  383. graphqlURL: server.URL,
  384. apiToken: "test-token",
  385. orgSlug: "test-org",
  386. tags: "tag1,tag2,tag3",
  387. visibility: "PUBLIC",
  388. }
  389. // Run benchmark
  390. b.ResetTimer()
  391. for i := 0; i < b.N; i++ {
  392. _ = client.CreateBookmark("https://example.com", "Test Title", "Test Content")
  393. }
  394. }
  395. func BenchmarkTagProcessing(b *testing.B) {
  396. // Benchmark tag splitting and limiting
  397. tags := "tag1,tag2,tag3,tag4,tag5,tag6,tag7,tag8,tag9,tag10,tag11,tag12,tag13,tag14,tag15"
  398. b.ResetTimer()
  399. for i := 0; i < b.N; i++ {
  400. tagsSplitFn := func(c rune) bool {
  401. return c == ',' || c == ' '
  402. }
  403. splitTags := strings.FieldsFunc(tags, tagsSplitFn)
  404. if len(splitTags) > maxTags {
  405. splitTags = splitTags[:maxTags]
  406. }
  407. _ = strings.Join(splitTags, ",")
  408. }
  409. }
  410. func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
  411. t.Helper()
  412. t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
  413. configParser := config.NewConfigParser()
  414. parsedOptions, err := configParser.ParseEnvironmentVariables()
  415. if err != nil {
  416. t.Fatalf("Unable to configure test options: %v", err)
  417. }
  418. previousOptions := config.Opts
  419. config.Opts = parsedOptions
  420. t.Cleanup(func() {
  421. config.Opts = previousOptions
  422. })
  423. }