mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
fix(providers): save compatible provider data URL icons (#10247)
This commit is contained in:
1
changelog.d/fixes/10247-provider-icon-data-url-save.md
Normal file
1
changelog.d/fixes/10247-provider-icon-data-url-save.md
Normal 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
98
tests/unit/ui/use-provider-node-actions.test.tsx
Normal file
98
tests/unit/ui/use-provider-node-actions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user