diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 4f75299..3481ec9 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -78,6 +78,8 @@ import { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } from "./ import { overlayLiveUsageCounters } from "./settings-live-overlay"; import { getLegacyDesktopLogDirectory, migrateLogDirectories, prepareLogDirectory, resolveLogDirectory } from "./log-storage"; import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-ledger"; +import { NotificationOutbox } from "./notification-outbox"; +import { sendNotification } from "./notify"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -118,6 +120,8 @@ export class AppController { private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); + private notificationOutbox: NotificationOutbox; + private logDirectory = this.storagePaths.baseDir; private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; @@ -150,9 +154,24 @@ export class AppController { } this.initializeLogStorage(); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); - const loadResult = loadSessionWithStatus(this.storagePaths); - const session = loadResult.session; - this.megaWebFallback = new MegaWebFallback(() => ({ + const loadResult = loadSessionWithStatus(this.storagePaths); + const session = loadResult.session; + this.notificationOutbox = new NotificationOutbox({ + filePath: this.storagePaths.notificationOutboxFile, + autoDrain: true, + send: (event) => sendNotification(this.settings.notifyUrl, { + title: event.payload.title, + message: event.payload.description || "", + mention: this.settings.notifyMention, + color: event.payload.color ?? (event.priority === "error" ? 0xe74c3c : 0x2ecc71), + fields: event.payload.fields, + timestamp: event.createdAt + }) + }); + void this.notificationOutbox.drain().catch((error) => { + logger.warn(`Notification-Outbox konnte nicht gestartet werden: ${String(error)}`); + }); + this.megaWebFallback = new MegaWebFallback(() => ({ login: this.settings.megaLogin, password: this.settings.megaPassword })); @@ -1273,9 +1292,12 @@ export class AppController { clearInterval(this.runtimeStatsTimer); this.runtimeStatsTimer = null; } - stopDebugServer(); - abortActiveUpdateDownload(); - cancelPendingAsyncSaves(); + stopDebugServer(); + abortActiveUpdateDownload(); + cancelPendingAsyncSaves(); + void this.notificationOutbox.drainForShutdown().catch((error) => { + logger.warn(`Notification-Outbox konnte beim Beenden nicht geleert werden: ${String(error)}`); + }); this.manager.prepareForShutdown(); this.megaWebFallback.dispose(); for (const fallback of this.realDebridWebFallbacks.values()) { diff --git a/src/main/notification-outbox.ts b/src/main/notification-outbox.ts new file mode 100644 index 0000000..9067da6 --- /dev/null +++ b/src/main/notification-outbox.ts @@ -0,0 +1,302 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import type { DiscordEmbedFieldPayload } from "./notify"; + +export type NotificationEventType = + | "package_completed" + | "package_partial" + | "package_failed" + | "run_completed" + | "run_stopped" + | "remaining_threshold_crossed" + | "download_stalled" + | "download_recovered"; + +export type NotificationPriority = "success" | "error"; + +export interface NotificationEventPayload { + title: string; + description?: string; + color?: number; + fields: DiscordEmbedFieldPayload[]; +} + +export interface NotificationEvent { + id: string; + type: NotificationEventType; + priority: NotificationPriority; + createdAt: number; + expiresAt: number; + attempts: number; + nextAttemptAt: number; + payload: NotificationEventPayload; +} + +export interface NotificationOutboxStatus { + queued: number; + lastSuccessAt: number; + lastFailureAt: number; +} + +export interface NotificationOutboxOptions { + filePath: string; + send: (event: NotificationEvent) => Promise; + now?: () => number; + autoDrain?: boolean; +} + +interface PersistedNotificationOutbox { + version: 1; + events: NotificationEvent[]; + lastSuccessAt: number; + lastFailureAt: number; +} + +const EVENT_TYPES = new Set([ + "package_completed", + "package_partial", + "package_failed", + "run_completed", + "run_stopped", + "remaining_threshold_crossed", + "download_stalled", + "download_recovered" +]); +const MAX_EVENTS = 250; +const MAX_RETRY_DELAY_MS = 10 * 60 * 1000; +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 3000; + +function finiteInteger(value: unknown, fallback = 0): number { + const numeric = Number(value); + return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback; +} + +function sanitizeEvent(value: unknown): NotificationEvent | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const raw = value as Partial; + const id = typeof raw.id === "string" ? raw.id.trim().slice(0, 256) : ""; + const type = EVENT_TYPES.has(raw.type as NotificationEventType) ? raw.type as NotificationEventType : null; + const priority = raw.priority === "success" || raw.priority === "error" ? raw.priority : null; + const payload = raw.payload && typeof raw.payload === "object" && !Array.isArray(raw.payload) + ? raw.payload as NotificationEventPayload + : null; + const title = typeof payload?.title === "string" ? payload.title.slice(0, 4096) : ""; + if (!id || !type || !priority || !payload || !title) { + return null; + } + const fields = Array.isArray(payload.fields) + ? payload.fields.slice(0, 25).flatMap((field) => { + if (!field || typeof field !== "object") { + return []; + } + const name = typeof field.name === "string" ? field.name.slice(0, 1024) : ""; + const fieldValue = typeof field.value === "string" ? field.value.slice(0, 4096) : ""; + return name && fieldValue ? [{ name, value: fieldValue, inline: Boolean(field.inline) }] : []; + }) + : []; + const description = typeof payload.description === "string" ? payload.description.slice(0, 8192) : undefined; + const color = Number.isFinite(payload.color) + ? Math.max(0, Math.min(0xffffff, Math.floor(payload.color as number))) + : undefined; + return { + id, + type, + priority, + createdAt: finiteInteger(raw.createdAt), + expiresAt: finiteInteger(raw.expiresAt), + attempts: finiteInteger(raw.attempts), + nextAttemptAt: finiteInteger(raw.nextAttemptAt), + payload: { + title, + ...(description !== undefined ? { description } : {}), + ...(color !== undefined ? { color } : {}), + fields + } + }; +} + +function oldestIndex(events: NotificationEvent[], predicate: (event: NotificationEvent) => boolean): number { + let selected = -1; + for (let index = 0; index < events.length; index += 1) { + if (!predicate(events[index])) { + continue; + } + if (selected < 0 || events[index].createdAt < events[selected].createdAt) { + selected = index; + } + } + return selected; +} + +function retryDelayMs(attempts: number): number { + return Math.min(MAX_RETRY_DELAY_MS, 1000 * (2 ** Math.min(9, Math.max(0, attempts - 1)))); +} + +export class NotificationOutbox { + private events: NotificationEvent[] = []; + private lastSuccessAt = 0; + private lastFailureAt = 0; + private operationChain: Promise = Promise.resolve(); + private readonly filePath: string; + private readonly sendEvent: (event: NotificationEvent) => Promise; + private readonly clock: () => number; + private readonly autoDrain: boolean; + private retryTimer: NodeJS.Timeout | null = null; + private shutdownRequested = false; + + public constructor(options: NotificationOutboxOptions) { + this.filePath = options.filePath; + this.sendEvent = options.send; + this.clock = options.now || Date.now; + this.autoDrain = Boolean(options.autoDrain); + this.load(); + } + + public async enqueue(event: NotificationEvent): Promise { + await this.runExclusive(async () => { + const normalized = sanitizeEvent(event); + if (normalized && !this.events.some((queuedEvent) => queuedEvent.id === normalized.id)) { + this.events.push(normalized); + } + await this.persist(this.clock()); + }); + if (this.autoDrain) { + this.scheduleDrain(0); + } + } + + public drain(now?: number): Promise { + return this.runExclusive(async () => { + const drainAt = finiteInteger(now ?? this.clock()); + this.enforceLimits(drainAt); + while (this.events.length > 0) { + const current = this.events[0]; + if (current.nextAttemptAt > drainAt) { + break; + } + let sent = false; + try { + sent = await this.sendEvent(current); + } catch { + sent = false; + } + if (!sent) { + current.attempts += 1; + current.nextAttemptAt = drainAt + retryDelayMs(current.attempts); + this.lastFailureAt = drainAt; + await this.persist(drainAt); + if (this.autoDrain) { + this.scheduleDrain(Math.max(0, current.nextAttemptAt - this.clock())); + } + break; + } + this.events.shift(); + this.lastSuccessAt = drainAt; + await this.persist(drainAt); + } + if (this.events.length === 0) { + this.clearRetryTimer(); + await this.persist(drainAt); + } + }); + } + + public async drainForShutdown(timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise { + this.shutdownRequested = true; + this.clearRetryTimer(); + let timer: NodeJS.Timeout | null = null; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, finiteInteger(timeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS))); + }); + await Promise.race([this.drain(), timeout]); + if (timer) { + clearTimeout(timer); + } + } + + public getStatus(): NotificationOutboxStatus { + return { + queued: this.events.length, + lastSuccessAt: this.lastSuccessAt, + lastFailureAt: this.lastFailureAt + }; + } + + private runExclusive(operation: () => Promise): Promise { + const result = this.operationChain.then(operation, operation); + this.operationChain = result.catch(() => {}); + return result; + } + + private scheduleDrain(delayMs: number): void { + if (this.shutdownRequested) { + return; + } + this.clearRetryTimer(); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + void this.drain().catch(() => {}); + }, delayMs); + this.retryTimer.unref?.(); + } + + private clearRetryTimer(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + } + + private load(): void { + try { + if (!fs.existsSync(this.filePath)) { + return; + } + const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8")) as Partial; + this.events = Array.isArray(parsed.events) + ? parsed.events.flatMap((event) => { + const normalized = sanitizeEvent(event); + return normalized ? [normalized] : []; + }) + : []; + this.lastSuccessAt = finiteInteger(parsed.lastSuccessAt); + this.lastFailureAt = finiteInteger(parsed.lastFailureAt); + this.enforceLimits(this.clock()); + } catch { + this.events = []; + this.lastSuccessAt = 0; + this.lastFailureAt = 0; + } + } + + private enforceLimits(now: number): void { + this.events = this.events.filter((event) => event.expiresAt > now); + while (this.events.length > MAX_EVENTS) { + const successIndex = oldestIndex(this.events, (event) => event.priority === "success"); + const removeIndex = successIndex >= 0 ? successIndex : oldestIndex(this.events, () => true); + this.events.splice(removeIndex, 1); + } + } + + private async persist(now: number): Promise { + this.enforceLimits(now); + await fsp.mkdir(path.dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.tmp`; + const state: PersistedNotificationOutbox = { + version: 1, + events: this.events, + lastSuccessAt: this.lastSuccessAt, + lastFailureAt: this.lastFailureAt + }; + try { + await fsp.writeFile(tempPath, JSON.stringify(state), "utf8"); + await fsp.rename(tempPath, this.filePath); + } catch (error) { + await fsp.rm(tempPath, { force: true }).catch(() => {}); + throw error; + } + } +} diff --git a/src/main/notify.ts b/src/main/notify.ts index 9f8c0b4..5e84ec5 100644 --- a/src/main/notify.ts +++ b/src/main/notify.ts @@ -1,17 +1,45 @@ import { logger } from "./logger"; -export interface NotifyPayload { - title: string; - message: string; - mention?: string; -} +export interface NotifyPayload { + title: string; + message: string; + mention?: string; + color?: number; + fields?: DiscordEmbedFieldPayload[]; + timestamp?: number | string; +} + +export interface DiscordEmbedFieldPayload { + name: string; + value: string; + inline?: boolean; +} + +export interface DiscordEmbedPayload { + title: string; + description: string; + color: number; + fields: Array<{ + name: string; + value: string; + inline: boolean; + }>; + timestamp?: string; +} const NOTIFY_TIMEOUT_MS = 5000; -const WEBHOOK_USERNAME = "Real-Debrid Downloader"; +const WEBHOOK_USERNAME = "Multi-Debrid Downloader"; const MIN_SEND_GAP_MS = 450; const RETRY_DELAYS_MS = [1000, 2500]; const RATE_LIMIT_MAX_WAIT_MS = 15_000; -const CONTENT_MAX_CHARS = 2000; +const CONTENT_MAX_CHARS = 2000; +const EMBED_TITLE_MAX_CHARS = 256; +const EMBED_DESCRIPTION_MAX_CHARS = 4096; +const EMBED_FIELD_NAME_MAX_CHARS = 256; +const EMBED_FIELD_VALUE_MAX_CHARS = 1024; +const EMBED_FIELDS_MAX = 25; +const EMBED_TOTAL_MAX_CHARS = 6000; +const DEFAULT_EMBED_COLOR = 0x2f81f7; export function isNotifyUrlValid(url: string): boolean { return /^https?:\/\/\S+$/i.test(String(url || "").trim()); @@ -32,7 +60,7 @@ export function normalizeDiscordMention(raw: string): string { // Discord counts the limit itself; slicing UTF-16 units can split a surrogate // pair at the boundary, which Discord rejects as invalid content. -export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string { +export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): string { if (content.length <= maxChars) { return content; } @@ -41,21 +69,73 @@ export function truncateContent(content: string, maxChars = CONTENT_MAX_CHARS): if (last >= 0xd800 && last <= 0xdbff) { cut = cut.slice(0, -1); } - return cut; -} - -export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } { - const mention = normalizeDiscordMention(payload.mention || ""); - const content = truncateContent(`${mention ? `${mention} ` : ""}**${payload.title}**\n${payload.message}`); - return { - url: String(url || "").trim(), - init: { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: WEBHOOK_USERNAME, content }) - } - }; -} + return cut; +} + +function normalizeTimestamp(value: number | string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toISOString() : undefined; +} + +function normalizeEmbedColor(value: number | undefined): number { + if (!Number.isFinite(value)) { + return DEFAULT_EMBED_COLOR; + } + return Math.max(0, Math.min(0xffffff, Math.floor(value as number))); +} + +function buildDiscordEmbed(payload: NotifyPayload): DiscordEmbedPayload { + let remaining = EMBED_TOTAL_MAX_CHARS; + const title = truncateContent(String(payload.title || ""), Math.min(EMBED_TITLE_MAX_CHARS, remaining)); + remaining -= title.length; + const description = truncateContent(String(payload.message || ""), Math.min(EMBED_DESCRIPTION_MAX_CHARS, remaining)); + remaining -= description.length; + const fields: DiscordEmbedPayload["fields"] = []; + for (const field of (payload.fields || []).slice(0, EMBED_FIELDS_MAX)) { + if (remaining < 2) { + break; + } + const name = truncateContent(String(field.name || ""), Math.min(EMBED_FIELD_NAME_MAX_CHARS, remaining - 1)); + if (!name) { + continue; + } + remaining -= name.length; + const value = truncateContent(String(field.value || ""), Math.min(EMBED_FIELD_VALUE_MAX_CHARS, remaining)); + if (!value) { + remaining += name.length; + continue; + } + remaining -= value.length; + fields.push({ name, value, inline: Boolean(field.inline) }); + } + const timestamp = normalizeTimestamp(payload.timestamp); + return { + title, + description, + color: normalizeEmbedColor(payload.color), + fields, + ...(timestamp ? { timestamp } : {}) + }; +} + +export function buildNotifyRequest(url: string, payload: NotifyPayload): { url: string; init: RequestInit } { + const mention = normalizeDiscordMention(payload.mention || ""); + return { + url: String(url || "").trim(), + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: WEBHOOK_USERNAME, + content: truncateContent(mention), + embeds: [buildDiscordEmbed(payload)] + }) + } + }; +} function delayMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/main/storage.ts b/src/main/storage.ts index 565043e..69cf2f0 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -696,22 +696,24 @@ export function normalizeSettings(settings: AppSettings): AppSettings { } export interface StoragePaths { - baseDir: string; - configFile: string; - sessionFile: string; + baseDir: string; + configFile: string; + sessionFile: string; historyFile: string; statisticsFile: string; -} + notificationOutboxFile: string; +} export function createStoragePaths(baseDir: string): StoragePaths { return { baseDir, - configFile: path.join(baseDir, "rd_downloader_config.json"), - sessionFile: path.join(baseDir, "rd_session_state.json"), + configFile: path.join(baseDir, "rd_downloader_config.json"), + sessionFile: path.join(baseDir, "rd_session_state.json"), historyFile: path.join(baseDir, "rd_history.json"), - statisticsFile: path.join(baseDir, "rd_statistics.json") - }; -} + statisticsFile: path.join(baseDir, "rd_statistics.json"), + notificationOutboxFile: path.join(baseDir, "rd_notification_outbox.json") + }; +} function ensureBaseDir(baseDir: string): void { try { diff --git a/tests/notification-outbox.test.ts b/tests/notification-outbox.test.ts new file mode 100644 index 0000000..b358099 --- /dev/null +++ b/tests/notification-outbox.test.ts @@ -0,0 +1,195 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NotificationEvent, NotificationOutbox } from "../src/main/notification-outbox"; + +const tempDirs: string[] = []; + +afterEach(() => { + vi.useRealTimers(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function createOutboxFile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-notification-outbox-")); + tempDirs.push(dir); + return path.join(dir, "notification-outbox.json"); +} + +function event(id: string, overrides: Partial = {}): NotificationEvent { + return { + id, + type: "package_failed", + priority: "error", + createdAt: 1000, + expiresAt: 86401000, + attempts: 0, + nextAttemptAt: 1000, + payload: { + title: "Paket fehlgeschlagen", + description: "Eine Datei ist fehlgeschlagen.", + fields: [] + }, + ...overrides + }; +} + +function persisted(filePath: string): { events: NotificationEvent[]; lastSuccessAt: number; lastFailureAt: number } { + return JSON.parse(fs.readFileSync(filePath, "utf8")) as { events: NotificationEvent[]; lastSuccessAt: number; lastFailureAt: number }; +} + +describe("NotificationOutbox", () => { + it("sends due events serially in stable enqueue order and removes each success", async () => { + const filePath = createOutboxFile(); + const sent: string[] = []; + const outbox = new NotificationOutbox({ + filePath, + now: () => 1000, + send: async (queuedEvent) => { + sent.push(queuedEvent.id); + return true; + } + }); + + await outbox.enqueue(event("first")); + await outbox.enqueue(event("second", { createdAt: 900 })); + await outbox.enqueue(event("third")); + await outbox.drain(1000); + + expect(sent).toEqual(["first", "second", "third"]); + expect(outbox.getStatus()).toEqual({ queued: 0, lastSuccessAt: 1000, lastFailureAt: 0 }); + expect(persisted(filePath).events).toEqual([]); + }); + + it("backs off a failed event without allowing later events to overtake it", async () => { + const filePath = createOutboxFile(); + const outcomes = [false, true, true]; + const sent: string[] = []; + const outbox = new NotificationOutbox({ + filePath, + now: () => 1000, + send: async (queuedEvent) => { + sent.push(queuedEvent.id); + return outcomes.shift() ?? true; + } + }); + + await outbox.enqueue(event("first")); + await outbox.enqueue(event("second")); + await outbox.drain(1000); + expect(sent).toEqual(["first"]); + expect(persisted(filePath).events[0]).toMatchObject({ id: "first", attempts: 1, nextAttemptAt: 2000 }); + expect(outbox.getStatus()).toEqual({ queued: 2, lastSuccessAt: 0, lastFailureAt: 1000 }); + + await outbox.drain(1999); + expect(sent).toEqual(["first"]); + await outbox.drain(2000); + expect(sent).toEqual(["first", "first", "second"]); + }); + + it("automatically drains new events and retries them at the persisted deadline", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1000); + const filePath = createOutboxFile(); + const outcomes = [false, true]; + const send = vi.fn().mockImplementation(async () => outcomes.shift() ?? true); + const outbox = new NotificationOutbox({ filePath, send, autoDrain: true }); + + await outbox.enqueue(event("automatic")); + await vi.advanceTimersByTimeAsync(0); + await outbox.drain(1000); + expect(send).toHaveBeenCalledTimes(1); + expect(outbox.getStatus().queued).toBe(1); + + await vi.advanceTimersByTimeAsync(999); + expect(send).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(send).toHaveBeenCalledTimes(2); + expect(outbox.getStatus().queued).toBe(0); + }); + + it("persists through a temporary file and atomic rename", async () => { + const filePath = createOutboxFile(); + const rename = vi.spyOn(fsp, "rename"); + try { + const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); + await outbox.enqueue(event("atomic")); + expect(rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath); + expect(fs.existsSync(`${filePath}.tmp`)).toBe(false); + expect(persisted(filePath).events.map((queuedEvent) => queuedEvent.id)).toEqual(["atomic"]); + } finally { + rename.mockRestore(); + } + }); + + it("drops expired events before persisting or sending", async () => { + const filePath = createOutboxFile(); + const send = vi.fn().mockResolvedValue(true); + const outbox = new NotificationOutbox({ filePath, send, now: () => 2000 }); + + await outbox.enqueue(event("expired", { expiresAt: 1999 })); + await outbox.drain(2000); + + expect(send).not.toHaveBeenCalled(); + expect(outbox.getStatus().queued).toBe(0); + expect(persisted(filePath).events).toEqual([]); + }); + + it("caps the queue at 250 and evicts the oldest success before errors", async () => { + const filePath = createOutboxFile(); + const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); + for (let index = 0; index < 249; index += 1) { + await outbox.enqueue(event(`error-${index}`, { createdAt: 1000 + index })); + } + await outbox.enqueue(event("success-old", { type: "package_completed", priority: "success", createdAt: 500 })); + await outbox.enqueue(event("success-new", { type: "package_completed", priority: "success", createdAt: 2000 })); + + const ids = persisted(filePath).events.map((queuedEvent) => queuedEvent.id); + expect(ids).toHaveLength(250); + expect(ids).not.toContain("success-old"); + expect(ids).toContain("success-new"); + expect(ids.filter((id) => id.startsWith("error-"))).toHaveLength(249); + }); + + it("never persists webhook or mention fields supplied outside the event contract", async () => { + const filePath = createOutboxFile(); + const outbox = new NotificationOutbox({ filePath, send: async () => true, now: () => 1000 }); + const unsafe = { + ...event("safe"), + url: "https://discord.example.test/private-webhook", + mention: "@private", + payload: { + ...event("safe").payload, + url: "https://discord.example.test/nested-private-webhook", + mention: "@nested-private" + } + } as unknown as NotificationEvent; + + await outbox.enqueue(unsafe); + + const raw = fs.readFileSync(filePath, "utf8"); + expect(raw).not.toContain("private-webhook"); + expect(raw).not.toContain("@private"); + expect(persisted(filePath).events[0]).toEqual(event("safe")); + }); + + it("returns after the default three-second shutdown budget when sending hangs", async () => { + vi.useFakeTimers(); + const filePath = createOutboxFile(); + const outbox = new NotificationOutbox({ filePath, send: async () => new Promise(() => {}), now: () => 1000 }); + await outbox.enqueue(event("hanging")); + + let completed = false; + const draining = outbox.drainForShutdown().then(() => { completed = true; }); + await vi.advanceTimersByTimeAsync(2999); + expect(completed).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await draining; + expect(completed).toBe(true); + expect(outbox.getStatus().queued).toBe(1); + }); +}); diff --git a/tests/notify.test.ts b/tests/notify.test.ts index e398714..a084e0b 100644 --- a/tests/notify.test.ts +++ b/tests/notify.test.ts @@ -52,26 +52,66 @@ describe("truncateContent", () => { }); }); -describe("buildNotifyRequest", () => { - it("builds a Discord-compatible JSON webhook POST (bold title + message as content)", () => { - const req = buildNotifyRequest(` ${WEBHOOK} `, { title: "✅ Paket fertig", message: "Show.S01\n5 Datei(en)" }); - expect(req.url).toBe(WEBHOOK); - expect(req.init.method).toBe("POST"); - expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" }); - const body = JSON.parse(String(req.init.body)); - expect(body.content).toBe("**✅ Paket fertig**\nShow.S01\n5 Datei(en)"); - expect(body.username).toBe("Real-Debrid Downloader"); - }); - it("prepends the mention so Discord pings (bare ID gets wrapped)", () => { - const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" }); - const body = JSON.parse(String(req.init.body)); - expect(body.content).toBe("<@123456789012345678> **T**\nM"); - }); - it("sends no mention prefix when the field is empty", () => { - const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "" }); - const body = JSON.parse(String(req.init.body)); - expect(body.content).toBe("**T**\nM"); - }); +describe("buildNotifyRequest", () => { + it("builds a bounded Discord embed with the product username", () => { + const req = buildNotifyRequest(` ${WEBHOOK} `, { + title: "Paket fehlgeschlagen", + message: "Eine Datei konnte nicht verarbeitet werden.", + color: 0xe74c3c, + fields: [ + { name: "Ergebnis", value: "Fehlgeschlagen", inline: true }, + { name: "Dateien", value: "0 erfolgreich, 1 fehlgeschlagen", inline: true } + ], + timestamp: 1000 + }); + expect(req.url).toBe(WEBHOOK); + expect(req.init.method).toBe("POST"); + expect(req.init.headers).toMatchObject({ "Content-Type": "application/json" }); + const body = JSON.parse(String(req.init.body)); + expect(body).toEqual({ + username: "Multi-Debrid Downloader", + content: "", + embeds: [{ + title: "Paket fehlgeschlagen", + description: "Eine Datei konnte nicht verarbeitet werden.", + color: 0xe74c3c, + fields: [ + { name: "Ergebnis", value: "Fehlgeschlagen", inline: true }, + { name: "Dateien", value: "0 erfolgreich, 1 fehlgeschlagen", inline: true } + ], + timestamp: "1970-01-01T00:00:01.000Z" + }] + }); + }); + it("prepends the mention so Discord pings (bare ID gets wrapped)", () => { + const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "123456789012345678" }); + const body = JSON.parse(String(req.init.body)); + expect(body.content).toBe("<@123456789012345678>"); + expect(body.embeds[0]).toMatchObject({ title: "T", description: "M" }); + }); + it("sends no mention prefix when the field is empty", () => { + const req = buildNotifyRequest(WEBHOOK, { title: "T", message: "M", mention: "" }); + const body = JSON.parse(String(req.init.body)); + expect(body.content).toBe(""); + }); + it("enforces Discord title, description, field, and total embed limits", () => { + const req = buildNotifyRequest(WEBHOOK, { + title: "T".repeat(400), + message: "M".repeat(5000), + fields: Array.from({ length: 30 }, (_, index) => ({ + name: `${index}-${"N".repeat(300)}`, + value: "V".repeat(1400) + })) + }); + const body = JSON.parse(String(req.init.body)); + const embed = body.embeds[0] as { title: string; description: string; fields: Array<{ name: string; value: string }> }; + expect(embed.title.length).toBeLessThanOrEqual(256); + expect(embed.description.length).toBeLessThanOrEqual(4096); + expect(embed.fields.length).toBeLessThanOrEqual(25); + expect(embed.fields.every((field) => field.name.length <= 256 && field.value.length <= 1024)).toBe(true); + const total = embed.title.length + embed.description.length + embed.fields.reduce((sum, field) => sum + field.name.length + field.value.length, 0); + expect(total).toBeLessThanOrEqual(6000); + }); }); describe("sendNotification", () => { @@ -104,20 +144,20 @@ describe("sendNotification", () => { await expect(sendNotification(WEBHOOK, { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false); expect(fetchFn).toHaveBeenCalledTimes(1); }); - it("serializes concurrent sends in order (burst protection)", async () => { - const order: string[] = []; - const fetchFn = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => { - order.push(JSON.parse(String(init.body)).content); - return new Response(null, { status: 204 }); - }); + it("serializes concurrent sends in order (burst protection)", async () => { + const order: string[] = []; + const fetchFn = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => { + order.push(JSON.parse(String(init.body)).embeds[0].title); + return new Response(null, { status: 204 }); + }); const sends = [ sendNotification(WEBHOOK, { title: "1", message: "" }, fetchFn, noSleep), sendNotification(WEBHOOK, { title: "2", message: "" }, fetchFn, noSleep), sendNotification(WEBHOOK, { title: "3", message: "" }, fetchFn, noSleep) - ]; - await expect(Promise.all(sends)).resolves.toEqual([true, true, true]); - expect(order).toEqual(["**1**\n", "**2**\n", "**3**\n"]); - }); + ]; + await expect(Promise.all(sends)).resolves.toEqual([true, true, true]); + expect(order).toEqual(["1", "2", "3"]); + }); it("does not call fetch for an invalid URL", async () => { const fetchFn = vi.fn(); await expect(sendNotification("", { title: "T", message: "M" }, fetchFn, noSleep)).resolves.toBe(false); diff --git a/tests/session-restart-loss.test.ts b/tests/session-restart-loss.test.ts index 39bb814..df1b64d 100644 Binary files a/tests/session-restart-loss.test.ts and b/tests/session-restart-loss.test.ts differ