fix(support): expose notification health aggregates
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user