Files
diegosouzapw--omniroute/tests/unit/shared/components/ProxyConfigModal.test.tsx
T
2026-07-13 13:39:12 +08:00

392 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (!values) return key;
return Object.entries(values).reduce(
(message, [name, value]) => message.replace(`{${name}}`, String(value)),
key
);
},
}));
type FetchCall = {
url: string;
method: string;
body: any;
};
type MockResponse = {
status?: number;
body?: unknown;
};
const cleanupCallbacks: Array<() => void> = [];
let fetchCalls: FetchCall[] = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => {
container.remove();
});
return container;
}
function jsonResponse(body: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}
function parseBody(init?: RequestInit) {
if (typeof init?.body !== "string") return null;
try {
return JSON.parse(init.body);
} catch {
return init.body;
}
}
function installFetchMock(handler: (url: string, init?: RequestInit) => MockResponse) {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
const method = String(init?.method || "GET").toUpperCase();
const body = parseBody(init);
fetchCalls.push({ url, method, body });
const response = handler(url, init);
return jsonResponse(response.body ?? {}, response.status ?? 200);
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
async function flushEffects() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
async function renderProxyConfigModal(props?: Partial<React.ComponentProps<any>>) {
const { default: ProxyConfigModal } = await import("@/shared/components/ProxyConfigModal");
const container = makeContainer();
const root: Root = createRoot(container);
cleanupCallbacks.push(() => root.unmount());
await act(async () => {
root.render(
<ProxyConfigModal
isOpen
onClose={vi.fn()}
level="provider"
levelId="claude"
levelLabel="Claude"
onSaved={vi.fn()}
{...props}
/>
);
});
await waitForModalToLoad(container);
return { container, root };
}
async function waitForModalToLoad(container: HTMLElement) {
for (let i = 0; i < 20; i++) {
await flushEffects();
if (!container.textContent?.includes("loading")) return;
}
}
function getInput(container: HTMLElement, placeholder: string) {
const input = Array.from(container.querySelectorAll("input")).find(
(item) => item.getAttribute("placeholder") === placeholder
);
expect(input).toBeTruthy();
return input as HTMLInputElement;
}
async function clickButton(container: HTMLElement, text: string) {
const expected = text.toLowerCase();
const buttons = Array.from(container.querySelectorAll("button"));
const getText = (item: HTMLButtonElement) => item.textContent?.trim().toLowerCase() || "";
const button =
buttons.find((item) => getText(item) === expected) ||
buttons.find(
(item) => getText(item).endsWith(expected) && !getText(item).includes("savedproxy")
) ||
buttons.find((item) => getText(item).includes(expected));
expect(button).toBeTruthy();
await act(async () => {
button?.click();
});
await flushEffects();
}
async function waitForCall(predicate: (call: FetchCall) => boolean) {
for (let i = 0; i < 20; i++) {
await flushEffects();
if (fetchCalls.some(predicate)) return;
}
}
async function setInputValue(input: HTMLInputElement, value: string) {
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await flushEffects();
}
function defaultProxyConfigResponses(url: string): MockResponse | null {
if (url === "/api/settings/proxies") {
return { body: { items: [], total: 0 } };
}
if (url.startsWith("/api/settings/proxies/assignments?") && url.includes("scope=provider")) {
return { body: { items: [], total: 0 } };
}
if (url.startsWith("/api/settings/proxy?level=provider")) {
return { body: { level: "provider", id: "claude", proxy: null } };
}
if (url === "/api/settings/proxy") {
return { body: {} };
}
return null;
}
describe("ProxyConfigModal custom registry saves", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
fetchCalls = [];
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("creates a dashboard-custom registry proxy, assigns it, and clears matching legacy config", async () => {
installFetchMock((url, init) => {
const method = String(init?.method || "GET").toUpperCase();
const body = parseBody(init);
if (method === "POST" && url === "/api/settings/proxies") {
return {
status: 201,
body: { id: "custom-proxy-1", ...body, assignment: { proxyId: "custom-proxy-1" } },
};
}
return defaultProxyConfigResponses(url) || { status: 404, body: {} };
});
const { container } = await renderProxyConfigModal();
await setInputValue(getInput(container, "hostPlaceholder"), "custom.local");
await setInputValue(getInput(container, "8080"), "3128");
await clickButton(container, "save");
await waitForCall((call) => call.method === "POST" && call.url === "/api/settings/proxies");
const createCall = fetchCalls.find(
(call) => call.method === "POST" && call.url === "/api/settings/proxies"
);
expect(createCall?.body).toMatchObject({
name: "Custom Provider Proxy (Claude)",
type: "http",
host: "custom.local",
port: 3128,
status: "active",
source: "dashboard-custom",
assignment: {
scope: "provider",
scopeId: "claude",
},
});
expect(
fetchCalls.some((call) => call.method === "PUT" && call.url === "/api/settings/proxy")
).toBe(false);
expect(
fetchCalls.some(
(call) => call.method === "PUT" && call.url === "/api/settings/proxies/assignments"
)
).toBe(false);
expect(
fetchCalls.some(
(call) =>
call.method === "DELETE" && call.url === "/api/settings/proxy?level=provider&id=claude"
)
).toBe(false);
});
it("updates an existing scope-owned dashboard-custom proxy instead of creating a duplicate", async () => {
installFetchMock((url, init) => {
const method = String(init?.method || "GET").toUpperCase();
const body = parseBody(init);
if (method === "GET" && url === "/api/settings/proxies") {
return {
body: {
items: [
{
id: "custom-proxy-1",
name: "Custom Provider Proxy (Claude)",
type: "http",
host: "old.local",
port: 8080,
source: "dashboard-custom",
},
],
total: 1,
},
};
}
if (url.startsWith("/api/settings/proxies/assignments?") && url.includes("scope=provider")) {
return {
body: {
items: [
{ proxyId: "other-proxy", scope: "provider", scopeId: "other-provider" },
{ proxyId: "custom-proxy-1", scope: "provider", scopeId: "claude" },
],
total: 1,
},
};
}
if (url === "/api/settings/proxies?id=custom-proxy-1&whereUsed=1") {
return {
body: {
count: 1,
assignments: [{ proxyId: "custom-proxy-1", scope: "provider", scopeId: "claude" }],
},
};
}
if (method === "PATCH" && url === "/api/settings/proxies") {
return {
body: { id: "custom-proxy-1", ...body, assignment: { proxyId: "custom-proxy-1" } },
};
}
return defaultProxyConfigResponses(url) || { status: 404, body: {} };
});
const { container } = await renderProxyConfigModal();
await setInputValue(getInput(container, "hostPlaceholder"), "updated.local");
await clickButton(container, "authOptional");
await setInputValue(getInput(container, "usernamePlaceholder"), "***");
await setInputValue(getInput(container, "passwordPlaceholder"), "***");
await clickButton(container, "save");
await waitForCall((call) => call.method === "PATCH" && call.url === "/api/settings/proxies");
expect(
fetchCalls.some((call) => call.method === "POST" && call.url === "/api/settings/proxies")
).toBe(false);
const updateCall = fetchCalls.find(
(call) => call.method === "PATCH" && call.url === "/api/settings/proxies"
);
expect(updateCall?.body).toMatchObject({
id: "custom-proxy-1",
host: "updated.local",
source: "dashboard-custom",
assignment: {
scope: "provider",
scopeId: "claude",
},
});
expect(updateCall?.body).not.toHaveProperty("username");
expect(updateCall?.body).not.toHaveProperty("password");
expect(
fetchCalls.some(
(call) => call.method === "PUT" && call.url === "/api/settings/proxies/assignments"
)
).toBe(false);
expect(
fetchCalls.some(
(call) =>
call.method === "DELETE" && call.url === "/api/settings/proxy?level=provider&id=claude"
)
).toBe(false);
});
it("creates a new dashboard-custom proxy when current assignment is a reusable manual proxy", async () => {
installFetchMock((url, init) => {
const method = String(init?.method || "GET").toUpperCase();
const body = parseBody(init);
if (method === "GET" && url === "/api/settings/proxies") {
return {
body: {
items: [
{
id: "manual-proxy-1",
name: "Shared Manual Proxy",
type: "http",
host: "shared.local",
port: 8080,
source: "manual",
},
],
total: 1,
},
};
}
if (url.startsWith("/api/settings/proxies/assignments?") && url.includes("scope=provider")) {
return {
body: {
items: [{ proxyId: "manual-proxy-1", scope: "provider", scopeId: "claude" }],
total: 1,
},
};
}
if (method === "POST" && url === "/api/settings/proxies") {
return {
status: 201,
body: { id: "custom-proxy-2", ...body, assignment: { proxyId: "custom-proxy-2" } },
};
}
return defaultProxyConfigResponses(url) || { status: 404, body: {} };
});
const { container } = await renderProxyConfigModal();
await clickButton(container, "custom");
await setInputValue(getInput(container, "hostPlaceholder"), "custom.local");
await clickButton(container, "save");
await waitForCall((call) => call.method === "POST" && call.url === "/api/settings/proxies");
expect(
fetchCalls.some((call) => call.method === "PATCH" && call.url === "/api/settings/proxies")
).toBe(false);
expect(
fetchCalls.some(
(call) =>
call.method === "POST" &&
call.url === "/api/settings/proxies" &&
call.body.assignment?.scope === "provider" &&
call.body.assignment?.scopeId === "claude"
)
).toBe(true);
expect(
fetchCalls.some(
(call) => call.method === "PUT" && call.url === "/api/settings/proxies/assignments"
)
).toBe(false);
});
});