// @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) => { 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>) { 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( ); }); 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); }); });