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:
@@ -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);
|
||||
|
||||
@@ -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 1–64 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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user