import { afterEach, describe, expect, it, vi } from "vitest"; import { api, ApiError } from "./client"; import { authApi } from "./auth"; import { healthApi } from "./health"; import { ApiUnavailableError, isTemporaryApiError, readApiJson } from "./response"; afterEach(() => vi.unstubAllGlobals()); describe("API responses during a restart", () => { it.each([200, 502, 503, 504])("handles HTML with status %i on every startup API", async (status) => { const fetchMock = vi.fn().mockImplementation(async () => new Response("

Restarting

", { status, headers: { "Content-Type": "text/html" }, })); vi.stubGlobal("fetch", fetchMock); for (const read of [healthApi.get, authApi.getSession, () => api.get("/cli-auth/me")]) { await expect(read()).rejects.toMatchObject({ name: "ApiUnavailableError", message: "Paperclip is temporarily unavailable. Please try again in a moment.", status, }); } expect(fetchMock).toHaveBeenCalledTimes(3); }); it("does not replay a mutation after an HTML response", async () => { const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 200 })); vi.stubGlobal("fetch", fetchMock); await expect(api.post("/issues", { title: "Save once" })).rejects.toBeInstanceOf(ApiUnavailableError); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("preserves empty mutation successes", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); await expect(api.delete("/issues/example")).resolves.toBeUndefined(); }); it("preserves valid startup health metadata for sign-in and invitations", async () => { const payload = { status: "starting", deploymentMode: "authenticated", cloud: { managed: true, managedBy: "paperclip-cloud", stackSlug: "example", cloudBaseUrl: "https://example.com" }, }; vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(payload))); await expect(healthApi.get()).resolves.toEqual(payload); }); it.each([null, {}, { status: "unhealthy" }])("rejects an unusable health payload: %j", async (payload) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(payload))); await expect(healthApi.get()).rejects.toBeInstanceOf(ApiUnavailableError); }); it.each([healthApi.get, authApi.getSession, () => api.get("/cli-auth/me")])( "retains the status of JSON gateway errors for retry classification", async (read) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "Service unavailable" }, { status: 503 }))); const error = await read().catch((error: unknown) => error); expect(isTemporaryApiError(error)).toBe(true); }, ); it("preserves HTTP denials with or without JSON", async () => { vi.stubGlobal("fetch", vi.fn() .mockResolvedValueOnce(new Response("Forbidden", { status: 403 })) .mockResolvedValueOnce(Response.json({ error: "Access denied" }, { status: 403 }))); await expect(api.get("/denied")).rejects.toMatchObject({ name: "ApiError", status: 403 }); await expect(api.get("/denied")).rejects.toMatchObject({ message: "Access denied", status: 403 }); }); it("does not swallow an aborted response body", async () => { const error = new DOMException("Aborted", "AbortError"); const response = new Response(); vi.spyOn(response, "json").mockRejectedValue(error); await expect(readApiJson(response)).rejects.toBe(error); }); it.each(["Failed to fetch", "NetworkError when attempting to fetch resource.", "Load failed"])( "recognizes the browser network failure: %s", (message) => { expect(isTemporaryApiError(new TypeError(message))).toBe(true); }, ); it.each([ new ApiError("Unauthorized", 401, null), new ApiError("Forbidden", 403, null), new ApiError("Not found", 404, null), new ApiError("Internal error", 500, null), new TypeError("Cannot read properties of undefined"), new DOMException("Aborted", "AbortError"), ])("does not automatically retry non-transport errors: %s", (error) => { expect(isTemporaryApiError(error)).toBe(false); }); });