| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
- import { WebhookNotifier } from "@/lib/webhook/notifier";
- import type { StructuredMessage } from "@/lib/webhook/types";
- describe("WebhookNotifier", () => {
- const mockFetch = vi.fn();
- beforeEach(() => {
- vi.stubGlobal("fetch", mockFetch);
- });
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.clearAllMocks();
- });
- const createMessage = (): StructuredMessage => ({
- header: { title: "测试", level: "info" },
- sections: [],
- timestamp: new Date(),
- });
- describe("provider detection", () => {
- it("should detect wechat provider", () => {
- const notifier = new WebhookNotifier(
- "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
- );
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
- });
- expect(() => notifier.send(createMessage())).not.toThrow();
- });
- it("should detect feishu provider", () => {
- const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx");
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ code: 0, msg: "success" }),
- });
- expect(() => notifier.send(createMessage())).not.toThrow();
- });
- it("should throw for unsupported provider", () => {
- expect(() => new WebhookNotifier("https://unknown.com/webhook")).toThrow(
- "Unsupported webhook hostname: unknown.com"
- );
- });
- });
- describe("send", () => {
- it("should send message and return success", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
- });
- const notifier = new WebhookNotifier(
- "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
- );
- const result = await notifier.send(createMessage());
- expect(result.success).toBe(true);
- expect(mockFetch).toHaveBeenCalledWith(
- "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",
- expect.objectContaining({
- method: "POST",
- headers: expect.objectContaining({ "Content-Type": "application/json" }),
- })
- );
- });
- it("should return error on API failure", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ errcode: 40001, errmsg: "invalid token" }),
- });
- const notifier = new WebhookNotifier(
- "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",
- { maxRetries: 1 }
- );
- const result = await notifier.send(createMessage());
- expect(result.success).toBe(false);
- expect(result.error).toContain("40001");
- });
- it("should retry on network failure", async () => {
- mockFetch.mockRejectedValueOnce(new Error("network error")).mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
- });
- const notifier = new WebhookNotifier(
- "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx",
- { maxRetries: 2 }
- );
- const result = await notifier.send(createMessage());
- expect(result.success).toBe(true);
- expect(mockFetch).toHaveBeenCalledTimes(2);
- });
- it("should send dingtalk message with signature params", async () => {
- vi.spyOn(Date, "now").mockReturnValue(1700000000000);
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ errcode: 0, errmsg: "ok" }),
- });
- const notifier = new WebhookNotifier({
- providerType: "dingtalk",
- webhookUrl: "https://oapi.dingtalk.com/robot/send?access_token=token",
- dingtalkSecret: "secret",
- });
- const result = await notifier.send(createMessage());
- expect(result.success).toBe(true);
- const calledUrl = String(mockFetch.mock.calls[0]?.[0]);
- const url = new URL(calledUrl);
- expect(url.searchParams.get("access_token")).toBe("token");
- expect(url.searchParams.get("timestamp")).toBe("1700000000000");
- expect(url.searchParams.get("sign")).toBeTruthy();
- });
- it("should send telegram message to bot endpoint", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ ok: true, result: {} }),
- });
- const notifier = new WebhookNotifier({
- providerType: "telegram",
- telegramBotToken: "token",
- telegramChatId: "123",
- });
- const result = await notifier.send(createMessage());
- expect(result.success).toBe(true);
- expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
- "https://api.telegram.org/bottoken/sendMessage"
- );
- const init = mockFetch.mock.calls[0]?.[1] as any;
- const body = JSON.parse(init.body) as any;
- expect(body.chat_id).toBe("123");
- expect(body.parse_mode).toBe("HTML");
- });
- it("should treat custom webhook as success without parsing json", async () => {
- const arrayBuffer = vi.fn(async () => new ArrayBuffer(0));
- mockFetch.mockResolvedValue({
- ok: true,
- arrayBuffer,
- });
- const notifier = new WebhookNotifier({
- providerType: "custom",
- webhookUrl: "https://example.com/hook",
- customTemplate: { text: "title={{title}}" },
- customHeaders: { "X-Test": "1" },
- });
- const result = await notifier.send(createMessage(), {
- notificationType: "circuit_breaker",
- data: { providerName: "OpenAI" },
- });
- expect(result.success).toBe(true);
- expect(arrayBuffer).toHaveBeenCalledTimes(1);
- const init = mockFetch.mock.calls[0]?.[1] as any;
- expect(init.headers["X-Test"]).toBe("1");
- });
- it("should include error body when webhook returns non-2xx", async () => {
- mockFetch.mockResolvedValue({
- ok: false,
- status: 500,
- statusText: "Internal Server Error",
- text: () => Promise.resolve("oops"),
- });
- const notifier = new WebhookNotifier(
- {
- providerType: "custom",
- webhookUrl: "https://example.com/hook",
- customTemplate: { text: "title={{title}}" },
- },
- { maxRetries: 1 }
- );
- const result = await notifier.send(createMessage());
- expect(result.success).toBe(false);
- expect(result.error).toContain("HTTP 500");
- expect(result.error).toContain("oops");
- });
- });
- describe("feishu response handling", () => {
- it("should handle feishu success response", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ code: 0, msg: "success", data: {} }),
- });
- const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx");
- const result = await notifier.send(createMessage());
- expect(result.success).toBe(true);
- });
- it("should handle feishu error response", async () => {
- mockFetch.mockResolvedValue({
- ok: true,
- json: () => Promise.resolve({ code: 19024, msg: "Key Words Not Found" }),
- });
- const notifier = new WebhookNotifier("https://open.feishu.cn/open-apis/bot/v2/hook/xxx", {
- maxRetries: 1,
- });
- const result = await notifier.send(createMessage());
- expect(result.success).toBe(false);
- expect(result.error).toContain("19024");
- });
- });
- });
|