aegida-console / components / chat / chat-message.test.tsx
chat-message.test.tsx
Raw
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(
      "Не удалось скачать вложение",
    );
  });
});