aegida-console / hooks / use-chat.test.tsx
use-chat.test.tsx
Raw
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("@/lib/auth/client", () => ({ authFetch: vi.fn() }));

import { useChat } from "@/hooks/use-chat";
import { authFetch } from "@/lib/auth/client";

const conversationHeaders = {
  "X-Conversation-Id": "conversation-1",
  "X-User-Message-Id": "user-1",
  "X-Assistant-Message-Id": "assistant-1",
};

describe("useChat", () => {
  beforeEach(() => {
    vi.mocked(authFetch).mockReset();
    vi.mocked(authFetch).mockImplementation((input) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/chat") {
        return Promise.resolve(streamResponse(["Привет", ", мир"], conversationHeaders));
      }
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        const model = new URL(input, "http://localhost").searchParams.get("model") ?? "auto";
        return Promise.resolve(Response.json({ quota: quotaSnapshot(model) }));
      }
      return Promise.resolve(Response.json({ model: "chatgpt" }));
    });
  });

  afterEach(() => {
    cleanup();
    vi.restoreAllMocks();
  });

  it("hydrates server history and streams a newly persisted assistant response", async () => {
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(authFetch).toHaveBeenCalledWith("/api/conversations", expect.any(Object)));

    await act(() => result.current.sendMessage("Тест"));

    expect(result.current.activeConversation?.messages.map((message) => message.content)).toEqual(["Тест", "Привет, мир"]);
    expect(result.current.activeConversation?.messages.at(-1)?.status).toBe("complete");
    const chatCall = vi.mocked(authFetch).mock.calls.find(([url]) => url === "/api/chat");
    expect(JSON.parse(String(chatCall?.[1]?.body))).toEqual({ model: "auto", content: "Тест" });
  });

  it("renders a new-chat user turn before chat response headers arrive", async () => {
    let resolveChat!: (response: Response) => void;
    vi.mocked(authFetch).mockImplementation((input) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/chat") {
        return new Promise<Response>((resolve) => {
          resolveChat = resolve;
        });
      }
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("auto") }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.quota).toMatchObject({ status: "ready" }));

    let sending!: Promise<void>;
    act(() => {
      sending = result.current.sendMessage("Покажи сразу");
    });

    expect(result.current.activeConversation?.messages).toMatchObject([
      {
        role: "user",
        content: "Покажи сразу",
        status: "complete",
      },
      {
        role: "assistant",
        content: "",
        status: "streaming",
      },
    ]);

    await act(async () => {
      resolveChat(streamResponse(["Готово"], conversationHeaders));
      await sending;
    });
    expect(result.current.activeConversation?.id).toBe("conversation-1");
    expect(result.current.activeConversation?.messages.map(({ id }) => id)).toEqual([
      "user-1",
      "assistant-1",
    ]);
    expect(result.current.activeConversation?.messages).toHaveLength(2);
  });

  it("appends an existing-chat user turn before chat response headers arrive", async () => {
    let resolveChat!: (response: Response) => void;
    vi.mocked(authFetch).mockImplementation((input) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({
          conversations: [conversation("conversation-1", "auto")],
        }));
      }
      if (input === "/api/chat") {
        return new Promise<Response>((resolve) => {
          resolveChat = resolve;
        });
      }
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("auto") }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.activeConversation?.id).toBe("conversation-1"));

    let sending!: Promise<void>;
    act(() => {
      sending = result.current.sendMessage("Продолжить сразу");
    });

    expect(result.current.activeConversation?.id).toBe("conversation-1");
    expect(result.current.activeConversation?.messages).toMatchObject([
      {
        role: "user",
        content: "Продолжить сразу",
      },
      {
        role: "assistant",
        content: "",
        status: "streaming",
      },
    ]);

    await act(async () => {
      resolveChat(streamResponse(["Продолжено"], conversationHeaders));
      await sending;
    });
    expect(result.current.activeConversation?.messages.map(({ id }) => id)).toEqual([
      "user-1",
      "assistant-1",
    ]);
  });

  it("renders uploaded attachment metadata immediately and sends only attachment IDs", async () => {
    let resolveChat!: (response: Response) => void;
    vi.mocked(authFetch).mockImplementation((input) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/chat") {
        return new Promise<Response>((resolve) => {
          resolveChat = resolve;
        });
      }
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("auto") }));
      }
      return Promise.resolve(Response.json({}));
    });
    const attachment = {
      id: "00000000-0000-4000-8000-000000000077",
      name: "brief.txt",
      contentType: "text/plain",
      size: 5,
    };
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.quota).toMatchObject({ status: "ready" }));

    let sending!: Promise<void>;
    act(() => {
      sending = result.current.sendMessage("Смотри", [attachment]);
    });

    expect(result.current.activeConversation?.messages[0]).toMatchObject({
      role: "user",
      content: "Смотри",
      attachments: [attachment],
    });
    const chatCall = vi.mocked(authFetch).mock.calls.find(([url]) => url === "/api/chat");
    expect(JSON.parse(String(chatCall?.[1]?.body))).toEqual({
      model: "auto",
      content: "Смотри",
      attachmentIds: [attachment.id],
    });

    await act(async () => {
      resolveChat(streamResponse(["Готово"], conversationHeaders));
      await sending;
    });
  });

  it("uses the selected model and saves it as the user preference", async () => {
    const { result } = renderHook(() => useChat("user-1"));
    act(() => result.current.setModel("qwen"));
    await act(() => result.current.sendMessage("Привет"));

    expect(vi.mocked(authFetch)).toHaveBeenCalledWith(
      "/api/me/model",
      expect.objectContaining({ body: JSON.stringify({ model: "qwen" }) }),
    );
    const chatCall = vi.mocked(authFetch).mock.calls.find(([url]) => url === "/api/chat");
    expect(JSON.parse(String(chatCall?.[1]?.body))).toMatchObject({ model: "qwen" });
  });

  it("marks an aborted stream as stopped", async () => {
    let signal: AbortSignal | undefined;
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") return Promise.resolve(Response.json({ conversations: [] }));
      if (input === "/api/chat") {
        signal = init?.signal ?? undefined;
        return Promise.resolve(new Response(new ReadableStream({
          start(controller) {
            controller.enqueue(new TextEncoder().encode("Начало"));
            signal?.addEventListener("abort", () => controller.error(new DOMException("Aborted", "AbortError")));
          },
        }), { headers: conversationHeaders }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    let sending!: Promise<void>;
    act(() => { sending = result.current.sendMessage("Длинный ответ"); });
    await waitFor(() => expect(result.current.activeConversation?.messages).toHaveLength(2));
    act(() => result.current.stopGenerating());
    await act(() => sending);

    expect(signal?.aborted).toBe(true);
    expect(result.current.activeConversation?.messages.at(-1)?.status).toBe("stopped");
    await waitFor(() => expect(quotaCalls("auto")).toHaveLength(2));
  });

  it("keeps the optimistic user turn and disables retry after a pre-header failure", async () => {
    let rejectChat!: (error: Error) => void;
    vi.mocked(authFetch).mockImplementation((input) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/chat") {
        return new Promise<Response>((_resolve, reject) => {
          rejectChat = reject;
        });
      }
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("auto") }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.quota).toMatchObject({ status: "ready" }));

    let sending!: Promise<void>;
    act(() => {
      sending = result.current.sendMessage("Оставь меня");
    });
    const pendingAssistantId = result.current.activeConversation?.messages[1]?.id;
    expect(result.current.activeConversation?.messages[1]).toMatchObject({
      role: "assistant",
      content: "",
      status: "streaming",
    });

    await act(async () => {
      rejectChat(new TypeError("network unavailable"));
      await sending;
    });

    expect(result.current.activeConversation?.messages.map((message) => message.role)).toEqual([
      "user",
      "assistant",
    ]);
    expect(result.current.activeConversation?.messages[0]).toMatchObject({
      content: "Оставь меня",
      status: "complete",
    });
    expect(result.current.activeConversation?.messages[1]).toMatchObject({
      id: pendingAssistantId,
      content: "Не удалось получить ответ. Попробуйте снова.",
      status: "error",
    });
    expect(result.current.canRetryLast).toBe(false);
  });

  it("does not create a server request for empty or over-limit content", async () => {
    const { result } = renderHook(() => useChat("user-1"));
    await act(() => result.current.sendMessage("  "));
    await act(() => result.current.sendMessage("x".repeat(32_001)));
    expect(vi.mocked(authFetch).mock.calls.filter(([url]) => url === "/api/chat")).toHaveLength(0);
  });

  it("loads quota for the initial effective model and refreshes after a successful switch", async () => {
    const { result } = renderHook(() => useChat("user-1", undefined, "claude-sonnet.4"));

    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "claude-sonnet.4", availableTokens: 42_000 },
    }));
    expect(vi.mocked(authFetch)).toHaveBeenCalledWith(
      "/api/me/quota?model=claude-sonnet.4",
      expect.objectContaining({ cache: "no-store", signal: expect.any(AbortSignal) }),
    );

    act(() => result.current.setModel("qwen"));

    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "qwen" },
    }));
  });

  it("refreshes the authoritative quota after both successful and failed turns", async () => {
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(quotaCalls("auto")).toHaveLength(1));

    await act(() => result.current.sendMessage("успех"));
    await waitFor(() => expect(quotaCalls("auto")).toHaveLength(2));

    vi.mocked(authFetch).mockImplementation((input) => {
      if (input === "/api/conversations") return Promise.resolve(Response.json({ conversations: [] }));
      if (input === "/api/chat") return Promise.resolve(Response.json({ error: "safe" }, { status: 502 }));
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("auto") }));
      }
      return Promise.resolve(Response.json({}));
    });

    await act(() => result.current.sendMessage("ошибка"));
    await waitFor(() => expect(quotaCalls("auto")).toHaveLength(3));
  });

  it("surfaces a 429 admission error and rejects a stale pre-turn quota response", async () => {
    const pendingQuota: Array<{
      signal: AbortSignal;
      resolve(response: Response): void;
    }> = [];
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/chat") {
        return Promise.resolve(
          Response.json(
            { error: "Лимит токенов исчерпан" },
            { status: 429, headers: conversationHeaders },
          ),
        );
      }
      if (input === "/api/me/quota?model=auto") {
        return new Promise<Response>((resolve) => {
          pendingQuota.push({ signal: init?.signal as AbortSignal, resolve });
        });
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(pendingQuota).toHaveLength(1));

    await act(() => result.current.sendMessage("Привет"));
    await waitFor(() => expect(pendingQuota).toHaveLength(2));
    expect(pendingQuota[0].signal.aborted).toBe(true);
    expect(result.current.activeConversation?.messages.map((message) => message.content)).toEqual([
      "Привет",
      "Лимит токенов исчерпан",
    ]);
    expect(result.current.activeConversation?.messages.at(-1)?.status).toBe("error");

    await act(async () => {
      pendingQuota[0].resolve(
        Response.json({ quota: { ...quotaSnapshot("auto"), availableTokens: 9_999 } }),
      );
    });
    expect(result.current.quota).toEqual({ status: "loading" });

    await act(async () => {
      pendingQuota[1].resolve(
        Response.json({
          quota: {
            ...quotaSnapshot("auto"),
            availableTokens: 0,
            exhausted: true,
          },
        }),
      );
    });
    await waitFor(() =>
      expect(result.current.quota).toMatchObject({
        status: "ready",
        snapshot: { availableTokens: 0, exhausted: true },
      }),
    );
  });

  it("aborts a stale model quota request so it cannot overwrite the current model", async () => {
    const pending: Array<{
      model: string;
      signal: AbortSignal;
      resolve(response: Response): void;
    }> = [];
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") return Promise.resolve(Response.json({ conversations: [] }));
      if (input === "/api/me/model") return Promise.resolve(Response.json({ model: "qwen" }));
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        const model = new URL(input, "http://localhost").searchParams.get("model") ?? "auto";
        return new Promise<Response>((resolve) => {
          pending.push({ model, signal: init?.signal as AbortSignal, resolve });
        });
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(pending).toHaveLength(1));

    act(() => result.current.setModel("qwen"));
    await waitFor(() => expect(pending).toHaveLength(2));
    expect(pending[0].signal.aborted).toBe(true);

    await act(async () => {
      pending[1].resolve(Response.json({ quota: quotaSnapshot("qwen") }));
    });
    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "qwen" },
    }));

    await act(async () => {
      pending[0].resolve(Response.json({ quota: quotaSnapshot("auto") }));
    });
    expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "qwen" },
    });
  });

  it("invalidates the old-model quota while the preference switch is pending", async () => {
    let resolvePreference!: (response: Response) => void;
    let resolveOldQuota!: (response: Response) => void;
    let oldQuotaSignal!: AbortSignal;
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/me/model") {
        return new Promise<Response>((resolve) => {
          resolvePreference = resolve;
        });
      }
      if (input === "/api/me/quota?model=auto") {
        oldQuotaSignal = init?.signal as AbortSignal;
        return new Promise<Response>((resolve) => {
          resolveOldQuota = resolve;
        });
      }
      if (input === "/api/me/quota?model=qwen") {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("qwen") }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(oldQuotaSignal).toBeInstanceOf(AbortSignal));

    act(() => result.current.setModel("qwen"));

    expect(oldQuotaSignal.aborted).toBe(true);
    expect(result.current.quota).toEqual({ status: "loading" });
    await act(async () => {
      resolveOldQuota(
        Response.json({
          quota: {
            ...quotaSnapshot("auto"),
            availableTokens: 0,
            exhausted: true,
          },
        }),
      );
    });
    expect(result.current.quota).toEqual({ status: "loading" });

    await act(async () => {
      resolvePreference(Response.json({ model: "qwen" }));
    });
    await waitFor(() =>
      expect(result.current.quota).toMatchObject({
        status: "ready",
        snapshot: { model: "qwen", exhausted: false },
      }),
    );
  });

  it("keeps the selected-model quota authoritative when an old-model stream finishes", async () => {
    let closeStream!: () => void;
    let resolveQwenQuota!: (response: Response) => void;
    let qwenQuotaSignal!: AbortSignal;
    let autoQuotaCount = 0;
    let chatCount = 0;
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/chat") {
        chatCount += 1;
        if (chatCount > 1) {
          return Promise.resolve(streamResponse(["Готово"], conversationHeaders));
        }
        return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
          start(controller) {
            controller.enqueue(new TextEncoder().encode("Начало"));
            closeStream = () => controller.close();
          },
        }), { headers: conversationHeaders }));
      }
      if (input === "/api/me/model") {
        return Promise.resolve(Response.json({ model: "qwen" }));
      }
      if (input === "/api/me/quota?model=auto") {
        autoQuotaCount += 1;
        return Promise.resolve(Response.json({ quota: {
          ...quotaSnapshot("auto"),
          availableTokens: autoQuotaCount === 1 ? 42_000 : 0,
          exhausted: autoQuotaCount > 1,
        } }));
      }
      if (input === "/api/me/quota?model=qwen") {
        qwenQuotaSignal = init?.signal as AbortSignal;
        return new Promise<Response>((resolve) => {
          resolveQwenQuota = resolve;
        });
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "auto" },
    }));

    let sending!: Promise<void>;
    act(() => { sending = result.current.sendMessage("Длинный ответ"); });
    await waitFor(() => expect(result.current.isGenerating).toBe(true));
    act(() => result.current.setModel("qwen"));
    await waitFor(() => expect(qwenQuotaSignal).toBeInstanceOf(AbortSignal));
    await act(async () => {
      resolveQwenQuota(Response.json({ quota: quotaSnapshot("qwen") }));
    });
    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "qwen", exhausted: false },
    }));

    await act(async () => closeStream());
    await act(() => sending);

    expect(qwenQuotaSignal.aborted).toBe(false);
    expect(autoQuotaCount).toBe(1);
    expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "qwen", exhausted: false },
    });
    await act(() => result.current.sendMessage("Новый запрос"));
    expect(chatCount).toBe(2);
  });

  it("does not let an old conversation stream replace the newly selected conversation quota", async () => {
    let closeStream!: () => void;
    let resolveQwenQuota!: (response: Response) => void;
    let qwenQuotaSignal!: AbortSignal;
    const conversations = [
      conversation("conversation-auto", "auto"),
      conversation("conversation-qwen", "qwen"),
    ];
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations }));
      }
      if (input === "/api/chat") {
        return Promise.resolve(new Response(new ReadableStream<Uint8Array>({
          start(controller) {
            closeStream = () => controller.close();
          },
        }), { headers: {
          ...conversationHeaders,
          "X-Conversation-Id": "conversation-auto",
        } }));
      }
      if (input === "/api/me/quota?model=auto") {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("auto") }));
      }
      if (input === "/api/me/quota?model=qwen") {
        qwenQuotaSignal = init?.signal as AbortSignal;
        return new Promise<Response>((resolve) => {
          resolveQwenQuota = resolve;
        });
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.activeConversation?.id).toBe("conversation-auto"));

    let sending!: Promise<void>;
    act(() => { sending = result.current.sendMessage("Продолжить"); });
    await waitFor(() => expect(result.current.isGenerating).toBe(true));
    act(() => result.current.selectConversation("conversation-qwen"));
    await waitFor(() => expect(qwenQuotaSignal).toBeInstanceOf(AbortSignal));
    await act(async () => closeStream());
    await act(() => sending);

    expect(qwenQuotaSignal.aborted).toBe(false);
    await act(async () => {
      resolveQwenQuota(Response.json({ quota: quotaSnapshot("qwen") }));
    });
    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "qwen" },
    }));
  });

  it("serializes rapid model PATCHes and settles a failed latest selection", async () => {
    const pendingPatches: Array<{
      model: string;
      resolve(response: Response): void;
    }> = [];
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/me/model") {
        const model = JSON.parse(String(init?.body)).model as string;
        return new Promise<Response>((resolve) => pendingPatches.push({ model, resolve }));
      }
      if (input === "/api/me/quota?model=auto") {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("auto") }));
      }
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        const model = new URL(input, "http://localhost").searchParams.get("model") ?? "auto";
        return Promise.resolve(Response.json({ quota: quotaSnapshot(model) }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.quota).toMatchObject({ status: "ready" }));

    act(() => {
      result.current.setModel("qwen");
      result.current.setModel("claude-sonnet.4");
    });

    await waitFor(() => expect(pendingPatches).toHaveLength(1));
    expect(pendingPatches[0].model).toBe("qwen");
    await act(async () => pendingPatches[0].resolve(Response.json({ model: "qwen" })));
    await waitFor(() => expect(pendingPatches).toHaveLength(2));
    expect(pendingPatches[1].model).toBe("claude-sonnet.4");
    expect(quotaCalls("qwen")).toHaveLength(0);

    await act(async () => pendingPatches[1].resolve(Response.json({}, { status: 503 })));
    await waitFor(() => expect(result.current.quota).toEqual({ status: "unavailable" }));
    expect(result.current.activeModelId).toBe("claude-sonnet.4");
    expect(quotaCalls("claude-sonnet.4")).toHaveLength(0);
  });

  it("drops queued model PATCHes when their user unmounts before dispatch", async () => {
    let currentToken = "token-a";
    const onUnauthorized = vi.fn();
    const pendingPatches: Array<{
      model: string;
      token: string;
      resolve(response: Response): void;
    }> = [];
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/me/model") {
        const model = JSON.parse(String(init?.body)).model as string;
        return new Promise<Response>((resolve) => {
          pendingPatches.push({ model, token: currentToken, resolve });
        });
      }
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        const model = new URL(input, "http://localhost").searchParams.get("model") ?? "auto";
        return Promise.resolve(Response.json({ quota: quotaSnapshot(model) }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result, unmount } = renderHook(() => useChat("user-a", onUnauthorized));
    await waitFor(() => expect(result.current.quota).toMatchObject({ status: "ready" }));

    act(() => {
      result.current.setModel("qwen");
      result.current.setModel("claude-sonnet.4");
    });
    await waitFor(() => expect(pendingPatches).toHaveLength(1));
    expect(pendingPatches[0]).toMatchObject({ model: "qwen", token: "token-a" });
    const quotaCallCount = quotaCalls("auto").length + quotaCalls("qwen").length
      + quotaCalls("claude-sonnet.4").length;

    unmount();
    currentToken = "token-b";
    await act(async () => {
      pendingPatches[0].resolve(Response.json({}, { status: 401 }));
      await Promise.resolve();
    });

    expect(pendingPatches).toHaveLength(1);
    expect(pendingPatches.some((patch) => patch.token === "token-b")).toBe(false);
    expect(onUnauthorized).not.toHaveBeenCalled();
    expect(
      quotaCalls("auto").length + quotaCalls("qwen").length
        + quotaCalls("claude-sonnet.4").length,
    ).toBe(quotaCallCount);
  });

  it("aborts an in-flight model PATCH on unmount and lets a new user persist independently", async () => {
    let currentToken = "token-a";
    let userAPatchSignal!: AbortSignal;
    const persisted: Array<{ model: string; token: string }> = [];
    vi.mocked(authFetch).mockImplementation((input, init) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [] }));
      }
      if (input === "/api/me/model") {
        const model = JSON.parse(String(init?.body)).model as string;
        persisted.push({ model, token: currentToken });
        if (currentToken === "token-a") {
          userAPatchSignal = init?.signal as AbortSignal;
          return new Promise<Response>((_resolve, reject) => {
            userAPatchSignal?.addEventListener(
              "abort",
              () => reject(new DOMException("Aborted", "AbortError")),
              { once: true },
            );
          });
        }
        return Promise.resolve(Response.json({ model }));
      }
      if (typeof input === "string" && input.startsWith("/api/me/quota?model=")) {
        const model = new URL(input, "http://localhost").searchParams.get("model") ?? "auto";
        return Promise.resolve(Response.json({ quota: quotaSnapshot(model) }));
      }
      return Promise.resolve(Response.json({}));
    });
    const userA = renderHook(() => useChat("user-a"));
    await waitFor(() => expect(userA.result.current.quota).toMatchObject({ status: "ready" }));

    act(() => userA.result.current.setModel("qwen"));
    await waitFor(() => expect(userAPatchSignal).toBeInstanceOf(AbortSignal));
    userA.unmount();

    expect(userAPatchSignal.aborted).toBe(true);
    currentToken = "token-b";
    const userB = renderHook(() => useChat("user-b"));
    await waitFor(() => expect(userB.result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "auto" },
    }));
    act(() => userB.result.current.setModel("claude-sonnet.4"));

    await waitFor(() => expect(userB.result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "claude-sonnet.4" },
    }));
    expect(userB.result.current.activeModelId).toBe("claude-sonnet.4");
    expect(persisted).toEqual([
      { model: "qwen", token: "token-a" },
      { model: "claude-sonnet.4", token: "token-b" },
    ]);
  });

  it("refreshes the replacement model quota when deleting the active conversation", async () => {
    vi.mocked(authFetch).mockImplementation((input) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({ conversations: [
          conversation("conversation-auto", "auto"),
          conversation("conversation-qwen", "qwen"),
        ] }));
      }
      if (input === "/api/me/quota?model=auto") {
        return Promise.resolve(Response.json({ quota: {
          ...quotaSnapshot("auto"),
          availableTokens: 0,
          exhausted: true,
        } }));
      }
      if (input === "/api/me/quota?model=qwen") {
        return Promise.resolve(Response.json({ quota: quotaSnapshot("qwen") }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "auto", exhausted: true },
    }));

    act(() => result.current.deleteConversation("conversation-auto"));

    expect(result.current.activeConversation?.id).toBe("conversation-qwen");
    expect(result.current.activeModelId).toBe("qwen");
    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "qwen", exhausted: false },
    }));
  });

  it("refreshes the effective new-chat quota after deleting the final conversation", async () => {
    let deleted = false;
    vi.mocked(authFetch).mockImplementation((input) => {
      if (input === "/api/conversations") {
        return Promise.resolve(Response.json({
          conversations: [conversation("conversation-auto", "auto")],
        }));
      }
      if (input === "/api/me/quota?model=auto") {
        return Promise.resolve(Response.json({ quota: deleted
          ? quotaSnapshot("auto")
          : { ...quotaSnapshot("auto"), availableTokens: 0, exhausted: true } }));
      }
      return Promise.resolve(Response.json({}));
    });
    const { result } = renderHook(() => useChat("user-1"));
    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { exhausted: true },
    }));
    const quotaCountBeforeDelete = quotaCalls("auto").length;

    deleted = true;
    act(() => result.current.deleteConversation("conversation-auto"));

    expect(result.current.activeConversation).toBeNull();
    await waitFor(() => expect(quotaCalls("auto")).toHaveLength(quotaCountBeforeDelete + 1));
    await waitFor(() => expect(result.current.quota).toMatchObject({
      status: "ready",
      snapshot: { model: "auto", exhausted: false },
    }));
  });
});

function quotaCalls(model: string) {
  return vi.mocked(authFetch).mock.calls.filter(([url]) => url === `/api/me/quota?model=${model}`);
}

function quotaSnapshot(model: string) {
  return {
    object: "aegida.quota",
    model,
    availableTokens: 42_000,
    unlimited: false,
    exhausted: false,
    resetsAt: null,
    asOf: "2026-08-04T12:00:00Z",
  };
}

function streamResponse(chunks: string[], headers: Record<string, string>) {
  const encoder = new TextEncoder();
  return new Response(new ReadableStream<Uint8Array>({
    start(controller) {
      for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
      controller.close();
    },
  }), { headers });
}

function conversation(id: string, modelId: string) {
  return {
    id,
    title: id,
    modelId,
    updatedAt: "2026-08-07T12:00:00Z",
    messages: [],
  };
}