diff --git a/README.md b/README.md index 1af14ce..4e176ae 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,31 @@ jobs: | `include_run_url` | no | `true` | Include the GitHub Actions run URL. | | `fail_on_error` | no | `false` | Fail the workflow if the Stoat request fails. | | `timeout_ms` | no | `10000` | HTTP timeout in milliseconds. | +| `dry_run` | no | `false` | Skip the HTTP request and report `status=dry_run` instead of sending. | + +## Outputs + +| Nom | Valeurs possibles | Description | +| --- | --- | --- | +| `sent` | `true` / `false` | `true` si le webhook a été envoyé avec succès, sinon `false`. | +| `status` | `sent` / `failed` / `skipped` / `dry_run` | Statut final de la notification. | +| `error` | chaîne courte / vide | Message d'erreur court (chaîne vide en cas de succès). La valeur de `webhook_url` est expurgée avant publication. | +| `attempts` | entier ≥ 0 | Nombre de tentatives HTTP effectuées (`0` si erreur de configuration, `dry_run` ou résolution sans correspondance ; `1` ou `2` selon que le retry 429 a été déclenché). | + +Example consumer workflow: + +```yaml +- name: Notify Stoat + id: notify + uses: systm-d/stoat-github-notify@v1 + with: + webhook_url: ${{ secrets.STOAT_WEBHOOK_URL }} + event: ci_failed + +- name: Record delivery failure + if: steps.notify.outputs.status == 'failed' + run: echo "Stoat notification failed after ${{ steps.notify.outputs.attempts }} attempt(s): ${{ steps.notify.outputs.error }}" +``` ## Event types diff --git a/action.yml b/action.yml index b4451d3..aebd950 100644 --- a/action.yml +++ b/action.yml @@ -47,6 +47,20 @@ inputs: description: "HTTP timeout in milliseconds" required: false default: "10000" + dry_run: + description: "Skip the HTTP request and report status=dry_run instead of sending the notification" + required: false + default: "false" + +outputs: + sent: + description: "'true' si le webhook a été envoyé avec succès, sinon 'false'" + status: + description: "Statut de la notification : sent | failed | skipped | dry_run" + error: + description: "Message d'erreur court, chaîne vide en cas de succès" + attempts: + description: "Nombre de tentatives HTTP effectuées (0 si erreur de configuration)" runs: using: "node20" diff --git a/dist/config.js b/dist/config.js index fd04104..8b12cc7 100644 --- a/dist/config.js +++ b/dist/config.js @@ -25,6 +25,7 @@ export function readConfig(env = process.env) { includeRunUrl: readBooleanInput(env, "include_run_url", true), failOnError: readBooleanInput(env, "fail_on_error", false), timeoutMs: readPositiveInteger(readInput(env, "timeout_ms") || "10000", "timeout_ms"), + dryRun: readBooleanInput(env, "dry_run", false), }; } export function maskSecret(value) { diff --git a/dist/index.js b/dist/index.js index 1e1bf9e..f3ed58c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,30 +1,67 @@ +import { appendFileSync } from "node:fs"; import { maskSecret, readConfig } from "./config.js"; import { readGitHubContext } from "./github-context.js"; import { buildPayload } from "./message-builder.js"; -import { sendStoatWebhook } from "./stoat-client.js"; +import { sendStoatWebhook, WebhookError } from "./stoat-client.js"; export async function run() { let failOnError = false; + let webhookUrl = ""; try { const config = readConfig(); failOnError = config.failOnError; + webhookUrl = config.webhookUrl; maskSecret(config.webhookUrl); + if (config.dryRun) { + writeOutputs({ sent: false, status: "dry_run", error: "", attempts: 0 }); + info("Stoat notification skipped (dry_run)."); + return; + } const context = readGitHubContext(); const payload = buildPayload(config, context); - await sendStoatWebhook(payload, config.webhookUrl, { + if (payload === null) { + writeOutputs({ sent: false, status: "skipped", error: "", attempts: 0 }); + info(`Stoat notification skipped: no template matches event '${context.eventName}'.`); + return; + } + const { attempts } = await sendStoatWebhook(payload, config.webhookUrl, { timeoutMs: config.timeoutMs, }); + writeOutputs({ sent: true, status: "sent", error: "", attempts }); info("Stoat notification sent."); } catch (error) { const message = error instanceof Error ? error.message : String(error); + const attempts = error instanceof WebhookError ? error.attempts : 0; + const sanitized = sanitizeError(message, webhookUrl); + writeOutputs({ sent: false, status: "failed", error: sanitized, attempts }); if (failOnError || isConfigurationError(message)) { - setFailed(message); + setFailed(sanitized); } else { - warning(message); + warning(sanitized); } } } +function writeOutputs(record) { + setOutput("sent", record.sent ? "true" : "false"); + setOutput("status", record.status); + setOutput("error", record.error); + setOutput("attempts", String(record.attempts)); +} +export function setOutput(name, value) { + const path = process.env.GITHUB_OUTPUT; + if (!path) { + return; + } + appendFileSync(path, `${name}=${value}\n`); +} +export function sanitizeError(message, webhookUrl) { + let sanitized = message; + if (webhookUrl) { + sanitized = sanitized.split(webhookUrl).join("[url]"); + } + return sanitized.replace(/https?:\/\/\S+/g, "[url]"); +} function isConfigurationError(message) { return (message.startsWith("Missing required input") || message.startsWith("Invalid ") || @@ -40,4 +77,6 @@ function setFailed(message) { console.log(`::error::${message}`); process.exitCode = 1; } -void run(); +if (!process.env.VITEST) { + void run(); +} diff --git a/dist/message-builder.js b/dist/message-builder.js index d2b2249..7eab4f5 100644 --- a/dist/message-builder.js +++ b/dist/message-builder.js @@ -1,6 +1,9 @@ import { buildRunUrl } from "./github-context.js"; export function buildPayload(config, context) { const event = resolveEvent(config.event, context); + if (event === null) { + return null; + } const runUrl = buildRunUrl(context); const title = config.title || buildDefaultTitle(event, context); const content = config.message || buildDefaultContent(title, context, runUrl); @@ -11,7 +14,7 @@ export function buildPayload(config, context) { embeds: [ { title, - description: buildDescription(config, context, runUrl), + description: buildDescription(config, event, context, runUrl), }, ], }; @@ -33,7 +36,7 @@ export function resolveEvent(event, context) { if (context.eventName === "push") { return "push"; } - return "custom"; + return null; } export function buildDefaultTitle(event, context) { switch (event) { @@ -59,8 +62,7 @@ function buildDefaultContent(title, context, runUrl) { const suffix = runUrl ? `\n${runUrl}` : ""; return `${title} on ${context.repository}${suffix}`; } -function buildDescription(config, context, runUrl) { - const event = resolveEvent(config.event, context); +function buildDescription(config, event, context, runUrl) { const lines = buildEventLines(event, context); lines.push(`Event: ${context.eventName}`); if (context.workflow) { diff --git a/dist/stoat-client.js b/dist/stoat-client.js index f405ead..dfeac56 100644 --- a/dist/stoat-client.js +++ b/dist/stoat-client.js @@ -1,3 +1,11 @@ +export class WebhookError extends Error { + attempts; + constructor(message, attempts) { + super(message); + this.attempts = attempts; + this.name = "WebhookError"; + } +} export async function sendStoatWebhook(payload, webhookUrl, options) { const fetchFn = options.fetchFn || fetch; const sleepFn = options.sleepFn || sleep; @@ -7,13 +15,14 @@ export async function sendStoatWebhook(payload, webhookUrl, options) { await sleepFn(retryAfter); const retryResponse = await postPayload(fetchFn, webhookUrl, payload, options.timeoutMs); if (!retryResponse.ok) { - throw new Error(await buildFailureMessage(retryResponse)); + throw new WebhookError(await buildFailureMessage(retryResponse), 2); } - return; + return { attempts: 2 }; } if (!response.ok) { - throw new Error(await buildFailureMessage(response)); + throw new WebhookError(await buildFailureMessage(response), 1); } + return { attempts: 1 }; } async function postPayload(fetchFn, webhookUrl, payload, timeoutMs) { const controller = new AbortController(); diff --git a/src/config.ts b/src/config.ts index cede4d8..52689ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ export interface ActionConfig { includeRunUrl: boolean; failOnError: boolean; timeoutMs: number; + dryRun: boolean; } const eventTypes = new Set([ @@ -53,6 +54,7 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): ActionConfig { includeRunUrl: readBooleanInput(env, "include_run_url", true), failOnError: readBooleanInput(env, "fail_on_error", false), timeoutMs: readPositiveInteger(readInput(env, "timeout_ms") || "10000", "timeout_ms"), + dryRun: readBooleanInput(env, "dry_run", false), }; } diff --git a/src/index.ts b/src/index.ts index c73dc0f..1519793 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,35 +1,89 @@ +import { appendFileSync } from "node:fs"; import { maskSecret, readConfig } from "./config.js"; import { readGitHubContext } from "./github-context.js"; import { buildPayload } from "./message-builder.js"; -import { sendStoatWebhook } from "./stoat-client.js"; +import { sendStoatWebhook, WebhookError } from "./stoat-client.js"; export async function run(): Promise { let failOnError = false; + let webhookUrl = ""; try { const config = readConfig(); failOnError = config.failOnError; + webhookUrl = config.webhookUrl; maskSecret(config.webhookUrl); + if (config.dryRun) { + writeOutputs({ sent: false, status: "dry_run", error: "", attempts: 0 }); + info("Stoat notification skipped (dry_run)."); + return; + } + const context = readGitHubContext(); const payload = buildPayload(config, context); - await sendStoatWebhook(payload, config.webhookUrl, { + if (payload === null) { + writeOutputs({ sent: false, status: "skipped", error: "", attempts: 0 }); + info(`Stoat notification skipped: no template matches event '${context.eventName}'.`); + return; + } + + const { attempts } = await sendStoatWebhook(payload, config.webhookUrl, { timeoutMs: config.timeoutMs, }); + writeOutputs({ sent: true, status: "sent", error: "", attempts }); info("Stoat notification sent."); } catch (error) { const message = error instanceof Error ? error.message : String(error); + const attempts = error instanceof WebhookError ? error.attempts : 0; + const sanitized = sanitizeError(message, webhookUrl); + + writeOutputs({ sent: false, status: "failed", error: sanitized, attempts }); if (failOnError || isConfigurationError(message)) { - setFailed(message); + setFailed(sanitized); } else { - warning(message); + warning(sanitized); } } } +interface OutputRecord { + sent: boolean; + status: "sent" | "failed" | "skipped" | "dry_run"; + error: string; + attempts: number; +} + +function writeOutputs(record: OutputRecord): void { + setOutput("sent", record.sent ? "true" : "false"); + setOutput("status", record.status); + setOutput("error", record.error); + setOutput("attempts", String(record.attempts)); +} + +export function setOutput(name: string, value: string): void { + const path = process.env.GITHUB_OUTPUT; + + if (!path) { + return; + } + + appendFileSync(path, `${name}=${value}\n`); +} + +export function sanitizeError(message: string, webhookUrl: string): string { + let sanitized = message; + + if (webhookUrl) { + sanitized = sanitized.split(webhookUrl).join("[url]"); + } + + return sanitized.replace(/https?:\/\/\S+/g, "[url]"); +} + function isConfigurationError(message: string): boolean { return ( message.startsWith("Missing required input") || @@ -51,4 +105,6 @@ function setFailed(message: string): void { process.exitCode = 1; } -void run(); +if (!process.env.VITEST) { + void run(); +} diff --git a/src/message-builder.ts b/src/message-builder.ts index 1f13de9..39735e7 100644 --- a/src/message-builder.ts +++ b/src/message-builder.ts @@ -14,8 +14,13 @@ export interface StoatPayload { embeds: StoatEmbed[]; } -export function buildPayload(config: ActionConfig, context: GitHubContext): StoatPayload { +export function buildPayload(config: ActionConfig, context: GitHubContext): StoatPayload | null { const event = resolveEvent(config.event, context); + + if (event === null) { + return null; + } + const runUrl = buildRunUrl(context); const title = config.title || buildDefaultTitle(event, context); const content = config.message || buildDefaultContent(title, context, runUrl); @@ -27,13 +32,13 @@ export function buildPayload(config: ActionConfig, context: GitHubContext): Stoa embeds: [ { title, - description: buildDescription(config, context, runUrl), + description: buildDescription(config, event, context, runUrl), }, ], }; } -export function resolveEvent(event: EventType, context: GitHubContext): EventType { +export function resolveEvent(event: EventType, context: GitHubContext): EventType | null { if (event !== "auto") { return event; } @@ -56,7 +61,7 @@ export function resolveEvent(event: EventType, context: GitHubContext): EventTyp return "push"; } - return "custom"; + return null; } export function buildDefaultTitle(event: EventType, context: GitHubContext): string { @@ -86,8 +91,7 @@ function buildDefaultContent(title: string, context: GitHubContext, runUrl: stri return `${title} on ${context.repository}${suffix}`; } -function buildDescription(config: ActionConfig, context: GitHubContext, runUrl: string): string { - const event = resolveEvent(config.event, context); +function buildDescription(config: ActionConfig, event: EventType, context: GitHubContext, runUrl: string): string { const lines = buildEventLines(event, context); lines.push(`Event: ${context.eventName}`); diff --git a/src/stoat-client.ts b/src/stoat-client.ts index a1f7fe1..1fb55b3 100644 --- a/src/stoat-client.ts +++ b/src/stoat-client.ts @@ -6,7 +6,18 @@ export interface SendOptions { sleepFn?: (ms: number) => Promise; } -export async function sendStoatWebhook(payload: StoatPayload, webhookUrl: string, options: SendOptions): Promise { +export class WebhookError extends Error { + constructor(message: string, readonly attempts: number) { + super(message); + this.name = "WebhookError"; + } +} + +export async function sendStoatWebhook( + payload: StoatPayload, + webhookUrl: string, + options: SendOptions, +): Promise<{ attempts: number }> { const fetchFn = options.fetchFn || fetch; const sleepFn = options.sleepFn || sleep; @@ -18,15 +29,17 @@ export async function sendStoatWebhook(payload: StoatPayload, webhookUrl: string const retryResponse = await postPayload(fetchFn, webhookUrl, payload, options.timeoutMs); if (!retryResponse.ok) { - throw new Error(await buildFailureMessage(retryResponse)); + throw new WebhookError(await buildFailureMessage(retryResponse), 2); } - return; + return { attempts: 2 }; } if (!response.ok) { - throw new Error(await buildFailureMessage(response)); + throw new WebhookError(await buildFailureMessage(response), 1); } + + return { attempts: 1 }; } async function postPayload( diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..6e53e0b --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { readConfig } from "../src/config.js"; + +const baseEnv = { + INPUT_WEBHOOK_URL: "https://example.test/webhook", +}; + +describe("config", () => { + it("defaults dryRun to false when INPUT_DRY_RUN is absent", () => { + const config = readConfig({ ...baseEnv }); + + expect(config.dryRun).toBe(false); + }); + + it("parses INPUT_DRY_RUN=true as dryRun:true", () => { + const config = readConfig({ ...baseEnv, INPUT_DRY_RUN: "true" }); + + expect(config.dryRun).toBe(true); + }); + + it("parses INPUT_DRY_RUN=false as dryRun:false", () => { + const config = readConfig({ ...baseEnv, INPUT_DRY_RUN: "false" }); + + expect(config.dryRun).toBe(false); + }); + + it("throws on an invalid boolean value for INPUT_DRY_RUN", () => { + expect(() => readConfig({ ...baseEnv, INPUT_DRY_RUN: "maybe" })).toThrow( + /Invalid boolean input for dry_run/, + ); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..85cb779 --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,251 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ActionConfig } from "../src/config.js"; +import type { GitHubContext } from "../src/github-context.js"; +import type { StoatPayload } from "../src/message-builder.js"; + +vi.mock("../src/config.js", () => ({ + readConfig: vi.fn(), + maskSecret: vi.fn(), +})); + +vi.mock("../src/github-context.js", () => ({ + readGitHubContext: vi.fn(), +})); + +vi.mock("../src/message-builder.js", () => ({ + buildPayload: vi.fn(), +})); + +vi.mock("../src/stoat-client.js", async () => { + const actual = await vi.importActual( + "../src/stoat-client.js", + ); + return { + ...actual, + sendStoatWebhook: vi.fn(), + }; +}); + +const { readConfig, maskSecret } = await import("../src/config.js"); +const { readGitHubContext } = await import("../src/github-context.js"); +const { buildPayload } = await import("../src/message-builder.js"); +const { sendStoatWebhook, WebhookError } = await import("../src/stoat-client.js"); +const { run, sanitizeError, setOutput } = await import("../src/index.js"); + +const webhookUrl = "https://example.test/webhook"; + +const baseConfig: ActionConfig = { + webhookUrl, + event: "ci_failed", + username: "GitHub", + includeActor: true, + includeRepository: true, + includeRef: true, + includeRunUrl: true, + failOnError: false, + timeoutMs: 10000, + dryRun: false, +}; + +const baseContext: GitHubContext = { + eventName: "workflow_run", + actor: "kevin", + repository: "systm-d/stoat-github-notify", + ref: "main", + sha: "deadbeef", + runId: "1", + serverUrl: "https://github.com", + workflow: "CI", + job: "validate", + payload: {}, +}; + +const basePayload: StoatPayload = { + content: "CI failed", + username: "GitHub", + embeds: [{ title: "CI failed", description: "" }], +}; + +let tmpDir: string; +let outputPath: string; + +beforeEach(() => { + vi.mocked(readConfig).mockReset(); + vi.mocked(maskSecret).mockReset(); + vi.mocked(readGitHubContext).mockReset(); + vi.mocked(buildPayload).mockReset(); + vi.mocked(sendStoatWebhook).mockReset(); + + vi.mocked(readGitHubContext).mockReturnValue(baseContext); + vi.mocked(buildPayload).mockReturnValue(basePayload); + + tmpDir = mkdtempSync(join(tmpdir(), "stoat-outputs-")); + outputPath = join(tmpDir, "output"); + writeFileSync(outputPath, ""); + process.env.GITHUB_OUTPUT = outputPath; + process.exitCode = undefined; +}); + +afterEach(() => { + delete process.env.GITHUB_OUTPUT; + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function readOutputs(): Record { + const content = readFileSync(outputPath, "utf8"); + const result: Record = {}; + + for (const line of content.split("\n")) { + if (!line) { + continue; + } + + const index = line.indexOf("="); + if (index === -1) { + continue; + } + + result[line.slice(0, index)] = line.slice(index + 1); + } + + return result; +} + +describe("run", () => { + it("writes sent=true,status=sent,attempts=1 on first-try success", async () => { + vi.mocked(readConfig).mockReturnValue(baseConfig); + vi.mocked(sendStoatWebhook).mockResolvedValue({ attempts: 1 }); + + await run(); + + expect(readOutputs()).toEqual({ + sent: "true", + status: "sent", + error: "", + attempts: "1", + }); + }); + + it("writes attempts=2 when the webhook succeeded after a retry", async () => { + vi.mocked(readConfig).mockReturnValue(baseConfig); + vi.mocked(sendStoatWebhook).mockResolvedValue({ attempts: 2 }); + + await run(); + + expect(readOutputs()).toMatchObject({ + sent: "true", + status: "sent", + attempts: "2", + }); + }); + + it("writes status=failed with attempts=1 on HTTP failure", async () => { + vi.mocked(readConfig).mockReturnValue(baseConfig); + vi.mocked(sendStoatWebhook).mockRejectedValue( + new WebhookError("Stoat notification failed: 500 boom", 1), + ); + + await run(); + + expect(readOutputs()).toMatchObject({ + sent: "false", + status: "failed", + attempts: "1", + error: "Stoat notification failed: 500 boom", + }); + }); + + it("writes attempts=2 when the failure happens after a retry", async () => { + vi.mocked(readConfig).mockReturnValue(baseConfig); + vi.mocked(sendStoatWebhook).mockRejectedValue( + new WebhookError("Stoat notification failed: 500 still", 2), + ); + + await run(); + + expect(readOutputs()).toMatchObject({ + sent: "false", + status: "failed", + attempts: "2", + }); + }); + + it("writes attempts=0 on configuration errors raised before any HTTP call", async () => { + vi.mocked(readConfig).mockImplementation(() => { + throw new Error("Missing required input: webhook_url"); + }); + + await run(); + + expect(readOutputs()).toMatchObject({ + sent: "false", + status: "failed", + attempts: "0", + error: "Missing required input: webhook_url", + }); + }); + + it("writes status=skipped when no auto-event template matches", async () => { + vi.mocked(readConfig).mockReturnValue({ ...baseConfig, event: "auto" }); + vi.mocked(buildPayload).mockReturnValue(null); + + await run(); + + expect(readOutputs()).toEqual({ + sent: "false", + status: "skipped", + error: "", + attempts: "0", + }); + expect(sendStoatWebhook).not.toHaveBeenCalled(); + }); + + it("writes status=dry_run when dryRun is enabled and skips the HTTP call", async () => { + vi.mocked(readConfig).mockReturnValue({ ...baseConfig, dryRun: true }); + + await run(); + + expect(readOutputs()).toEqual({ + sent: "false", + status: "dry_run", + error: "", + attempts: "0", + }); + expect(sendStoatWebhook).not.toHaveBeenCalled(); + }); + + it("strips the webhook_url value from the error output", async () => { + vi.mocked(readConfig).mockReturnValue(baseConfig); + vi.mocked(sendStoatWebhook).mockRejectedValue( + new WebhookError(`Stoat notification failed at ${webhookUrl}`, 1), + ); + + await run(); + + const outputs = readOutputs(); + expect(outputs.error).not.toContain(webhookUrl); + expect(outputs.error).toContain("[url]"); + }); +}); + +describe("sanitizeError", () => { + it("replaces the exact webhook URL with [url]", () => { + expect(sanitizeError(`Failure at ${webhookUrl}/x`, webhookUrl)).not.toContain(webhookUrl); + }); + + it("also strips other URLs that leak through error bodies", () => { + expect(sanitizeError("see https://logs.example/details for more", "")).toBe( + "see [url] for more", + ); + }); +}); + +describe("setOutput", () => { + it("is a no-op when GITHUB_OUTPUT is absent", () => { + delete process.env.GITHUB_OUTPUT; + expect(() => setOutput("status", "sent")).not.toThrow(); + }); +}); diff --git a/tests/message-builder.test.ts b/tests/message-builder.test.ts index 321a937..eb331c1 100644 --- a/tests/message-builder.test.ts +++ b/tests/message-builder.test.ts @@ -13,6 +13,7 @@ const baseConfig: ActionConfig = { includeRunUrl: true, failOnError: false, timeoutMs: 10000, + dryRun: false, }; const baseContext: GitHubContext = { @@ -88,6 +89,16 @@ describe("message builder", () => { ).toBe("deployment_failed"); }); + it("returns null when auto resolution has no matching event", () => { + expect(resolveEvent("auto", { ...baseContext, eventName: "schedule" })).toBeNull(); + }); + + it("buildPayload returns null when auto resolution has no matching event", () => { + const payload = buildPayload({ ...baseConfig, event: "auto" }, { ...baseContext, eventName: "schedule" }); + + expect(payload).toBeNull(); + }); + it("renders release details from the GitHub event payload", () => { const payload = buildPayload( { diff --git a/tests/stoat-client.test.ts b/tests/stoat-client.test.ts index 049443f..30bf457 100644 --- a/tests/stoat-client.test.ts +++ b/tests/stoat-client.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { StoatPayload } from "../src/message-builder.js"; -import { sendStoatWebhook } from "../src/stoat-client.js"; +import { sendStoatWebhook, WebhookError } from "../src/stoat-client.js"; const payload: StoatPayload = { content: "CI failed", @@ -14,14 +14,15 @@ const payload: StoatPayload = { }; describe("stoat client", () => { - it("posts the webhook payload", async () => { + it("posts the webhook payload and resolves with attempts:1", async () => { const fetchFn = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); - await sendStoatWebhook(payload, "https://example.test/webhook", { + const result = await sendStoatWebhook(payload, "https://example.test/webhook", { timeoutMs: 10000, fetchFn, }); + expect(result).toEqual({ attempts: 1 }); expect(fetchFn).toHaveBeenCalledOnce(); expect(fetchFn.mock.calls[0]?.[1]).toMatchObject({ method: "POST", @@ -32,31 +33,62 @@ describe("stoat client", () => { }); }); - it("retries once after a 429 response", async () => { + it("retries once after a 429 response and resolves with attempts:2", async () => { const fetchFn = vi .fn() .mockResolvedValueOnce(new Response(JSON.stringify({ retry_after: 1 }), { status: 429 })) .mockResolvedValueOnce(new Response(null, { status: 204 })); const sleepFn = vi.fn().mockResolvedValue(undefined); - await sendStoatWebhook(payload, "https://example.test/webhook", { + const result = await sendStoatWebhook(payload, "https://example.test/webhook", { timeoutMs: 10000, fetchFn, sleepFn, }); + expect(result).toEqual({ attempts: 2 }); expect(fetchFn).toHaveBeenCalledTimes(2); expect(sleepFn).toHaveBeenCalledWith(1000); }); - it("throws on non-success responses", async () => { + it("throws WebhookError with attempts:1 on non-429 failure", async () => { const fetchFn = vi.fn().mockResolvedValue(new Response("nope", { status: 500 })); - await expect( - sendStoatWebhook(payload, "https://example.test/webhook", { + let caught: unknown; + try { + await sendStoatWebhook(payload, "https://example.test/webhook", { timeoutMs: 10000, fetchFn, - }), - ).rejects.toThrow("Stoat notification failed: 500 nope"); + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(WebhookError); + expect((caught as WebhookError).attempts).toBe(1); + expect((caught as WebhookError).message).toBe("Stoat notification failed: 500 nope"); + }); + + it("throws WebhookError with attempts:2 when retry also fails", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ retry_after: 1 }), { status: 429 })) + .mockResolvedValueOnce(new Response("still broken", { status: 500 })); + const sleepFn = vi.fn().mockResolvedValue(undefined); + + let caught: unknown; + try { + await sendStoatWebhook(payload, "https://example.test/webhook", { + timeoutMs: 10000, + fetchFn, + sleepFn, + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(WebhookError); + expect((caught as WebhookError).attempts).toBe(2); + expect((caught as WebhookError).message).toBe("Stoat notification failed: 500 still broken"); }); });