agent-orchestrator/packages/plugins/notifier-webhook/src/index.test.ts

309 lines
9.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { OrchestratorEvent, NotifyAction } from "@composio/ao-core";
import { manifest, create } from "./index.js";
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
return {
id: "evt-1",
type: "ci.failing",
priority: "action",
sessionId: "app-1",
projectId: "my-project",
timestamp: new Date("2025-06-15T12:00:00Z"),
message: "CI check failed on app-1",
data: { checkName: "lint" },
...overrides,
};
}
describe("notifier-webhook", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("manifest", () => {
it("has correct metadata", () => {
expect(manifest.name).toBe("webhook");
expect(manifest.slot).toBe("notifier");
expect(manifest.version).toBe("0.1.0");
});
});
describe("create", () => {
it("returns a notifier with name 'webhook'", () => {
const notifier = create({ url: "https://example.com/hook" });
expect(notifier.name).toBe("webhook");
});
it("warns when no url configured", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
create();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No url configured"));
});
it("throws on invalid URL scheme", () => {
expect(() => create({ url: "ftp://example.com" })).toThrow("must be http(s)");
});
it("throws on javascript: URL", () => {
expect(() => create({ url: "javascript:alert(1)" })).toThrow("must be http(s)");
});
it("accepts http:// URL", () => {
expect(() => create({ url: "http://localhost:8080/hook" })).not.toThrow();
});
it("accepts https:// URL", () => {
expect(() => create({ url: "https://example.com/hook" })).not.toThrow();
});
});
describe("notify", () => {
it("does nothing when no url", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const notifier = create();
await notifier.notify(makeEvent());
expect(fetchMock).not.toHaveBeenCalled();
});
it("POSTs event as JSON to the configured URL", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ url: "https://example.com/hook" });
await notifier.notify(makeEvent());
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe("https://example.com/hook");
const opts = fetchMock.mock.calls[0][1];
expect(opts.method).toBe("POST");
expect(opts.headers["Content-Type"]).toBe("application/json");
const body = JSON.parse(opts.body);
expect(body.type).toBe("notification");
expect(body.event.id).toBe("evt-1");
expect(body.event.sessionId).toBe("app-1");
expect(body.event.type).toBe("ci.failing");
});
it("serializes event timestamp as ISO string", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ url: "https://example.com/hook" });
await notifier.notify(makeEvent());
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.event.timestamp).toBe("2025-06-15T12:00:00.000Z");
});
it("includes custom headers", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
headers: { Authorization: "Bearer tok123" },
});
await notifier.notify(makeEvent());
const headers = fetchMock.mock.calls[0][1].headers;
expect(headers["Authorization"]).toBe("Bearer tok123");
expect(headers["Content-Type"]).toBe("application/json");
});
});
describe("notifyWithActions", () => {
it("includes actions in payload", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const actions: NotifyAction[] = [
{ label: "Merge", url: "https://github.com/pull/1/merge" },
{ label: "Kill", callbackEndpoint: "/api/kill/app-1" },
];
const notifier = 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).toHaveLength(2);
});
});
describe("post", () => {
it("sends a plain message with context", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({ url: "https://example.com/hook" });
const result = await notifier.post!("All sessions complete", {
projectId: "my-project",
});
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.type).toBe("message");
expect(body.message).toBe("All sessions complete");
expect(result).toBeNull();
});
});
describe("retry logic", () => {
it("retries on 5xx and succeeds", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 503,
text: () => Promise.resolve("unavailable"),
})
.mockResolvedValueOnce({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 2,
retryDelayMs: 1,
});
await notifier.notify(makeEvent());
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("retries on 429 Too Many Requests", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 429,
text: () => Promise.resolve("rate limited"),
})
.mockResolvedValueOnce({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 2,
retryDelayMs: 1,
});
await notifier.notify(makeEvent());
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does NOT retry on 400 Bad Request", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve("bad request") });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 2,
retryDelayMs: 1,
});
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (400)");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does NOT retry on 401 Unauthorized", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 2,
retryDelayMs: 1,
});
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (401)");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does NOT retry on 403 Forbidden", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: false, status: 403, text: () => Promise.resolve("forbidden") });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 2,
retryDelayMs: 1,
});
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (403)");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does NOT retry on 404 Not Found", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: false, status: 404, text: () => Promise.resolve("not found") });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 2,
retryDelayMs: 1,
});
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed (404)");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("throws after all retries exhausted on 5xx", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("error") });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 2,
retryDelayMs: 1,
});
await expect(notifier.notify(makeEvent())).rejects.toThrow(
"Webhook POST failed (500): error",
);
// 1 initial + 2 retries = 3
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("retries on network errors", async () => {
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
.mockResolvedValueOnce({ ok: true });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 1,
retryDelayMs: 1,
});
await notifier.notify(makeEvent());
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("respects retries=0 (no retries)", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("fail") });
vi.stubGlobal("fetch", fetchMock);
const notifier = create({
url: "https://example.com/hook",
retries: 0,
retryDelayMs: 1,
});
await expect(notifier.notify(makeEvent())).rejects.toThrow("Webhook POST failed");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
});