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); }); });