Files
2026-07-13 13:39:12 +08:00

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");
});
});