fix(support): expose notification health aggregates

This commit is contained in:
Sucukdeluxe
2026-08-22 08:00:25 +02:00
parent 16679e73b5
commit 1b8f6fe4b6
7 changed files with 219 additions and 47 deletions
+59 -9
View File
@@ -53,7 +53,8 @@ import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/ma
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage";
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
import type { DownloadManager } from "../src/main/download-manager";
import type { DownloadManager } from "../src/main/download-manager";
import type { NotificationSupportPayload } from "../src/main/support-data";
import type { UiSnapshot } from "../src/shared/types";
const tempDirs: string[] = [];
@@ -326,15 +327,25 @@ async function createFixture() {
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000,
events: [{ payload: "PRIVATE_DEBUG_EVENT_PAYLOAD" }],
url: "https://private.example.test/webhook",
mention: "@private"
};
startDebugServer(manager, baseDir, () => notificationStatus);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health`);
await new Promise((resolve) => setTimeout(resolve, 300));
return {
baseUrl,
token,
baseDir
return {
baseUrl,
token,
baseDir,
notificationStatus
};
}
@@ -359,6 +370,36 @@ afterEach(() => {
});
describe("debug-server", () => {
it("serves the exact safe notification DTO in its endpoint and diagnostics", async () => {
const fixture = await createFixture();
const response = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, unknown>;
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
fixture.notificationStatus.queued = 9;
fixture.notificationStatus.lastSuccessAt = 1_700_000_060_000;
const currentResponse = await authedFetch(`${fixture.baseUrl}/notifications`, fixture.token);
const currentPayload = await currentResponse.json() as Record<string, unknown>;
expect(currentPayload).toEqual({
queued: 9,
lastSuccessAt: 1_700_000_060_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
const diagnosticsResponse = await authedFetch(`${fixture.baseUrl}/diagnostics`, fixture.token);
const diagnostics = await diagnosticsResponse.json() as Record<string, any>;
expect(diagnostics.notifications).toEqual(currentPayload);
expect(JSON.stringify([payload, currentPayload, diagnostics.notifications])).not.toMatch(/PRIVATE_|https:\/\/private|@private|events|payload/i);
});
it("serves diagnostics with main, session, and package log tails", async () => {
const fixture = await createFixture();
const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token);
@@ -584,7 +625,8 @@ describe("debug-server", () => {
expect(entries).toContain("overview/accounts.json");
expect(entries).toContain("overview/debug-setup.json");
expect(entries).toContain("overview/self-check.json");
expect(entries).toContain("overview/trace-config.json");
expect(entries).toContain("overview/trace-config.json");
expect(entries).toContain("overview/notifications.json");
expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/rename.log");
expect(entries).toContain("logs/trace.log");
@@ -592,8 +634,16 @@ describe("debug-server", () => {
expect(entries).toContain("overview/support-manifest.json");
expect(entries).not.toContain(`runtime/${legacyManifestFile}`);
expect(entries).not.toContain(["overview/", "a", "i-manifest.json"].join(""));
expect(entries).not.toContain("runtime/debug_token.txt");
});
expect(entries).not.toContain("runtime/debug_token.txt");
const notifications = JSON.parse(zip.getEntry("overview/notifications.json")?.getData().toString("utf8") || "null");
expect(notifications).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "no_data",
incidentAgeMs: 30_000
});
expect(buffer.toString("utf8")).not.toMatch(/PRIVATE_DEBUG_EVENT_PAYLOAD|https:\/\/private|@private/);
});
it("rejects unauthenticated requests", async () => {
const fixture = await createFixture();
+42 -7
View File
@@ -2,9 +2,11 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
import { afterEach, describe, expect, it } from "vitest";
import { buildSupportBundle } from "../src/main/support-bundle";
import type { DownloadManager } from "../src/main/download-manager";
import { afterEach, describe, expect, it } from "vitest";
import { buildSupportBundle } from "../src/main/support-bundle";
import { createStoragePaths } from "../src/main/storage";
import type { DownloadManager } from "../src/main/download-manager";
import type { NotificationSupportPayload } from "../src/main/support-data";
const tempDirs: string[] = [];
const legacyManifestFile = ["debug_", "a", "i", "_manifest.json"].join("");
@@ -60,7 +62,7 @@ describe("buildSupportBundle (async, non-blocking)", () => {
expect(hostEntry?.getData().toString("utf8")).toBe("host-info-test");
});
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
it("does not block the event loop while building (a concurrent timer still fires)", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-"));
tempDirs.push(root);
@@ -69,6 +71,39 @@ describe("buildSupportBundle (async, non-blocking)", () => {
await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none" });
clearTimeout(timer);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(timerFired).toBe(true);
});
});
expect(timerFired).toBe(true);
});
it("writes only the safe notification aggregate and excludes its runtime files", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-bundle-notifications-"));
tempDirs.push(root);
const paths = createStoragePaths(root);
fs.writeFileSync(paths.notificationOutboxFile, "PRIVATE_OUTBOX_RUNTIME_PAYLOAD", "utf8");
fs.writeFileSync(paths.notificationHealthFile, "PRIVATE_HEALTH_RUNTIME_PAYLOAD", "utf8");
const notificationStatus: NotificationSupportPayload & Record<string, unknown> = {
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 45_000,
events: [{ payload: "PRIVATE_EVENT_PAYLOAD" }],
url: "https://private.example.test/webhook",
mention: "@private"
};
const buffer = await buildSupportBundle(fakeManager(), root, { hostDiagnosticsMode: "none", notificationStatus });
const zip = new AdmZip(buffer);
const entry = zip.getEntry("overview/notifications.json");
const payload = JSON.parse(entry?.getData().toString("utf8") || "null");
const serialized = buffer.toString("utf8");
expect(payload).toEqual({
queued: 7,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 45_000
});
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationOutboxFile));
expect(zip.getEntries().map((item) => item.entryName)).not.toContain(path.basename(paths.notificationHealthFile));
expect(serialized).not.toMatch(/PRIVATE_|https:\/\/private|@private/);
});
});
+36 -1
View File
@@ -2,7 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import AdmZip from "adm-zip";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { AppController } from "../src/main/app-controller";
import { defaultSettings } from "../src/main/constants";
import { buildAccountSummary, buildNotificationSupportPayload, buildStatsPayload } from "../src/main/support-data";
import { buildSupportBundle } from "../src/main/support-bundle";
@@ -11,6 +12,40 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun
import { createVisualFixture } from "./visual/fixtures";
describe("Real-Debrid support summary", () => {
it("projects the current private AppController notification state into the safe DTO", () => {
const controller = Object.create(AppController.prototype) as AppController;
const internals = controller as unknown as {
notificationOutbox: { getStatus: () => Record<string, unknown> };
downloadHealthMonitor: { getState: () => Record<string, unknown> };
};
internals.notificationOutbox = {
getStatus: () => ({
queued: 4,
lastSuccessAt: 1_700_000_000_000,
lastFailureAt: 1_700_000_010_000,
events: [{ payload: "PRIVATE_CONTROLLER_EVENT" }]
})
};
internals.downloadHealthMonitor = {
getState: () => ({
incidentType: "scheduler",
incidentStartedAt: 1_700_000_020_000,
runFingerprint: "PRIVATE_CONTROLLER_FINGERPRINT"
})
};
vi.spyOn(Date, "now").mockReturnValue(1_700_000_050_000);
const payload = controller.getNotificationSupportPayload();
expect(payload).toEqual({
queued: 4,
lastSuccessAt: 1_700_000_000_000,
incidentType: "scheduler",
incidentAgeMs: 30_000
});
expect(JSON.stringify(payload)).not.toMatch(/PRIVATE_|event|payload|fingerprint|lastFailure/i);
vi.restoreAllMocks();
});
it("projects only safe notification delivery and incident aggregates", () => {
const payload = buildNotificationSupportPayload(
{