import { afterEach, describe, expect, it, vi } from "vitest"; import { loadSettings, resetSettings, saveSettings } from "./settingsApi"; const REST_URL = "/wp-json/b3-wvs/v1"; const REVISION = `sha256:${"a".repeat(64)}`; function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json", ETag: `"${REVISION}"` }, }); } function restErrorResponse(code: string, status: number): Response { return new Response( JSON.stringify({ code, message: `Synthetic ${code}.`, data: { status }, }), { status, headers: { "Content-Type": "application/json" }, }, ); } afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); describe("settings API", () => { it("accepts raw and legacy-wrapped GET settings without dropping extension keys", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( jsonResponse({ layout: "grid", extension_custom: { enabled: true } }), ) .mockResolvedValueOnce( jsonResponse({ settings: { layout: "flow", extension_custom: "preserved" }, }), ); vi.stubGlobal("fetch", fetchMock); await expect(loadSettings(REST_URL, "nonce")).resolves.toEqual({ baseline: { layout: "grid", extension_custom: { enabled: true } }, revision: REVISION, }); await expect(loadSettings(REST_URL, "nonce")).resolves.toEqual({ baseline: { layout: "flow", extension_custom: "preserved" }, revision: REVISION, }); expect(fetchMock.mock.calls.map(([endpoint]) => endpoint)).toEqual([ `${window.location.origin}/wp-json/b3-wvs/v1/settings`, `${window.location.origin}/wp-json/b3-wvs/v1/settings`, ]); }); it("pins pretty and rest_route roots to the exact settings siblings", async () => { const queryRoot = `/?rest_route=${encodeURIComponent("/b3-wvs/v1")}`; const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( jsonResponse({ success: true, settings: { layout: "grid" }, }), ), ); vi.stubGlobal("fetch", fetchMock); await saveSettings(queryRoot, "nonce", { layout: "grid" }, REVISION); await resetSettings(queryRoot, "nonce", REVISION); expect(fetchMock.mock.calls.map(([endpoint]) => endpoint)).toEqual([ `${window.location.origin}/?rest_route=${encodeURIComponent( "/b3-wvs/v1/settings", )}`, `${window.location.origin}/?rest_route=${encodeURIComponent( "/b3-wvs/v1/settings/reset", )}`, ]); }); it("retries only the bounded transient settings GET failures", async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockResolvedValueOnce(restErrorResponse("b3_settings_busy", 503)) .mockResolvedValueOnce( restErrorResponse("b3_settings_revision_unavailable", 503), ) .mockResolvedValueOnce(jsonResponse({ layout: "grid" })); vi.stubGlobal("fetch", fetchMock); const request = loadSettings(REST_URL, "nonce"); await vi.runAllTimersAsync(); await expect(request).resolves.toEqual({ baseline: { layout: "grid" }, revision: REVISION, }); expect(fetchMock).toHaveBeenCalledTimes(3); expect( fetchMock.mock.calls.every( ([, init]) => (init as RequestInit).method === "GET", ), ).toBe(true); }); it("stops retrying a transient settings GET after the bounded attempts", async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockImplementation(() => Promise.resolve( restErrorResponse("b3_settings_revision_unavailable", 503), ), ); vi.stubGlobal("fetch", fetchMock); const request = loadSettings(REST_URL, "nonce"); const rejection = request.catch((error: unknown) => error); await vi.runAllTimersAsync(); await expect(rejection).resolves.toMatchObject({ status: 503, code: "b3_settings_revision_unavailable", }); expect(fetchMock).toHaveBeenCalledTimes(3); }); it("propagates caller abort while waiting to retry a settings GET", async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockImplementation(() => Promise.resolve(restErrorResponse("b3_settings_busy", 503)), ); const controller = new AbortController(); const reason = new DOMException("Settings view unmounted.", "AbortError"); vi.stubGlobal("fetch", fetchMock); const request = loadSettings(REST_URL, "nonce", controller.signal); await vi.advanceTimersByTimeAsync(0); controller.abort(reason); await expect(request).rejects.toBe(reason); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("requires the explicit successful mutation envelope", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( jsonResponse({ success: true, settings: { layout: "pill", extension_custom: 7 }, }), ) .mockResolvedValueOnce( jsonResponse({ success: true, settings: { layout: "wrap" } }), ); vi.stubGlobal("fetch", fetchMock); await expect( saveSettings(REST_URL, "nonce", { layout: "grid" }, REVISION), ).resolves.toEqual({ baseline: { layout: "pill", extension_custom: 7 }, revision: REVISION, }); await expect(resetSettings(REST_URL, "nonce", REVISION)).resolves.toEqual({ baseline: { layout: "wrap" }, revision: REVISION, }); const saveInit = fetchMock.mock.calls[0]?.[1] as RequestInit; expect(saveInit.method).toBe("POST"); expect(saveInit.body).toBe(JSON.stringify({ layout: "grid" })); expect(new Headers(saveInit.headers).get("Content-Type")).toBe( "application/json", ); expect(new Headers(saveInit.headers).get("If-Match")).toBe(`"${REVISION}"`); expect(fetchMock.mock.calls[1]?.[0]).toBe( `${window.location.origin}/wp-json/b3-wvs/v1/settings/reset`, ); }); it("never retries a 409 settings mutation", async () => { const fetchMock = vi .fn() .mockResolvedValue(restErrorResponse("b3_settings_conflict", 409)); vi.stubGlobal("fetch", fetchMock); await expect( saveSettings(REST_URL, "nonce", { layout: "grid" }, REVISION), ).rejects.toMatchObject({ status: 409, code: "b3_settings_conflict", }); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("retries an explicitly busy settings save with the exact original request", async () => { vi.useFakeTimers(); const settings = { layout: "grid", extension_custom: { enabled: true } }; const fetchMock = vi .fn() .mockResolvedValueOnce(restErrorResponse("b3_settings_busy", 503)) .mockResolvedValueOnce( jsonResponse({ success: true, settings: { layout: "grid" } }), ); vi.stubGlobal("fetch", fetchMock); const request = saveSettings( REST_URL, "nonce", settings as unknown as Parameters[2], REVISION, ); settings.layout = "pill"; settings.extension_custom.enabled = false; await vi.runAllTimersAsync(); await expect(request).resolves.toEqual({ baseline: { layout: "grid" }, revision: REVISION, }); expect(fetchMock).toHaveBeenCalledTimes(2); const requests = fetchMock.mock.calls.map(([, rawInit]) => { const init = rawInit as RequestInit; const headers = new Headers(init.headers); return { method: init.method, body: init.body, ifMatch: headers.get("If-Match"), }; }); expect(requests).toEqual([ { method: "POST", body: JSON.stringify({ layout: "grid", extension_custom: { enabled: true }, }), ifMatch: `"${REVISION}"`, }, { method: "POST", body: JSON.stringify({ layout: "grid", extension_custom: { enabled: true }, }), ifMatch: `"${REVISION}"`, }, ]); }); it("stops retrying an explicitly busy settings save after its bounded budget", async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockImplementation(() => Promise.resolve(restErrorResponse("b3_settings_busy", 503)), ); vi.stubGlobal("fetch", fetchMock); const request = saveSettings( REST_URL, "nonce", { layout: "grid" }, REVISION, ); const rejection = request.catch((error: unknown) => error); await vi.runAllTimersAsync(); await expect(rejection).resolves.toMatchObject({ status: 503, code: "b3_settings_busy", }); expect(fetchMock).toHaveBeenCalledTimes(3); }); it("retries an explicitly busy settings reset with the same precondition", async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockResolvedValueOnce(restErrorResponse("b3_settings_busy", 503)) .mockResolvedValueOnce( jsonResponse({ success: true, settings: { layout: "wrap" } }), ); vi.stubGlobal("fetch", fetchMock); const request = resetSettings(REST_URL, "nonce", REVISION); await vi.runAllTimersAsync(); await expect(request).resolves.toEqual({ baseline: { layout: "wrap" }, revision: REVISION, }); expect(fetchMock).toHaveBeenCalledTimes(2); expect( fetchMock.mock.calls.map(([, rawInit]) => { const init = rawInit as RequestInit; return { method: init.method, ifMatch: new Headers(init.headers).get("If-Match"), }; }), ).toEqual([ { method: "POST", ifMatch: `"${REVISION}"` }, { method: "POST", ifMatch: `"${REVISION}"` }, ]); }); it("stops a save retry at a conflict after a busy response", async () => { vi.useFakeTimers(); const settings = { layout: "grid", swatch_gap: 12 } as const; const fetchMock = vi .fn() .mockResolvedValueOnce(restErrorResponse("b3_settings_busy", 503)) .mockResolvedValueOnce(restErrorResponse("b3_settings_conflict", 409)); vi.stubGlobal("fetch", fetchMock); const request = saveSettings(REST_URL, "nonce", settings, REVISION); const rejection = request.catch((error: unknown) => error); await vi.runAllTimersAsync(); await expect(rejection).resolves.toMatchObject({ status: 409, code: "b3_settings_conflict", }); expect(fetchMock).toHaveBeenCalledTimes(2); expect( fetchMock.mock.calls.map(([, rawInit]) => { const init = rawInit as RequestInit; return { method: init.method, body: init.body, ifMatch: new Headers(init.headers).get("If-Match"), }; }), ).toEqual([ { method: "POST", body: JSON.stringify(settings), ifMatch: `"${REVISION}"`, }, { method: "POST", body: JSON.stringify(settings), ifMatch: `"${REVISION}"`, }, ]); }); it("stops a reset retry at a conflict after a busy response", async () => { vi.useFakeTimers(); const fetchMock = vi .fn() .mockResolvedValueOnce(restErrorResponse("b3_settings_busy", 503)) .mockResolvedValueOnce(restErrorResponse("b3_settings_conflict", 409)); vi.stubGlobal("fetch", fetchMock); const request = resetSettings(REST_URL, "nonce", REVISION); const rejection = request.catch((error: unknown) => error); await vi.runAllTimersAsync(); await expect(rejection).resolves.toMatchObject({ status: 409, code: "b3_settings_conflict", }); expect(fetchMock).toHaveBeenCalledTimes(2); expect( fetchMock.mock.calls.map(([, rawInit]) => { const init = rawInit as RequestInit; return { method: init.method, body: init.body ?? null, ifMatch: new Headers(init.headers).get("If-Match"), }; }), ).toEqual([ { method: "POST", body: null, ifMatch: `"${REVISION}"` }, { method: "POST", body: null, ifMatch: `"${REVISION}"` }, ]); }); it.each([ ["another 503", restErrorResponse("service_unavailable", 503)], ["a network error", new TypeError("Synthetic network failure")], ])("does not retry %s while saving settings", async (_label, failure) => { const fetchMock = vi.fn(); if (failure instanceof Response) { fetchMock.mockResolvedValue(failure); } else { fetchMock.mockRejectedValue(failure); } vi.stubGlobal("fetch", fetchMock); await expect( saveSettings(REST_URL, "nonce", { layout: "grid" }, REVISION), ).rejects.toBeTruthy(); expect(fetchMock).toHaveBeenCalledTimes(1); }); it.each([ ["cross-origin endpoint", "https://example.com/wp-json/b3-wvs/v1", "nonce"], ["another same-origin route", "/wp-json/wp/v2", "nonce"], ["a route suffix", "/wp-json/b3-wvs/v1/other", "nonce"], ["a pretty route query", `${REST_URL}?force=true`, "nonce"], [ "an unexpected rest_route query", `/?rest_route=${encodeURIComponent("/b3-wvs/v1")}&force=true`, "nonce", ], [ "duplicate rest_route parameters", `/?rest_route=${encodeURIComponent( "/b3-wvs/v1", )}&rest_route=${encodeURIComponent("/wp/v2/users")}`, "nonce", ], ["blank nonce", REST_URL, " "], ["padded nonce", REST_URL, " nonce"], ["control-bearing nonce", REST_URL, "nonce\nvalue"], ])("does not fetch for a %s", async (_label, restUrl, nonce) => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); await expect(loadSettings(restUrl, nonce)).rejects.toThrow(); expect(fetchMock).not.toHaveBeenCalled(); }); it.each([ ["blank", ""], ["padded", ` ${REVISION}`], ["weak", `W/"${REVISION}"`], ["quoted", `"${REVISION}"`], ["wrong digest", `sha1:${"a".repeat(40)}`], ["uppercase digest", `sha256:${"A".repeat(64)}`], ["missing", undefined as unknown as string], ])( "rejects a %s mutation revision before fetch", async (_label, revision) => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); await expect( saveSettings(REST_URL, "nonce", { layout: "grid" }, revision), ).rejects.toThrow("Invalid settings revision."); await expect(resetSettings(REST_URL, "nonce", revision)).rejects.toThrow( "Invalid settings revision.", ); expect(fetchMock).not.toHaveBeenCalled(); }, ); it.each([ ["GET array", () => loadSettings(REST_URL, "nonce"), []], [ "GET malformed wrapper", () => loadSettings(REST_URL, "nonce"), { settings: null }, ], [ "POST missing success", () => saveSettings(REST_URL, "nonce", {}, REVISION), { settings: {} }, ], [ "reset false success", () => resetSettings(REST_URL, "nonce", REVISION), { success: false, settings: {} }, ], ])("rejects a malformed %s response", async (_label, request, response) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(response))); await expect(request()).rejects.toThrow("Invalid settings response."); }); });