feat(KB-101): add ntfy notification test button in settings

- Add /api/settings/test-ntfy endpoint for sending test notifications
- Add test notification button to SettingsModal when ntfy is enabled
- Display success/error feedback after test notification attempt
- Add comprehensive tests for test notification flow
- Include changeset for patch release
This commit is contained in:
gsxdsm
2026-03-30 16:54:01 -07:00
parent 304506b237
commit 0ab4f251cf
5 changed files with 214 additions and 2 deletions

View File

@@ -152,6 +152,12 @@ export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
});
}
export function testNtfyNotification(): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/settings/test-ntfy", {
method: "POST",
});
}
export async function uploadAttachment(id: string, file: File): Promise<TaskAttachment> {
const formData = new FormData();
formData.append("file", file);

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { THINKING_LEVELS } from "@kb/core";
import type { Settings, ThemeMode, ColorTheme } from "@kb/core";
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api";
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../api";
import type { AuthProvider, ModelInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -79,6 +79,9 @@ export function SettingsModal({
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
// Test notification state
const [testNotificationLoading, setTestNotificationLoading] = useState(false);
useEffect(() => {
fetchSettings()
.then((s) => {
@@ -168,6 +171,27 @@ export function SettingsModal({
}
}, [addToast, loadAuthStatus]);
const handleTestNotification = useCallback(async () => {
// Validate ntfy is enabled and topic is valid
if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) {
return;
}
setTestNotificationLoading(true);
try {
const result = await testNtfyNotification();
if (result.success) {
addToast("Test notification sent — check your ntfy app!", "success");
} else {
addToast("Failed to send test notification", "error");
}
} catch (err: any) {
addToast(err.message || "Failed to send test notification", "error");
} finally {
setTestNotificationLoading(false);
}
}, [addToast, form.ntfyEnabled, form.ntfyTopic]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -586,6 +610,18 @@ export function SettingsModal({
Topic must be 164 alphanumeric, hyphen, or underscore characters
</small>
)}
<button
type="button"
className="btn btn-sm"
onClick={handleTestNotification}
disabled={
testNotificationLoading ||
!form.ntfyTopic ||
!/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)
}
>
{testNotificationLoading ? "Sending…" : "Test notification"}
</button>
</div>
)}
</>

View File

@@ -31,9 +31,10 @@ vi.mock("../../api", () => ({
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
])),
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
}));
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../../api";
import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification } from "../../api";
const onClose = vi.fn();
const addToast = vi.fn();
@@ -1034,4 +1035,121 @@ describe("SettingsModal", () => {
expect(screen.getByText(/No models match/)).toBeTruthy();
expect(screen.getByText("0 models")).toBeTruthy();
});
// --- Test notification button tests ---
it("Test notification button is disabled when ntfy is disabled", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
// When ntfy is disabled, the topic input (and test button) should not be visible
expect(screen.queryByLabelText("ntfy Topic")).toBeNull();
expect(screen.queryByRole("button", { name: /Test notification/i })).toBeNull();
});
it("Test notification button is disabled when topic is invalid", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
// Enable ntfy
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
// Enter invalid topic
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "invalid topic with spaces!" } });
// Test button should be disabled
const testButton = screen.getByRole("button", { name: "Test notification" });
expect(testButton).toBeDisabled();
});
it("Test notification button is enabled when ntfy is enabled with valid topic", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
// Enable ntfy
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
// Enter valid topic
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "my-valid-topic" } });
// Test button should be enabled
const testButton = screen.getByRole("button", { name: "Test notification" });
expect(testButton).toBeEnabled();
});
it("Clicking test button calls testNtfyNotification API", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
// Enable ntfy
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
// Enter valid topic
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "my-valid-topic" } });
// Click test button
const testButton = screen.getByRole("button", { name: "Test notification" });
fireEvent.click(testButton);
await waitFor(() => expect(testNtfyNotification).toHaveBeenCalledTimes(1));
});
it("Success toast is shown when test notification succeeds", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
// Enable ntfy
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
// Enter valid topic
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "my-valid-topic" } });
// Click test button
const testButton = screen.getByRole("button", { name: "Test notification" });
fireEvent.click(testButton);
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Test notification sent — check your ntfy app!", "success"));
});
it("Error toast is shown when test notification fails", async () => {
// Mock the API to return an error
(testNtfyNotification as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
// Enable ntfy
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
// Enter valid topic
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "my-valid-topic" } });
// Click test button
const testButton = screen.getByRole("button", { name: "Test notification" });
fireEvent.click(testButton);
await waitFor(() => expect(addToast).toHaveBeenCalledWith("Network error", "error"));
});
});

View File

@@ -605,6 +605,53 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/settings/test-ntfy
* Send a test notification to verify ntfy configuration.
* Returns: { success: true } on success, { error: string } on failure.
*/
router.post("/settings/test-ntfy", async (_req, res) => {
try {
const settings = await store.getSettings();
// Validate ntfy is enabled
if (!settings.ntfyEnabled) {
res.status(400).json({ error: "ntfy notifications are not enabled" });
return;
}
// Validate topic exists and matches required format
const topic = settings.ntfyTopic;
if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) {
res.status(400).json({ error: "ntfy topic is not configured or invalid" });
return;
}
// Send test notification to ntfy.sh
const ntfyBaseUrl = "https://ntfy.sh";
const url = `${ntfyBaseUrl}/${topic}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Title": "kb test notification",
"Priority": "default",
"Content-Type": "text/plain",
},
body: "kb test notification — your notifications are working!",
});
if (!response.ok) {
res.status(502).json({ error: `ntfy.sh returned ${response.status}: ${response.statusText}` });
return;
}
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message ?? "Failed to send test notification" });
}
});
// Models
registerModelsRoute(router, options?.modelRegistry);