feat(settings): configure notification center
This commit is contained in:
@@ -1,11 +1,41 @@
|
|||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
import { getRealDebridAccounts } from "../shared/real-debrid-accounts";
|
||||||
import { isNotifyUrlValid } from "./notify";
|
import { isNotifyUrlValid } from "./notify";
|
||||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
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 {
|
function hasText(value: unknown): boolean {
|
||||||
return String(value || "").trim().length > 0;
|
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<NotificationOutboxStatus, "queued" | "lastSuccessAt">,
|
||||||
|
health: Pick<DownloadHealthState, "incidentType" | "incidentStartedAt">,
|
||||||
|
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<string, unknown> {
|
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
||||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ import {
|
|||||||
buildTargetedAccountCheck,
|
buildTargetedAccountCheck,
|
||||||
projectAccountRows,
|
projectAccountRows,
|
||||||
resolveHistoryRetentionSelection,
|
resolveHistoryRetentionSelection,
|
||||||
|
normalizeNotificationNumberField,
|
||||||
sortAccountRows,
|
sortAccountRows,
|
||||||
formatAccountContextHeading,
|
formatAccountContextHeading,
|
||||||
type AccountAddOption,
|
type AccountAddOption,
|
||||||
@@ -279,7 +280,7 @@ interface RendererSettingsDraft extends RendererSettings {
|
|||||||
notifyUrl: string;
|
notifyUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
|
export function createSettingsDraft(settings: RendererSettings, current?: RendererSettingsDraft): RendererSettingsDraft {
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
archivePasswordList: current?.archivePasswordList || "",
|
archivePasswordList: current?.archivePasswordList || "",
|
||||||
@@ -5311,6 +5312,11 @@ export function App(): ReactElement {
|
|||||||
setBool(fieldId as keyof RendererSettingsDraft, value);
|
setBool(fieldId as keyof RendererSettingsDraft, value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const notificationNumber = normalizeNotificationNumberField(fieldId, value);
|
||||||
|
if (notificationNumber !== undefined) {
|
||||||
|
setNum(fieldId as keyof RendererSettingsDraft, notificationNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const numericLimits: Partial<Record<keyof RendererSettingsDraft, [number, number, number]>> = {
|
const numericLimits: Partial<Record<keyof RendererSettingsDraft, [number, number, number]>> = {
|
||||||
maxParallel: [1, 50, 1],
|
maxParallel: [1, 50, 1],
|
||||||
retryLimit: [0, 99, 0],
|
retryLimit: [0, 99, 0],
|
||||||
|
|||||||
@@ -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"],
|
["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)"],
|
["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"],
|
["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"],
|
["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."],
|
["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"],
|
["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"],
|
||||||
|
|||||||
@@ -270,6 +270,22 @@ export interface SettingsFormProjectionInput {
|
|||||||
themeChoice?: "light" | "dark" | "system";
|
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({
|
export function buildSettingsFormViewModel({
|
||||||
settings,
|
settings,
|
||||||
section,
|
section,
|
||||||
@@ -600,7 +616,24 @@ export function buildSettingsFormViewModel({
|
|||||||
{ id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention },
|
{ 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: "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: "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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ describe("renderer localization", () => {
|
|||||||
expect(translateUiText("Animations", "de")).toBe("Animationen");
|
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", () => {
|
it("translates dynamic update and pagination text", () => {
|
||||||
expect(translateUiText("v2.0.14 ist verfügbar. Installierte Version: 2.0.13.", "en"))
|
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.");
|
.toBe("v2.0.14 is available. Installed version: 2.0.13.");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { createRendererSettings, createRendererState } from "../src/main/renderer-state";
|
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 { buildAccountReplaceCommand, createAccountEditState, type AccountEditTarget } from "../src/renderer/account-edit";
|
||||||
import {
|
import {
|
||||||
buildScopedAccountEnabledState,
|
buildScopedAccountEnabledState,
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
filterAccountAddOptions,
|
filterAccountAddOptions,
|
||||||
getSettingsSaveLabel,
|
getSettingsSaveLabel,
|
||||||
getSettingsSelectNavigationIndex,
|
getSettingsSelectNavigationIndex,
|
||||||
|
normalizeNotificationNumberField,
|
||||||
projectAccountRows,
|
projectAccountRows,
|
||||||
pruneAccountSelection,
|
pruneAccountSelection,
|
||||||
reconcileAccountAddDraft,
|
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", () => {
|
it("supports keyboard navigation in animated settings selects", () => {
|
||||||
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
|
expect(getSettingsSelectNavigationIndex(1, 3, "ArrowDown")).toBe(2);
|
||||||
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
|
expect(getSettingsSelectNavigationIndex(2, 3, "ArrowDown")).toBe(0);
|
||||||
@@ -1165,6 +1268,15 @@ describe("account workspace", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("settings App integration", () => {
|
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", () => {
|
it("loads and preserves the stored archive password list in the extraction section", () => {
|
||||||
const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener");
|
const revealBlock = sourceBlock(appSource, "const showToast", "const clearImportQueueFocusListener");
|
||||||
const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings");
|
const applyBlock = sourceBlock(appSource, "const applyPersistedSettings", "const syncLiveProviderUsageSettings");
|
||||||
|
|||||||
@@ -4,13 +4,49 @@ import path from "node:path";
|
|||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
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 { buildSupportBundle } from "../src/main/support-bundle";
|
||||||
import { createStoragePaths } from "../src/main/storage";
|
import { createStoragePaths } from "../src/main/storage";
|
||||||
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts";
|
||||||
import { createVisualFixture } from "./visual/fixtures";
|
import { createVisualFixture } from "./visual/fixtures";
|
||||||
|
|
||||||
describe("Real-Debrid support summary", () => {
|
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<typeof buildNotificationSupportPayload>[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<typeof buildNotificationSupportPayload>[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", () => {
|
it("reports pool counts without exposing account IDs or credentials", () => {
|
||||||
const summary = buildAccountSummary({
|
const summary = buildAccountSummary({
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
@@ -93,6 +93,26 @@ describe("visual fixtures", () => {
|
|||||||
expect(createVisualFixture("dense")).toEqual(dense);
|
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 () => {
|
it("freezes runtime and recurring chart timers across visual frames", async () => {
|
||||||
const dense = createVisualFixture("dense");
|
const dense = createVisualFixture("dense");
|
||||||
const originalDateNow = Date.now;
|
const originalDateNow = Date.now;
|
||||||
|
|||||||
@@ -164,11 +164,11 @@ function createSettings(): AppSettings {
|
|||||||
notifyOnPackageFailed: true,
|
notifyOnPackageFailed: true,
|
||||||
notifyOnRunFinished: true,
|
notifyOnRunFinished: true,
|
||||||
notifyPackageSuccessMode: "digest",
|
notifyPackageSuccessMode: "digest",
|
||||||
notifyOnRemainingBelow: false,
|
notifyOnRemainingBelow: true,
|
||||||
notifyRemainingThresholdGb: 50,
|
notifyRemainingThresholdGb: 75,
|
||||||
notifyOnDownloadStall: false,
|
notifyOnDownloadStall: true,
|
||||||
notifyStallAfterSeconds: 90,
|
notifyStallAfterSeconds: 120,
|
||||||
notifyStallCooldownMinutes: 10,
|
notifyStallCooldownMinutes: 15,
|
||||||
notifyOnDownloadRecovery: true,
|
notifyOnDownloadRecovery: true,
|
||||||
totalDownloadedAllTime: 987654321000,
|
totalDownloadedAllTime: 987654321000,
|
||||||
totalCompletedFilesAllTime: 842,
|
totalCompletedFilesAllTime: 842,
|
||||||
|
|||||||
Reference in New Issue
Block a user