diff --git a/.changeset/fn-6953-ntfy-test-unsaved-config.md b/.changeset/fn-6953-ntfy-test-unsaved-config.md new file mode 100644 index 0000000000..6371518d61 --- /dev/null +++ b/.changeset/fn-6953-ntfy-test-unsaved-config.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix ntfy test notifications to honor unsaved Settings form config so users can enable ntfy, enter a valid topic/server/token, and send a test notification before saving. diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index b5d7af8c75..8ab9bd73e1 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -696,13 +696,16 @@ The embedded title reads like other embedded-view titles (Planning modal-header- } } +/* +FNXC:SettingsMobile 2026-06-23-09:02: +Settings section headings should preserve hierarchy through spacing and type only. Avoid per-heading divider borders so mobile and desktop shared Settings sections keep the lighter scrollbar-focused chrome contract. +*/ .settings-section-heading { font-size: 14px; font-weight: 600; padding: var(--space-lg) 0 var(--space-md); margin: 0 0 var(--space-md); color: var(--text); - border-bottom: 1px solid var(--border); } /* First heading inside the section drops top padding to remove a redundant diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index ee7f96253a..28d6d01ed3 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1851,17 +1851,22 @@ export function SettingsModal({ return next; }); try { + /* + FNXC:Notifications 2026-06-23-08:49: + Settings notification tests must send the current unsaved ntfy form values for every ntfy test affordance. Users validate the exact topic/server/token they just typed before saving, so message/room test requests carry the same request-scoped config as the general ntfy test. + */ + const currentNtfyConfig = { + ntfyEnabled: form.ntfyEnabled, + ntfyTopic: form.ntfyTopic, + ...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}), + ...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}), + }; const config = providerId === "ntfy" - ? { - ntfyEnabled: form.ntfyEnabled, - ntfyTopic: form.ntfyTopic, - ...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}), - ...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}), - } + ? currentNtfyConfig : providerId === "ntfy-message" - ? { messageEventType: "message:agent-to-user" } + ? { ...currentNtfyConfig, messageEventType: "message:agent-to-user" } : providerId === "ntfy-room" - ? { messageEventType: "message:room" } + ? { ...currentNtfyConfig, messageEventType: "message:room" } : { webhookUrl: form.webhookUrl, webhookFormat: form.webhookFormat || "generic", diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 6b9602f3c2..2313590734 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -4895,6 +4895,54 @@ describe("SettingsModal", () => { }); }); + it("sends unsaved ntfy form config before saving", async () => { + mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined }); + renderModal(); + await waitForSettingsModalReady(); + await openNotificationsSection(); + + await user.click(screen.getByLabelText("Enable")); + await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic"); + await user.click(screen.getByText("Advanced")); + await user.type(screen.getByLabelText("Custom ntfy server URL (optional)"), "https://ntfy.override.example//"); + await user.type(screen.getByLabelText("Access token (optional)"), "override-token"); + await user.click(screen.getByRole("button", { name: /Test notification/ })); + + await waitFor(() => { + expect(mockTestNotification).toHaveBeenCalledWith( + "ntfy", + expect.objectContaining({ + ntfyEnabled: true, + ntfyTopic: "fresh-topic", + ntfyBaseUrl: "https://ntfy.override.example//", + ntfyAccessToken: "override-token", + }), + undefined, + ); + }); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + expect(mockUpdateGlobalSettings).not.toHaveBeenCalled(); + }); + + it("keeps ntfy test disabled until the current form has a valid topic", async () => { + mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, ntfyEnabled: false, ntfyTopic: undefined }); + renderModal(); + await waitForSettingsModalReady(); + await openNotificationsSection(); + + await user.click(screen.getByLabelText("Enable")); + const testButton = screen.getByRole("button", { name: /Test notification/ }); + expect(testButton).toBeDisabled(); + + await user.type(screen.getByLabelText("ntfy Topic"), "bad topic!"); + expect(testButton).toBeDisabled(); + expect(mockTestNotification).not.toHaveBeenCalled(); + + await user.clear(screen.getByLabelText("ntfy Topic")); + await user.type(screen.getByLabelText("ntfy Topic"), "fresh-topic"); + expect(testButton).toBeEnabled(); + }); + it("clears a saved ntfy access token via global null-as-delete semantics", async () => { mockFetchSettings.mockResolvedValueOnce({ ...defaultSettings, @@ -4929,7 +4977,11 @@ describe("SettingsModal", () => { await waitFor(() => { expect(mockTestNotification).toHaveBeenCalledWith( "ntfy", - { messageEventType: "message:agent-to-user" }, + expect.objectContaining({ + messageEventType: "message:agent-to-user", + ntfyEnabled: true, + ntfyTopic: "test-topic", + }), undefined, ); }); @@ -4953,7 +5005,11 @@ describe("SettingsModal", () => { await waitFor(() => { expect(mockTestNotification).toHaveBeenCalledWith( "ntfy", - { messageEventType: "message:room" }, + expect.objectContaining({ + messageEventType: "message:room", + ntfyEnabled: true, + ntfyTopic: "test-topic", + }), undefined, ); }); diff --git a/packages/dashboard/src/__tests__/routes-settings.test.ts b/packages/dashboard/src/__tests__/routes-settings.test.ts index 6bff37c484..dc07d77c17 100644 --- a/packages/dashboard/src/__tests__/routes-settings.test.ts +++ b/packages/dashboard/src/__tests__/routes-settings.test.ts @@ -1772,6 +1772,37 @@ describe("POST /settings/test-ntfy", () => { expect(url).toBe("https://ntfy.override.example/my-topic"); }); + it("uses unsaved request ntfy config when saved settings are disabled", async () => { + (store.getSettings as ReturnType).mockResolvedValue({ + ntfyEnabled: false, + ntfyTopic: undefined, + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", + }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/settings/test-ntfy", + JSON.stringify({ + ntfyEnabled: true, + ntfyTopic: "fresh-topic", + ntfyBaseUrl: "https://ntfy.override.example//", + ntfyAccessToken: "override-token", + }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(store.updateSettings).not.toHaveBeenCalled(); + expect(store.updateGlobalSettings).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const url = fetchSpy.mock.calls[0]?.[0] as string; + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(url).toBe("https://ntfy.override.example/fresh-topic"); + expect(options.headers).toHaveProperty("Authorization", "Bearer override-token"); + }); + it("falls back to saved ntfyBaseUrl when request override is blank", async () => { (store.getSettings as ReturnType).mockResolvedValue({ ntfyEnabled: true, @@ -1973,69 +2004,82 @@ describe("POST /settings/test-notification", () => { ); }); - it("ntfy provider dispatches a message-event pipeline test when messageEventType is provided", async () => { - const dispatchSpy = vi.fn().mockResolvedValue(undefined); - mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy }); + it("ntfy provider sends a message-event test with unsaved config when messageEventType is provided", async () => { (store.getSettings as ReturnType).mockResolvedValue({ - ntfyEnabled: true, - ntfyTopic: "test-topic", + ntfyEnabled: false, + ntfyTopic: "saved-topic", + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", }); const res = await REQUEST( buildApp(), "POST", "/api/settings/test-notification", - JSON.stringify({ providerId: "ntfy", messageEventType: "message:agent-to-user" }), + JSON.stringify({ + providerId: "ntfy", + config: { + messageEventType: "message:agent-to-user", + ntfyEnabled: true, + ntfyTopic: "fresh-message-topic", + ntfyBaseUrl: "https://ntfy.message.example//", + ntfyAccessToken: "message-token", + }, + }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(dispatchSpy).toHaveBeenCalledWith( - "message:agent-to-user", - expect.objectContaining({ - event: "message:agent-to-user", - metadata: expect.objectContaining({ - fromId: "system", - toId: "user", - preview: "Fusion test message notification", - }), - }), - ); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockGetActiveNotificationService).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.message.example/fresh-message-topic"); + expect(options.headers).toMatchObject({ + Title: "New message from Fusion", + Priority: "high", + Authorization: "Bearer message-token", + }); + expect(options.body).toBe("Fusion → you: Fusion test message notification"); }); - it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => { - const dispatchSpy = vi.fn().mockResolvedValue(undefined); - mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy }); + it("ntfy provider sends a room message-event test with unsaved config when messageEventType is message:room", async () => { (store.getSettings as ReturnType).mockResolvedValue({ - ntfyEnabled: true, - ntfyTopic: "test-topic", + ntfyEnabled: false, + ntfyTopic: "saved-topic", + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", }); const res = await REQUEST( buildApp(), "POST", "/api/settings/test-notification", - JSON.stringify({ providerId: "ntfy", messageEventType: "message:room" }), + JSON.stringify({ + providerId: "ntfy", + config: { + messageEventType: "message:room", + ntfyEnabled: true, + ntfyTopic: "fresh-room-topic", + ntfyBaseUrl: "https://ntfy.room.example//", + ntfyAccessToken: "room-token", + }, + }), { "content-type": "application/json" }, ); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(dispatchSpy).toHaveBeenCalledWith( - "message:room", - expect.objectContaining({ - event: "message:room", - metadata: expect.objectContaining({ - roomId: "test-room", - roomName: "Test Room", - senderName: "Fusion", - preview: "Fusion test room notification", - }), - }), - ); - expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockGetActiveNotificationService).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.room.example/fresh-room-topic"); + expect(options.headers).toMatchObject({ + Title: "#Test Room — Fusion", + Priority: "default", + Authorization: "Bearer room-token", + }); + expect(options.body).toBe("Fusion in #Test Room: Fusion test room notification"); }); it("ntfy provider uses config override for baseUrl", async () => { @@ -2057,6 +2101,69 @@ describe("POST /settings/test-notification", () => { expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic"); }); + it("ntfy provider sends with unsaved config when saved settings are disabled", async () => { + (store.getSettings as ReturnType).mockResolvedValue({ + ntfyEnabled: false, + ntfyTopic: undefined, + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", + }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/settings/test-notification", + JSON.stringify({ + providerId: "ntfy", + config: { + ntfyEnabled: true, + ntfyTopic: "fresh-topic", + ntfyBaseUrl: "https://ntfy.override.example//", + ntfyAccessToken: "override-token", + }, + }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(store.updateSettings).not.toHaveBeenCalled(); + expect(store.updateGlobalSettings).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/fresh-topic"); + expect(options.headers).toHaveProperty("Authorization", "Bearer override-token"); + }); + + it("ntfy provider ignores blank request baseUrl and token overrides", async () => { + (store.getSettings as ReturnType).mockResolvedValue({ + ntfyEnabled: true, + ntfyTopic: "saved-topic", + ntfyBaseUrl: "https://ntfy.saved.example", + ntfyAccessToken: "saved-token", + }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/settings/test-notification", + JSON.stringify({ + providerId: "ntfy", + config: { + ntfyEnabled: true, + ntfyTopic: "fresh-topic", + ntfyBaseUrl: " ", + ntfyAccessToken: " ", + }, + }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + const options = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.saved.example/fresh-topic"); + expect(options.headers).toHaveProperty("Authorization", "Bearer saved-token"); + }); + it("ntfy provider sends Authorization header from saved or override token", async () => { (store.getSettings as ReturnType).mockResolvedValue({ ntfyEnabled: true, diff --git a/packages/dashboard/src/routes/register-settings-memory-routes.ts b/packages/dashboard/src/routes/register-settings-memory-routes.ts index 59850ccca4..e8205ab684 100644 --- a/packages/dashboard/src/routes/register-settings-memory-routes.ts +++ b/packages/dashboard/src/routes/register-settings-memory-routes.ts @@ -47,7 +47,6 @@ import { import { buildSessionSkillContextSync, createFnAgent as engineCreateFnAgent, - getActiveNotificationService, probeWorktrunk, resolveWorktrunkBinary, } from "@fusion/engine"; @@ -2044,84 +2043,160 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin * Returns the user's global pi extension settings from ~/.pi/agent/settings.json. * Includes packages, extension paths, skill paths, prompt template paths, and theme paths. */ - router.post("/settings/test-ntfy", async (req, res) => { - const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => { - const trimmed = value.trim(); - if (!trimmed) { - throw badRequest("ntfy server URL cannot be empty"); - } + const normalizeHttpUrl = (value: string, fieldName: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + throw badRequest(`${fieldName} cannot be empty`); + } - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - throw badRequest(`ntfy server URL from ${source} must be a valid URL`); - } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw badRequest(`${fieldName} must be a valid URL`); + } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw badRequest("ntfy server URL must use http:// or https://"); - } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw badRequest(`${fieldName} must use http:// or https://`); + } - return trimmed.replace(/\/+$/, ""); + return trimmed; + }; + + const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => { + const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`); + return normalized.replace(/\/+$/, ""); + }; + + const getOwnValue = (source: Record, key: string): unknown => ( + Object.prototype.hasOwnProperty.call(source, key) ? source[key] : undefined + ); + + const getRequestNtfyValue = (body: Record, config: Record, key: string): unknown => { + const configValue = getOwnValue(config, key); + return configValue !== undefined ? configValue : getOwnValue(body, key); + }; + + type NtfyTestMessageEventType = "message:agent-to-user" | "message:agent-to-agent" | "message:room"; + + function resolveEffectiveNtfyTestConfig( + settings: Record, + body: Record, + config: Record = {}, + ): { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string } { + /* + FNXC:Notifications 2026-06-23-08:34: + Test sends must honor unsaved Settings form state because users enable ntfy, enter a topic/server/token, and test before saving. Resolve request-scoped values ahead of persisted settings without persisting or logging tokens. + + FNXC:Notifications 2026-06-23-10:21: + Every ntfy test affordance, including message and room tests, must publish with the request-scoped topic/server/token instead of the active notification service's persisted provider state. + */ + const enabledOverride = getRequestNtfyValue(body, config, "ntfyEnabled"); + if (enabledOverride !== undefined && enabledOverride !== null && typeof enabledOverride !== "boolean") { + throw badRequest("ntfy enabled must be a boolean"); + } + const ntfyEnabled = typeof enabledOverride === "boolean" ? enabledOverride : settings.ntfyEnabled === true; + if (!ntfyEnabled) { + throw badRequest("ntfy notifications are not enabled"); + } + + const topicOverride = getRequestNtfyValue(body, config, "ntfyTopic"); + if (topicOverride !== undefined && topicOverride !== null && typeof topicOverride !== "string") { + throw badRequest("ntfy topic must be a string"); + } + const topic = typeof topicOverride === "string" ? topicOverride : settings.ntfyTopic; + if (typeof topic !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) { + throw badRequest("ntfy topic is not configured or invalid"); + } + + const baseUrlOverride = getRequestNtfyValue(body, config, "ntfyBaseUrl"); + if (baseUrlOverride !== undefined && baseUrlOverride !== null && typeof baseUrlOverride !== "string") { + throw badRequest("ntfy server URL must be a string"); + } + const requestBaseUrl = typeof baseUrlOverride === "string" && baseUrlOverride.trim() + ? normalizeNtfyBaseUrl(baseUrlOverride, "request") + : undefined; + const storedBaseUrl = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim() + ? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings") + : undefined; + + const tokenOverride = getRequestNtfyValue(body, config, "ntfyAccessToken"); + if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") { + throw badRequest("ntfy access token must be a string"); + } + const requestToken = typeof tokenOverride === "string" && tokenOverride.trim() + ? tokenOverride.trim() + : undefined; + const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim() + ? settings.ntfyAccessToken.trim() + : undefined; + + return { + topic, + ntfyBaseUrl: requestBaseUrl ?? storedBaseUrl ?? "https://ntfy.sh", + ntfyAccessToken: requestToken ?? storedToken, }; + } + + async function sendNtfyTestNotification( + options: { topic: string; ntfyBaseUrl: string; ntfyAccessToken?: string; messageEventType?: NtfyTestMessageEventType }, + ): Promise { + const contentByEvent: Record = { + default: { + title: "Fusion test notification", + message: "Fusion test notification — your notifications are working!", + priority: "default", + }, + "message:agent-to-user": { + title: "New message from Fusion", + message: "Fusion → you: Fusion test message notification", + priority: "high", + }, + "message:agent-to-agent": { + title: "Fusion → recipient", + message: "Fusion messaged recipient: Fusion test message notification", + priority: "default", + }, + "message:room": { + title: "#Test Room — Fusion", + message: "Fusion in #Test Room: Fusion test room notification", + priority: "default", + }, + }; + const content = contentByEvent[options.messageEventType ?? "default"]; + const headers: Record = { + Title: content.title, + Priority: content.priority, + "Content-Type": "text/plain", + }; + if (options.ntfyAccessToken) { + headers.Authorization = `Bearer ${options.ntfyAccessToken}`; + } + + const response = await fetch(`${options.ntfyBaseUrl}/${options.topic}`, { + method: "POST", + headers, + body: content.message, + }); + + if (!response.ok) { + throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`); + } + } + + router.post("/settings/test-ntfy", async (req, res) => { try { + const body = (req.body ?? {}) as Record; + const configValue = body.config; + if (configValue !== undefined && (typeof configValue !== "object" || configValue === null || Array.isArray(configValue))) { + throw badRequest("config must be an object when provided"); + } + const config = (configValue ?? {}) as Record; const { store: scopedStore } = await getProjectContext(req); const settings = await scopedStore.getSettings(); - - // Validate ntfy is enabled - if (!settings.ntfyEnabled) { - throw badRequest("ntfy notifications are not enabled"); - } - - // Validate topic exists and matches required format - const topic = settings.ntfyTopic; - if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) { - throw badRequest("ntfy topic is not configured or invalid"); - } - - const overrideValue = req.body?.ntfyBaseUrl; - if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") { - throw badRequest("ntfy server URL must be a string"); - } - - const requestOverride = typeof overrideValue === "string" && overrideValue.trim() - ? normalizeNtfyBaseUrl(overrideValue, "request") - : undefined; - const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim() - ? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings") - : undefined; - const tokenOverride = req.body?.ntfyAccessToken; - if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") { - throw badRequest("ntfy access token must be a string"); - } - const requestToken = typeof tokenOverride === "string" && tokenOverride.trim() - ? tokenOverride.trim() - : undefined; - const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim() - ? settings.ntfyAccessToken.trim() - : undefined; - const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh"; - const url = `${ntfyBaseUrl}/${topic}`; - const headers: Record = { - "Title": "Fusion test notification", - "Priority": "default", - "Content-Type": "text/plain", - }; - const ntfyAccessToken = requestToken ?? storedToken; - if (ntfyAccessToken) { - headers.Authorization = `Bearer ${ntfyAccessToken}`; - } - - const response = await fetch(url, { - method: "POST", - headers, - body: "Fusion test notification — your notifications are working!", - }); - - if (!response.ok) { - throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`); - } + const configForTest = resolveEffectiveNtfyTestConfig(settings as Record, body, config); + await sendNtfyTestNotification(configForTest); res.json({ success: true }); } catch (err: unknown) { @@ -2133,31 +2208,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin }); router.post("/settings/test-notification", async (req, res) => { - const normalizeHttpUrl = (value: string, fieldName: string): string => { - const trimmed = value.trim(); - if (!trimmed) { - throw badRequest(`${fieldName} cannot be empty`); - } - - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - throw badRequest(`${fieldName} must be a valid URL`); - } - - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - throw badRequest(`${fieldName} must use http:// or https://`); - } - - return trimmed; - }; - - const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => { - const normalized = normalizeHttpUrl(value, `ntfy server URL from ${source}`); - return normalized.replace(/\/+$/, ""); - }; - try { const body = (req.body ?? {}) as Record; const providerId = body.providerId; @@ -2176,113 +2226,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin if (providerId === "ntfy") { const requestedMessageEventType = config.messageEventType ?? body.messageEventType; - if (requestedMessageEventType !== undefined) { - if ( - requestedMessageEventType !== "message:agent-to-user" - && requestedMessageEventType !== "message:agent-to-agent" - && requestedMessageEventType !== "message:room" - ) { - throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room"); - } - - const notificationService = getActiveNotificationService(); - if (!notificationService) { - throw new ApiError(502, "Notification service is not active"); - } - - try { - const messageId = `test-${crypto.randomUUID()}`; - if (requestedMessageEventType === "message:room") { - await notificationService.dispatch(requestedMessageEventType, { - taskId: undefined, - taskTitle: undefined, - event: requestedMessageEventType, - metadata: { - messageId, - roomId: "test-room", - roomName: "Test Room", - senderAgentId: "system", - senderName: "Fusion", - preview: "Fusion test room notification", - type: "room-assistant", - }, - }); - } else { - const messageType = requestedMessageEventType.split(":")[1] ?? "agent-to-user"; - await notificationService.dispatch(requestedMessageEventType, { - taskId: undefined, - taskTitle: undefined, - event: requestedMessageEventType, - metadata: { - messageId, - fromId: "system", - fromType: "agent", - toId: "user", - toType: "user", - type: messageType, - preview: "Fusion test message notification", - }, - }); - } - res.json({ success: true }); - return; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new ApiError(502, `Failed to dispatch message notification: ${message}`); - } + if ( + requestedMessageEventType !== undefined + && requestedMessageEventType !== "message:agent-to-user" + && requestedMessageEventType !== "message:agent-to-agent" + && requestedMessageEventType !== "message:room" + ) { + throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or message:room"); } - if (!settings.ntfyEnabled) { - throw badRequest("ntfy notifications are not enabled"); - } - - const topic = settings.ntfyTopic; - if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) { - throw badRequest("ntfy topic is not configured or invalid"); - } - - const overrideValue = config.ntfyBaseUrl ?? body.ntfyBaseUrl; - if (overrideValue !== undefined && overrideValue !== null && typeof overrideValue !== "string") { - throw badRequest("ntfy server URL must be a string"); - } - - const requestOverride = typeof overrideValue === "string" && overrideValue.trim() - ? normalizeNtfyBaseUrl(overrideValue, "request") - : undefined; - const storedServer = typeof settings.ntfyBaseUrl === "string" && settings.ntfyBaseUrl.trim() - ? normalizeNtfyBaseUrl(settings.ntfyBaseUrl, "settings") - : undefined; - const tokenOverride = config.ntfyAccessToken ?? body.ntfyAccessToken; - if (tokenOverride !== undefined && tokenOverride !== null && typeof tokenOverride !== "string") { - throw badRequest("ntfy access token must be a string"); - } - const requestToken = typeof tokenOverride === "string" && tokenOverride.trim() - ? tokenOverride.trim() - : undefined; - const storedToken = typeof settings.ntfyAccessToken === "string" && settings.ntfyAccessToken.trim() - ? settings.ntfyAccessToken.trim() - : undefined; - const ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh"; - const url = `${ntfyBaseUrl}/${topic}`; - const headers: Record = { - "Title": "Fusion test notification", - "Priority": "default", - "Content-Type": "text/plain", - }; - const ntfyAccessToken = requestToken ?? storedToken; - if (ntfyAccessToken) { - headers.Authorization = `Bearer ${ntfyAccessToken}`; - } - - const response = await fetch(url, { - method: "POST", - headers, - body: "Fusion test notification — your notifications are working!", + const configForTest = resolveEffectiveNtfyTestConfig(settings as Record, body, config); + await sendNtfyTestNotification({ + ...configForTest, + messageEventType: requestedMessageEventType as NtfyTestMessageEventType | undefined, }); - if (!response.ok) { - throw new ApiError(502, `ntfy server returned ${response.status}: ${response.statusText}`); - } - res.json({ success: true }); return; }