Files
glchat/web/tests/links.test.ts
T
grendervill 45a5eaae64 feat(web): карточка превью ссылки в ленте (Фаза 7)
Под текстом сообщения показывается карточка: имя сайта, заголовок, описание и
картинка — всё с сервера (`GET /link-previews`), клиент сам в интернет не ходит.

- `web/src/lib/links.ts`: первая ссылка сообщения (markdown и «голая» формы),
  блоки кода и инлайновый код игнорируются — `curl https://…` из примера не
  должен тянуть чужую страницу;
- `web/src/components/chat/LinkPreviewCard.tsx`: запросов нет, пока
  `meta.features.unfurl_enabled` не подтверждён; карточка молчит на
  loading/ошибке/статусах empty, blocked и error (превью — украшение, а не
  действие); картинка рисуется только по http(s) с
  `referrerpolicy=no-referrer`, текст выводится обычными строками без
  `dangerouslySetInnerHTML`; компонент монтируется только при найденной ссылке,
  поэтому сообщение без ссылок не создаёт ни запроса, ни узла;
- `web/src/api/unfurl.ts`: типизированный клиент и ключ запроса.

Тесты: `web/tests/links.test.ts` (разбор ссылок) и `web/tests/linkPreview.test.tsx`
(карточка, отказ при выключенном unfurl, опасные схемы картинки, ровно один
запрос при 429).
2026-09-26 16:15:54 +03:00

80 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import { firstLink } from '@/lib/links';
/**
* Первая ссылка сообщения: markdown-разметка, «голые» адреса и всё, что
* превьюшить нельзя, — код, чужие схемы, хвостовая пунктуация.
*/
describe('firstLink', () => {
it('находит адрес в markdown-ссылке', () => {
expect(firstLink('смотри [документацию](https://example.com/docs) там')).toBe(
'https://example.com/docs',
);
});
it('находит «голую» ссылку', () => {
expect(firstLink('новость тут https://example.com/news и всё')).toBe(
'https://example.com/news',
);
expect(firstLink('http://example.com')).toBe('http://example.com');
});
it('возвращает первую ссылку и не смотрит на следующие', () => {
expect(firstLink('https://one.example и https://two.example')).toBe('https://one.example');
expect(firstLink('первая строка\nhttps://second.example')).toBe('https://second.example');
});
it('в `[https://a](https://b)` берёт адрес разметки, а не текст ссылки', () => {
expect(firstLink('[https://текст.example](https://цель.example)')).toBe('https://цель.example');
});
it('пропускает блок кода', () => {
expect(firstLink('```\nhttps://code.example\n```')).toBeNull();
expect(firstLink('```bash\ncurl https://code.example\n```\nа вот https://real.example')).toBe(
'https://real.example',
);
});
it('пропускает инлайновый код', () => {
expect(firstLink('`https://code.example`')).toBeNull();
expect(firstLink('в примере `https://code.example`, а живьём https://real.example')).toBe(
'https://real.example',
);
});
it('находит ссылку в цитате', () => {
expect(firstLink('> https://quoted.example')).toBe('https://quoted.example');
});
it('обрезает хвостовую пунктуацию', () => {
expect(firstLink('ссылка https://example.com/page.')).toBe('https://example.com/page');
expect(firstLink('ссылка https://example.com/page, и ещё')).toBe('https://example.com/page');
expect(firstLink('«https://example.com/page»')).toBe('https://example.com/page');
expect(firstLink('что это: https://example.com/page?!')).toBe('https://example.com/page');
});
it('обрезает markdown-обёртки', () => {
expect(firstLink('**https://example.com/bold**')).toBe('https://example.com/bold');
expect(firstLink('~~https://example.com/strike~~')).toBe('https://example.com/strike');
expect(firstLink('_https://example.com/italic_')).toBe('https://example.com/italic');
});
it('не считает ссылкой адрес без хоста', () => {
expect(firstLink('просто https://')).toBeNull();
expect(firstLink('просто https://.')).toBeNull();
});
it('игнорирует другие схемы', () => {
expect(firstLink('ftp://example.com/file.txt')).toBeNull();
expect(firstLink('javascript:alert(1)')).toBeNull();
expect(firstLink('mailto:user@example.com')).toBeNull();
expect(firstLink('[клик](javascript:alert(1))')).toBeNull();
});
it('возвращает null, если ссылки нет', () => {
expect(firstLink('обычный текст без адресов')).toBeNull();
expect(firstLink('')).toBeNull();
});
});