gotify_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package gotify
  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 TestSendMessageSuccess(t *testing.T) {
  15. configureIntegrationAllowPrivateNetworksOption(t)
  16. var receivedToken string
  17. var receivedContentType string
  18. var receivedBody map[string]any
  19. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  20. if r.Method != http.MethodPost {
  21. t.Errorf("expected POST method, got %s", r.Method)
  22. }
  23. if r.URL.Path != "/message" {
  24. t.Errorf("expected path /message, got %s", r.URL.Path)
  25. }
  26. receivedToken = r.Header.Get("X-Gotify-Key")
  27. receivedContentType = r.Header.Get("Content-Type")
  28. body, err := io.ReadAll(r.Body)
  29. if err != nil {
  30. t.Fatalf("failed to read body: %v", err)
  31. }
  32. if err := json.Unmarshal(body, &receivedBody); err != nil {
  33. t.Fatalf("failed to unmarshal request body: %v", err)
  34. }
  35. w.Header().Set("Content-Type", "application/json")
  36. w.WriteHeader(http.StatusOK)
  37. w.Write([]byte(`{"id":1,"appid":1,"message":"test message","title":"test title","priority":5}`))
  38. }))
  39. defer server.Close()
  40. client := NewClient(server.URL, "test-token-123", 5)
  41. extras := map[string]any{
  42. "client::notification": map[string]any{
  43. "click": map[string]any{
  44. "url": "https://example.com/article",
  45. },
  46. },
  47. }
  48. err := client.SendMessage("test title", "test message", 5, extras)
  49. if err != nil {
  50. t.Fatalf("unexpected error: %v", err)
  51. }
  52. if receivedToken != "test-token-123" {
  53. t.Errorf("expected token %q, got %q", "test-token-123", receivedToken)
  54. }
  55. if receivedContentType != "application/json" {
  56. t.Errorf("expected content type %q, got %q", "application/json", receivedContentType)
  57. }
  58. if receivedBody["title"] != "test title" {
  59. t.Errorf("expected title %q, got %v", "test title", receivedBody["title"])
  60. }
  61. if receivedBody["message"] != "test message" {
  62. t.Errorf("expected message %q, got %v", "test message", receivedBody["message"])
  63. }
  64. if int(receivedBody["priority"].(float64)) != 5 {
  65. t.Errorf("expected priority 5, got %v", receivedBody["priority"])
  66. }
  67. notificationExtra, ok := receivedBody["extras"].(map[string]any)["client::notification"].(map[string]any)
  68. if !ok {
  69. t.Fatalf("expected client::notification extra, got %v", receivedBody["extras"])
  70. }
  71. clickExtra, ok := notificationExtra["click"].(map[string]any)
  72. if !ok {
  73. t.Fatalf("expected click extra, got %v", notificationExtra)
  74. }
  75. if clickExtra["url"] != "https://example.com/article" {
  76. t.Errorf("expected click url %q, got %v", "https://example.com/article", clickExtra["url"])
  77. }
  78. }
  79. func TestSendMessageWithoutPriorityOrExtras(t *testing.T) {
  80. configureIntegrationAllowPrivateNetworksOption(t)
  81. var receivedBody map[string]any
  82. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  83. body, err := io.ReadAll(r.Body)
  84. if err != nil {
  85. t.Fatalf("failed to read body: %v", err)
  86. }
  87. if err := json.Unmarshal(body, &receivedBody); err != nil {
  88. t.Fatalf("failed to unmarshal request body: %v", err)
  89. }
  90. w.Header().Set("Content-Type", "application/json")
  91. w.WriteHeader(http.StatusOK)
  92. w.Write([]byte(`{"id":2,"message":"plain message"}`))
  93. }))
  94. defer server.Close()
  95. client := NewClient(server.URL, "test-token", 0)
  96. err := client.SendMessage("", "plain message", 0, nil)
  97. if err != nil {
  98. t.Fatalf("unexpected error: %v", err)
  99. }
  100. if receivedBody["message"] != "plain message" {
  101. t.Errorf("expected message %q, got %v", "plain message", receivedBody["message"])
  102. }
  103. if _, exists := receivedBody["title"]; exists {
  104. t.Errorf("expected title to be omitted, got %v", receivedBody["title"])
  105. }
  106. if _, exists := receivedBody["priority"]; exists {
  107. t.Errorf("expected priority to be omitted, got %v", receivedBody["priority"])
  108. }
  109. if _, exists := receivedBody["extras"]; exists {
  110. t.Errorf("expected extras to be omitted, got %v", receivedBody["extras"])
  111. }
  112. }
  113. func TestSendMessageEndpointFormats(t *testing.T) {
  114. configureIntegrationAllowPrivateNetworksOption(t)
  115. requestCount := 0
  116. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  117. requestCount++
  118. if r.URL.Path != "/message" {
  119. t.Errorf("expected path /message, got %s", r.URL.Path)
  120. }
  121. w.WriteHeader(http.StatusOK)
  122. w.Write([]byte(`{"id":1}`))
  123. }))
  124. defer server.Close()
  125. testURLs := []string{
  126. server.URL,
  127. server.URL + "/",
  128. server.URL + "/message",
  129. server.URL + "/message/",
  130. }
  131. for _, u := range testURLs {
  132. client := NewClient(u, "test-token", 0)
  133. if err := client.SendMessage("title", "msg", 0, nil); err != nil {
  134. t.Errorf("failed for URL %q: %v", u, err)
  135. }
  136. }
  137. if requestCount != len(testURLs) {
  138. t.Errorf("expected %d requests, got %d", len(testURLs), requestCount)
  139. }
  140. }
  141. func TestSendMessageValidationErrors(t *testing.T) {
  142. tests := []struct {
  143. name string
  144. baseURL string
  145. token string
  146. message string
  147. priority int
  148. errContains string
  149. }{
  150. {
  151. name: "missing base URL",
  152. baseURL: "",
  153. token: "token",
  154. message: "hello",
  155. priority: 0,
  156. errContains: "gotify: missing base URL or token",
  157. },
  158. {
  159. name: "missing token",
  160. baseURL: "https://gotify.example.com",
  161. token: "",
  162. message: "hello",
  163. priority: 0,
  164. errContains: "gotify: missing base URL or token",
  165. },
  166. {
  167. name: "missing message",
  168. baseURL: "https://gotify.example.com",
  169. token: "token",
  170. message: "",
  171. priority: 0,
  172. errContains: "gotify: missing message",
  173. },
  174. {
  175. name: "negative priority",
  176. baseURL: "https://gotify.example.com",
  177. token: "token",
  178. message: "hello",
  179. priority: -1,
  180. errContains: "gotify: priority must be non-negative",
  181. },
  182. }
  183. for _, tt := range tests {
  184. t.Run(tt.name, func(t *testing.T) {
  185. client := NewClient(tt.baseURL, tt.token, 0)
  186. err := client.SendMessage("title", tt.message, tt.priority, nil)
  187. if err == nil {
  188. t.Fatalf("expected error, got nil")
  189. }
  190. if !strings.Contains(err.Error(), tt.errContains) {
  191. t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
  192. }
  193. })
  194. }
  195. }
  196. func TestSendMessageServerErrors(t *testing.T) {
  197. configureIntegrationAllowPrivateNetworksOption(t)
  198. tests := []struct {
  199. name string
  200. statusCode int
  201. response string
  202. errContains string
  203. }{
  204. {
  205. name: "unauthorized 401",
  206. statusCode: http.StatusUnauthorized,
  207. response: `{"error":"Unauthorized","errorCode":401}`,
  208. errContains: "status=401",
  209. },
  210. {
  211. name: "bad request 400",
  212. statusCode: http.StatusBadRequest,
  213. response: `{"error":"Bad Request","errorCode":400}`,
  214. errContains: "status=400",
  215. },
  216. {
  217. name: "internal server error 500",
  218. statusCode: http.StatusInternalServerError,
  219. response: `{"error":"Internal Error","errorCode":500}`,
  220. errContains: "status=500",
  221. },
  222. }
  223. for _, tt := range tests {
  224. t.Run(tt.name, func(t *testing.T) {
  225. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  226. w.WriteHeader(tt.statusCode)
  227. w.Write([]byte(tt.response))
  228. }))
  229. defer server.Close()
  230. client := NewClient(server.URL, "token", 0)
  231. err := client.SendMessage("title", "msg", 0, nil)
  232. if err == nil {
  233. t.Fatalf("expected error, got nil")
  234. }
  235. if !strings.Contains(err.Error(), tt.errContains) {
  236. t.Fatalf("expected error containing %q, got %q", tt.errContains, err.Error())
  237. }
  238. })
  239. }
  240. }
  241. func TestSendMessages(t *testing.T) {
  242. configureIntegrationAllowPrivateNetworksOption(t)
  243. var recordedRequests []map[string]any
  244. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  245. body, err := io.ReadAll(r.Body)
  246. if err != nil {
  247. t.Fatalf("failed to read body: %v", err)
  248. }
  249. var parsed map[string]any
  250. if err := json.Unmarshal(body, &parsed); err != nil {
  251. t.Fatalf("failed to unmarshal request body: %v", err)
  252. }
  253. recordedRequests = append(recordedRequests, parsed)
  254. w.WriteHeader(http.StatusOK)
  255. w.Write([]byte(`{"id":1}`))
  256. }))
  257. defer server.Close()
  258. client := NewClient(server.URL, "token", 6)
  259. feed := &model.Feed{Title: "Feed Title"}
  260. entries := model.Entries{
  261. {
  262. Title: "First Article",
  263. URL: "https://example.com/first",
  264. },
  265. {
  266. Title: "",
  267. URL: "https://example.com/second",
  268. },
  269. nil,
  270. }
  271. err := client.SendMessages(feed, entries)
  272. if err != nil {
  273. t.Fatalf("unexpected error: %v", err)
  274. }
  275. if len(recordedRequests) != 2 {
  276. t.Fatalf("expected 2 recorded requests, got %d", len(recordedRequests))
  277. }
  278. req1 := recordedRequests[0]
  279. if req1["title"] != "Feed Title" {
  280. t.Errorf("req1: expected title %q, got %v", "Feed Title", req1["title"])
  281. }
  282. if req1["message"] != "First Article" {
  283. t.Errorf("req1: expected message %q, got %v", "First Article", req1["message"])
  284. }
  285. if int(req1["priority"].(float64)) != 6 {
  286. t.Errorf("req1: expected priority 6, got %v", req1["priority"])
  287. }
  288. extra1 := req1["extras"].(map[string]any)["client::notification"].(map[string]any)["click"].(map[string]any)
  289. if extra1["url"] != "https://example.com/first" {
  290. t.Errorf("req1: expected click url %q, got %v", "https://example.com/first", extra1["url"])
  291. }
  292. req2 := recordedRequests[1]
  293. if req2["title"] != "Feed Title" {
  294. t.Errorf("req2: expected title %q, got %v", "Feed Title", req2["title"])
  295. }
  296. if req2["message"] != "https://example.com/second" {
  297. t.Errorf("req2: expected fallback message %q, got %v", "https://example.com/second", req2["message"])
  298. }
  299. extra2 := req2["extras"].(map[string]any)["client::notification"].(map[string]any)["click"].(map[string]any)
  300. if extra2["url"] != "https://example.com/second" {
  301. t.Errorf("req2: expected click url %q, got %v", "https://example.com/second", extra2["url"])
  302. }
  303. }
  304. func TestPayloadSchemaValidation(t *testing.T) {
  305. t.Run("valid minimal payload", func(t *testing.T) {
  306. payload := &Payload{
  307. Message: "A minimal notification message",
  308. }
  309. if err := payload.Validate(); err != nil {
  310. t.Fatalf("expected valid payload, got error: %v", err)
  311. }
  312. data, err := json.Marshal(payload)
  313. if err != nil {
  314. t.Fatalf("unexpected marshal error: %v", err)
  315. }
  316. var decoded map[string]any
  317. if err := json.Unmarshal(data, &decoded); err != nil {
  318. t.Fatalf("unexpected unmarshal error: %v", err)
  319. }
  320. if decoded["message"] != "A minimal notification message" {
  321. t.Errorf("expected message %q, got %v", "A minimal notification message", decoded["message"])
  322. }
  323. if _, ok := decoded["title"]; ok {
  324. t.Errorf("title should be omitted when empty")
  325. }
  326. if _, ok := decoded["priority"]; ok {
  327. t.Errorf("priority should be omitted when zero")
  328. }
  329. if _, ok := decoded["extras"]; ok {
  330. t.Errorf("extras should be omitted when nil")
  331. }
  332. })
  333. t.Run("valid complete payload with notification click extras", func(t *testing.T) {
  334. payload := &Payload{
  335. Title: "Feed Update",
  336. Message: "New post published",
  337. Priority: 5,
  338. Extras: map[string]any{
  339. "client::notification": map[string]any{
  340. "click": map[string]any{
  341. "url": "https://example.com/post",
  342. },
  343. },
  344. },
  345. }
  346. if err := payload.Validate(); err != nil {
  347. t.Fatalf("expected valid payload, got error: %v", err)
  348. }
  349. data, err := json.Marshal(payload)
  350. if err != nil {
  351. t.Fatalf("unexpected marshal error: %v", err)
  352. }
  353. var decoded map[string]any
  354. if err := json.Unmarshal(data, &decoded); err != nil {
  355. t.Fatalf("unexpected unmarshal error: %v", err)
  356. }
  357. if decoded["title"] != "Feed Update" {
  358. t.Errorf("expected title %q, got %v", "Feed Update", decoded["title"])
  359. }
  360. if decoded["message"] != "New post published" {
  361. t.Errorf("expected message %q, got %v", "New post published", decoded["message"])
  362. }
  363. if int(decoded["priority"].(float64)) != 5 {
  364. t.Errorf("expected priority 5, got %v", decoded["priority"])
  365. }
  366. extras, ok := decoded["extras"].(map[string]any)
  367. if !ok {
  368. t.Fatalf("expected extras map, got %v", decoded["extras"])
  369. }
  370. notif, ok := extras["client::notification"].(map[string]any)
  371. if !ok {
  372. t.Fatalf("expected client::notification map, got %v", extras)
  373. }
  374. click, ok := notif["click"].(map[string]any)
  375. if !ok {
  376. t.Fatalf("expected click map, got %v", notif)
  377. }
  378. if click["url"] != "https://example.com/post" {
  379. t.Errorf("expected click url %q, got %v", "https://example.com/post", click["url"])
  380. }
  381. })
  382. t.Run("invalid payload with empty message", func(t *testing.T) {
  383. payload := &Payload{
  384. Title: "Some Title",
  385. Message: "",
  386. }
  387. err := payload.Validate()
  388. if err == nil {
  389. t.Fatalf("expected error for empty message, got nil")
  390. }
  391. if !strings.Contains(err.Error(), "gotify: missing message") {
  392. t.Errorf("expected missing message error, got %v", err)
  393. }
  394. })
  395. t.Run("invalid payload with negative priority", func(t *testing.T) {
  396. payload := &Payload{
  397. Message: "Valid message",
  398. Priority: -2,
  399. }
  400. err := payload.Validate()
  401. if err == nil {
  402. t.Fatalf("expected error for negative priority, got nil")
  403. }
  404. if !strings.Contains(err.Error(), "gotify: priority must be non-negative") {
  405. t.Errorf("expected non-negative priority error, got %v", err)
  406. }
  407. })
  408. }
  409. func TestSendMessagesMissingConfigOrEmptyEntries(t *testing.T) {
  410. configureIntegrationAllowPrivateNetworksOption(t)
  411. t.Run("missing base URL returns error", func(t *testing.T) {
  412. client := NewClient("", "token", 0)
  413. err := client.SendMessages(&model.Feed{}, model.Entries{{Title: "Test"}})
  414. if err == nil {
  415. t.Fatal("expected error for missing base URL, got nil")
  416. }
  417. if !strings.Contains(err.Error(), "gotify: missing base URL or token") {
  418. t.Errorf("unexpected error: %v", err)
  419. }
  420. })
  421. t.Run("missing token returns error", func(t *testing.T) {
  422. client := NewClient("https://gotify.example.com", "", 0)
  423. err := client.SendMessages(&model.Feed{}, model.Entries{{Title: "Test"}})
  424. if err == nil {
  425. t.Fatal("expected error for missing token, got nil")
  426. }
  427. if !strings.Contains(err.Error(), "gotify: missing base URL or token") {
  428. t.Errorf("unexpected error: %v", err)
  429. }
  430. })
  431. t.Run("skip entries with empty title and empty url", func(t *testing.T) {
  432. requestCount := 0
  433. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  434. requestCount++
  435. w.WriteHeader(http.StatusOK)
  436. w.Write([]byte(`{"id":1}`))
  437. }))
  438. defer server.Close()
  439. client := NewClient(server.URL, "token", 0)
  440. feed := &model.Feed{Title: "Feed"}
  441. entries := model.Entries{
  442. nil,
  443. {Title: "", URL: ""},
  444. {Title: "Valid Title", URL: "https://example.com/item"},
  445. }
  446. if err := client.SendMessages(feed, entries); err != nil {
  447. t.Fatalf("unexpected error: %v", err)
  448. }
  449. if requestCount != 1 {
  450. t.Errorf("expected 1 request for valid entry, got %d", requestCount)
  451. }
  452. })
  453. }
  454. func TestNewClientPriorityClamping(t *testing.T) {
  455. client := NewClient("https://gotify.example.com", "token", -5)
  456. if client.priority != 0 {
  457. t.Errorf("expected negative priority clamped to 0, got %d", client.priority)
  458. }
  459. }
  460. func configureIntegrationAllowPrivateNetworksOption(t *testing.T) {
  461. t.Helper()
  462. t.Setenv("INTEGRATION_ALLOW_PRIVATE_NETWORKS", "1")
  463. configParser := config.NewConfigParser()
  464. parsedOptions, err := configParser.ParseEnvironmentVariables()
  465. if err != nil {
  466. t.Fatalf("Unable to configure test options: %v", err)
  467. }
  468. previousOptions := config.Opts
  469. config.Opts = parsedOptions
  470. t.Cleanup(func() {
  471. config.Opts = previousOptions
  472. })
  473. }