summaryrefslogtreecommitdiffhomepage
path: root/packages/api/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:28:30 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:28:30 +0900
commit21cdb1199599c4dc6e2a941e52713ba6511cd675 (patch)
treee3711384f16c476bf5d8def148dc922f9ceb5f3d /packages/api/tests
parent5e72191cac9469c2ade91aaba1e62f69fa1ad94a (diff)
downloaddispatch-21cdb1199599c4dc6e2a941e52713ba6511cd675.tar.gz
dispatch-21cdb1199599c4dc6e2a941e52713ba6511cd675.zip
feat(api): wire notification dispatcher into app + /notifications routes
PermissionManager: add onPromptAdded(listener) callback. Fires exactly once per unique pending prompt id, even when broadcastPending is called repeatedly for unrelated mutations (e.g. another prompt resolving while this one is still pending). app.ts: instantiate NotificationDispatcher, attach to both AgentManager and PermissionManager. Tab-title lookup via core's getTab so the notifications carry human-readable context instead of raw UUIDs. routes/notifications.ts: - GET /notifications — current config (auth token redacted) plus the event-type catalog and defaults - PUT /notifications — partial update; auth token semantics are undefined=keep, ''=clear, otherwise replace - POST /notifications/test — sends a test notification with the current config (rejects if disabled or topic invalid) Tests: - new permission-manager.test.ts covers the onPromptAdded contract (one-fire-per-prompt, dedup across rebroadcasts, unsubscribe, listener throws don't break siblings) - existing routes.test.ts gets stubs for the new core notification exports so the @dispatch/core mock stays complete
Diffstat (limited to 'packages/api/tests')
-rw-r--r--packages/api/tests/permission-manager.test.ts99
-rw-r--r--packages/api/tests/routes.test.ts51
2 files changed, 150 insertions, 0 deletions
diff --git a/packages/api/tests/permission-manager.test.ts b/packages/api/tests/permission-manager.test.ts
new file mode 100644
index 0000000..172adb3
--- /dev/null
+++ b/packages/api/tests/permission-manager.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it, vi } from "vitest";
+
+// Mock @dispatch/core to provide only the PermissionService impl this test
+// touches — the core barrel transitively pulls in bun:sqlite, which vitest
+// running under Node cannot resolve.
+vi.mock("@dispatch/core", async () => {
+ const mod = await import("../../core/src/permission/service.js");
+ return {
+ PermissionService: mod.PermissionService,
+ };
+});
+
+const { PermissionManager } = await import("../src/permission-manager.js");
+
+interface PermissionRequest {
+ permission: string;
+ patterns: string[];
+ always: string[];
+ description: string;
+ metadata: Record<string, unknown>;
+}
+
+function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
+ return {
+ permission: "bash",
+ patterns: ["git *"],
+ always: ["git status"],
+ description: "Run git status",
+ metadata: {},
+ ...overrides,
+ };
+}
+
+describe("PermissionManager.onPromptAdded", () => {
+ it("fires once per newly-added pending prompt", () => {
+ const mgr = new PermissionManager();
+ const seen: Array<{ id: string; permission: string }> = [];
+ mgr.onPromptAdded((p) => {
+ seen.push({ id: p.id, permission: p.permission });
+ });
+
+ void mgr.ask(makeRequest(), []);
+ void mgr.ask(makeRequest({ permission: "read", description: "Read X" }), []);
+
+ expect(seen).toHaveLength(2);
+ expect(seen[0].permission).toBe("bash");
+ expect(seen[1].permission).toBe("read");
+ // Distinct ids
+ expect(seen[0].id).not.toBe(seen[1].id);
+ });
+
+ it("does not re-fire when the pending list is rebroadcast for an unrelated change", async () => {
+ const mgr = new PermissionManager();
+ const seen: string[] = [];
+ mgr.onPromptAdded((p) => seen.push(p.id));
+
+ // Two prompts in; should see two notifications.
+ const p1 = mgr.ask(makeRequest(), []);
+ void mgr.ask(makeRequest({ permission: "read" }), []);
+ expect(seen).toHaveLength(2);
+
+ // Resolve the first one — broadcastPending fires again, but the
+ // remaining (already-announced) prompt must NOT re-notify.
+ const pending = mgr.getPending();
+ const firstId = pending[0].id;
+ mgr.reply(firstId, "once");
+ await p1;
+
+ expect(seen).toHaveLength(2);
+ });
+
+ it("unsubscribe stops further notifications", () => {
+ const mgr = new PermissionManager();
+ const seen: string[] = [];
+ const unsub = mgr.onPromptAdded((p) => seen.push(p.id));
+ void mgr.ask(makeRequest(), []);
+ unsub();
+ void mgr.ask(makeRequest({ permission: "read" }), []);
+ expect(seen).toHaveLength(1);
+ });
+
+ it("listener throws are caught and don't break other listeners", () => {
+ const mgr = new PermissionManager();
+ const seen: string[] = [];
+ mgr.onPromptAdded(() => {
+ throw new Error("boom");
+ });
+ mgr.onPromptAdded((p) => seen.push(p.id));
+ // Swallow the warn during this test.
+ const origWarn = console.warn;
+ console.warn = () => {};
+ try {
+ void mgr.ask(makeRequest(), []);
+ } finally {
+ console.warn = origWarn;
+ }
+ expect(seen).toHaveLength(1);
+ });
+});
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index 9ab2afe..c07f932 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -268,6 +268,57 @@ vi.mock("@dispatch/core", () => ({
execute: async () => "mock",
};
},
+ // ── ntfy notifications stubs ──────────────────────────────────
+ NotificationDispatcher: class MockNotificationDispatcher {
+ attachToAgentManager() {
+ return () => {};
+ }
+ attachToPermissionManager() {
+ return () => {};
+ }
+ notify() {}
+ dispose() {}
+ },
+ loadNtfyConfig() {
+ return {
+ enabled: false,
+ topicUrl: "",
+ authToken: "",
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ };
+ },
+ saveNtfyConfig() {},
+ normalizeNtfyConfig(c: unknown) {
+ return c;
+ },
+ defaultNtfyConfig() {
+ return {
+ enabled: false,
+ topicUrl: "",
+ authToken: "",
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ };
+ },
+ redactNtfyConfig(c: { authToken?: string }) {
+ return { ...c, authToken: "", hasAuthToken: false };
+ },
+ NTFY_EVENT_TYPES: ["turn-completed", "turn-error", "permission-required", "agent-spawned"],
+ async sendNtfy() {
+ return { ok: true };
+ },
+ validateTopicUrl() {
+ return null;
+ },
}));
const { app } = await import("../src/app.js");