|
1 | 1 | import { afterEach, describe, expect, it, vi } from "vitest"; |
2 | | -import { notifyActionToDiscord, notifyActionToSlack, resolveDiscordWebhook } from "../../src/services/notify-discord"; |
| 2 | +import { deliverRecapToDiscord, notifyActionToDiscord, notifyActionToSlack, resolveDiscordWebhook } from "../../src/services/notify-discord"; |
3 | 3 | import { createTestEnv } from "../helpers/d1"; |
| 4 | +import type { RecapReport } from "../../src/types"; |
4 | 5 |
|
5 | 6 | const HOOK = "https://discord.com/api/webhooks/123/abc"; |
6 | 7 | const FALLBACK = "https://discord.com/api/webhooks/999/zzz"; |
@@ -238,3 +239,68 @@ describe("notifyActionToSlack (#11 — modular self-host Slack channel)", () => |
238 | 239 | expect(await externalNotificationAudit(env, "slack")).toEqual([expect.objectContaining({ outcome: "error", detail: "slack_webhook_http_403" })]); |
239 | 240 | }); |
240 | 241 | }); |
| 242 | + |
| 243 | +const SAMPLE_RECAP: RecapReport = { |
| 244 | + generatedAt: "2026-07-08T00:00:00.000Z", |
| 245 | + windowDays: 7, |
| 246 | + repos: [{ repoFullName: "acme/widgets", reviewed: 5, merged: 3, closed: 2, gateFalsePositives: 1, gateOverrides: 1, reversals: 0 }], |
| 247 | + totals: { reviewed: 5, merged: 3, closed: 2, blocked: 4, gateFalsePositives: 1, gateOverrides: 1, reversals: 0, gateFalsePositiveRate: 0.25 }, |
| 248 | + summary: [ |
| 249 | + "Maintainer recap over the last 7 day(s): 1 repo(s), 5 reviewed, 3 merged, 2 closed.", |
| 250 | + "Gate false-positive rate: 25% (1/4 block(s) later merged).", |
| 251 | + "1 maintainer override(s), 0 recommendation reversal(s).", |
| 252 | + ], |
| 253 | +}; |
| 254 | + |
| 255 | +async function recapAudit(env: Env): Promise<Array<{ outcome: string; detail: string }>> { |
| 256 | + const rows = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? order by created_at").bind("maintainer_recap_notification.discord").all<{ outcome: string; detail: string }>(); |
| 257 | + return rows.results ?? []; |
| 258 | +} |
| 259 | + |
| 260 | +describe("deliverRecapToDiscord (#2245 maintainer recap → Discord)", () => { |
| 261 | + it("posts the recap as an embed to the global DISCORD_WEBHOOK_URL and records a completed audit when configured", async () => { |
| 262 | + let posted: { url: string; body: string } | null = null; |
| 263 | + vi.stubGlobal("fetch", async (url: RequestInfo | URL, init?: RequestInit) => { |
| 264 | + posted = { url: String(url), body: init?.body ? String(init.body) : "" }; |
| 265 | + return new Response(null, { status: 204 }); |
| 266 | + }); |
| 267 | + const env = withEnv({ DISCORD_WEBHOOK_URL: HOOK }); |
| 268 | + expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: true }); |
| 269 | + expect(posted).not.toBeNull(); |
| 270 | + expect(posted!.url).toBe(HOOK); |
| 271 | + const parsed = JSON.parse(posted!.body) as { embeds: { title: string; description: string; fields: { name: string; value: string }[] }[] }; |
| 272 | + const embed = parsed.embeds[0]!; |
| 273 | + expect(embed.title).toContain("Maintainer recap"); |
| 274 | + expect(embed.description).toContain("Gate false-positive rate"); |
| 275 | + expect(embed.fields.map((f) => f.name)).toContain("Reversals"); |
| 276 | + // public-safe: the digest must never leak an economic/identity term |
| 277 | + expect(posted!.body.toLowerCase()).not.toMatch(/reward|wallet|hotkey|coldkey|trustscore/); |
| 278 | + expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "completed", detail: "sent" })]); |
| 279 | + }); |
| 280 | + |
| 281 | + it("no-ops (never fetches) and records a denied audit when DISCORD_WEBHOOK_URL is unset", async () => { |
| 282 | + delete process.env.DISCORD_WEBHOOK_URL; |
| 283 | + const calls = stubFetch(); |
| 284 | + const env = createTestEnv(); |
| 285 | + expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "missing_global_webhook" }); |
| 286 | + expect(calls).toEqual([]); |
| 287 | + expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "denied", detail: "missing_global_webhook" })]); |
| 288 | + }); |
| 289 | + |
| 290 | + it("no-ops (never fetches) and records a denied audit when DISCORD_WEBHOOK_URL fails validation (non-https)", async () => { |
| 291 | + const calls = stubFetch(); |
| 292 | + const env = withEnv({ DISCORD_WEBHOOK_URL: "http://discord.com/api/webhooks/1/x" }); |
| 293 | + expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "invalid_global_webhook" }); |
| 294 | + expect(calls).toEqual([]); |
| 295 | + expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "denied", detail: "invalid_global_webhook" })]); |
| 296 | + }); |
| 297 | + |
| 298 | + it("swallows a send failure — best-effort, records an error audit, never throws", async () => { |
| 299 | + vi.stubGlobal("fetch", async () => { |
| 300 | + throw new Error("network down"); |
| 301 | + }); |
| 302 | + const env = withEnv({ DISCORD_WEBHOOK_URL: HOOK }); |
| 303 | + expect(await deliverRecapToDiscord(env, SAMPLE_RECAP)).toEqual({ sent: false, reason: "network down" }); |
| 304 | + expect(await recapAudit(env)).toEqual([expect.objectContaining({ outcome: "error", detail: "network down" })]); |
| 305 | + }); |
| 306 | +}); |
0 commit comments