import { describe, expect, it } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query';
import type { Message } from '@/api/messages';
import { metaQueryKey } from '@/api/meta';
import { linkPreviewQueryKey, type LinkPreviewStatus } from '@/api/unfurl';
import { LinkPreviewCard } from '@/components/chat/LinkPreviewCard';
import { MessageList } from '@/components/chat/MessageList';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import {
createTestQueryClient,
findRequest,
installFetch,
json,
makeMessage,
recordedRequests,
type FetchRoute,
} from './helpers';
/**
* Карточка превью ссылки (Фаза 7): показывает заголовок и ведёт по ссылке,
* молчит на `empty`/`blocked`/`error`, не подставляет картинку с чужой схемой
* и вообще не запрашивает превью, пока инстанс не подтвердил `unfurl_enabled`.
*/
const url = 'https://example.com/page';
/** Карточка, какой её отдаёт сервер: адрес канонизирован, поля необязательны. */
const preview = {
url: 'https://example.com/canonical',
title: 'Заголовок страницы',
description: 'Короткое описание страницы',
site_name: 'Example',
image_url: 'https://cdn.example.com/cover.png',
};
interface CardOptions {
/** Ответ ручки превью; по умолчанию — карточка со статусом `ok`. */
response?: () => Response;
/** Флаг инстанса `meta.features.unfurl_enabled`. */
unfurlEnabled?: boolean;
}
/** Маршруты `/meta` и `/link-previews` (остальное моку незнакомо). */
function cardRoutes(options: CardOptions = {}): FetchRoute[] {
return [
{
match: '/api/v1/meta',
response: () => json({ features: { unfurl_enabled: options.unfurlEnabled ?? true } }),
},
{
match: '/api/v1/link-previews',
response: options.response ?? (() => json({ status: 'ok', preview })),
},
];
}
function renderCard(targetUrl: string | null, options: CardOptions = {}) {
const fetchMock = installFetch(cardRoutes(options));
const client = createTestQueryClient();
const view = render(
,
);
return { ...view, fetchMock, client };
}
/** Ждём, пока запрос превью завершится с нужным исходом. */
async function waitForPreview(
client: QueryClient,
status: 'success' | 'error' = 'success',
target = url,
): Promise {
await waitFor(() => {
expect(client.getQueryState(linkPreviewQueryKey(target))?.status).toBe(status);
});
}
/** Ответ ручки превью с заданным статусом (без карточки). */
function statusResponse(status: LinkPreviewStatus): () => Response {
return () => json({ status });
}
describe('карточка превью ссылки', () => {
it('показывает заголовок и ведёт по адресу из ответа', async () => {
const { fetchMock, client } = renderCard(url);
const link = await screen.findByTestId('link-preview');
expect(within(link).getByText('Заголовок страницы')).toBeVisible();
expect(within(link).getByText('Короткое описание страницы')).toBeVisible();
expect(within(link).getByText('Example')).toBeVisible();
// Ссылка ведёт туда, куда указал сервер (он мог канонизировать адрес).
expect(link).toHaveAttribute('href', 'https://example.com/canonical');
expect(link).toHaveAttribute('target', '_blank');
expect(link.getAttribute('rel')).toContain('noopener');
expect(link.getAttribute('rel')).toContain('noreferrer');
expect(link).toHaveAttribute('title', 'Открыть ссылку');
// Картинка: только по проверенному адресу, без referer и с ленивой загрузкой.
const image = within(link).getByRole('img');
expect(image).toHaveAttribute('src', 'https://cdn.example.com/cover.png');
expect(image).toHaveAttribute('alt', 'Картинка превью');
expect(image).toHaveAttribute('loading', 'lazy');
expect(image).toHaveAttribute('referrerpolicy', 'no-referrer');
expect(
findRequest(fetchMock, {
url: `/api/v1/link-previews?url=${encodeURIComponent(url)}`,
}),
).toBeDefined();
await waitForPreview(client);
});
it.each(['javascript:alert(1)', 'data:image/png;base64,AAAA'])(
'не подставляет картинку со схемой %s',
async (imageUrl) => {
const { client } = renderCard(url, {
response: () => json({ status: 'ok', preview: { ...preview, image_url: imageUrl } }),
});
const link = await screen.findByTestId('link-preview');
expect(within(link).getByText('Заголовок страницы')).toBeVisible();
expect(within(link).queryByRole('img')).toBeNull();
await waitForPreview(client);
},
);
it('не показывает карточку без метаданных', async () => {
const { client } = renderCard(url, { response: statusResponse('empty') });
await waitForPreview(client);
expect(screen.queryByTestId('link-preview')).toBeNull();
});
it('не показывает карточку при сбое загрузки', async () => {
const { client } = renderCard(url, { response: statusResponse('error') });
await waitForPreview(client);
expect(screen.queryByTestId('link-preview')).toBeNull();
});
it('не показывает карточку, если сервер запретил адрес', async () => {
const { client } = renderCard(url, { response: statusResponse('blocked') });
await waitForPreview(client);
expect(screen.queryByTestId('link-preview')).toBeNull();
});
it('не показывает карточку, если карточка пустая', async () => {
const { client } = renderCard(url, {
response: () => json({ status: 'ok', preview: { url: preview.url } }),
});
await waitForPreview(client);
expect(screen.queryByTestId('link-preview')).toBeNull();
});
it('молчит на ошибку ручки и не повторяет запрос', async () => {
const { fetchMock, client } = renderCard(url, {
response: () =>
json(
{ error: { code: 'rate_limited', message: 'слишком часто', retry_after_ms: 1000 } },
429,
),
});
await waitForPreview(client, 'error');
expect(screen.queryByTestId('link-preview')).toBeNull();
expect(
recordedRequests(fetchMock).filter((request) => request.url.includes('link-previews')),
).toHaveLength(1);
});
it('без ссылки не рисует карточку и ничего не запрашивает', () => {
const { fetchMock } = renderCard(null);
expect(screen.queryByTestId('link-preview')).toBeNull();
expect(recordedRequests(fetchMock)).toHaveLength(0);
});
it('не запрашивает превью, если unfurl выключен на инстансе', async () => {
const { fetchMock, client } = renderCard(url, { unfurlEnabled: false });
await waitFor(() => {
expect(client.getQueryState(metaQueryKey)?.status).toBe('success');
});
expect(
recordedRequests(fetchMock).filter((request) => request.url.includes('link-previews')),
).toHaveLength(0);
expect(screen.queryByTestId('link-preview')).toBeNull();
});
});
describe('карточка превью в ленте', () => {
const authors: AuthorDirectory = {
resolve: () => ({
id: 'user-2',
name: 'Bob',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isOwner: false,
isMember: true,
}),
resolveWebhook: () => ({
id: '',
name: 'вебхук',
avatarFileId: undefined,
colorHex: null,
isInstanceAdmin: false,
isOwner: false,
isMember: false,
}),
resolveMention: (userId) => ({ name: userId, isMe: false }),
loaded: true,
};
function renderList(messages: Message[]) {
const fetchMock = installFetch(cardRoutes());
const client = createTestQueryClient();
const view = render(
undefined}
onLoadMore={() => undefined}
onReply={() => undefined}
/>
,
);
return { ...view, fetchMock, client };
}
it('рисуется под обычным сообщением и не рисуется под системным', async () => {
renderList([
makeMessage({
id: 'm-1',
channel_id: 'c-1',
author_id: 'user-2',
content: 'смотри https://example.com/page',
}),
makeMessage({
id: 'm-2',
channel_id: 'c-1',
type: 'system',
content: 'https://example.com/page',
}),
]);
const message = await screen.findByTestId('message-m-1');
const link = await within(message).findByTestId('link-preview');
expect(link).toHaveAttribute('href', 'https://example.com/canonical');
// Системное сообщение карточку не получает, даже если в тексте есть ссылка.
const system = screen.getByTestId('message-m-2');
expect(within(system).queryByTestId('link-preview')).toBeNull();
});
it('не показывает карточку и не запрашивает превью у сообщения без ссылки', async () => {
const { fetchMock } = renderList([
makeMessage({ id: 'm-3', channel_id: 'c-1', author_id: 'user-2', content: 'просто текст' }),
]);
const message = await screen.findByTestId('message-m-3');
expect(within(message).queryByTestId('link-preview')).toBeNull();
// Ссылки нет — карточка не монтируется, запросов нет вовсе.
expect(recordedRequests(fetchMock)).toHaveLength(0);
});
});