Ctrl K
ComunidadGuías…

lib/client-read.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CLIENT_READ_TIMEOUT_MS, fetchClientRead } from "./client-read";
import { fetchIssuesApi } from "./issues-api";
import { fetchGlobalBoardApi } from "./global-board-api";
import { fetchProjectsApi } from "./projects-api";
import { downloadPageExportApi, fetchPagesApi } from "./pages-api";
import { fetchAppTabs, createAppTab } from "./app-tabs-api";
import { fetchPullRequestApi, ApiError } from "./agent-api";
import { fetchBillingUsageApi } from "./billing-api";

const auth = vi.hoisted(() => ({ getSession: vi.fn() }));
vi.mock("./supabase", () => ({ getSupabase: () => ({ auth }) }));
vi.mock("./analytics", () => ({ trackEvent: vi.fn() }));
const session = (id = "owner") => ({ data: { session: { user: { id } } }, error: null });
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
  vi.useFakeTimers();
  auth.getSession.mockReset().mockResolvedValue(session());
  fetchMock.mockReset().mockImplementation(async () => Response.json({ ready: true }));
  vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });

describe("protected client reads", () => {
  it("lets archive generation and body transfer exceed the normal read deadline", async () => {
    let headers!: (response: Response) => void;
    let body!: ReadableStreamDefaultController<Uint8Array>;
    const stream = new ReadableStream<Uint8Array>({ start(controller) { body = controller; } });
    fetchMock.mockImplementationOnce(() => new Promise(resolve => { headers = resolve; }));
    const anchor = { href: "", download: "", click: vi.fn(), remove: vi.fn() };
    vi.stubGlobal("document", { createElement: () => anchor, body: { appendChild: vi.fn() } });
    vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:archive");
    vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
    const pending = downloadPageExportApi("project", "page", { branch: true });
    await vi.advanceTimersByTimeAsync(CLIENT_READ_TIMEOUT_MS + 1);
    expect(fetchMock).toHaveBeenCalledWith("/api/projects/project/pages/page/export?scope=branch", expect.anything());
    expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(false);
    headers(new Response(stream, { headers: { "Content-Disposition": "attachment; filename*=UTF-8''complete.zip" } }));
    await vi.advanceTimersByTimeAsync(CLIENT_READ_TIMEOUT_MS + 1);
    expect(anchor.click).not.toHaveBeenCalled();
    body.enqueue(new TextEncoder().encode("complete archive"));
    body.close();
    await pending;
    expect(anchor.download).toBe("complete.zip");
    expect(anchor.click).toHaveBeenCalledOnce();
    expect(auth.getSession).toHaveBeenCalledTimes(2);
    await vi.advanceTimersByTimeAsync(0);
    expect(vi.getTimerCount()).toBe(0);
  });

  it("bounds archive session preparation and rejects an account switch after transfer", async () => {
    auth.getSession.mockImplementationOnce(() => new Promise(() => {}));
    const pending = fetchClientRead("/api/projects/project/pages/page/export", undefined, { timeoutMs: null });
    const rejected = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
    await vi.advanceTimersByTimeAsync(CLIENT_READ_TIMEOUT_MS);
    await rejected;
    expect(fetchMock).not.toHaveBeenCalled();
    auth.getSession.mockResolvedValueOnce(session()).mockResolvedValueOnce(session("other"));
    await expect(fetchClientRead("/api/projects/project/pages/page/export", undefined, { timeoutMs: null })).rejects.toThrow("account changed");
    expect(vi.getTimerCount()).toBe(0);
  });

  it("cancels archive transport even after the normal read deadline", async () => {
    fetchMock.mockImplementationOnce(() => new Promise(() => {}));
    const caller = new AbortController();
    const pending = fetchClientRead("/api/projects/project/pages/page/export", { signal: caller.signal }, { timeoutMs: null });
    await vi.advanceTimersByTimeAsync(CLIENT_READ_TIMEOUT_MS + 1);
    caller.abort();
    await expect(pending).rejects.toMatchObject({ name: "AbortError" });
    expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true);
    expect(vi.getTimerCount()).toBe(0);
  });

  it.each([
    ["project board", () => fetchIssuesApi("project")],
    ["global board", () => fetchGlobalBoardApi()],
    ["projects", () => fetchProjectsApi()],
    ["page", () => fetchPagesApi("project")],
    ["tabs", () => fetchAppTabs()],
    ["pull request", () => fetchPullRequestApi("pr")],
    ["usage", () => fetchBillingUsageApi()],
  ] as const)("waits for renewed cookies before fetching %s", async (_name, read) => {
    let renew!: (value: ReturnType<typeof session>) => void;
    auth.getSession.mockImplementationOnce(() => new Promise(resolve => { renew = resolve; }));
    const pending = read();
    expect(fetchMock).not.toHaveBeenCalled();
    renew(session());
    await expect(pending).resolves.toEqual({ ready: true });
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it("times out a stalled session and never starts its read after late renewal", async () => {
    let renew!: (value: ReturnType<typeof session>) => void;
    auth.getSession.mockImplementationOnce(() => new Promise(resolve => { renew = resolve; }));
    const pending = fetchClientRead("/api/projects");
    const rejected = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
    await vi.advanceTimersByTimeAsync(CLIENT_READ_TIMEOUT_MS);
    await rejected;
    renew(session());
    await vi.advanceTimersByTimeAsync(0);
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it("bounds headers and body stalls, aborts transport, and permits a later read", async () => {
    for (const phase of ["headers", "body"]) {
      let transport!: AbortSignal;
      fetchMock.mockImplementationOnce(async (_url, init) => {
        transport = init!.signal!;
        if (phase === "headers") return new Promise<Response>(() => {});
        return new Response(new ReadableStream({ start() {} }));
      });
      const pending = fetchClientRead("/api/projects");
      const rejected = expect(pending).rejects.toMatchObject({ name: "TimeoutError" });
      await vi.advanceTimersByTimeAsync(CLIENT_READ_TIMEOUT_MS);
      await rejected;
      expect(transport.aborted).toBe(true);
    }
    await expect((await fetchClientRead("/api/projects")).json()).resolves.toEqual({ ready: true });
    expect(vi.getTimerCount()).toBe(0);
  });

  it("preserves cancellation while waiting for session renewal", async () => {
    let renew!: (value: ReturnType<typeof session>) => void;
    auth.getSession.mockImplementationOnce(() => new Promise(resolve => { renew = resolve; }));
    const controller = new AbortController();
    const pending = fetchClientRead("/api/projects", { signal: controller.signal });
    controller.abort();
    await expect(pending).rejects.toMatchObject({ name: "AbortError" });
    renew(session());
    await vi.advanceTimersByTimeAsync(0);
    expect(fetchMock).not.toHaveBeenCalled();
    expect(vi.getTimerCount()).toBe(0);
  });

  it("does not serve data after the account changes during a read", async () => {
    auth.getSession.mockResolvedValueOnce(session()).mockResolvedValueOnce(session("other"));
    await expect(fetchClientRead("/api/projects")).rejects.toThrow("account changed");
  });

  it("does not send reads for absent sessions or failed renewals", async () => {
    auth.getSession.mockResolvedValueOnce({ data: { session: null }, error: null });
    await expect(fetchClientRead("/api/projects")).rejects.toMatchObject({ status: 401 });
    const unavailable = new Error("Auth unavailable");
    auth.getSession.mockResolvedValueOnce({ data: { session: null }, error: unavailable });
    await expect(fetchClientRead("/api/projects")).rejects.toBe(unavailable);
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it("preserves PR status, code and Retry-After without replaying authorization failures", async () => {
    fetchMock.mockResolvedValueOnce(Response.json({ error: "Paused", code: "rateLimited" }, {
      status: 429, headers: { "Retry-After": "60" },
    }));
    const error = await fetchPullRequestApi("pr").catch(error => error);
    expect(error).toBeInstanceOf(ApiError);
    expect(error).toMatchObject({ status: 429, code: "rateLimited", retryAt: Date.now() + 60_000 });
    fetchMock.mockResolvedValueOnce(Response.json({ error: "Unauthorized" }, { status: 401 }));
    await expect(fetchPullRequestApi("pr")).rejects.toMatchObject({ status: 401 });
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it("rejects writes and external paths, while tab creation retains its existing request", async () => {
    expect(() => fetchClientRead("/api/projects", { method: "POST" })).toThrow("GET method");
    expect(() => fetchClientRead("https://other.example/api/projects")).toThrow("relative API path");
    fetchMock.mockResolvedValueOnce(Response.json({ tab: { id: "tab" } }));
    await expect(createAppTab(true, "tab")).resolves.toEqual({ id: "tab" });
    expect(auth.getSession).not.toHaveBeenCalled();
    expect(fetchMock).toHaveBeenCalledTimes(1);
    expect(fetchMock.mock.calls[0][1]?.method).toBe("POST");
  });
});