linktaco_test.go 12 KB

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