import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatMessage } from "@/components/chat/chat-message";
import { clearStoredToken, storeToken } from "@/lib/auth/client";
describe("ChatMessage", () => {
let downloadedHref = "";
let downloadedName = "";
let tokens: Map<string, string>;
beforeEach(() => {
tokens = new Map();
vi.stubGlobal("localStorage", {
clear: vi.fn(() => tokens.clear()),
getItem: vi.fn((key: string) => tokens.get(key) ?? null),
removeItem: vi.fn((key: string) => tokens.delete(key)),
setItem: vi.fn((key: string, value: string) => tokens.set(key, value)),
});
storeToken("application-token");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("private contents", {
headers: { "Content-Type": "text/plain" },
}),
),
);
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn(() => "blob:download"),
revokeObjectURL: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
downloadedHref = this.href;
downloadedName = this.download;
});
});
afterEach(() => {
clearStoredToken();
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("shows an accessible animated placeholder for an empty streaming response", () => {
render(
<ChatMessage
canRetry={false}
message={{
id: "client-message-pending",
role: "assistant",
content: "",
createdAt: "2026-08-09T06:00:00.000Z",
status: "streaming",
}}
onRetry={vi.fn()}
/>,
);
expect(
screen.getByRole("status", { name: "Модель формирует ответ" }),
).toBeVisible();
expect(screen.getAllByTestId("response-placeholder-dot")).toHaveLength(3);
expect(screen.queryByText("Думаю…")).not.toBeInTheDocument();
});
it("replaces the placeholder with the first streamed content", () => {
render(
<ChatMessage
canRetry={false}
message={{
id: "assistant-message",
role: "assistant",
content: "Первый фрагмент",
createdAt: "2026-08-09T06:00:00.000Z",
status: "streaming",
}}
onRetry={vi.fn()}
/>,
);
expect(screen.getByText("Первый фрагмент")).toBeVisible();
expect(
screen.queryByRole("status", { name: "Модель формирует ответ" }),
).not.toBeInTheDocument();
});
it("renders assistant Markdown but keeps the original source for full-response copy", async () => {
const source = "## Ответ\n\n**Готово**\n\n```js\nconsole.log('ok')\n```";
const user = userEvent.setup();
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue(undefined);
const { container } = render(
<ChatMessage
canRetry={false}
message={{
id: "assistant-markdown",
role: "assistant",
content: source,
createdAt: "2026-08-10T10:00:00.000Z",
status: "complete",
}}
onRetry={vi.fn()}
/>,
);
expect(screen.getByRole("heading", { level: 2, name: "Ответ" })).toBeVisible();
expect(screen.getByText("Готово").closest("span")).toHaveAttribute(
"data-streamdown",
"strong",
);
expect(container.querySelector(".assistant-markdown")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Копировать ответ" }));
expect(writeText).toHaveBeenCalledWith(source);
});
it("keeps user-entered Markdown literal", () => {
const source = "# Заголовок\n\n**текст**\n\n```js\nalert('literal')\n```";
const { container } = render(
<ChatMessage
canRetry={false}
message={{
id: "user-markdown",
role: "user",
content: source,
createdAt: "2026-08-10T10:00:00.000Z",
status: "complete",
}}
onRetry={vi.fn()}
/>,
);
expect(container.querySelector("p.whitespace-pre-wrap")).toHaveTextContent(
source,
{ normalizeWhitespace: false },
);
expect(container.querySelector("h1, strong, [data-streamdown]")).toBeNull();
});
it("exposes contained overflow hooks for assistant code blocks and tables", () => {
const { container } = render(
<ChatMessage
canRetry={false}
message={{
id: "assistant-overflow",
role: "assistant",
content: `\`\`\`text
very-long-code-line
\`\`\`
| Column |
| --- |
| value |`,
createdAt: "2026-08-10T10:00:00.000Z",
status: "complete",
}}
onRetry={vi.fn()}
/>,
);
expect(
container.querySelector(
'.assistant-markdown [data-streamdown="code-block-body"]',
),
).toBeInTheDocument();
expect(
container.querySelector(
'.assistant-markdown [data-streamdown="table-wrapper"]',
),
).toBeInTheDocument();
});
it("keeps stopped status and retry behavior available", async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
render(
<ChatMessage
canRetry
message={{
id: "assistant-stopped",
role: "assistant",
content: "Частичный ответ",
createdAt: "2026-08-10T10:00:00.000Z",
status: "stopped",
}}
onRetry={onRetry}
/>,
);
expect(screen.getByText("Генерация остановлена")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Повторить запрос" }));
expect(onRetry).toHaveBeenCalledOnce();
});
it.each(["stopped", "error"] as const)(
"does not mount assistant Markdown for an empty %s message",
(status) => {
const { container } = render(
<ChatMessage
canRetry={false}
message={{
id: `assistant-empty-${status}`,
role: "assistant",
content: "",
createdAt: "2026-08-10T10:00:00.000Z",
status,
}}
onRetry={vi.fn()}
/>,
);
expect(container.querySelector(".assistant-markdown")).toBeNull();
expect(
screen.queryByRole("status", { name: "Модель формирует ответ" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Копировать ответ" }),
).not.toBeInTheDocument();
},
);
it("downloads an attachment with the stored Bearer token from the app origin", async () => {
const user = userEvent.setup();
render(
<ChatMessage
canRetry={false}
message={{
id: "message-id",
role: "user",
content: "See attachment",
createdAt: "2026-07-29T08:00:00.000Z",
status: "complete",
attachments: [
{
id: "00000000-0000-4000-8000-000000000077",
name: "договор 1.txt",
contentType: "text/plain",
size: 15,
},
],
}}
onRetry={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Скачать договор 1.txt" }));
await waitFor(() => expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce());
const [input, init] = vi.mocked(fetch).mock.calls[0];
expect(input).toBe("/api/attachments/00000000-0000-4000-8000-000000000077");
expect(new Headers(init?.headers).get("Authorization")).toBe(
"Bearer application-token",
);
expect(downloadedHref).toBe("blob:download");
expect(downloadedName).toBe("договор 1.txt");
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:download");
});
it("shows a safe error when the authenticated download fails", async () => {
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }));
const user = userEvent.setup();
render(
<ChatMessage
canRetry={false}
message={{
id: "message-id",
role: "user",
content: "See attachment",
createdAt: "2026-07-29T08:00:00.000Z",
status: "complete",
attachments: [
{
id: "00000000-0000-4000-8000-000000000077",
name: "report.txt",
contentType: "text/plain",
size: 15,
},
],
}}
onRetry={vi.fn()}
/>,
);
await user.click(screen.getByRole("button", { name: "Скачать report.txt" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"Не удалось скачать вложение",
);
});
});