import { beforeEach, describe, expect, it, vi } from "vitest"; import { type AttackPathScan, type AttackPathScansResponse, SCAN_STATES, } from "@/types/attack-paths"; const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( () => ({ fetchMock: vi.fn(), getAuthHeadersMock: vi.fn(), handleApiResponseMock: vi.fn(), }), ); vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, })); vi.mock("@/lib/server-actions-helper", () => ({ handleApiResponse: handleApiResponseMock, })); import { getAttackPathScans } from "./scans"; const makeScan = (id: string): AttackPathScan => ({ type: "attack-paths-scans", id, attributes: { state: SCAN_STATES.COMPLETED, progress: 100, graph_data_ready: true, provider_alias: `alias-${id}`, provider_type: "aws", provider_uid: id, inserted_at: "2026-04-23T10:00:00Z", started_at: "2026-04-23T10:00:00Z", completed_at: "2026-04-23T10:10:00Z", duration: 600, }, relationships: {} as AttackPathScan["relationships"], }); const pageResponse = ( ids: string[], page: number, pages: number, count: number, ): AttackPathScansResponse => ({ data: ids.map(makeScan), links: { first: "first", last: "last", next: page < pages ? "next" : null, prev: page > 1 ? "prev" : null, }, meta: { pagination: { page, pages, count }, }, }); const getFetchedPageNumber = (call: unknown[]) => { const url = new URL(String(call[0])); return Number(url.searchParams.get("page[number]")); }; describe("getAttackPathScans", () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal("fetch", fetchMock); getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); fetchMock.mockResolvedValue(new Response(null, { status: 200 })); }); it("requests page[size]=100 with page[number]=1 on the first call", async () => { // Given handleApiResponseMock.mockResolvedValueOnce(pageResponse(["s1"], 1, 1, 1)); // When await getAttackPathScans(); // Then expect(fetchMock).toHaveBeenCalledTimes(1); const call = fetchMock.mock.calls[0]; const url = new URL(String(call[0])); expect(url.pathname).toBe("/api/v1/attack-paths-scans"); expect(url.searchParams.get("page[number]")).toBe("1"); expect(url.searchParams.get("page[size]")).toBe("100"); }); it("iterates across every backend page and aggregates all scans", async () => { // Given three pages totalling 22 scans handleApiResponseMock .mockResolvedValueOnce( pageResponse( Array.from({ length: 10 }, (_, i) => `a${i}`), 1, 3, 22, ), ) .mockResolvedValueOnce( pageResponse( Array.from({ length: 10 }, (_, i) => `b${i}`), 2, 3, 22, ), ) .mockResolvedValueOnce(pageResponse(["c0", "c1"], 3, 3, 22)); // When const result = await getAttackPathScans(); // Then expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock.mock.calls.map(getFetchedPageNumber)).toEqual([1, 2, 3]); expect(result?.data).toHaveLength(22); }); it("stops requesting when the current page equals meta.pagination.pages", async () => { // Given a single-page response handleApiResponseMock.mockResolvedValueOnce( pageResponse(["only"], 1, 1, 1), ); // When const result = await getAttackPathScans(); // Then expect(fetchMock).toHaveBeenCalledTimes(1); expect(result?.data).toHaveLength(1); }); it("stops when a page returns an empty data array", async () => { // Given the second page is unexpectedly empty handleApiResponseMock .mockResolvedValueOnce(pageResponse(["a0"], 1, 3, 3)) .mockResolvedValueOnce({ data: [], links: { first: "", last: "", next: null, prev: null }, meta: { pagination: { page: 2, pages: 3, count: 3 } }, } as AttackPathScansResponse); // When const result = await getAttackPathScans(); // Then expect(fetchMock).toHaveBeenCalledTimes(2); expect(result?.data).toHaveLength(1); }); it("returns undefined when the first fetch throws", async () => { // Given handleApiResponseMock.mockRejectedValueOnce(new Error("network down")); // When const result = await getAttackPathScans(); // Then expect(result).toBeUndefined(); }); it("preserves scans from earlier pages when a later fetch throws", async () => { // Given the first page resolves but the second page errors mid-iteration handleApiResponseMock .mockResolvedValueOnce(pageResponse(["a0", "a1"], 1, 3, 5)) .mockRejectedValueOnce(new Error("network blip")); // When const result = await getAttackPathScans(); // Then we keep the scans we already fetched instead of discarding everything expect(result?.data).toHaveLength(2); expect(result?.data.map((scan) => scan.id)).toEqual(["a0", "a1"]); }); it("returns an empty list when the first page has no data", async () => { // Given handleApiResponseMock.mockResolvedValueOnce(undefined); // When const result = await getAttackPathScans(); // Then expect(result).toEqual({ data: [] }); expect(fetchMock).toHaveBeenCalledTimes(1); }); });