Files
OmniRoute/tests/unit/ui/edit-compatible-node-icon-url.test.tsx

132 lines
4.8 KiB
TypeScript

// @vitest-environment jsdom
//
// Field-level icon URL validation for the compatible-provider Edit modal: an invalid
// iconUrl must surface as an inline field error BEFORE the onSave callback fires, and a
// valid data:image/*;base64 iconUrl must submit. Mirrors the shared validator
// (src/shared/validation/iconUrl.ts) used by both the UI and the server schema.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const { default: EditCompatibleNodeModal } =
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal");
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
function render(node: Record<string, unknown>, onSave?: () => Promise<void>) {
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={() => {}}
/>
);
});
containers.push({ root, el });
return el;
}
function inputByLabel(el: Element, label: string): HTMLInputElement {
const inputs = Array.from(el.querySelectorAll<HTMLInputElement>("input"));
const found = inputs.find((i) => {
const labelEl = i.previousElementSibling || i.parentElement?.previousElementSibling;
return labelEl?.textContent === label;
});
if (!found) throw new Error(`No input for label: ${label}`);
return found;
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
act(() => {
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
const start = Date.now();
while (!fn()) {
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
await new Promise((r) => setTimeout(r, 20));
}
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.unstubAllGlobals();
});
const NODE = {
id: "oc-1",
name: "My Node",
prefix: "mynode",
baseUrl: "https://api.example.com/v1",
apiType: "chat",
iconUrl: "https://example.com/logo.png",
};
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 modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "iconUrlLabel"), "javascript:alert(1)");
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("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 modal = el.querySelector('[role="dialog"]')!;
setInputValue(inputByLabel(modal, "iconUrlLabel"), "data:text/html;base64,QUJD");
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("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 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(() => onSave.mock.calls.length > 0);
expect(modal.textContent).not.toContain("iconUrlInvalid");
const payload = onSave.mock.calls[0][0];
expect(payload.iconUrl).toBe("data:image/png;base64,iVBORw0KGgo=");
});
});