FN-6953: honor unsaved ntfy config in tests
Let ntfy test notifications use the current Settings form values before they are saved. - Send unsaved ntfy enabled/topic/server/token values from all Settings ntfy test buttons. - Resolve request-scoped ntfy config on the backend for general, message, and room test notifications. - Publish message and room ntfy tests directly with the resolved config instead of the active persisted notification service. - Cover unsaved ntfy test behavior across Settings UI and API routes. Files changed: .changeset/fn-6953-ntfy-test-unsaved-config.md | 5 + .../dashboard/app/components/SettingsModal.css | 5 +- .../dashboard/app/components/SettingsModal.tsx | 21 +- .../components/__tests__/SettingsModal.test.tsx | 60 +++- .../src/__tests__/routes-settings.test.ts | 181 ++++++++--- .../src/routes/register-settings-memory-routes.ts | 355 +++++++++------------ 6 files changed, 380 insertions(+), 247 deletions(-) Fusion-Task-Id: FN-6953 Fusion-Task-Lineage: 90557168-3b11-4ab4-b5c1-4261225600ba
This commit is contained in:
5
.changeset/fn-6953-ntfy-test-unsaved-config.md
Normal file
5
.changeset/fn-6953-ntfy-test-unsaved-config.md
Normal file
@@ -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.
|
||||||
@@ -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 {
|
.settings-section-heading {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: var(--space-lg) 0 var(--space-md);
|
padding: var(--space-lg) 0 var(--space-md);
|
||||||
margin: 0 0 var(--space-md);
|
margin: 0 0 var(--space-md);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* First heading inside the section drops top padding to remove a redundant
|
/* First heading inside the section drops top padding to remove a redundant
|
||||||
|
|||||||
@@ -1851,17 +1851,22 @@ export function SettingsModal({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
try {
|
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"
|
const config = providerId === "ntfy"
|
||||||
? {
|
? currentNtfyConfig
|
||||||
ntfyEnabled: form.ntfyEnabled,
|
|
||||||
ntfyTopic: form.ntfyTopic,
|
|
||||||
...(form.ntfyBaseUrl?.trim() ? { ntfyBaseUrl: form.ntfyBaseUrl.trim() } : {}),
|
|
||||||
...(form.ntfyAccessToken?.trim() ? { ntfyAccessToken: form.ntfyAccessToken.trim() } : {}),
|
|
||||||
}
|
|
||||||
: providerId === "ntfy-message"
|
: providerId === "ntfy-message"
|
||||||
? { messageEventType: "message:agent-to-user" }
|
? { ...currentNtfyConfig, messageEventType: "message:agent-to-user" }
|
||||||
: providerId === "ntfy-room"
|
: providerId === "ntfy-room"
|
||||||
? { messageEventType: "message:room" }
|
? { ...currentNtfyConfig, messageEventType: "message:room" }
|
||||||
: {
|
: {
|
||||||
webhookUrl: form.webhookUrl,
|
webhookUrl: form.webhookUrl,
|
||||||
webhookFormat: form.webhookFormat || "generic",
|
webhookFormat: form.webhookFormat || "generic",
|
||||||
|
|||||||
@@ -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 () => {
|
it("clears a saved ntfy access token via global null-as-delete semantics", async () => {
|
||||||
mockFetchSettings.mockResolvedValueOnce({
|
mockFetchSettings.mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
@@ -4929,7 +4977,11 @@ describe("SettingsModal", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||||
"ntfy",
|
"ntfy",
|
||||||
{ messageEventType: "message:agent-to-user" },
|
expect.objectContaining({
|
||||||
|
messageEventType: "message:agent-to-user",
|
||||||
|
ntfyEnabled: true,
|
||||||
|
ntfyTopic: "test-topic",
|
||||||
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -4953,7 +5005,11 @@ describe("SettingsModal", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockTestNotification).toHaveBeenCalledWith(
|
expect(mockTestNotification).toHaveBeenCalledWith(
|
||||||
"ntfy",
|
"ntfy",
|
||||||
{ messageEventType: "message:room" },
|
expect.objectContaining({
|
||||||
|
messageEventType: "message:room",
|
||||||
|
ntfyEnabled: true,
|
||||||
|
ntfyTopic: "test-topic",
|
||||||
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1772,6 +1772,37 @@ describe("POST /settings/test-ntfy", () => {
|
|||||||
expect(url).toBe("https://ntfy.override.example/my-topic");
|
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<typeof vi.fn>).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 () => {
|
it("falls back to saved ntfyBaseUrl when request override is blank", async () => {
|
||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ntfyEnabled: true,
|
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 () => {
|
it("ntfy provider sends a message-event test with unsaved config when messageEventType is provided", async () => {
|
||||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
|
||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ntfyEnabled: true,
|
ntfyEnabled: false,
|
||||||
ntfyTopic: "test-topic",
|
ntfyTopic: "saved-topic",
|
||||||
|
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||||
|
ntfyAccessToken: "saved-token",
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
buildApp(),
|
buildApp(),
|
||||||
"POST",
|
"POST",
|
||||||
"/api/settings/test-notification",
|
"/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" },
|
{ "content-type": "application/json" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual({ success: true });
|
expect(res.body).toEqual({ success: true });
|
||||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
|
||||||
"message:agent-to-user",
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
expect.objectContaining({
|
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||||
event: "message:agent-to-user",
|
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.message.example/fresh-message-topic");
|
||||||
metadata: expect.objectContaining({
|
expect(options.headers).toMatchObject({
|
||||||
fromId: "system",
|
Title: "New message from Fusion",
|
||||||
toId: "user",
|
Priority: "high",
|
||||||
preview: "Fusion test message notification",
|
Authorization: "Bearer message-token",
|
||||||
}),
|
});
|
||||||
}),
|
expect(options.body).toBe("Fusion → you: Fusion test message notification");
|
||||||
);
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ntfy provider dispatches a room message-event pipeline test when messageEventType is message:room", async () => {
|
it("ntfy provider sends a room message-event test with unsaved config when messageEventType is message:room", async () => {
|
||||||
const dispatchSpy = vi.fn().mockResolvedValue(undefined);
|
|
||||||
mockGetActiveNotificationService.mockReturnValue({ dispatch: dispatchSpy });
|
|
||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ntfyEnabled: true,
|
ntfyEnabled: false,
|
||||||
ntfyTopic: "test-topic",
|
ntfyTopic: "saved-topic",
|
||||||
|
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||||
|
ntfyAccessToken: "saved-token",
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await REQUEST(
|
const res = await REQUEST(
|
||||||
buildApp(),
|
buildApp(),
|
||||||
"POST",
|
"POST",
|
||||||
"/api/settings/test-notification",
|
"/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" },
|
{ "content-type": "application/json" },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body).toEqual({ success: true });
|
expect(res.body).toEqual({ success: true });
|
||||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
expect(mockGetActiveNotificationService).not.toHaveBeenCalled();
|
||||||
"message:room",
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
expect.objectContaining({
|
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||||
event: "message:room",
|
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.room.example/fresh-room-topic");
|
||||||
metadata: expect.objectContaining({
|
expect(options.headers).toMatchObject({
|
||||||
roomId: "test-room",
|
Title: "#Test Room — Fusion",
|
||||||
roomName: "Test Room",
|
Priority: "default",
|
||||||
senderName: "Fusion",
|
Authorization: "Bearer room-token",
|
||||||
preview: "Fusion test room notification",
|
});
|
||||||
}),
|
expect(options.body).toBe("Fusion in #Test Room: Fusion test room notification");
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ntfy provider uses config override for baseUrl", async () => {
|
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");
|
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<typeof vi.fn>).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<typeof vi.fn>).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 () => {
|
it("ntfy provider sends Authorization header from saved or override token", async () => {
|
||||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
ntfyEnabled: true,
|
ntfyEnabled: true,
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildSessionSkillContextSync,
|
buildSessionSkillContextSync,
|
||||||
createFnAgent as engineCreateFnAgent,
|
createFnAgent as engineCreateFnAgent,
|
||||||
getActiveNotificationService,
|
|
||||||
probeWorktrunk,
|
probeWorktrunk,
|
||||||
resolveWorktrunkBinary,
|
resolveWorktrunkBinary,
|
||||||
} from "@fusion/engine";
|
} 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.
|
* 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.
|
* Includes packages, extension paths, skill paths, prompt template paths, and theme paths.
|
||||||
*/
|
*/
|
||||||
router.post("/settings/test-ntfy", async (req, res) => {
|
const normalizeHttpUrl = (value: string, fieldName: string): string => {
|
||||||
const normalizeNtfyBaseUrl = (value: string, source: "request" | "settings"): string => {
|
const trimmed = value.trim();
|
||||||
const trimmed = value.trim();
|
if (!trimmed) {
|
||||||
if (!trimmed) {
|
throw badRequest(`${fieldName} cannot be empty`);
|
||||||
throw badRequest("ntfy server URL cannot be empty");
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(trimmed);
|
parsed = new URL(trimmed);
|
||||||
} catch {
|
} catch {
|
||||||
throw badRequest(`ntfy server URL from ${source} must be a valid URL`);
|
throw badRequest(`${fieldName} must be a valid URL`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
throw badRequest("ntfy server URL must use http:// or 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<string, unknown>, key: string): unknown => (
|
||||||
|
Object.prototype.hasOwnProperty.call(source, key) ? source[key] : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRequestNtfyValue = (body: Record<string, unknown>, config: Record<string, unknown>, 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<string, unknown>,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
config: Record<string, unknown> = {},
|
||||||
|
): { 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<void> {
|
||||||
|
const contentByEvent: Record<NtfyTestMessageEventType | "default", { title: string; message: string; priority: "default" | "high" }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 {
|
try {
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
const settings = await scopedStore.getSettings();
|
const settings = await scopedStore.getSettings();
|
||||||
|
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
|
||||||
// Validate ntfy is enabled
|
await sendNtfyTestNotification(configForTest);
|
||||||
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<string, string> = {
|
|
||||||
"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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -2133,31 +2208,6 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("/settings/test-notification", async (req, res) => {
|
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 {
|
try {
|
||||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
const providerId = body.providerId;
|
const providerId = body.providerId;
|
||||||
@@ -2176,113 +2226,20 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
|||||||
|
|
||||||
if (providerId === "ntfy") {
|
if (providerId === "ntfy") {
|
||||||
const requestedMessageEventType = config.messageEventType ?? body.messageEventType;
|
const requestedMessageEventType = config.messageEventType ?? body.messageEventType;
|
||||||
if (requestedMessageEventType !== undefined) {
|
if (
|
||||||
if (
|
requestedMessageEventType !== undefined
|
||||||
requestedMessageEventType !== "message:agent-to-user"
|
&& requestedMessageEventType !== "message:agent-to-user"
|
||||||
&& requestedMessageEventType !== "message:agent-to-agent"
|
&& requestedMessageEventType !== "message:agent-to-agent"
|
||||||
&& requestedMessageEventType !== "message:room"
|
&& requestedMessageEventType !== "message:room"
|
||||||
) {
|
) {
|
||||||
throw badRequest("messageEventType must be message:agent-to-user, message:agent-to-agent, or 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 (!settings.ntfyEnabled) {
|
const configForTest = resolveEffectiveNtfyTestConfig(settings as Record<string, unknown>, body, config);
|
||||||
throw badRequest("ntfy notifications are not enabled");
|
await sendNtfyTestNotification({
|
||||||
}
|
...configForTest,
|
||||||
|
messageEventType: requestedMessageEventType as NtfyTestMessageEventType | undefined,
|
||||||
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<string, string> = {
|
|
||||||
"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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user