summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:28:21 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:28:21 +0900
commit5e72191cac9469c2ade91aaba1e62f69fa1ad94a (patch)
treebfa21aa2841277b8861bd05a0e2472211bed0aa9 /packages/core/tests
parent97c1b40ead19cdfe54b9a7aeb2c0fdcc1c9653b1 (diff)
downloaddispatch-5e72191cac9469c2ade91aaba1e62f69fa1ad94a.tar.gz
dispatch-5e72191cac9469c2ade91aaba1e62f69fa1ad94a.zip
feat(core): ntfy.sh notification dispatcher module
Adds a transport-agnostic NotificationDispatcher and a fire-and-forget ntfy.sh transport (no SDK; just fetch). Configuration is persisted as a single global JSON blob under the 'ntfy_config' settings key. Event taxonomy (per-event toggles): - turn-completed — assistant turn finished cleanly - turn-error — final turn error (after all fallbacks) - permission-required — new permission prompt was created - agent-spawned — top-level user-agent tab spawned via 'summon' Design: - Single internal notify(event) interface so a future transport (email, webhook) plugs in without changing call sites. - attachToAgentManager + attachToPermissionManager subscribe to the existing event streams via narrow listener interfaces (no @dispatch/api dependency back into core). - 5s in-memory dedupe window on dedupeKey suppresses permission re-emits. - 10s per-request abort timeout so a hung ntfy server can't pin a worker. - All sends are fire-and-forget: void Promise.resolve(...).catch(warn). Tests (39 new): - ntfy transport: URL/headers/body/auth/click, header sanitization, per-event-type defaults, error paths. - config: defaults, normalization tolerance, round-trip, redaction. - dispatcher: master switch, per-event toggle, dedupe, agent/permission hookups, top-level-only filtering for agent-spawned, dispose.
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/notifications/config.test.ts128
-rw-r--r--packages/core/tests/notifications/dispatcher.test.ts323
-rw-r--r--packages/core/tests/notifications/ntfy.test.ts168
3 files changed, 619 insertions, 0 deletions
diff --git a/packages/core/tests/notifications/config.test.ts b/packages/core/tests/notifications/config.test.ts
new file mode 100644
index 0000000..64a9637
--- /dev/null
+++ b/packages/core/tests/notifications/config.test.ts
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// In-memory fake for the settings table — mounted before the module under
+// test is imported (vi.mock is hoisted).
+const fakeSettings = new Map<string, string>();
+
+vi.mock("../../src/db/settings.js", () => ({
+ getSetting: vi.fn((key: string) => fakeSettings.get(key) ?? null),
+ setSetting: vi.fn((key: string, value: string) => {
+ fakeSettings.set(key, value);
+ }),
+ deleteSetting: vi.fn((key: string) => {
+ fakeSettings.delete(key);
+ }),
+}));
+
+const {
+ clearNtfyConfig,
+ defaultNtfyConfig,
+ loadNtfyConfig,
+ normalizeNtfyConfig,
+ NTFY_CONFIG_KEY,
+ redactNtfyConfig,
+ saveNtfyConfig,
+} = await import("../../src/notifications/config.js");
+
+describe("defaultNtfyConfig", () => {
+ it("disables notifications and ships sane per-event defaults", () => {
+ const cfg = defaultNtfyConfig();
+ expect(cfg.enabled).toBe(false);
+ expect(cfg.topicUrl).toBe("");
+ expect(cfg.authToken).toBe("");
+ expect(cfg.events["turn-completed"]).toBe(true);
+ expect(cfg.events["turn-error"]).toBe(true);
+ expect(cfg.events["permission-required"]).toBe(true);
+ expect(cfg.events["agent-spawned"]).toBe(false);
+ });
+});
+
+describe("normalizeNtfyConfig", () => {
+ it("returns defaults for non-object input", () => {
+ expect(normalizeNtfyConfig(null)).toEqual(defaultNtfyConfig());
+ expect(normalizeNtfyConfig(undefined)).toEqual(defaultNtfyConfig());
+ expect(normalizeNtfyConfig(42)).toEqual(defaultNtfyConfig());
+ });
+
+ it("fills in missing event toggles with defaults (newly-added types default OFF)", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: true,
+ topicUrl: "https://ntfy.sh/x",
+ events: { "turn-completed": false },
+ });
+ expect(normalized.events["turn-completed"]).toBe(false);
+ // Defaults preserved for fields the persisted blob doesn't have.
+ expect(normalized.events["turn-error"]).toBe(true);
+ expect(normalized.events["agent-spawned"]).toBe(false);
+ });
+
+ it("ignores extraneous fields and wrong-typed values", () => {
+ const normalized = normalizeNtfyConfig({
+ enabled: "yes", // wrong type ⇒ default
+ topicUrl: 42, // wrong type ⇒ default
+ authToken: null, // wrong type ⇒ default
+ events: { "turn-completed": "no", bogus: true },
+ extra: "ignored",
+ });
+ expect(normalized.enabled).toBe(false);
+ expect(normalized.topicUrl).toBe("");
+ expect(normalized.authToken).toBe("");
+ expect(normalized.events["turn-completed"]).toBe(true); // default kept
+ expect((normalized.events as Record<string, boolean>).bogus).toBeUndefined();
+ });
+});
+
+describe("load/save round-trip", () => {
+ beforeEach(() => {
+ fakeSettings.clear();
+ });
+
+ it("returns defaults when nothing is persisted", () => {
+ expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
+ });
+
+ it("round-trips a complete config", () => {
+ const cfg = {
+ enabled: true,
+ topicUrl: "https://ntfy.sh/team",
+ authToken: "tk_abc",
+ events: {
+ "turn-completed": false,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": true,
+ },
+ } as const;
+ saveNtfyConfig({ ...cfg });
+ const loaded = loadNtfyConfig();
+ expect(loaded).toEqual(cfg);
+ // Persisted as a JSON string under the documented key.
+ expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
+ });
+
+ it("returns defaults when stored JSON is corrupt", () => {
+ fakeSettings.set(NTFY_CONFIG_KEY, "{ not json");
+ expect(loadNtfyConfig()).toEqual(defaultNtfyConfig());
+ });
+
+ it("clearNtfyConfig removes the persisted entry", () => {
+ saveNtfyConfig({ ...defaultNtfyConfig(), enabled: true, topicUrl: "https://ntfy.sh/x" });
+ expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(true);
+ clearNtfyConfig();
+ expect(fakeSettings.has(NTFY_CONFIG_KEY)).toBe(false);
+ });
+});
+
+describe("redactNtfyConfig", () => {
+ it("strips authToken and surfaces a hasAuthToken flag", () => {
+ const cfg = { ...defaultNtfyConfig(), authToken: "tk_secret" };
+ const redacted = redactNtfyConfig(cfg);
+ expect(redacted.authToken).toBe("");
+ expect(redacted.hasAuthToken).toBe(true);
+ });
+
+ it("hasAuthToken is false for blank tokens", () => {
+ expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: "" }).hasAuthToken).toBe(false);
+ expect(redactNtfyConfig({ ...defaultNtfyConfig(), authToken: " " }).hasAuthToken).toBe(false);
+ });
+});
diff --git a/packages/core/tests/notifications/dispatcher.test.ts b/packages/core/tests/notifications/dispatcher.test.ts
new file mode 100644
index 0000000..db05de4
--- /dev/null
+++ b/packages/core/tests/notifications/dispatcher.test.ts
@@ -0,0 +1,323 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
+
+// The dispatcher imports `loadNtfyConfig` from config.ts, which transitively
+// pulls in `db/index.js` (bun:sqlite). Stub the DB so vitest under Node can
+// load this file. All tests inject `loadConfig` explicitly, so the real
+// settings table is never read.
+vi.mock("../../src/db/index.js", () => ({
+ getDatabase: vi.fn(() => ({
+ query: () => ({ get: () => null, run: () => {} }),
+ run: () => {},
+ })),
+}));
+
+const { NotificationDispatcher } = await import("../../src/notifications/dispatcher.js");
+
+function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
+ return {
+ enabled: true,
+ topicUrl: "https://ntfy.sh/topic",
+ authToken: "",
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": true,
+ },
+ ...overrides,
+ };
+}
+
+interface FakeAgentSource {
+ onEvent(
+ listener: (event: { type: string; tabId: string; [k: string]: unknown }) => void,
+ ): () => void;
+ emit(event: { type: string; tabId: string; [k: string]: unknown }): void;
+}
+
+function makeAgentSource(): FakeAgentSource {
+ let l: ((event: { type: string; tabId: string; [k: string]: unknown }) => void) | null = null;
+ return {
+ onEvent(listener) {
+ l = listener;
+ return () => {
+ l = null;
+ };
+ },
+ emit(event) {
+ l?.(event);
+ },
+ };
+}
+
+interface FakePermissionSource {
+ onPromptAdded(
+ listener: (prompt: { id: string; permission: string; description: string }) => void,
+ ): () => void;
+ emit(prompt: { id: string; permission: string; description: string }): void;
+}
+
+function makePermissionSource(): FakePermissionSource {
+ let l: ((prompt: { id: string; permission: string; description: string }) => void) | null = null;
+ return {
+ onPromptAdded(listener) {
+ l = listener;
+ return () => {
+ l = null;
+ };
+ },
+ emit(p) {
+ l?.(p);
+ },
+ };
+}
+
+// Microtask flush so the dispatcher's `void Promise.resolve(...).catch(...)`
+// has a chance to settle before assertions.
+async function flush(): Promise<void> {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe("NotificationDispatcher.notify", () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it("does not send when master switch is disabled", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig({ enabled: false }),
+ send,
+ });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("does not send when per-event-type toggle is off", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({
+ loadConfig: () =>
+ makeConfig({
+ events: {
+ "turn-completed": false,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ }),
+ send,
+ });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("sends when enabled and toggle is on", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not throw or block when the transport rejects", async () => {
+ const send = vi.fn(async () => {
+ throw new Error("boom");
+ });
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ expect(() => d.notify({ type: "turn-completed", title: "x", message: "y" })).not.toThrow();
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalled();
+ });
+
+ it("dedupes events with the same dedupeKey within the window", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig(),
+ send,
+ dedupeWindowMs: 1000,
+ });
+ const event: NotificationEvent = {
+ type: "permission-required",
+ title: "p",
+ message: "p",
+ dedupeKey: "permission:42",
+ };
+ d.notify(event);
+ d.notify(event);
+ d.notify(event);
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not dedupe events without a dedupeKey", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ d.notify({ type: "turn-completed", title: "x", message: "y" });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe("NotificationDispatcher.attachToAgentManager", () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it("maps `done` → turn-completed (with tab title in the body)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () => makeConfig(),
+ send,
+ getTabTitle: (id) => (id === "tab-1" ? "My chat" : null),
+ });
+ d.attachToAgentManager(source);
+ source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ const event = send.mock.calls[0][1] as NotificationEvent;
+ expect(event.type).toBe("turn-completed");
+ expect(event.title).toContain("My chat");
+ expect(event.tabId).toBe("tab-1");
+ });
+
+ it("maps `error` → turn-error and includes the error text", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToAgentManager(source);
+ source.emit({ type: "error", tabId: "tab-1", error: "Rate limit", statusCode: 429 });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ const event = send.mock.calls[0][1] as NotificationEvent;
+ expect(event.type).toBe("turn-error");
+ expect(event.message).toContain("Rate limit");
+ expect(event.message).toContain("429");
+ });
+
+ it("ignores `status` events (would spam every transition)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToAgentManager(source);
+ source.emit({ type: "status", tabId: "tab-1", status: "running" });
+ source.emit({ type: "status", tabId: "tab-1", status: "idle" });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+
+ it("maps `tab-created` to agent-spawned only for top-level user agents (parentTabId=null AND agentSlug set)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToAgentManager(source);
+
+ // Manual "new tab" with no agent slug ⇒ no notification.
+ source.emit({
+ type: "tab-created",
+ tabId: "tab-1",
+ id: "tab-1",
+ title: "New Tab",
+ parentTabId: null,
+ agentSlug: null,
+ });
+ // Subagent (has a parent) ⇒ no notification.
+ source.emit({
+ type: "tab-created",
+ tabId: "tab-2",
+ id: "tab-2",
+ title: "Subagent",
+ parentTabId: "tab-1",
+ agentSlug: "researcher",
+ });
+ // Top-level user agent ⇒ notify.
+ source.emit({
+ type: "tab-created",
+ tabId: "tab-3",
+ id: "tab-3",
+ title: "Refactor auth code",
+ parentTabId: null,
+ agentSlug: "engineer",
+ });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(1);
+ const event = send.mock.calls[0][1] as NotificationEvent;
+ expect(event.type).toBe("agent-spawned");
+ expect(event.message).toBe("Refactor auth code");
+ expect(event.title).toContain("engineer");
+ });
+
+ it("respects the per-event-type toggle (turn-completed off ⇒ silent)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({
+ loadConfig: () =>
+ makeConfig({
+ events: {
+ "turn-completed": false,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ }),
+ send,
+ });
+ d.attachToAgentManager(source);
+ source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+});
+
+describe("NotificationDispatcher.attachToPermissionManager", () => {
+ let warnSpy: ReturnType<typeof vi.spyOn>;
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it("notifies once per unique prompt id (dedupes re-emits)", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makePermissionSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToPermissionManager(source);
+
+ source.emit({ id: "1", permission: "bash", description: "Run git status" });
+ source.emit({ id: "1", permission: "bash", description: "Run git status" });
+ source.emit({ id: "2", permission: "read", description: "Read /etc/hosts" });
+ await flush();
+ expect(send).toHaveBeenCalledTimes(2);
+ const events = send.mock.calls.map((c) => c[1] as NotificationEvent);
+ expect(events.map((e) => e.type)).toEqual(["permission-required", "permission-required"]);
+ expect(events.every((e) => e.dedupeKey?.startsWith("permission:"))).toBe(true);
+ });
+});
+
+describe("NotificationDispatcher.dispose", () => {
+ it("releases attached subscriptions", async () => {
+ const send = vi.fn(async () => ({ ok: true }));
+ const source = makeAgentSource();
+ const d = new NotificationDispatcher({ loadConfig: () => makeConfig(), send });
+ d.attachToAgentManager(source);
+ d.dispose();
+ source.emit({ type: "done", tabId: "tab-1", message: { role: "assistant", chunks: [] } });
+ await flush();
+ expect(send).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/tests/notifications/ntfy.test.ts b/packages/core/tests/notifications/ntfy.test.ts
new file mode 100644
index 0000000..3fb1d51
--- /dev/null
+++ b/packages/core/tests/notifications/ntfy.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it, vi } from "vitest";
+import { sendNtfy, validateTopicUrl } from "../../src/notifications/ntfy.js";
+import type { NotificationEvent, NtfyConfig } from "../../src/notifications/types.js";
+
+function makeConfig(overrides: Partial<NtfyConfig> = {}): NtfyConfig {
+ return {
+ enabled: true,
+ topicUrl: "https://ntfy.sh/my-topic",
+ authToken: "",
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": true,
+ },
+ ...overrides,
+ };
+}
+
+function makeEvent(overrides: Partial<NotificationEvent> = {}): NotificationEvent {
+ return {
+ type: "turn-completed",
+ title: "Done",
+ message: "all good",
+ ...overrides,
+ };
+}
+
+function makeFetch(
+ response: Partial<{ ok: boolean; status: number; statusText: string; body: string }> = {},
+) {
+ const fetchImpl = vi.fn(async () => ({
+ ok: response.ok ?? true,
+ status: response.status ?? 200,
+ statusText: response.statusText ?? "OK",
+ text: async () => response.body ?? "",
+ }));
+ return fetchImpl;
+}
+
+describe("validateTopicUrl", () => {
+ it("accepts ntfy.sh-style URLs", () => {
+ expect(validateTopicUrl("https://ntfy.sh/my-topic")).toBeNull();
+ expect(validateTopicUrl("http://ntfy.example.com/team-alerts")).toBeNull();
+ });
+
+ it("rejects empty / whitespace", () => {
+ expect(validateTopicUrl("")).toMatch(/required/);
+ expect(validateTopicUrl(" ")).toMatch(/required/);
+ });
+
+ it("rejects malformed URLs", () => {
+ expect(validateTopicUrl("not a url")).toMatch(/valid URL/);
+ });
+
+ it("rejects non-http(s) schemes", () => {
+ expect(validateTopicUrl("ftp://ntfy.sh/topic")).toMatch(/http/);
+ });
+
+ it("rejects URLs missing a topic path", () => {
+ expect(validateTopicUrl("https://ntfy.sh")).toMatch(/topic/);
+ expect(validateTopicUrl("https://ntfy.sh/")).toMatch(/topic/);
+ });
+});
+
+describe("sendNtfy", () => {
+ it("POSTs to the topic URL with Title/Priority/Tags/Content-Type headers and body", async () => {
+ const fetchImpl = makeFetch();
+ const result = await sendNtfy(
+ makeConfig(),
+ makeEvent({ title: "Hello", message: "World", tags: ["bell"], priority: 4 }),
+ fetchImpl,
+ );
+ expect(result.ok).toBe(true);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchImpl.mock.calls[0];
+ expect(url).toBe("https://ntfy.sh/my-topic");
+ expect(init.method).toBe("POST");
+ expect(init.headers.Title).toBe("Hello");
+ expect(init.headers.Priority).toBe("4");
+ expect(init.headers.Tags).toBe("bell");
+ expect(init.headers["Content-Type"]).toMatch(/text\/plain/);
+ expect(init.body).toBe("World");
+ });
+
+ it("uses per-event-type defaults for priority and tags", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig(), makeEvent({ type: "turn-error" }), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Priority).toBe("4"); // NTFY_DEFAULT_PRIORITIES["turn-error"]
+ expect(init.headers.Tags).toBe("rotating_light");
+ });
+
+ it("attaches Authorization header when authToken is set", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig({ authToken: "tk_secret " }), makeEvent(), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Authorization).toBe("Bearer tk_secret");
+ });
+
+ it("omits Authorization when authToken is blank", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig({ authToken: " " }), makeEvent(), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Authorization).toBeUndefined();
+ });
+
+ it("attaches Click header when clickUrl is set", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig(), makeEvent({ clickUrl: "https://example.com/tab/abc" }), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Click).toBe("https://example.com/tab/abc");
+ });
+
+ it("appends short tab tag when tabId is set", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(
+ makeConfig(),
+ makeEvent({ tabId: "abcdef0123456789", tags: ["bell"] }),
+ fetchImpl,
+ );
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Tags).toBe("bell,tab-abcdef01");
+ });
+
+ it("strips CR/LF/control chars from header values (injection guard)", async () => {
+ const fetchImpl = makeFetch();
+ await sendNtfy(makeConfig(), makeEvent({ title: "line1\r\nInjected: yes" }), fetchImpl);
+ const init = fetchImpl.mock.calls[0][1];
+ expect(init.headers.Title).not.toContain("\n");
+ expect(init.headers.Title).not.toContain("\r");
+ expect(init.headers.Title).toBe("line1 Injected: yes");
+ });
+
+ it("returns ok:false when notifications are disabled", async () => {
+ const fetchImpl = makeFetch();
+ const result = await sendNtfy(makeConfig({ enabled: false }), makeEvent(), fetchImpl);
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatch(/disabled/);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it("returns ok:false on invalid topic URL without calling fetch", async () => {
+ const fetchImpl = makeFetch();
+ const result = await sendNtfy(makeConfig({ topicUrl: "not a url" }), makeEvent(), fetchImpl);
+ expect(result.ok).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it("returns ok:false with status on non-2xx response", async () => {
+ const fetchImpl = makeFetch({ ok: false, status: 403, statusText: "Forbidden", body: "nope" });
+ const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
+ expect(result.ok).toBe(false);
+ expect(result.status).toBe(403);
+ expect(result.error).toMatch(/403/);
+ expect(result.error).toMatch(/nope/);
+ });
+
+ it("returns ok:false with error message on fetch throwing", async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new Error("ECONNREFUSED");
+ });
+ const result = await sendNtfy(makeConfig(), makeEvent(), fetchImpl);
+ expect(result.ok).toBe(false);
+ expect(result.error).toMatch(/ECONNREFUSED/);
+ });
+});