notifier.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
  2. import { WebhookNotifier } from "@/lib/webhook/notifier";
  3. import type { StructuredMessage } from "@/lib/webhook/types";
  4. describe("WebhookNotifier", () => {
  5. const mockFetch = vi.fn();
  6. beforeEach(() => {
  7. vi.stubGlobal("fetch", mockFetch);
  8. });
  9. afterEach(() => {
  10. vi.unstubAllGlobals();
  11. vi.clearAllMocks();
  12. });
  13. const createMessage = (): StructuredMessage => ({
  14. header: { title: "测试", level: "info" },
  15. sections: [],
  16. timestamp: new Date(),
  17. });
  18. describe("provider detection", () => {
  19. it("should detect wechat provider", () => {
  20. const notifier = new WebhookNotifier(
  21. "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
  22. );
  23. mockFetch.mockResolvedValue({
  24. ok: true,
  25. json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
  26. });
  27. expect(() => notifier.send(createMessage())).not.toThrow();
  28. });
  29. it("should detect feishu provider", () => {
  30. const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx");
  31. mockFetch.mockResolvedValue({
  32. ok: true,
  33. json: () => Promise.resolve({ code: 0, msg: "success" }),
  34. });
  35. expect(() => notifier.send(createMessage())).not.toThrow();
  36. });
  37. it("should throw for unsupported provider", () => {
  38. expect(() => new WebhookNotifier("https://unknown.com/webhook")).toThrow(
  39. "Unsupported webhook hostname: unknown.com"
  40. );
  41. });
  42. });
  43. describe("send", () => {
  44. it("should send message and return success", async () => {
  45. mockFetch.mockResolvedValue({
  46. ok: true,
  47. json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
  48. });
  49. const notifier = new WebhookNotifier(
  50. "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
  51. );
  52. const result = await notifier.send(createMessage());
  53. expect(result.success).toBe(true);
  54. expect(mockFetch).toHaveBeenCalledWith(
  55. "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",
  56. expect.objectContaining({
  57. method: "POST",
  58. headers: expect.objectContaining({ "Content-Type": "application/json" }),
  59. })
  60. );
  61. });
  62. it("should return error on API failure", async () => {
  63. mockFetch.mockResolvedValue({
  64. ok: true,
  65. json: () => Promise.resolve({ errcode: 40001, errmsg: "invalid token" }),
  66. });
  67. const notifier = new WebhookNotifier(
  68. "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",
  69. { maxRetries: 1 }
  70. );
  71. const result = await notifier.send(createMessage());
  72. expect(result.success).toBe(false);
  73. expect(result.error).toContain("40001");
  74. });
  75. it("should retry on network failure", async () => {
  76. mockFetch.mockRejectedValueOnce(new Error("network error")).mockResolvedValue({
  77. ok: true,
  78. json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
  79. });
  80. const notifier = new WebhookNotifier(
  81. "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",
  82. { maxRetries: 2 }
  83. );
  84. const result = await notifier.send(createMessage());
  85. expect(result.success).toBe(true);
  86. expect(mockFetch).toHaveBeenCalledTimes(2);
  87. });
  88. it("should send dingtalk message with signature params", async () => {
  89. vi.spyOn(Date, "now").mockReturnValue(1700000000000);
  90. mockFetch.mockResolvedValue({
  91. ok: true,
  92. json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
  93. });
  94. const notifier = new WebhookNotifier({
  95. providerType: "dingtalk",
  96. webhookUrl: "https://oapi.dingtalk.com/robot/send?access_token=token",
  97. dingtalkSecret: "secret",
  98. });
  99. const result = await notifier.send(createMessage());
  100. expect(result.success).toBe(true);
  101. const calledUrl = String(mockFetch.mock.calls[0]?.[0]);
  102. const url = new URL(calledUrl);
  103. expect(url.searchParams.get("access_token")).toBe("token");
  104. expect(url.searchParams.get("timestamp")).toBe("1700000000000");
  105. expect(url.searchParams.get("sign")).toBeTruthy();
  106. });
  107. it("should send telegram message to bot endpoint", async () => {
  108. mockFetch.mockResolvedValue({
  109. ok: true,
  110. json: () => Promise.resolve({ ok: true, result: {} }),
  111. });
  112. const notifier = new WebhookNotifier({
  113. providerType: "telegram",
  114. telegramBotToken: "token",
  115. telegramChatId: "123",
  116. });
  117. const result = await notifier.send(createMessage());
  118. expect(result.success).toBe(true);
  119. expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
  120. "https://api.telegram.org/bottoken/sendMessage"
  121. );
  122. const init = mockFetch.mock.calls[0]?.[1] as any;
  123. const body = JSON.parse(init.body) as any;
  124. expect(body.chat_id).toBe("123");
  125. expect(body.parse_mode).toBe("HTML");
  126. });
  127. it("should treat custom webhook as success without parsing json", async () => {
  128. const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));
  129. mockFetch.mockResolvedValue({
  130. ok: true,
  131. arrayBuffer,
  132. });
  133. const notifier = new WebhookNotifier({
  134. providerType: "custom",
  135. webhookUrl: "https://example.com/hook",
  136. customTemplate: { text: "title={{title}}" },
  137. customHeaders: { "X-Test": "1" },
  138. });
  139. const result = await notifier.send(createMessage(), {
  140. notificationType: "circuit_breaker",
  141. data: { providerName: "OpenAI" },
  142. });
  143. expect(result.success).toBe(true);
  144. expect(arrayBuffer).toHaveBeenCalledTimes(1);
  145. const init = mockFetch.mock.calls[0]?.[1] as any;
  146. expect(init.headers["X-Test"]).toBe("1");
  147. });
  148. it("should include error body when webhook returns non-2xx", async () => {
  149. mockFetch.mockResolvedValue({
  150. ok: false,
  151. status: 500,
  152. statusText: "Internal Server Error",
  153. text: () => Promise.resolve("oops"),
  154. });
  155. const notifier = new WebhookNotifier(
  156. {
  157. providerType: "custom",
  158. webhookUrl: "https://example.com/hook",
  159. customTemplate: { text: "title={{title}}" },
  160. },
  161. { maxRetries: 1 }
  162. );
  163. const result = await notifier.send(createMessage());
  164. expect(result.success).toBe(false);
  165. expect(result.error).toContain("HTTP 500");
  166. expect(result.error).toContain("oops");
  167. });
  168. });
  169. describe("feishu response handling", () => {
  170. it("should handle feishu success response", async () => {
  171. mockFetch.mockResolvedValue({
  172. ok: true,
  173. json: () => Promise.resolve({ code: 0, msg: "success", data: {} }),
  174. });
  175. const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx");
  176. const result = await notifier.send(createMessage());
  177. expect(result.success).toBe(true);
  178. });
  179. it("should handle feishu error response", async () => {
  180. mockFetch.mockResolvedValue({
  181. ok: true,
  182. json: () => Promise.resolve({ code: 19024, msg: "Key Words Not Found" }),
  183. });
  184. const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx", {
  185. maxRetries: 1,
  186. });
  187. const result = await notifier.send(createMessage());
  188. expect(result.success).toBe(false);
  189. expect(result.error).toContain("19024");
  190. });
  191. });
  192. });