fix(providers): save compatible provider data URL icons (#10247)

This commit is contained in:
Xiangzhe
2026-08-14 11:57:33 +08:00
committed by GitHub
parent ed48328c7c
commit 8101c879e8
10 changed files with 498 additions and 54 deletions

View File

@@ -0,0 +1 @@
- **fix(providers):** compatible/custom providers now save valid Data URL icons and show Add/Edit save failures instead of silently doing nothing ([#10247](https://github.com/diegosouzapw/OmniRoute/pull/10247)) — thanks @xz-dev

View File

@@ -60,9 +60,10 @@ export default function EditCompatibleNodeModal({
}>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [iconUrlError, setIconUrlError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
if (node) {
if (isOpen && node) {
const psd = (node.providerSpecificData || {}) as Record<string, unknown>;
setFormData({
name: node.name || "",
@@ -83,6 +84,8 @@ export default function EditCompatibleNodeModal({
newApiUserId: typeof psd.newApiUserId === "string" ? psd.newApiUserId : "",
quotaPerUnit: typeof psd.quotaPerUnit === "number" ? String(psd.quotaPerUnit) : "",
});
setSaveError(null);
setIconUrlError(null);
setShowAdvanced(
!!(
node.chatPath ||
@@ -91,7 +94,7 @@ export default function EditCompatibleNodeModal({
)
);
}
}, [node, isAnthropic, isCcCompatible]);
}, [isOpen, node, isAnthropic, isCcCompatible]);
const apiTypeOptions = [
{ value: "chat", label: t("chatCompletions") },
@@ -110,6 +113,7 @@ export default function EditCompatibleNodeModal({
return;
}
setIconUrlError(null);
setSaveError(null);
setSaving(true);
try {
const payload: any = {
@@ -140,6 +144,12 @@ export default function EditCompatibleNodeModal({
}
}
await onSave(payload);
} catch (error) {
setSaveError(
error instanceof Error && error.message.trim()
? error.message
: providerText(t, "failedSave", "Failed to save")
);
} finally {
setSaving(false);
}
@@ -359,6 +369,15 @@ export default function EditCompatibleNodeModal({
)}
</div>
)}
{saveError && (
<div
role="alert"
aria-live="assertive"
className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2"
>
{saveError}
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleSubmit}

View File

@@ -1,5 +1,6 @@
// Phase 1t.4 extraction — Issue #3501
// Encapsulates handleUpdateNode and handleUpdateConnection async handlers.
import { readFetchErrorMessage } from "@/shared/utils/fetchError";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface UseProviderNodeActionsParams {
@@ -22,20 +23,22 @@ export function useProviderNodeActions({
t,
}: UseProviderNodeActionsParams) {
const handleUpdateNode = async (formData: any) => {
const res = await fetch(`/api/provider-nodes/${providerId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!res.ok) {
throw new Error(await readFetchErrorMessage(res, t("errorOccurred")));
}
const data = await res.json();
setProviderNode(data.node);
setShowEditNodeModal(false);
try {
const res = await fetch(`/api/provider-nodes/${providerId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
const data = await res.json();
if (res.ok) {
setProviderNode(data.node);
await fetchConnections();
setShowEditNodeModal(false);
}
await fetchConnections();
} catch (error) {
console.log("Error updating provider node:", error);
console.log("Provider node updated, but connections refresh failed:", error);
}
};

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
import { readFetchErrorMessage } from "@/shared/utils/fetchError";
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
import {
CLIENT_IDENTITY_PROFILE_OPTIONS,
@@ -118,6 +119,7 @@ export default function AddCompatibleProviderModal({
}>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [iconUrlError, setIconUrlError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const apiTypeOptions = useMemo(
() => [
@@ -137,6 +139,8 @@ export default function AddCompatibleProviderModal({
setValidationResult(null);
setCheckKey("");
setShowAdvanced(false);
setSaveError(null);
setIconUrlError(null);
}, [isOpen, mode]);
const modalTitle =
@@ -187,6 +191,8 @@ export default function AddCompatibleProviderModal({
setCheckKey("");
setValidationResult(null);
setShowAdvanced(false);
setSaveError(null);
setIconUrlError(null);
};
const handleSubmit = async () => {
@@ -197,6 +203,7 @@ export default function AddCompatibleProviderModal({
return;
}
setIconUrlError(null);
setSaveError(null);
setSubmitting(true);
try {
const body: Record<string, unknown> = {
@@ -242,13 +249,21 @@ export default function AddCompatibleProviderModal({
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await res.json()) as { node: CompatibleProviderNode };
if (res.ok) {
const failedCreate = providerText(t, "failedCreate", "Failed to create provider");
if (!res.ok) {
setSaveError(await readFetchErrorMessage(res, failedCreate));
return;
}
const data = await res.json().catch(() => ({}));
if (data.node) {
onCreated(data.node);
resetAfterCreate();
return;
}
} catch (error) {
console.log(`Error creating ${mode} compatible node:`, error);
setSaveError(failedCreate);
} catch {
setSaveError(providerText(t, "networkError", "Network error"));
} finally {
setSubmitting(false);
}
@@ -445,6 +460,15 @@ export default function AddCompatibleProviderModal({
</div>
)}
{saveError && (
<div
role="alert"
aria-live="assertive"
className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2"
>
{saveError}
</div>
)}
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!hasRequiredFields || submitting}>
{submitting ? t("creating") : t("add")}

View File

@@ -34,7 +34,6 @@ import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
const providerNodeIconUrlSchema = z
.string()
.trim()
.max(2000)
.refine((value) => isValidProviderIconUrl(value), {
message: "Icon URL must be a valid http(s) or data:image/*;base64 URL",
})
@@ -335,8 +334,8 @@ export const createProviderNodeSchema = z
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
// #2166: optional operator-supplied remote icon URL for the provider node. Empty
// string is accepted so callers can explicitly submit "no custom icon" (falls back
// to the built-in @lobehub/static resolution). Restricted to http(s) — `.url()` alone
// also accepts syntactically-valid-but-unsafe schemes like `javascript:`/`data:`.
// to the built-in @lobehub/static resolution). Length/scheme limits live in
// isValidProviderIconUrl (2000 chars for http(s), 256 KiB for data:image).
iconUrl: providerNodeIconUrlSchema,
customHeaders: customHeadersSchema,
})

View File

@@ -6,7 +6,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isValidProviderIconUrl } from "../../src/shared/validation/iconUrl.ts";
import {
isValidProviderIconUrl,
MAX_ICON_DATA_URL_LENGTH,
MAX_ICON_URL_LENGTH,
} from "../../src/shared/validation/iconUrl.ts";
import {
createProviderNodeSchema,
updateProviderNodeSchema,
@@ -19,7 +23,7 @@ const VALID_SVG_DATA_URL =
const VALID_XICON_DATA_URL = "data:image/x-icon;base64,QUJDRA==";
const VALID_JPEG_DATA_URL = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
const VALID_HTTP = "https://example.com/logo.png";
const VALID_HTTP_2000 = "https://example.com/" + "a".repeat(1970) + ".png";
const VALID_HTTP_2000 = "https://example.com/" + "a".repeat(1976) + ".png";
// ---- shared validator ----
test("isValidProviderIconUrl accepts empty and http(s)", () => {
@@ -220,3 +224,84 @@ test("updateProviderNodeSchema accepts a valid data:image/*;base64 iconUrl", ()
});
assert.equal(result.success, true);
});
function dataIconUrlNearLength(maxLength: number): string {
const prefix = "data:image/png;base64,";
const payloadLength = maxLength - prefix.length;
const aligned = payloadLength - (payloadLength % 4);
return prefix + "A".repeat(aligned);
}
function dataIconUrlOverLimit(maxLength: number): string {
const atLimit = dataIconUrlNearLength(maxLength);
return atLimit + "AAAA";
}
test("create/update schemas accept a data:image iconUrl up to the shared 256 KiB cap", () => {
const iconUrl = dataIconUrlNearLength(MAX_ICON_DATA_URL_LENGTH);
assert.ok(iconUrl.length > MAX_ICON_URL_LENGTH);
assert.ok(iconUrl.length <= MAX_ICON_DATA_URL_LENGTH);
assert.equal(isValidProviderIconUrl(iconUrl), true);
const created = createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl,
});
assert.equal(created.success, true, created.success ? "" : JSON.stringify(created.error.issues));
const updated = updateProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
baseUrl: "https://test.com",
iconUrl,
});
assert.equal(updated.success, true, updated.success ? "" : JSON.stringify(updated.error.issues));
});
test("create/update schemas reject an over-limit data:image iconUrl", () => {
const iconUrl = dataIconUrlOverLimit(MAX_ICON_DATA_URL_LENGTH);
assert.ok(iconUrl.length > MAX_ICON_DATA_URL_LENGTH);
assert.ok(iconUrl.length <= MAX_ICON_DATA_URL_LENGTH + 4);
assert.equal(isValidProviderIconUrl(iconUrl), false);
const created = createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl,
});
assert.equal(created.success, false);
const updated = updateProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
baseUrl: "https://test.com",
iconUrl,
});
assert.equal(updated.success, false);
});
test("create/update schemas keep the 2000-char cap for http(s) iconUrl", () => {
const tooLongHttp = VALID_HTTP + "a".repeat(MAX_ICON_URL_LENGTH);
assert.ok(tooLongHttp.length > MAX_ICON_URL_LENGTH);
assert.equal(
createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl: VALID_HTTP_2000,
}).success,
true
);
assert.equal(
createProviderNodeSchema.safeParse({
name: "Test",
prefix: "test",
apiType: "chat",
iconUrl: tooLongHttp,
}).success,
false
);
});

View File

@@ -129,6 +129,58 @@ test("provider nodes route creates an OpenAI-compatible node with iconUrl", asyn
assert.equal(body.node.iconUrl, "https://cdn.example.com/icons/custom.png");
});
const LONG_DATA_ICON_URL = "data:image/png;base64," + "A".repeat(2500);
type ProviderNodeResponse = {
node?: { id?: string; iconUrl?: string | null };
error?: { message?: string; details?: unknown };
};
test("provider nodes route creates a node with a data:image iconUrl longer than 2000 chars", async () => {
const response = await providerNodesRoute.POST(
makeRequest({
name: "Data Icon Node",
prefix: "data-icon",
apiType: "chat",
baseUrl: "https://dataicon.example.com/v1",
iconUrl: LONG_DATA_ICON_URL,
})
);
const body = (await response.json()) as ProviderNodeResponse;
assert.equal(response.status, 201, JSON.stringify(body));
assert.equal(body.node?.iconUrl, LONG_DATA_ICON_URL);
});
test("provider nodes route update accepts a data:image iconUrl longer than 2000 chars", async () => {
const createResponse = await providerNodesRoute.POST(
makeRequest({
name: "Data Icon Update Node",
prefix: "data-icon-update",
apiType: "chat",
baseUrl: "https://dataicon-update.example.com/v1",
})
);
const created = (await createResponse.json()) as ProviderNodeResponse;
const nodeId = created.node?.id;
assert.ok(nodeId);
const updateResponse = await providerNodesIdRoute.PUT(
makeUpdateRequest(nodeId, {
name: "Data Icon Update Node",
prefix: "data-icon-update",
apiType: "chat",
baseUrl: "https://dataicon-update.example.com/v1",
iconUrl: LONG_DATA_ICON_URL,
}),
{ params: Promise.resolve({ id: nodeId }) }
);
const updated = (await updateResponse.json()) as ProviderNodeResponse;
assert.equal(updateResponse.status, 200, JSON.stringify(updated));
assert.equal(updated.node?.iconUrl, LONG_DATA_ICON_URL);
});
test("provider nodes route creates nodes without iconUrl (null)", async () => {
const response = await providerNodesRoute.POST(
makeRequest({

View File

@@ -17,23 +17,28 @@ const { default: AddCompatibleProviderModal } =
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function render(props: Record<string, unknown>) {
type ModalProps = React.ComponentProps<typeof AddCompatibleProviderModal>;
function render(props: Partial<ModalProps>) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<AddCompatibleProviderModal
isOpen
mode="openai"
onClose={() => {}}
onCreated={() => {}}
{...(props as any)}
/>
);
});
const renderProps = (nextProps: Partial<ModalProps>) => {
act(() => {
root.render(
<AddCompatibleProviderModal
isOpen
mode="openai"
onClose={() => {}}
onCreated={() => {}}
{...nextProps}
/>
);
});
};
renderProps(props);
containers.push({ root, el });
return el;
return { el, rerender: renderProps };
}
function inputByLabel(el: Element, label: string): HTMLInputElement {
@@ -84,7 +89,7 @@ afterEach(() => {
describe("AddCompatibleProviderModal — iconUrl field-level validation", () => {
it("shows an inline error for an unsafe scheme and does NOT submit", async () => {
const el = render({});
const { el } = render({});
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
@@ -102,7 +107,7 @@ describe("AddCompatibleProviderModal — iconUrl field-level validation", () =>
});
it("shows an inline error for a non-image data URL and does NOT submit", async () => {
const el = render({});
const { el } = render({});
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
@@ -119,7 +124,7 @@ describe("AddCompatibleProviderModal — iconUrl field-level validation", () =>
});
it("accepts a valid data:image/*;base64 iconUrl and submits", async () => {
const el = render({});
const { el } = render({});
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
@@ -137,4 +142,98 @@ describe("AddCompatibleProviderModal — iconUrl field-level validation", () =>
const body = JSON.parse(String(call[1].body));
expect(body.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo=");
});
it("shows an inline error for an over-limit data URL and does NOT submit", async () => {
const { el } = render({});
const modal = el.querySelector('[role="dialog"]')!;
const tooLong = "data:image/png;base64," + "A".repeat(256 * 1024);
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
setInputValue(inputByLabel(modal, "iconUrlLabel"), tooLong);
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
const addBtn = buttons.find((b) => b.textContent === "add");
act(() => addBtn!.click());
await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false);
expect(modal.textContent).toContain("iconUrlInvalid");
expect(fetch).not.toHaveBeenCalled();
});
it("surfaces a server non-2xx validation error instead of staying silent", async () => {
const onCreated = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: false,
status: 400,
json: () => Promise.resolve({ error: { message: "Icon URL too large" } }),
} as Response)
)
);
const { el } = render({ onCreated });
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
const addBtn = buttons.find((b) => b.textContent === "add");
act(() => addBtn!.click());
await waitFor(() => modal.textContent?.includes("Icon URL too large") ?? false);
expect(modal.textContent).toContain("Icon URL too large");
expect(onCreated).not.toHaveBeenCalled();
});
it("surfaces a network error when create fetch fails", async () => {
const onCreated = vi.fn();
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline")))
);
const { el } = render({ onCreated });
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
const addBtn = buttons.find((b) => b.textContent === "add");
act(() => addBtn!.click());
await waitFor(() => modal.textContent?.includes("Network error") ?? false);
const alert = modal.querySelector('[role="alert"]');
expect(alert?.textContent).toContain("Network error");
expect(alert?.getAttribute("aria-live")).toBe("assertive");
expect(onCreated).not.toHaveBeenCalled();
});
it("clears a previous save error when the modal reopens", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline")))
);
const { el, rerender } = render({});
let modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "nameLabel"), "My Node");
setInputValue(inputByLabel(modal, "prefixLabel"), "mynode");
act(() =>
Array.from(modal.querySelectorAll("button"))
.find((button) => button.textContent === "add")!
.click()
);
await waitFor(() => modal.textContent?.includes("Network error") ?? false);
rerender({ isOpen: false });
rerender({ isOpen: true });
modal = el.querySelector('[role="dialog"]')!;
expect(modal.textContent).not.toContain("Network error");
expect(modal.querySelector('[role="alert"]')).toBeNull();
});
});

View File

@@ -17,22 +17,30 @@ const { default: EditCompatibleNodeModal } =
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function render(node: Record<string, unknown>, onSave?: () => Promise<void>) {
type ModalProps = React.ComponentProps<typeof EditCompatibleNodeModal>;
type ModalNode = NonNullable<ModalProps["node"]>;
function render(node: ModalNode, onSave?: ModalProps["onSave"]) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<EditCompatibleNodeModal
isOpen
node={node as any}
onSave={onSave || (async () => {})}
onClose={() => {}}
/>
);
});
const renderProps = (props: Partial<ModalProps>) => {
act(() => {
root.render(
<EditCompatibleNodeModal
isOpen
node={node}
onSave={onSave || (async () => {})}
onClose={() => {}}
{...props}
/>
);
});
};
renderProps({});
containers.push({ root, el });
return el;
return { el, rerender: renderProps };
}
function inputByLabel(el: Element, label: string): HTMLInputElement {
@@ -85,7 +93,7 @@ const NODE = {
describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
it("shows an inline error for an unsafe scheme and does NOT call onSave", async () => {
const onSave = vi.fn(async () => {});
const el = render({ ...NODE, iconUrl: "javascript:alert(1)" });
const { el } = render({ ...NODE, iconUrl: "javascript:alert(1)" });
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "iconUrlLabel"), "javascript:alert(1)");
@@ -100,7 +108,7 @@ describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
it("shows an inline error for a non-image data URL and does NOT call onSave", async () => {
const onSave = vi.fn(async () => {});
const el = render({ ...NODE, iconUrl: "data:text/html;base64,QUJD" });
const { el } = render({ ...NODE, iconUrl: "data:text/html;base64,QUJD" });
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:text/html;base64,QUJD");
@@ -115,7 +123,7 @@ describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
it("accepts a valid data:image/*;base64 iconUrl and calls onSave with it", async () => {
const onSave = vi.fn(async () => {});
const el = render({ ...NODE, iconUrl: "" }, onSave);
const { el } = render({ ...NODE, iconUrl: "" }, onSave);
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
@@ -128,4 +136,60 @@ describe("EditCompatibleNodeModal — iconUrl field-level validation", () => {
const payload = onSave.mock.calls[0][0];
expect(payload.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo=");
});
it("shows an inline error for an over-limit data URL and does NOT call onSave", async () => {
const onSave = vi.fn(async () => {});
const { el } = render({ ...NODE, iconUrl: "" }, onSave);
const modal = el.querySelector('[role="dialog"]')!;
const tooLong = "data:image/png;base64," + "A".repeat(256 * 1024);
setInputValue(inputByLabel(modal, "iconUrlLabel"), tooLong);
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
const saveBtn = buttons.find((b) => b.textContent === "save");
act(() => saveBtn!.click());
await waitFor(() => modal.textContent?.includes("iconUrlInvalid") ?? false);
expect(modal.textContent).toContain("iconUrlInvalid");
expect(onSave).not.toHaveBeenCalled();
});
it("surfaces an error thrown by onSave instead of staying silent", async () => {
const onSave = vi.fn(async () => {
throw new Error("server said no");
});
const { el } = render({ ...NODE, iconUrl: "" }, onSave);
const modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:image/png;base64,iVBORw0KGgo=");
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>("button"));
const saveBtn = buttons.find((b) => b.textContent === "save");
act(() => saveBtn!.click());
await waitFor(() => modal.textContent?.includes("server said no") ?? false);
const alert = modal.querySelector('[role="alert"]');
expect(alert?.textContent).toContain("server said no");
expect(alert?.getAttribute("aria-live")).toBe("assertive");
expect(onSave).toHaveBeenCalledOnce();
});
it("clears a previous save error when the same node modal reopens", async () => {
const onSave = vi.fn(async () => {
throw new Error("server said no");
});
const { el, rerender } = render({ ...NODE, iconUrl: "" }, onSave);
let modal = el.querySelector('[role="dialog"]')!;
act(() =>
Array.from(modal.querySelectorAll("button"))
.find((button) => button.textContent === "save")!
.click()
);
await waitFor(() => modal.textContent?.includes("server said no") ?? false);
rerender({ isOpen: false });
rerender({ isOpen: true });
modal = el.querySelector('[role="dialog"]')!;
expect(modal.textContent).not.toContain("server said no");
expect(modal.querySelector('[role="alert"]')).toBeNull();
});
});

View File

@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProviderNodeActions } from "../../../src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderNodeActions";
type Hook = ReturnType<typeof useProviderNodeActions>;
const t = ((key: string) => key) as Parameters<typeof useProviderNodeActions>[0]["t"];
function response(ok: boolean, body: unknown): Response {
return {
ok,
json: async () => body,
} as Response;
}
describe("useProviderNodeActions.handleUpdateNode", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let captured: Hook | null = null;
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
captured = null;
vi.unstubAllGlobals();
});
async function renderHook(overrides: Partial<Parameters<typeof useProviderNodeActions>[0]> = {}) {
const params: Parameters<typeof useProviderNodeActions>[0] = {
providerId: "node-1",
fetchConnections: vi.fn(async () => {}),
selectedConnection: null,
setProviderNode: vi.fn(),
setShowEditNodeModal: vi.fn(),
setShowEditModal: vi.fn(),
t,
...overrides,
};
function Probe() {
const hook = useProviderNodeActions(params);
React.useEffect(() => {
captured = hook;
}, [hook]);
return null;
}
await act(async () => {
root = createRoot(container!);
root.render(<Probe />);
});
return params;
}
it("throws the server message and keeps the modal open when PUT fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async () => response(false, { error: { message: "Icon URL too large" } }))
);
const params = await renderHook();
await expect(captured!.handleUpdateNode({ iconUrl: "bad" })).rejects.toThrow(
"Icon URL too large"
);
expect(params.setProviderNode).not.toHaveBeenCalled();
expect(params.fetchConnections).not.toHaveBeenCalled();
expect(params.setShowEditNodeModal).not.toHaveBeenCalled();
});
it("does not report a successful PUT as a save failure when refresh rejects", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>(async () => response(true, { node: { id: "node-1" } }))
);
const fetchConnections = vi.fn(async () => {
throw new Error("refresh failed");
});
const params = await renderHook({ fetchConnections });
await expect(
captured!.handleUpdateNode({ iconUrl: "data:image/png;base64,QUJD" })
).resolves.toBe(undefined);
expect(params.setProviderNode).toHaveBeenCalledWith({ id: "node-1" });
expect(params.setShowEditNodeModal).toHaveBeenCalledWith(false);
expect(fetchConnections).toHaveBeenCalledOnce();
});
});