diff --git a/src/main/support-data.ts b/src/main/support-data.ts index 2678cac..6c506be 100644 --- a/src/main/support-data.ts +++ b/src/main/support-data.ts @@ -1,11 +1,41 @@ import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getRealDebridAccounts } from "../shared/real-debrid-accounts"; -import { isNotifyUrlValid } from "./notify"; -import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types"; +import { isNotifyUrlValid } from "./notify"; +import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types"; +import type { DownloadHealthState } from "./download-health-monitor"; +import type { NotificationOutboxStatus } from "./notification-outbox"; -function hasText(value: unknown): boolean { - return String(value || "").trim().length > 0; -} +function hasText(value: unknown): boolean { + return String(value || "").trim().length > 0; +} + +export interface NotificationSupportPayload { + queued: number; + lastSuccessAt: number | null; + incidentType: DownloadHealthState["incidentType"]; + incidentAgeMs: number | null; +} + +export function buildNotificationSupportPayload( + outbox: Pick, + health: Pick, + now: number = Date.now() +): NotificationSupportPayload { + const queued = Number.isFinite(outbox.queued) ? Math.max(0, Math.floor(outbox.queued)) : 0; + const lastSuccessAt = Number.isFinite(outbox.lastSuccessAt) && outbox.lastSuccessAt > 0 + ? Math.floor(outbox.lastSuccessAt) + : null; + const incidentType = health.incidentType === "scheduler" || health.incidentType === "no_data" + ? health.incidentType + : null; + const incidentStartedAt = Number.isFinite(health.incidentStartedAt) && health.incidentStartedAt > 0 + ? Math.floor(health.incidentStartedAt) + : 0; + const incidentAgeMs = incidentType && incidentStartedAt > 0 + ? Math.max(0, Math.floor(Number.isFinite(now) ? now : Date.now()) - incidentStartedAt) + : null; + return { queued, lastSuccessAt, incidentType, incidentAgeMs }; +} export function buildAccountSummary(settings: AppSettings): Record { const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 98b0814..81804aa 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -120,6 +120,7 @@ import { buildTargetedAccountCheck, projectAccountRows, resolveHistoryRetentionSelection, + normalizeNotificationNumberField, sortAccountRows, formatAccountContextHeading, type AccountAddOption, @@ -279,7 +280,7 @@ interface RendererSettingsDraft extends RendererSettings { notifyUrl: string; } -function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft { +export function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft { return { ...settings, archivePasswordList: current?.archivePasswordList || "", @@ -5311,6 +5312,11 @@ export function App(): ReactElement { setBool(fieldId as keyof RendererSettingsDraft, value); return; } + const notificationNumber = normalizeNotificationNumberField(fieldId, value); + if (notificationNumber !== undefined) { + setNum(fieldId as keyof RendererSettingsDraft, notificationNumber); + return; + } const numericLimits: Partial> = { maxParallel: [1, 50, 1], retryLimit: [0, 99, 0], diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 5c9640f..308f667 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -16,6 +16,9 @@ const pairs = [ ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"], ["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"], + ["Erfolgsmeldungen senden", "Send success notifications"], ["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"], ["Jedes Paket einzeln", "Each package individually"], + ["Melden, wenn der gesamte Lauf fertig ist", "Notify when the entire run completes"], ["Melden, wenn die Restmenge unterschritten wird", "Notify when the remaining amount falls below the threshold"], ["Restmengenschwelle (GB)", "Remaining amount threshold (GB)"], + ["Melden, wenn Downloads stillstehen", "Notify when downloads stall"], ["Stillstand bestätigen nach (Sek.)", "Confirm stall after (sec.)"], ["Frühestens erneut melden nach (Min.)", "Notify again after at least (min.)"], ["Melden, wenn Downloads wieder laufen", "Notify when downloads resume"], ["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"], ["Jetzt nach einer neuen Version suchen", "Check for a new version now"], ["Nach Updates suchen", "Check for updates"], ["Quelle im Format Benutzer/Repository.", "Source in owner/repository format."], ["Update verfügbar", "Update available"], ["Eine neue Version ist bereit. Klicke hier, um sie zu installieren.", "A new version is ready. Click here to install it."], ["Update installieren", "Install update"], diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index 79381fe..50d0be3 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -270,6 +270,22 @@ export interface SettingsFormProjectionInput { themeChoice?: "light" | "dark" | "system"; } +const NOTIFICATION_NUMBER_LIMITS = { + notifyRemainingThresholdGb: { min: 1, max: 100000, fallback: 50 }, + notifyStallAfterSeconds: { min: 60, max: 3600, fallback: 90 }, + notifyStallCooldownMinutes: { min: 5, max: 1440, fallback: 10 } +} as const; + +export function normalizeNotificationNumberField(fieldId: string, value: unknown): number | undefined { + const limits = NOTIFICATION_NUMBER_LIMITS[fieldId as keyof typeof NOTIFICATION_NUMBER_LIMITS]; + if (!limits) { + return undefined; + } + const parsed = Number(value); + const normalized = Number.isFinite(parsed) ? Math.floor(parsed) : limits.fallback; + return Math.max(limits.min, Math.min(limits.max, normalized)); +} + export function buildSettingsFormViewModel({ settings, section, @@ -600,7 +616,24 @@ export function buildSettingsFormViewModel({ { id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention }, { id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted }, { id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed }, - { id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn alles fertig ist", value: settings.notifyOnRunFinished } + { + id: "notifyPackageSuccessMode", + kind: "select", + label: "Erfolgsmeldungen senden", + value: settings.notifyPackageSuccessMode, + disabled: !settings.notifyOnPackageCompleted, + options: [ + { value: "digest", label: "Gesammelt (alle 2 Minuten)" }, + { value: "individual", label: "Jedes Paket einzeln" } + ] + }, + { id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn der gesamte Lauf fertig ist", value: settings.notifyOnRunFinished }, + { id: "notifyOnRemainingBelow", kind: "switch", label: "Melden, wenn die Restmenge unterschritten wird", value: settings.notifyOnRemainingBelow }, + { id: "notifyRemainingThresholdGb", kind: "number", label: "Restmengenschwelle (GB)", value: String(settings.notifyRemainingThresholdGb), min: 1, max: 100000, disabled: !settings.notifyOnRemainingBelow }, + { id: "notifyOnDownloadStall", kind: "switch", label: "Melden, wenn Downloads stillstehen", value: settings.notifyOnDownloadStall }, + { id: "notifyStallAfterSeconds", kind: "number", label: "Stillstand bestätigen nach (Sek.)", value: String(settings.notifyStallAfterSeconds), min: 60, max: 3600, disabled: !settings.notifyOnDownloadStall }, + { id: "notifyStallCooldownMinutes", kind: "number", label: "Frühestens erneut melden nach (Min.)", value: String(settings.notifyStallCooldownMinutes), min: 5, max: 1440, disabled: !settings.notifyOnDownloadStall }, + { id: "notifyOnDownloadRecovery", kind: "switch", label: "Melden, wenn Downloads wieder laufen", value: settings.notifyOnDownloadRecovery, disabled: !settings.notifyOnDownloadStall } ] } ] diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts index f9bd2d7..55e8356 100644 --- a/tests/i18n.test.ts +++ b/tests/i18n.test.ts @@ -16,6 +16,22 @@ describe("renderer localization", () => { expect(translateUiText("Animations", "de")).toBe("Animationen"); }); + it.each([ + ["Erfolgsmeldungen senden", "Send success notifications"], + ["Gesammelt (alle 2 Minuten)", "Grouped (every 2 minutes)"], + ["Jedes Paket einzeln", "Each package individually"], + ["Melden, wenn der gesamte Lauf fertig ist", "Notify when the entire run completes"], + ["Melden, wenn die Restmenge unterschritten wird", "Notify when the remaining amount falls below the threshold"], + ["Restmengenschwelle (GB)", "Remaining amount threshold (GB)"], + ["Melden, wenn Downloads stillstehen", "Notify when downloads stall"], + ["Stillstand bestätigen nach (Sek.)", "Confirm stall after (sec.)"], + ["Frühestens erneut melden nach (Min.)", "Notify again after at least (min.)"], + ["Melden, wenn Downloads wieder laufen", "Notify when downloads resume"] + ])("translates notification center setting %s in both directions", (german, english) => { + expect(translateUiText(german, "en")).toBe(english); + expect(translateUiText(english, "de")).toBe(german); + }); + it("translates dynamic update and pagination text", () => { expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en")) .toBe("v2.0.14 is available. Installed version: 2.0.13."); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 2fe472c..f3e2a83 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { defaultSettings } from "../src/main/constants"; import { createRendererSettings, createRendererState } from "../src/main/renderer-state"; -import { buildAccountAddFields, createAccountDialogState } from "../src/renderer/App"; +import { buildAccountAddFields, createAccountDialogState, createSettingsDraft } from "../src/renderer/App"; import { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit"; import { buildScopedAccountEnabledState, @@ -23,6 +23,7 @@ import { filterAccountAddOptions, getSettingsSaveLabel, getSettingsSelectNavigationIndex, + normalizeNotificationNumberField, projectAccountRows, pruneAccountSelection, reconcileAccountAddDraft, @@ -579,6 +580,108 @@ describe("settings views", () => { }); }); + it("projects every notification control in the required order with exact bounds", () => { + const form = buildSettingsFormViewModel({ + settings: { + ...createRendererSettings(defaultSettings()), + archivePasswordList: "", + notifyUrl: "https://discord.com/api/webhooks/example", + notifyOnPackageCompleted: true, + notifyOnRemainingBelow: true, + notifyOnDownloadStall: true + }, + section: "allgemein", + speedLimitInput: "0", + scheduleSpeedInputs: {} + }); + const fields = form.groups.find((group) => group.id === "general-notifications")?.fields ?? []; + + expect(fields.map((field) => field.id)).toEqual([ + "notifyUrl", + "notifyMention", + "notifyOnPackageCompleted", + "notifyOnPackageFailed", + "notifyPackageSuccessMode", + "notifyOnRunFinished", + "notifyOnRemainingBelow", + "notifyRemainingThresholdGb", + "notifyOnDownloadStall", + "notifyStallAfterSeconds", + "notifyStallCooldownMinutes", + "notifyOnDownloadRecovery" + ]); + expect(fields.find((field) => field.id === "notifyPackageSuccessMode")).toEqual({ + id: "notifyPackageSuccessMode", + kind: "select", + label: "Erfolgsmeldungen senden", + value: "digest", + disabled: false, + options: [ + { value: "digest", label: "Gesammelt (alle 2 Minuten)" }, + { value: "individual", label: "Jedes Paket einzeln" } + ] + }); + expect(fields.find((field) => field.id === "notifyRemainingThresholdGb")).toEqual({ + id: "notifyRemainingThresholdGb", + kind: "number", + label: "Restmengenschwelle (GB)", + value: "50", + min: 1, + max: 100000, + disabled: false + }); + expect(fields.find((field) => field.id === "notifyStallAfterSeconds")).toEqual({ + id: "notifyStallAfterSeconds", + kind: "number", + label: "Stillstand bestätigen nach (Sek.)", + value: "90", + min: 60, + max: 3600, + disabled: false + }); + expect(fields.find((field) => field.id === "notifyStallCooldownMinutes")).toEqual({ + id: "notifyStallCooldownMinutes", + kind: "number", + label: "Frühestens erneut melden nach (Min.)", + value: "10", + min: 5, + max: 1440, + disabled: false + }); + }); + + it("disables notification controls that depend on an inactive switch", () => { + const form = buildSettingsFormViewModel({ + settings: { ...createRendererSettings(defaultSettings()), archivePasswordList: "", notifyUrl: "" }, + section: "allgemein", + speedLimitInput: "0", + scheduleSpeedInputs: {} + }); + const fields = form.groups.find((group) => group.id === "general-notifications")?.fields ?? []; + const disabled = Object.fromEntries(fields.map((field) => [field.id, Boolean(field.disabled)])); + + expect(disabled).toMatchObject({ + notifyPackageSuccessMode: true, + notifyRemainingThresholdGb: true, + notifyStallAfterSeconds: true, + notifyStallCooldownMinutes: true, + notifyOnDownloadRecovery: true + }); + }); + + it("clamps notification number fields and restores their exact defaults", () => { + expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "0")).toBe(1); + expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "100001")).toBe(100000); + expect(normalizeNotificationNumberField("notifyRemainingThresholdGb", "invalid")).toBe(50); + expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "59")).toBe(60); + expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "3601")).toBe(3600); + expect(normalizeNotificationNumberField("notifyStallAfterSeconds", "invalid")).toBe(90); + expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "4")).toBe(5); + expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "1441")).toBe(1440); + expect(normalizeNotificationNumberField("notifyStallCooldownMinutes", "invalid")).toBe(10); + expect(normalizeNotificationNumberField("maxParallel", "8")).toBeUndefined(); + }); + it("supports keyboard navigation in animated settings selects", () => { expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2); expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0); @@ -1165,6 +1268,15 @@ describe("account workspace", () => { }); describe("settings App integration", () => { + it("preserves the write-only webhook during live snapshots and clears it for backup reseeding", () => { + const safe = createRendererSettings({ ...defaultSettings(), notifyUrl: "https://private.example.test/hook" }); + const current = { ...safe, archivePasswordList: "loaded-password", notifyUrl: "https://private.example.test/hook" }; + + expect(createSettingsDraft(safe, current).notifyUrl).toBe("https://private.example.test/hook"); + expect(createSettingsDraft(safe).notifyUrl).toBe(""); + expect(appSource).toContain("applyPersistedSettings(fresh.settings, false)"); + }); + it("loads and preserves the stored archive password list in the extraction section", () => { const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener"); const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings"); diff --git a/tests/support-data.test.ts b/tests/support-data.test.ts index 8db13dd..84eaf40 100644 --- a/tests/support-data.test.ts +++ b/tests/support-data.test.ts @@ -4,13 +4,49 @@ import path from "node:path"; import AdmZip from "adm-zip"; import { describe, expect, it } from "vitest"; import { defaultSettings } from "../src/main/constants"; -import { buildAccountSummary, buildStatsPayload } from "../src/main/support-data"; +import { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data"; import { buildSupportBundle } from "../src/main/support-bundle"; import { createStoragePaths } from "../src/main/storage"; import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import { createVisualFixture } from "./visual/fixtures"; describe("Real-Debrid support summary", () => { + it("projects only safe notification delivery and incident aggregates", () => { + const payload = buildNotificationSupportPayload( + { + queued: 7, + lastSuccessAt: 1_700_000_000_000, + lastFailureAt: 1_700_000_010_000, + events: [{ payload: { url: "https://private.example.test/hook", mention: "@private" } }] + } as Parameters[0], + { + status: "alerted", + incidentType: "no_data", + incidentStartedAt: 1_700_000_020_000, + runFingerprint: "private-run", + url: "https://private.example.test/hook", + mention: "@private" + } as Parameters[1], + 1_700_000_050_000 + ); + + expect(payload).toEqual({ + queued: 7, + lastSuccessAt: 1_700_000_000_000, + incidentType: "no_data", + incidentAgeMs: 30_000 + }); + expect(JSON.stringify(payload)).not.toMatch(/payload|https?:|mention|private|lastFailure/i); + }); + + it("reports no active incident when the health state has no incident", () => { + expect(buildNotificationSupportPayload( + { queued: 0, lastSuccessAt: 0 }, + { incidentType: null, incidentStartedAt: 0 }, + 1_700_000_050_000 + )).toEqual({ queued: 0, lastSuccessAt: null, incidentType: null, incidentAgeMs: null }); + }); + it("reports pool counts without exposing account IDs or credentials", () => { const summary = buildAccountSummary({ ...defaultSettings(), diff --git a/tests/visual-fixtures.test.ts b/tests/visual-fixtures.test.ts index 3342624..eff977b 100644 --- a/tests/visual-fixtures.test.ts +++ b/tests/visual-fixtures.test.ts @@ -93,6 +93,26 @@ describe("visual fixtures", () => { expect(createVisualFixture("dense")).toEqual(dense); }); + it("keeps the complete notification center state deterministic without exposing its webhook", () => { + const settings = createVisualFixture("dense").snapshot.settings; + + expect(settings).toEqual(expect.objectContaining({ + notifyUrlConfigured: true, + notifyMention: "@visual", + notifyOnPackageCompleted: true, + notifyOnPackageFailed: true, + notifyPackageSuccessMode: "digest", + notifyOnRunFinished: true, + notifyOnRemainingBelow: true, + notifyRemainingThresholdGb: 75, + notifyOnDownloadStall: true, + notifyStallAfterSeconds: 120, + notifyStallCooldownMinutes: 15, + notifyOnDownloadRecovery: true + })); + expect(settings).not.toHaveProperty("notifyUrl"); + }); + it("freezes runtime and recurring chart timers across visual frames", async () => { const dense = createVisualFixture("dense"); const originalDateNow = Date.now; diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 13e7654..3f16aea 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -164,11 +164,11 @@ function createSettings(): AppSettings { notifyOnPackageFailed: true, notifyOnRunFinished: true, notifyPackageSuccessMode: "digest", - notifyOnRemainingBelow: false, - notifyRemainingThresholdGb: 50, - notifyOnDownloadStall: false, - notifyStallAfterSeconds: 90, - notifyStallCooldownMinutes: 10, + notifyOnRemainingBelow: true, + notifyRemainingThresholdGb: 75, + notifyOnDownloadStall: true, + notifyStallAfterSeconds: 120, + notifyStallCooldownMinutes: 15, notifyOnDownloadRecovery: true, totalDownloadedAllTime: 987654321000, totalCompletedFilesAllTime: 842,