feat(FN-2879): merge fusion/fn-2879

This commit is contained in:
gsxdsm
2026-04-28 14:03:03 -07:00
parent 14d35b443d
commit 9c19749e44
4 changed files with 400 additions and 6 deletions

View File

@@ -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[] }>;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 ─────────────────────────────────
/**