285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
|
|
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(
|
||
|
|
<QueryClientProvider client={client}>
|
||
|
|
<LinkPreviewCard url={targetUrl} />
|
||
|
|
</QueryClientProvider>,
|
||
|
|
);
|
||
|
|
return { ...view, fetchMock, client };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Ждём, пока запрос превью завершится с нужным исходом. */
|
||
|
|
async function waitForPreview(
|
||
|
|
client: QueryClient,
|
||
|
|
status: 'success' | 'error' = 'success',
|
||
|
|
target = url,
|
||
|
|
): Promise<void> {
|
||
|
|
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(
|
||
|
|
<QueryClientProvider client={client}>
|
||
|
|
<MessageList
|
||
|
|
channelId="c-1"
|
||
|
|
channelName="общий"
|
||
|
|
messages={messages}
|
||
|
|
status="ready"
|
||
|
|
error={null}
|
||
|
|
hasMore={false}
|
||
|
|
loadingMore={false}
|
||
|
|
unreadAnchorId={null}
|
||
|
|
highlightId={null}
|
||
|
|
authors={authors}
|
||
|
|
currentUserId="user-1"
|
||
|
|
canManageMessages
|
||
|
|
canSend
|
||
|
|
onReload={() => undefined}
|
||
|
|
onLoadMore={() => undefined}
|
||
|
|
onReply={() => undefined}
|
||
|
|
/>
|
||
|
|
</QueryClientProvider>,
|
||
|
|
);
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|