diff --git a/src/services/alerts/discord-embed.ts b/src/services/alerts/discord-embed.ts new file mode 100644 index 000000000..05b9b363a --- /dev/null +++ b/src/services/alerts/discord-embed.ts @@ -0,0 +1,149 @@ +import { ErrorLevel } from "@domain/shared" +import { recordExceptionInCurrentSpan } from "@services/tracing" +import axios, { isAxiosError } from "axios" + +/** + * Shared Discord rich-embed contract for the alert senders (discord.ts and + * ops-events.ts). One place for Discord's documented limits, the field builder, + * the aggregate clamp, and the rate-limit-aware POST — so a future Discord + * change (a new limit, different retry logic) is made once, not twice. + * + * Discord embed limits: 25 fields, each value <= 1024 chars, each name <= 256, + * title <= 256, description <= 4096, and — the one the per-field caps do NOT + * bound — an aggregate <= 6000 chars summed across title + description + every + * field name + every field value (+ author/footer text). clampEmbedToBudget + * enforces both the aggregate AND the 25-field cap; postEmbed applies it before + * every POST. + */ + +export const MAX_FIELDS = 25 +export const MAX_FIELD_VALUE = 1024 +export const MAX_TITLE = 256 +export const MAX_DESCRIPTION = 4096 +export const MAX_EMBED_TOTAL = 6000 + +// A little under Discord's hard 6000 aggregate, leaving headroom for embed +// scaffolding that also counts toward the limit (author line, timestamp label). +const AGGREGATE_BUDGET = 5900 + +const POST_TIMEOUT_MS = 5_000 + +export type DiscordEmbedField = { name: string; value: string; inline: boolean } + +export type DiscordEmbed = { + author?: { name: string } + title: string + description?: string + color: number + fields: DiscordEmbedField[] + timestamp: string +} + +// Trim a string to at most `max` characters, marking the cut with an ellipsis +// so a reader can tell it was truncated. The ellipsis counts toward the cap: +// the result length is never greater than `max`. +export const truncate = (value: string, max: number): string => + value.length > max ? value.slice(0, max - 1) + "…" : value + +// A closure that appends non-empty, name-capped, value-capped fields to the +// given array. Shared so both alert senders build fields identically. +export const makeFieldBuilder = + (fields: DiscordEmbedField[]) => + (name: string, value: string | undefined | null, inline = true): void => { + if (value === undefined || value === null || value.length === 0) return + fields.push({ + name: truncate(name, MAX_TITLE), + value: truncate(value, MAX_FIELD_VALUE), + inline, + }) + } + +const fieldCost = (field: DiscordEmbedField): number => + field.name.length + field.value.length + +// Keep an embed under Discord's aggregate character limit AND its 25-field cap. +// The per-field caps bound each part but not their sum, so a caller that stuffs +// a large value into one field can still build a >6000-char embed; likewise a +// caller that emits one field per item can exceed 25 fields. Either overflow +// makes Discord 400 the whole embed (which postEmbed then swallows as a Warn, +// silently dropping the alert). We trim the description if it alone blows the +// budget, then drop trailing fields once the running total OR the field count +// would exceed the limit. Centralizing the count cap here protects every caller +// (both discord.ts and ops-events.ts) without each having to slice itself. +export const clampEmbedToBudget = (embed: DiscordEmbed): DiscordEmbed => { + const scaffold = (embed.author?.name.length ?? 0) + embed.title.length + let remaining = AGGREGATE_BUDGET - scaffold + + let description = embed.description + if (description !== undefined) { + if (remaining <= 1) { + description = undefined + } else if (description.length > remaining) { + description = truncate(description, remaining) + } + remaining -= description?.length ?? 0 + } + + const fields: DiscordEmbedField[] = [] + for (const field of embed.fields) { + if (fields.length >= MAX_FIELDS) break + const cost = fieldCost(field) + if (cost > remaining) break + remaining -= cost + fields.push(field) + } + + if (fields.length === embed.fields.length && description === embed.description) { + return embed + } + return { ...embed, description, fields } +} + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + +// Discord rate-limit responses carry retry_after in seconds (possibly float), +// in the body and/or the Retry-After header. Returns the wait in ms for a 429, +// or undefined for any other (terminal) error. +const retryAfterMs = (error: unknown): number | undefined => { + if (!isAxiosError(error) || error.response?.status !== 429) return undefined + const body = error.response.data as { retry_after?: number } | undefined + const retryAfter = body?.retry_after ?? Number(error.response.headers?.["retry-after"]) + if (typeof retryAfter !== "number" || !Number.isFinite(retryAfter)) return 1_000 + return Math.ceil(retryAfter * 1000) +} + +/** + * POST a single rich embed to a Discord incoming webhook. Best-effort: never + * throws. Clamps the embed to Discord's aggregate limit first; on HTTP 429 it + * honors Discord's retry_after once; any other failure (or a failed retry) is + * recorded as a Warn and swallowed. + */ +export const postEmbed = async ( + url: string, + embed: DiscordEmbed, + timeoutMs: number = POST_TIMEOUT_MS, +): Promise => { + const body = { embeds: [clampEmbedToBudget(embed)] } + const post = () => + axios.post(url, body, { + timeout: timeoutMs, + headers: { "Content-Type": "application/json" }, + }) + + try { + await post() + } catch (error) { + const waitMs = retryAfterMs(error) + if (waitMs === undefined) { + recordExceptionInCurrentSpan({ error, level: ErrorLevel.Warn }) + return + } + await sleep(waitMs) + try { + await post() + } catch (retryError) { + recordExceptionInCurrentSpan({ error: retryError, level: ErrorLevel.Warn }) + } + } +} diff --git a/src/services/alerts/discord.ts b/src/services/alerts/discord.ts index df9ba0bac..07168261c 100644 --- a/src/services/alerts/discord.ts +++ b/src/services/alerts/discord.ts @@ -1,34 +1,75 @@ import { ALERT_DISCORD_WEBHOOK_URL } from "@config" -import { ErrorLevel } from "@domain/shared" -import { recordExceptionInCurrentSpan } from "@services/tracing" -import axios from "axios" import { BridgeAlert } from "./index.types" +import { + DiscordEmbed, + DiscordEmbedField, + MAX_DESCRIPTION, + MAX_TITLE, + makeFieldBuilder, + postEmbed, + truncate, +} from "./discord-embed" -// Discord caps message content at 2000 chars; leave headroom. -const DISCORD_CONTENT_MAX = 1900 +// Warm red for a page, amber for a warning — the embed's left border colour. +const COLOR_CRITICAL = 0xe01e5a +const COLOR_WARNING = 0xf2c744 -// Discord incoming webhook ({ content }). +// Friendly label for the embed author line, so a Fygaro alert doesn't read as a +// "Bridge alert". Falls back to the raw source for anything unmapped. +const SOURCE_LABEL: Record = { + "fygaro-webhook": "Fygaro", + "bridge-webhook": "Bridge", + "bridge-api": "Bridge API", + "ibex": "IBEX", + "erpnext-audit": "ERPNext", +} + +// Turn a context key (snake_case / camelCase) into a Title Case field name: +// "transaction_id" -> "Transaction Id", "balanceUsd" -> "Balance Usd". +const prettyKey = (key: string): string => + key + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()) + +const contextFields = ( + context: Record | undefined, +): DiscordEmbedField[] => { + const fields: DiscordEmbedField[] = [] + if (!context) return fields + const field = makeFieldBuilder(fields) + for (const [key, raw] of Object.entries(context)) { + if (raw === undefined || raw === null) continue + field(prettyKey(key), String(raw)) + } + return fields +} + +// Discord incoming webhook — a single rich embed (colour by severity, title, +// detail as the description, and each context entry as its own field) instead +// of a raw JSON dump. Delivery is best-effort via postEmbed: the embed is +// clamped to Discord's aggregate limit, a 429 is retried once, and any other +// failure is swallowed as a Warn. export const sendDiscord = async (alert: BridgeAlert): Promise => { if (!ALERT_DISCORD_WEBHOOK_URL) return - const label = alert.severity === "critical" ? "[CRITICAL]" : "[WARNING]" - let content = `${label} **Bridge alert** - ${alert.title}\nsource: \`${alert.source}\` | severity: \`${alert.severity}\`` - if (alert.detail) content += `\n${alert.detail}` - if (alert.context) { - content += "\n```json\n" + JSON.stringify(alert.context, null, 2) + "\n```" - } - if (content.length > DISCORD_CONTENT_MAX) { - content = content.slice(0, DISCORD_CONTENT_MAX) + "..." - } + const fields: DiscordEmbedField[] = [] + const field = makeFieldBuilder(fields) + field("Source", alert.source) + field("Severity", alert.severity) + fields.push(...contextFields(alert.context)) - try { - await axios.post( - ALERT_DISCORD_WEBHOOK_URL, - { content }, - { timeout: 5000, headers: { "Content-Type": "application/json" } }, - ) - } catch (error) { - recordExceptionInCurrentSpan({ error, level: ErrorLevel.Warn }) + const embed: DiscordEmbed = { + author: { name: SOURCE_LABEL[alert.source] ?? alert.source }, + title: truncate(alert.title, MAX_TITLE), + description: alert.detail ? truncate(alert.detail, MAX_DESCRIPTION) : undefined, + color: alert.severity === "critical" ? COLOR_CRITICAL : COLOR_WARNING, + // Field count is capped centrally by clampEmbedToBudget (applied in + // postEmbed), so no per-caller slice is needed here. + fields, + timestamp: new Date().toISOString(), } + + await postEmbed(ALERT_DISCORD_WEBHOOK_URL, embed) } diff --git a/src/services/alerts/ops-events.ts b/src/services/alerts/ops-events.ts index 4b0ab18f4..f483c2310 100644 --- a/src/services/alerts/ops-events.ts +++ b/src/services/alerts/ops-events.ts @@ -1,7 +1,13 @@ import { NETWORK, OPS_DISCORD_WEBHOOK_URL } from "@config" import { ErrorLevel, JMDAmount, USDAmount, USDTAmount } from "@domain/shared" import { recordExceptionInCurrentSpan } from "@services/tracing" -import axios, { isAxiosError } from "axios" + +import { + DiscordEmbed, + DiscordEmbedField, + makeFieldBuilder, + postEmbed, +} from "./discord-embed" /** * Fire-and-forget ops event feed: posts color-coded Discord embeds to @@ -95,20 +101,10 @@ const titleCase = (phrase: string): string => .map((word) => (word === "otp" ? "OTP" : word)) .join(" ") -type DiscordEmbedField = { name: string; value: string; inline: boolean } -type DiscordEmbed = { - title: string - color: number - fields: DiscordEmbedField[] - timestamp: string -} - export const buildEmbed = (event: OpsEvent): DiscordEmbed => { const flowTitle = event.flow[0].toUpperCase() + event.flow.slice(1) const fields: DiscordEmbedField[] = [] - const field = (name: string, value: string | undefined, inline = true) => { - if (value) fields.push({ name, value, inline }) - } + const field = makeFieldBuilder(fields) field("account", event.accountId && truncateId(event.accountId)) field("user", event.userId && truncateId(event.userId)) @@ -146,53 +142,22 @@ const queue: OpsEvent[] = [] let droppedCount = 0 let draining: Promise | undefined -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -// Discord rate limit responses carry retry_after in seconds (possibly float), -// in the body and/or the Retry-After header. -const retryAfterMs = (error: unknown): number | undefined => { - if (!isAxiosError(error) || error.response?.status !== 429) return undefined - const body = error.response.data as { retry_after?: number } | undefined - const retryAfter = body?.retry_after ?? Number(error.response.headers?.["retry-after"]) - if (typeof retryAfter !== "number" || !Number.isFinite(retryAfter)) return 1_000 - return Math.ceil(retryAfter * 1000) -} - -const postEmbed = async (embed: DiscordEmbed): Promise => { - const post = () => - axios.post( - OPS_DISCORD_WEBHOOK_URL as string, - { embeds: [embed] }, - { timeout: FETCH_TIMEOUT_MS, headers: { "Content-Type": "application/json" } }, - ) - - try { - await post() - } catch (error) { - const waitMs = retryAfterMs(error) - if (waitMs === undefined) { - recordExceptionInCurrentSpan({ error, level: ErrorLevel.Warn }) - return - } - await sleep(waitMs) - try { - await post() - } catch (retryError) { - recordExceptionInCurrentSpan({ error: retryError, level: ErrorLevel.Warn }) - } - } -} +// Deliver via the shared, rate-limit-aware sender. It clamps to Discord's +// aggregate limit and honors a 429's retry_after once; FETCH_TIMEOUT_MS keeps +// the ops feed's tighter 3s timeout. +const send = (embed: DiscordEmbed): Promise => + postEmbed(OPS_DISCORD_WEBHOOK_URL as string, embed, FETCH_TIMEOUT_MS) const drain = async (): Promise => { try { while (queue.length > 0) { const event = queue.shift() - if (event) await postEmbed(buildEmbed(event)) + if (event) await send(buildEmbed(event)) if (queue.length === 0 && droppedCount > 0) { const dropped = droppedCount droppedCount = 0 - await postEmbed(droppedSummaryEmbed(dropped)) + await send(droppedSummaryEmbed(dropped)) } } } catch (error) { diff --git a/test/flash/unit/services/alerts/discord-embed.spec.ts b/test/flash/unit/services/alerts/discord-embed.spec.ts new file mode 100644 index 000000000..add07dd98 --- /dev/null +++ b/test/flash/unit/services/alerts/discord-embed.spec.ts @@ -0,0 +1,195 @@ +jest.mock("axios", () => ({ + post: jest.fn(), + isAxiosError: (error: unknown) => + Boolean((error as { isAxiosError?: boolean })?.isAxiosError), +})) + +jest.mock("@services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), +})) + +import axios from "axios" + +import { recordExceptionInCurrentSpan } from "@services/tracing" + +import { + DiscordEmbed, + MAX_EMBED_TOTAL, + MAX_FIELDS, + MAX_FIELD_VALUE, + MAX_TITLE, + clampEmbedToBudget, + makeFieldBuilder, + postEmbed, + truncate, +} from "@services/alerts/discord-embed" + +const mockPost = axios.post as jest.Mock +const mockRecord = recordExceptionInCurrentSpan as jest.Mock + +const embedSize = (embed: DiscordEmbed): number => + (embed.author?.name.length ?? 0) + + embed.title.length + + (embed.description?.length ?? 0) + + embed.fields.reduce((sum, f) => sum + f.name.length + f.value.length, 0) + +const baseEmbed = (over: Partial = {}): DiscordEmbed => ({ + title: "Alert", + color: 0x000000, + fields: [], + timestamp: "2026-01-01T00:00:00.000Z", + ...over, +}) + +beforeEach(() => { + jest.clearAllMocks() + mockPost.mockResolvedValue({ status: 204 }) +}) + +describe("truncate", () => { + it("leaves a string at or under the cap unchanged", () => { + expect(truncate("abc", 3)).toBe("abc") + expect(truncate("ab", 3)).toBe("ab") + }) + + it("trims an over-length string to exactly the cap, ending with an ellipsis", () => { + const out = truncate("x".repeat(MAX_TITLE + 50), MAX_TITLE) + expect(out.length).toBe(MAX_TITLE) + expect(out.endsWith("…")).toBe(true) + }) + + it("caps a field value at exactly the value limit", () => { + const out = truncate("y".repeat(MAX_FIELD_VALUE + 1), MAX_FIELD_VALUE) + expect(out.length).toBe(MAX_FIELD_VALUE) + expect(out.endsWith("…")).toBe(true) + }) +}) + +describe("makeFieldBuilder", () => { + it("skips undefined, null and empty values", () => { + const fields: DiscordEmbed["fields"] = [] + const field = makeFieldBuilder(fields) + field("a", undefined) + field("b", null) + field("c", "") + field("d", "kept") + expect(fields).toEqual([{ name: "d", value: "kept", inline: true }]) + }) + + it("truncates over-long names and values as it builds", () => { + const fields: DiscordEmbed["fields"] = [] + makeFieldBuilder(fields)("N".repeat(300), "V".repeat(2000)) + expect(fields[0].name.length).toBe(MAX_TITLE) + expect(fields[0].value.length).toBe(MAX_FIELD_VALUE) + }) +}) + +describe("clampEmbedToBudget", () => { + it("returns the same embed untouched when it is already under budget", () => { + const embed = baseEmbed({ + fields: [{ name: "a", value: "b", inline: true }], + }) + expect(clampEmbedToBudget(embed)).toBe(embed) + }) + + it("drops trailing fields so the aggregate stays under the 6000 limit", () => { + const fields = Array.from({ length: 10 }, (_, i) => ({ + name: `f${i}`, + value: "x".repeat(MAX_FIELD_VALUE), + inline: true, + })) + const clamped = clampEmbedToBudget(baseEmbed({ fields })) + + expect(embedSize(clamped)).toBeLessThanOrEqual(MAX_EMBED_TOTAL) + expect(clamped.fields.length).toBeGreaterThan(0) + expect(clamped.fields.length).toBeLessThan(10) + }) + + it("caps the field count at Discord's 25-field limit", () => { + // Many tiny fields: nowhere near the 6000-char aggregate, so only the + // field-count cap can bound them. An over-25-field embed would otherwise be + // rejected wholesale by Discord (400) and silently dropped by postEmbed. + const fields = Array.from({ length: MAX_FIELDS + 10 }, (_, i) => ({ + name: `f${i}`, + value: "x", + inline: true, + })) + const clamped = clampEmbedToBudget(baseEmbed({ fields })) + + expect(clamped.fields.length).toBe(MAX_FIELDS) + expect(embedSize(clamped)).toBeLessThanOrEqual(MAX_EMBED_TOTAL) + }) + + it("truncates the description when it alone would blow the budget", () => { + const clamped = clampEmbedToBudget( + baseEmbed({ + description: "d".repeat(6000), + fields: [{ name: "a", value: "b", inline: true }], + }), + ) + expect(embedSize(clamped)).toBeLessThanOrEqual(MAX_EMBED_TOTAL) + expect((clamped.description as string).endsWith("…")).toBe(true) + }) +}) + +describe("postEmbed", () => { + it("posts once on success with the default 5s timeout", async () => { + await postEmbed("https://discord.test/webhook", baseEmbed()) + expect(mockPost).toHaveBeenCalledTimes(1) + const [url, body, opts] = mockPost.mock.calls[0] + expect(url).toBe("https://discord.test/webhook") + expect(body.embeds).toHaveLength(1) + expect(opts.timeout).toBe(5000) + }) + + it("honors a caller-supplied timeout", async () => { + await postEmbed("https://discord.test/webhook", baseEmbed(), 3000) + expect(mockPost.mock.calls[0][2].timeout).toBe(3000) + }) + + it("clamps the embed to the aggregate limit before posting", async () => { + const fields = Array.from({ length: 10 }, (_, i) => ({ + name: `f${i}`, + value: "x".repeat(MAX_FIELD_VALUE), + inline: true, + })) + await postEmbed("https://discord.test/webhook", baseEmbed({ fields })) + const posted = mockPost.mock.calls[0][1].embeds[0] + expect(embedSize(posted)).toBeLessThanOrEqual(MAX_EMBED_TOTAL) + }) + + it("retries once after a 429, honoring retry_after", async () => { + mockPost + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 429, data: { retry_after: 0.01 }, headers: {} }, + }) + .mockResolvedValueOnce({ status: 204 }) + + const started = Date.now() + await postEmbed("https://discord.test/webhook", baseEmbed()) + + expect(mockPost).toHaveBeenCalledTimes(2) + expect(Date.now() - started).toBeGreaterThanOrEqual(9) + expect(mockRecord).not.toHaveBeenCalled() + }) + + it("records a Warn and does not retry on a non-429 failure", async () => { + mockPost.mockRejectedValue(new Error("boom")) + await postEmbed("https://discord.test/webhook", baseEmbed()) + expect(mockPost).toHaveBeenCalledTimes(1) + expect(mockRecord).toHaveBeenCalledTimes(1) + }) + + it("records a Warn when the 429 retry also fails", async () => { + mockPost + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 429, data: { retry_after: 0.01 }, headers: {} }, + }) + .mockRejectedValueOnce(new Error("still down")) + await postEmbed("https://discord.test/webhook", baseEmbed()) + expect(mockPost).toHaveBeenCalledTimes(2) + expect(mockRecord).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/flash/unit/services/alerts/discord.spec.ts b/test/flash/unit/services/alerts/discord.spec.ts new file mode 100644 index 000000000..c17a871eb --- /dev/null +++ b/test/flash/unit/services/alerts/discord.spec.ts @@ -0,0 +1,177 @@ +const mockPost = jest.fn() +jest.mock("axios", () => ({ + __esModule: true, + default: { post: (...a: unknown[]) => mockPost(...a) }, + isAxiosError: (error: unknown) => + Boolean((error as { isAxiosError?: boolean })?.isAxiosError), +})) + +const mockAlertDiscordUrl = { + value: "https://discord.test/webhook" as string | undefined, +} +jest.mock("@config", () => ({ + get ALERT_DISCORD_WEBHOOK_URL() { + return mockAlertDiscordUrl.value + }, +})) + +jest.mock("@services/tracing", () => ({ recordExceptionInCurrentSpan: jest.fn() })) + +import { sendDiscord } from "@services/alerts/discord" +import { BridgeAlert } from "@services/alerts/index.types" + +const baseAlert: BridgeAlert = { + dedupKey: "fygaro:not-credited:tx1", + source: "fygaro-webhook", + severity: "warning", + title: "Fygaro auto-credit disabled in settings — payment recorded, not auto-credited", + detail: "reason=auto-credit-disabled currency=USD gross=1.97", + context: { + transaction_id: "d994e6b2-6848", + amount: "1.97", + reason: "auto-credit-disabled", + }, +} + +const lastEmbed = () => mockPost.mock.calls.at(-1)?.[1]?.embeds?.[0] + +beforeEach(() => { + jest.clearAllMocks() + mockAlertDiscordUrl.value = "https://discord.test/webhook" +}) + +describe("sendDiscord", () => { + it("posts a single rich embed, not a raw content dump", async () => { + await sendDiscord(baseAlert) + + expect(mockPost).toHaveBeenCalledTimes(1) + const body = mockPost.mock.calls[0][1] + expect(body.content).toBeUndefined() + expect(Array.isArray(body.embeds)).toBe(true) + expect(body.embeds).toHaveLength(1) + }) + + it("uses a friendly author label per source, not a hardcoded 'Bridge alert'", async () => { + await sendDiscord(baseAlert) + expect(lastEmbed().author.name).toBe("Fygaro") + + await sendDiscord({ ...baseAlert, source: "bridge-webhook" }) + expect(lastEmbed().author.name).toBe("Bridge") + }) + + it("colors critical red and warning amber", async () => { + await sendDiscord({ ...baseAlert, severity: "critical" }) + expect(lastEmbed().color).toBe(0xe01e5a) + + await sendDiscord({ ...baseAlert, severity: "warning" }) + expect(lastEmbed().color).toBe(0xf2c744) + }) + + it("renders each context entry as its own Title-Cased field, no JSON blob", async () => { + await sendDiscord(baseAlert) + const embed = lastEmbed() + const names = embed.fields.map((f: { name: string }) => f.name) + expect(names).toEqual( + expect.arrayContaining([ + "Source", + "Severity", + "Transaction Id", + "Amount", + "Reason", + ]), + ) + expect(JSON.stringify(embed)).not.toContain("```") + }) + + it("skips null/undefined/empty context values (Discord rejects empty field values)", async () => { + await sendDiscord({ + ...baseAlert, + context: { good: "x", empty: "", missing: undefined, none: null }, + }) + const values = lastEmbed().fields.map((f: { value: string }) => f.value) + expect(values).not.toContain("") + expect(lastEmbed().fields.some((f: { name: string }) => f.name === "Good")).toBe(true) + expect( + lastEmbed().fields.some((f: { name: string }) => /Empty|Missing|None/.test(f.name)), + ).toBe(false) + }) + + it("caps fields at Discord's 25-field limit", async () => { + const context: Record = {} + for (let i = 0; i < 40; i++) context[`k${i}`] = `v${i}` + await sendDiscord({ ...baseAlert, context }) + expect(lastEmbed().fields.length).toBeLessThanOrEqual(25) + }) + + it("no-ops when the alert Discord webhook url is unset", async () => { + mockAlertDiscordUrl.value = undefined + await sendDiscord(baseAlert) + expect(mockPost).not.toHaveBeenCalled() + }) + + it("caps an over-long title at exactly 256 chars ending with an ellipsis", async () => { + await sendDiscord({ ...baseAlert, title: "T".repeat(300) }) + const { title } = lastEmbed() + expect(title.length).toBe(256) + expect(title.endsWith("…")).toBe(true) + }) + + it("caps an over-long field value at exactly 1024 chars ending with an ellipsis", async () => { + await sendDiscord({ ...baseAlert, context: { note: "v".repeat(2000) } }) + const note = lastEmbed().fields.find((f: { name: string }) => f.name === "Note") + expect(note.value.length).toBe(1024) + expect(note.value.endsWith("…")).toBe(true) + }) + + it("Title-Cases a camelCase context key: balanceUsd -> Balance Usd", async () => { + await sendDiscord({ ...baseAlert, context: { balanceUsd: "5.00" } }) + const names = lastEmbed().fields.map((f: { name: string }) => f.name) + expect(names).toContain("Balance Usd") + }) + + it("falls back to the raw source string for an unmapped author label", async () => { + const alert = { ...baseAlert, source: "unmapped-source" } as unknown as BridgeAlert + await sendDiscord(alert) + expect(lastEmbed().author.name).toBe("unmapped-source") + }) + + it("keeps the embed under Discord's 6000-char aggregate limit, dropping trailing fields", async () => { + const context: Record = {} + // Ten fields whose values each hit the 1024 per-field cap sum to > 6000 + // even after per-field truncation; the aggregate clamp must intervene. + for (let i = 0; i < 10; i++) context[`field_${i}`] = "x".repeat(1024) + + await sendDiscord({ ...baseAlert, context }) + + const embed = lastEmbed() + const total = + (embed.author?.name.length ?? 0) + + embed.title.length + + (embed.description?.length ?? 0) + + embed.fields.reduce( + (sum: number, f: { name: string; value: string }) => + sum + f.name.length + f.value.length, + 0, + ) + expect(total).toBeLessThanOrEqual(6000) + // Some fields had to be dropped: Source + Severity + 10 context = 12 built. + expect(embed.fields.length).toBeLessThan(12) + }) + + it("retries once honoring retry_after on a 429 instead of dropping the alert", async () => { + mockPost + .mockRejectedValueOnce({ + isAxiosError: true, + response: { status: 429, data: { retry_after: 0.01 }, headers: {} }, + }) + .mockResolvedValueOnce({ status: 204 }) + + const started = Date.now() + await sendDiscord({ ...baseAlert, severity: "critical" }) + + expect(mockPost).toHaveBeenCalledTimes(2) + expect(Date.now() - started).toBeGreaterThanOrEqual(9) + // The retry posts the same embed body — the alert is delivered, not dropped. + expect(mockPost.mock.calls[1][1]).toEqual(mockPost.mock.calls[0][1]) + }) +})