282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
// @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<string, string> = {};
|
|
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<typeof createRoot>; el: HTMLDivElement }> = [];
|
|
|
|
function renderTab() {
|
|
const el = document.createElement("div");
|
|
document.body.appendChild(el);
|
|
const root = createRoot(el);
|
|
act(() => {
|
|
root.render(<FreePoolTab />);
|
|
});
|
|
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");
|
|
});
|
|
});
|