feat(FN-2879): merge fusion/fn-2879
This commit is contained in:
@@ -861,13 +861,25 @@ export function updatePiExtensions(disabledIds: string[], projectId?: string): P
|
||||
});
|
||||
}
|
||||
|
||||
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string; ntfyBaseUrl?: string }, projectId?: string): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>(withProjectId("/settings/test-ntfy", projectId), {
|
||||
/**
|
||||
* Test a notification provider by sending a test notification.
|
||||
* Supports "ntfy" and "webhook" provider IDs.
|
||||
*/
|
||||
export function testNotification(providerId: string, config?: Record<string, unknown>, projectId?: string): Promise<{ success: boolean }> {
|
||||
return api<{ success: boolean }>(withProjectId("/settings/test-notification", projectId), {
|
||||
method: "POST",
|
||||
body: config ? JSON.stringify(config) : undefined,
|
||||
body: JSON.stringify({ providerId, ...(config ?? {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible ntfy test helper.
|
||||
* Wraps testNotification() while preserving the legacy function signature.
|
||||
*/
|
||||
export function testNtfyNotification(config?: { ntfyEnabled?: boolean; ntfyTopic?: string; ntfyBaseUrl?: string }, projectId?: string): Promise<{ success: boolean }> {
|
||||
return testNotification("ntfy", config as Record<string, unknown> | undefined, projectId);
|
||||
}
|
||||
|
||||
/** Pi extension settings from ~/.pi/agent/settings.json (global scope) */
|
||||
export interface PiSettings {
|
||||
packages: Array<string | { source: string; extensions?: string[]; skills?: string[]; prompts?: string[]; themes?: string[] }>;
|
||||
|
||||
@@ -14739,6 +14739,252 @@ describe("GET /api/memory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /settings/test-notification", () => {
|
||||
let store: TaskStore;
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn<any, any>>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("ntfy provider sends Fusion-branded test notification", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "ntfy" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Title: "Fusion test notification",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ntfy provider uses config override for baseUrl", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", ntfyBaseUrl: "https://ntfy.override.example//" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
|
||||
it("ntfy provider returns 400 when ntfy not enabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ntfyEnabled: false });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "ntfy" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not enabled");
|
||||
});
|
||||
|
||||
it("ntfy provider returns 400 when topic missing", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ntfyEnabled: true, ntfyTopic: undefined });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "ntfy" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("webhook provider sends test notification (generic format)", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
webhookEnabled: true,
|
||||
webhookUrl: "https://hooks.example.com/test",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://hooks.example.com/test");
|
||||
const payload = JSON.parse(String(options.body)) as Record<string, string>;
|
||||
expect(payload.event).toBe("test");
|
||||
expect(payload.message).toBe("Fusion test notification");
|
||||
expect(payload.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("webhook provider sends Slack format", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
webhookEnabled: true,
|
||||
webhookUrl: "https://hooks.slack.com/test",
|
||||
webhookFormat: "slack",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(options.body))).toEqual({
|
||||
text: "Fusion test notification — your webhook notifications are working!",
|
||||
});
|
||||
});
|
||||
|
||||
it("webhook provider sends Discord format", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
webhookEnabled: true,
|
||||
webhookUrl: "https://discord.com/api/webhooks/test",
|
||||
webhookFormat: "discord",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(options.body))).toEqual({
|
||||
content: "Fusion test notification — your webhook notifications are working!",
|
||||
});
|
||||
});
|
||||
|
||||
it("webhook provider uses config override for format", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
webhookEnabled: true,
|
||||
webhookUrl: "https://hooks.example.com/test",
|
||||
webhookFormat: "generic",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "webhook", webhookFormat: "slack" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const options = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(JSON.parse(String(options.body))).toEqual({
|
||||
text: "Fusion test notification — your webhook notifications are working!",
|
||||
});
|
||||
});
|
||||
|
||||
it("webhook provider returns 400 when not enabled", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ webhookEnabled: false });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not enabled");
|
||||
});
|
||||
|
||||
it("webhook provider returns 400 when URL missing", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ webhookEnabled: true, webhookUrl: undefined });
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not configured");
|
||||
});
|
||||
|
||||
it("webhook provider returns 502 on server error", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
webhookEnabled: true,
|
||||
webhookUrl: "https://hooks.example.com/test",
|
||||
});
|
||||
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 500, statusText: "Internal Server Error" }));
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "webhook" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it("unknown provider returns 400", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({ providerId: "email" }), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Unknown notification provider: email");
|
||||
});
|
||||
|
||||
it("missing providerId returns 400", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-notification", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("providerId");
|
||||
});
|
||||
|
||||
it("backward compat — test-ntfy still works", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "compat-topic",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/settings/test-ntfy");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("client→server contract for ntfy override via testNotification pattern", async () => {
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "my-topic",
|
||||
ntfyBaseUrl: "https://ntfy.saved.example",
|
||||
});
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/settings/test-notification",
|
||||
JSON.stringify({ providerId: "ntfy", ntfyBaseUrl: "https://ntfy.override.example/" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.override.example/my-topic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/memory/backend", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
||||
@@ -1202,9 +1202,8 @@ 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.
|
||||
* GET /api/executor/stats
|
||||
* Returns executor status metadata for dashboard status surfaces.
|
||||
*/
|
||||
router.get("/executor/stats", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1529,6 +1529,143 @@ 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<string, unknown>;
|
||||
const providerId = body.providerId;
|
||||
if (typeof providerId !== "string" || !providerId.trim()) {
|
||||
throw badRequest("providerId is required and must be a string");
|
||||
}
|
||||
|
||||
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 settings = await scopedStore.getSettings();
|
||||
|
||||
if (providerId === "ntfy") {
|
||||
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 ntfyBaseUrl = requestOverride ?? storedServer ?? "https://ntfy.sh";
|
||||
const url = `${ntfyBaseUrl}/${topic}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Title": "Fusion test notification",
|
||||
"Priority": "default",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
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 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerId === "webhook") {
|
||||
if (!settings.webhookEnabled) {
|
||||
throw badRequest("webhook notifications are not enabled");
|
||||
}
|
||||
|
||||
if (typeof settings.webhookUrl !== "string" || !settings.webhookUrl.trim()) {
|
||||
throw badRequest("webhook URL is not configured");
|
||||
}
|
||||
|
||||
const webhookUrl = normalizeHttpUrl(settings.webhookUrl, "webhook URL");
|
||||
const formatOverride = config.webhookFormat ?? body.webhookFormat;
|
||||
const resolvedFormat = typeof formatOverride === "string" && formatOverride
|
||||
? formatOverride
|
||||
: settings.webhookFormat ?? "generic";
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
if (resolvedFormat === "slack") {
|
||||
payload = { text: "Fusion test notification — your webhook notifications are working!" };
|
||||
} else if (resolvedFormat === "discord") {
|
||||
payload = { content: "Fusion test notification — your webhook notifications are working!" };
|
||||
} else {
|
||||
payload = {
|
||||
event: "test",
|
||||
message: "Fusion test notification",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(502, `webhook server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
throw badRequest(`Unknown notification provider: ${providerId}. Supported providers: ntfy, webhook`);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to send test notification");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Settings Export/Import Routes ─────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user