325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
|
* Integration tests for notifier-webhook.
|
|
*
|
|
* Mocks ONLY the I/O boundary: global fetch + vi.useFakeTimers().
|
|
* Everything else runs for real: config parsing, retry logic, payload serialization.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import type { NotifyAction } from "@aoagents/ao-core";
|
|
import webhookPlugin from "@aoagents/ao-plugin-notifier-webhook";
|
|
import { makeEvent } from "./helpers/event-factory.js";
|
|
|
|
describe("notifier-webhook integration", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("payload serialization", () => {
|
|
it("notify serializes event with ISO timestamp and preserves data", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({ url: "https://example.com/hook" });
|
|
const event = makeEvent({
|
|
type: "ci.failing",
|
|
priority: "action",
|
|
message: "CI check failed",
|
|
data: { checkName: "lint" },
|
|
});
|
|
await notifier.notify(event);
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.type).toBe("notification");
|
|
expect(body.event.id).toBe("evt-test-1");
|
|
expect(body.event.type).toBe("ci.failing");
|
|
expect(body.event.priority).toBe("action");
|
|
expect(body.event.sessionId).toBe("app-1");
|
|
expect(body.event.projectId).toBe("my-project");
|
|
expect(body.event.timestamp).toBe("2025-06-15T12:00:00.000Z");
|
|
expect(body.event.message).toBe("CI check failed");
|
|
expect(body.event.data).toEqual({ checkName: "lint" });
|
|
});
|
|
|
|
it("notifyWithActions includes actions array in payload", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const actions: NotifyAction[] = [
|
|
{ label: "Merge", url: "https://github.com/merge" },
|
|
{ label: "Kill", callbackEndpoint: "/api/kill" },
|
|
];
|
|
|
|
const notifier = webhookPlugin.create({ url: "https://example.com/hook" });
|
|
await notifier.notifyWithActions!(makeEvent(), actions);
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.type).toBe("notification_with_actions");
|
|
expect(body.actions).toEqual([
|
|
{ label: "Merge", url: "https://github.com/merge", callbackEndpoint: undefined },
|
|
{ label: "Kill", url: undefined, callbackEndpoint: "/api/kill" },
|
|
]);
|
|
});
|
|
|
|
it("post includes message and context in payload", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({ url: "https://example.com/hook" });
|
|
await notifier.post!("All sessions done", { projectId: "my-project", channel: "#ops" });
|
|
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.type).toBe("message");
|
|
expect(body.message).toBe("All sessions done");
|
|
expect(body.context).toEqual({ projectId: "my-project", channel: "#ops" });
|
|
});
|
|
});
|
|
|
|
describe("custom headers", () => {
|
|
it("merges custom headers with Content-Type", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
headers: { Authorization: "Bearer tok", "X-Custom": "val" },
|
|
});
|
|
await notifier.notify(makeEvent());
|
|
|
|
const headers = fetchMock.mock.calls[0][1].headers;
|
|
expect(headers["Content-Type"]).toBe("application/json");
|
|
expect(headers["Authorization"]).toBe("Bearer tok");
|
|
expect(headers["X-Custom"]).toBe("val");
|
|
});
|
|
|
|
it("ignores non-string header values", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
headers: { valid: "yes", invalid: 42 },
|
|
});
|
|
await notifier.notify(makeEvent());
|
|
|
|
const headers = fetchMock.mock.calls[0][1].headers;
|
|
expect(headers["valid"]).toBe("yes");
|
|
expect(headers["invalid"]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("retry timing with fake timers", () => {
|
|
it("retries=2, retryDelayMs=100: 503, 503, 200 -> 3 fetch calls with exponential backoff", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 503,
|
|
text: () => Promise.resolve("unavailable"),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 503,
|
|
text: () => Promise.resolve("unavailable"),
|
|
})
|
|
.mockResolvedValueOnce({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
retries: 2,
|
|
retryDelayMs: 100,
|
|
});
|
|
|
|
const promise = notifier.notify(makeEvent());
|
|
|
|
// First attempt happens immediately
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
// First retry after 100ms (100 * 2^0)
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
|
|
// Second retry after 200ms (100 * 2^1)
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
|
|
await promise;
|
|
});
|
|
|
|
it("retries=1, retryDelayMs=500: delay * 2^0 = 500ms for first retry", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("error") })
|
|
.mockResolvedValueOnce({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
retries: 1,
|
|
retryDelayMs: 500,
|
|
});
|
|
|
|
const promise = notifier.notify(makeEvent());
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
// At 499ms: not yet retried
|
|
await vi.advanceTimersByTimeAsync(499);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
// At 500ms: retry fires
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
|
|
await promise;
|
|
});
|
|
});
|
|
|
|
describe("non-retryable errors", () => {
|
|
it.each([400, 401, 403, 404])(
|
|
"HTTP %d -> single fetch call, immediate rejection",
|
|
async (status) => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status,
|
|
text: () => Promise.resolve("client error"),
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
retries: 3,
|
|
retryDelayMs: 1,
|
|
});
|
|
|
|
await expect(notifier.notify(makeEvent())).rejects.toThrow(
|
|
`Webhook POST failed (${status})`,
|
|
);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("retryable errors", () => {
|
|
it("429 is retried", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
text: () => Promise.resolve("rate limited"),
|
|
})
|
|
.mockResolvedValueOnce({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
retries: 1,
|
|
retryDelayMs: 1,
|
|
});
|
|
await notifier.notify(makeEvent());
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("network errors (ECONNREFUSED) are retried", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
|
.mockResolvedValueOnce({ ok: true });
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
retries: 1,
|
|
retryDelayMs: 1,
|
|
});
|
|
await notifier.notify(makeEvent());
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("throws after all retries exhausted", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: () => Promise.resolve("server error"),
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
retries: 2,
|
|
retryDelayMs: 1,
|
|
});
|
|
|
|
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (500)");
|
|
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
|
|
});
|
|
});
|
|
|
|
describe("config validation", () => {
|
|
it("throws on non-http(s) URL", () => {
|
|
expect(() => webhookPlugin.create({ url: "ftp://example.com" })).toThrow("must be http(s)");
|
|
});
|
|
|
|
it("accepts http URL", () => {
|
|
expect(() => webhookPlugin.create({ url: "http://localhost:3000/hook" })).not.toThrow();
|
|
});
|
|
|
|
it("retries defaults to 2 when not specified", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: () => Promise.resolve("error"),
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({ url: "https://example.com/hook", retryDelayMs: 1 });
|
|
await expect(notifier.notify(makeEvent())).rejects.toThrow();
|
|
expect(fetchMock).toHaveBeenCalledTimes(3); // 1 + 2 default retries
|
|
});
|
|
|
|
it("retries=0 means no retries", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 500,
|
|
text: () => Promise.resolve("error"),
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const notifier = webhookPlugin.create({
|
|
url: "https://example.com/hook",
|
|
retries: 0,
|
|
retryDelayMs: 1,
|
|
});
|
|
await expect(notifier.notify(makeEvent())).rejects.toThrow();
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("no-op behavior", () => {
|
|
it("all methods no-op when no URL configured", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const notifier = webhookPlugin.create();
|
|
await notifier.notify(makeEvent());
|
|
await notifier.notifyWithActions!(makeEvent(), [{ label: "Test" }]);
|
|
const result = await notifier.post!("msg");
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
});
|