// @vitest-environment jsdom import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // ── Mocks ───────────────────────────────────────────────────────────────────── vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key, })); // Stub localStorage before importing the component const lsStore: Record = {}; const localStorageMock = { getItem: (k: string) => lsStore[k] ?? null, setItem: (k: string, v: string) => { lsStore[k] = v; }, removeItem: (k: string) => { delete lsStore[k]; }, clear: () => { for (const k in lsStore) delete lsStore[k]; }, }; Object.defineProperty(globalThis, "localStorage", { value: localStorageMock, writable: true, configurable: true, }); // ── Import component after mocks ───────────────────────────────────────────── const { default: FreePoolTab } = await import("../../src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab"); // ── Helpers ─────────────────────────────────────────────────────────────────── const defaultStats = { total: 0, inPool: 0, avgQuality: null, lastSyncAt: null }; function okJson(data: unknown) { return Promise.resolve({ ok: true, json: () => Promise.resolve(data) } as Response); } function setupFetch(items: unknown[] = [], stats = defaultStats) { const mockFetch = vi.fn((url: string) => { if (String(url).includes("/stats")) return okJson({ stats }); return okJson({ items }); }); vi.stubGlobal("fetch", mockFetch); return mockFetch; } const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; function renderTab() { const el = document.createElement("div"); document.body.appendChild(el); const root = createRoot(el); act(() => { root.render(); }); containers.push({ root, el }); return el; } async function waitForCondition(fn: () => boolean, timeoutMs = 2000) { const start = Date.now(); while (!fn()) { if (Date.now() - start > timeoutMs) throw new Error("waitForCondition timed out"); await new Promise((r) => setTimeout(r, 20)); } } // ── Lifecycle ───────────────────────────────────────────────────────────────── beforeEach(() => { localStorageMock.clear(); vi.clearAllMocks(); setupFetch(); }); afterEach(() => { for (const { root, el } of containers.splice(0)) { act(() => root.unmount()); el.remove(); } vi.unstubAllGlobals(); }); // ── Tests ───────────────────────────────────────────────────────────────────── describe("FreePoolTab source toggles", () => { it("renders a toggle group with exactly 3 buttons", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const bar = el.querySelector("[role='group']")!; expect(bar).toBeTruthy(); const buttons = bar.querySelectorAll("button"); expect(buttons.length).toBe(3); }); it("all toggles start enabled (aria-pressed=true)", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const buttons = Array.from(el.querySelector("[role='group']")!.querySelectorAll("button")); buttons.forEach((btn) => expect(btn.getAttribute("aria-pressed")).toBe("true")); }); it("clicking a toggle disables it (aria-pressed=false)", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const bar = el.querySelector("[role='group']")!; const first = bar.querySelectorAll("button")[0]; act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(first.getAttribute("aria-pressed")).toBe("false"); }); it("clicking a disabled toggle re-enables it", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const bar = el.querySelector("[role='group']")!; const first = bar.querySelectorAll("button")[0]; act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(first.getAttribute("aria-pressed")).toBe("false"); act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(first.getAttribute("aria-pressed")).toBe("true"); }); it("multiple sources can be disabled independently", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const buttons = el.querySelector("[role='group']")!.querySelectorAll("button"); act(() => { buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); }); act(() => { buttons[2].dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(buttons[0].getAttribute("aria-pressed")).toBe("false"); expect(buttons[1].getAttribute("aria-pressed")).toBe("true"); // second still enabled expect(buttons[2].getAttribute("aria-pressed")).toBe("false"); }); it("disabled source is persisted in localStorage", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const first = el.querySelector("[role='group']")!.querySelectorAll("button")[0]; act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const stored = JSON.parse(localStorageMock.getItem("freePool.disabledSources") ?? "[]"); expect(Array.isArray(stored)).toBe(true); expect(stored).toContain("1proxy"); }); it("button labels are 1proxy, Proxifly, IPLocate", async () => { const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const texts = Array.from(el.querySelector("[role='group']")!.querySelectorAll("button")).map( (b) => b.textContent?.trim() ); expect(texts).toContain("1proxy"); expect(texts).toContain("Proxifly"); expect(texts).toContain("IPLocate"); }); }); describe("FreePoolTab data loading", () => { it("shows 'No proxies found' message when list is empty", async () => { const el = renderTab(); await waitForCondition(() => el.textContent?.includes("No proxies found") === true); expect(el.textContent).toMatch(/No proxies found/i); }); it("calls /api/settings/free-proxies on mount", async () => { const mockFetch = setupFetch(); renderTab(); await waitForCondition(() => mockFetch.mock.calls.some(([url]) => String(url).includes("/free-proxies")) ); expect(mockFetch.mock.calls.some(([url]) => String(url).includes("/free-proxies"))).toBe(true); }); it("calls /api/settings/free-proxies/stats on mount", async () => { const mockFetch = setupFetch(); renderTab(); await waitForCondition(() => mockFetch.mock.calls.some(([url]) => String(url).includes("/stats")) ); expect(mockFetch.mock.calls.some(([url]) => String(url).includes("/stats"))).toBe(true); }); it("disabling a source re-fetches with sources= filter", async () => { const mockFetch = vi.fn((url: string) => { if (String(url).includes("/stats")) return okJson({ stats: defaultStats }); return okJson({ items: [] }); }); vi.stubGlobal("fetch", mockFetch); const el = renderTab(); // Wait for initial load await waitForCondition(() => mockFetch.mock.calls.some(([url]) => String(url).includes("/free-proxies")) ); const initialCallCount = mockFetch.mock.calls.length; const bar = el.querySelector("[role='group']")!; const first = bar.querySelectorAll("button")[0]; // disable 1proxy act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await waitForCondition(() => mockFetch.mock.calls.length > initialCallCount); const proxiesCalls = mockFetch.mock.calls .slice(initialCallCount) .map(([url]) => String(url)) .filter((u) => u.includes("/free-proxies?") && !u.includes("/stats")); expect(proxiesCalls.length).toBeGreaterThan(0); expect(proxiesCalls.some((u) => u.includes("sources="))).toBe(true); }); it("displays stats when available", async () => { setupFetch([], { total: 7, inPool: 2, avgQuality: null, lastSyncAt: null }); const el = renderTab(); await waitForCondition(() => el.textContent?.includes("Total: 7") === true); expect(el.textContent).toMatch(/Total: 7/); expect(el.textContent).toMatch(/In pool: 2/); }); }); describe("FreePoolTab sync error surfacing (#5595)", () => { it("renders the per-source errors the sync route returns instead of failing silently", async () => { const mockFetch = vi.fn((url: string) => { if (String(url).includes("/sync")) { return okJson({ success: true, results: { proxifly: { fetched: 0, added: 0, updated: 0, errors: ["TLS handshake failed"] }, iplocate: { fetched: 0, added: 0, updated: 0, errors: ["http: HTTP 404"] }, "1proxy": { fetched: 5, added: 5, updated: 0, errors: [] }, }, lastSyncAt: "2026-06-30T00:00:00Z", }); } if (String(url).includes("/stats")) return okJson({ stats: defaultStats }); return okJson({ items: [] }); }); vi.stubGlobal("fetch", mockFetch); const el = renderTab(); await waitForCondition(() => el.querySelector("[role='group']") !== null); const syncBtn = Array.from(el.querySelectorAll("button")).find((b) => b.textContent?.includes("proxyFreePoolSyncAll") )!; expect(syncBtn).toBeTruthy(); act(() => { syncBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); // RED before the fix: handleSync discarded the response, so no error box appears. await waitForCondition( () => el.querySelector("[data-testid='free-pool-sync-errors']") !== null ); const errBox = el.querySelector("[data-testid='free-pool-sync-errors']")!; expect(errBox.textContent).toContain("TLS handshake failed"); expect(errBox.textContent).toContain("HTTP 404"); // A source with no errors must not be listed. expect(errBox.textContent).not.toContain("1proxy"); }); });