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: [],
};
}