test(7.6): add tests for comm block components (BlockRenderer + all block types)

This commit is contained in:
Agent Zero
2026-07-24 01:28:06 +02:00
parent da9cd7a5a2
commit 43c4b623c2
@@ -0,0 +1,614 @@
/**
* Tests for Comm Block Components — Phase 7 Task 7.6
*
* Tests BlockRenderer and all block types:
* text, markdown, html, image, audio, video, file, action_card, contact_card, miniapp
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import BlockRenderer from '@/components/comm/blocks/BlockRenderer';
import MarkdownBlock from '@/components/comm/blocks/MarkdownBlock';
import HtmlBlock from '@/components/comm/blocks/HtmlBlock';
import ImageBlock from '@/components/comm/blocks/ImageBlock';
import AudioBlock from '@/components/comm/blocks/AudioBlock';
import VideoBlock from '@/components/comm/blocks/VideoBlock';
import FileBlock from '@/components/comm/blocks/FileBlock';
import ActionCardBlock from '@/components/comm/blocks/ActionCardBlock';
import ContactCardBlock from '@/components/comm/blocks/ContactCardBlock';
import MiniAppBlock from '@/components/comm/blocks/MiniAppBlock';
import type { MessageBlock } from '@/store/commStore';
// ─── Mock Data Helpers ───
function makeBlock(
id: string,
block_type: string,
block_data: Record<string, any>,
sort_order: number = 0,
): MessageBlock {
return { id, block_type, block_data, sort_order };
}
// ─── BlockRenderer Tests ───
describe('BlockRenderer', () => {
it('renders null when blocks array is empty', () => {
const { container } = render(<BlockRenderer blocks={[]} />);
expect(container.firstChild).toBeNull();
});
it('renders null when blocks is undefined', () => {
const { container } = render(<BlockRenderer blocks={undefined as any} />);
expect(container.firstChild).toBeNull();
});
it('renders a text block with content', () => {
const blocks = [makeBlock('b1', 'text', { text: 'Hello World' })];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText('Hello World')).toBeInTheDocument();
});
it('renders text block using content fallback when text is missing', () => {
const blocks = [makeBlock('b1', 'text', { content: 'Fallback content' })];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText('Fallback content')).toBeInTheDocument();
});
it('renders text block as empty string when no text or content', () => {
const blocks = [makeBlock('b1', 'text', {})];
const { container } = render(<BlockRenderer blocks={blocks} />);
const textDiv = container.querySelector('.text-sm');
expect(textDiv).toBeInTheDocument();
expect(textDiv?.textContent).toBe('');
});
it('renders multiple blocks in sort_order sequence', () => {
const blocks = [
makeBlock('b2', 'text', { text: 'Second' }, 2),
makeBlock('b1', 'text', { text: 'First' }, 1),
makeBlock('b3', 'text', { text: 'Third' }, 3),
];
const { container } = render(<BlockRenderer blocks={blocks} />);
const wrappers = container.querySelectorAll('.block-wrapper');
expect(wrappers).toHaveLength(3);
expect(wrappers[0].textContent).toBe('First');
expect(wrappers[1].textContent).toBe('Second');
expect(wrappers[2].textContent).toBe('Third');
});
it('renders unknown block type with fallback message', () => {
const blocks = [makeBlock('b1', 'unknown_type', {})];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText(/Unbekannter Block-Typ: unknown_type/)).toBeInTheDocument();
});
it('renders markdown block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'markdown', { markdown: '# Heading' })];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText('Heading')).toBeInTheDocument();
});
it('renders html block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'html', { html: '<p>HTML content</p>' })];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText('HTML content')).toBeInTheDocument();
});
it('renders image block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'image', { url: 'https://example.com/img.png', alt: 'Test Image' })];
render(<BlockRenderer blocks={blocks} />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'https://example.com/img.png');
expect(img).toHaveAttribute('alt', 'Test Image');
});
it('renders audio block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'audio', { url: 'https://example.com/audio.mp3' })];
const { container } = render(<BlockRenderer blocks={blocks} />);
const audio = container.querySelector('audio');
expect(audio).toBeInTheDocument();
expect(audio).toHaveAttribute('src', 'https://example.com/audio.mp3');
});
it('renders video block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'video', { url: 'https://example.com/video.mp4' })];
const { container } = render(<BlockRenderer blocks={blocks} />);
const video = container.querySelector('video');
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute('src', 'https://example.com/video.mp4');
});
it('renders file block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'file', { url: 'https://example.com/doc.pdf', name: 'doc.pdf' })];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
it('renders action_card block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'action_card', { title: 'Action Title', body: 'Action Body' })];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText('Action Title')).toBeInTheDocument();
expect(screen.getByText('Action Body')).toBeInTheDocument();
});
it('renders contact_card block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'contact_card', { contact_id: '123', name: 'John Doe' })];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
it('renders miniapp block via BlockRenderer', () => {
const blocks = [makeBlock('b1', 'miniapp', { app_id: 'my-app' })];
render(<BlockRenderer blocks={blocks} />);
expect(screen.getByText(/Mini-App: my-app/)).toBeInTheDocument();
});
});
// ─── MarkdownBlock Tests ───
describe('MarkdownBlock', () => {
it('renders null when markdown content is empty', () => {
const block = makeBlock('b1', 'markdown', {});
const { container } = render(<MarkdownBlock block={block} />);
expect(container.firstChild).toBeNull();
});
it('renders heading from markdown', () => {
const block = makeBlock('b1', 'markdown', { markdown: '# My Heading' });
render(<MarkdownBlock block={block} />);
expect(screen.getByText('My Heading')).toBeInTheDocument();
});
it('renders paragraph from markdown', () => {
const block = makeBlock('b1', 'markdown', { markdown: 'This is a paragraph.' });
render(<MarkdownBlock block={block} />);
expect(screen.getByText('This is a paragraph.')).toBeInTheDocument();
});
it('renders unordered list from markdown', () => {
const block = makeBlock('b1', 'markdown', { markdown: '- Item 1\n- Item 2' });
render(<MarkdownBlock block={block} />);
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
});
it('renders ordered list from markdown', () => {
const block = makeBlock('b1', 'markdown', { markdown: '1. First\n2. Second' });
render(<MarkdownBlock block={block} />);
expect(screen.getByText('First')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
});
it('renders link from markdown', () => {
const block = makeBlock('b1', 'markdown', { markdown: '[Click here](https://example.com)' });
render(<MarkdownBlock block={block} />);
const link = screen.getByText('Click here');
expect(link.closest('a')).toHaveAttribute('href', 'https://example.com');
});
it('renders blockquote from markdown', () => {
const block = makeBlock('b1', 'markdown', { markdown: '> This is a quote' });
render(<MarkdownBlock block={block} />);
expect(screen.getByText('This is a quote')).toBeInTheDocument();
});
it('uses content fallback when markdown field is missing', () => {
const block = makeBlock('b1', 'markdown', { content: 'Fallback markdown' });
render(<MarkdownBlock block={block} />);
expect(screen.getByText('Fallback markdown')).toBeInTheDocument();
});
});
// ─── HtmlBlock Tests ───
describe('HtmlBlock', () => {
it('renders null when html content is empty', () => {
const block = makeBlock('b1', 'html', {});
const { container } = render(<HtmlBlock block={block} />);
expect(container.firstChild).toBeNull();
});
it('renders basic HTML content', () => {
const block = makeBlock('b1', 'html', { html: '<p>Simple HTML</p>' });
render(<HtmlBlock block={block} />);
expect(screen.getByText('Simple HTML')).toBeInTheDocument();
});
it('strips script tags from HTML', () => {
const block = makeBlock('b1', 'html', {
html: '<p>Safe content</p><script>alert("xss")</script>',
});
const { container } = render(<HtmlBlock block={block} />);
expect(screen.getByText('Safe content')).toBeInTheDocument();
expect(container.querySelector('script')).toBeNull();
});
it('strips onclick event handlers', () => {
const block = makeBlock('b1', 'html', {
html: '<p onclick="alert(1)">Clickable</p>',
});
const { container } = render(<HtmlBlock block={block} />);
const p = container.querySelector('p');
expect(p).not.toHaveAttribute('onclick');
});
it('replaces javascript: URLs in href', () => {
const block = makeBlock('b1', 'html', {
html: '<a href="javascript:alert(1)">Bad Link</a>',
});
const { container } = render(<HtmlBlock block={block} />);
const link = container.querySelector('a');
expect(link?.getAttribute('href')).not.toContain('javascript:');
});
it('renders div with dangerouslySetInnerHTML', () => {
const block = makeBlock('b1', 'html', { html: '<span data-test="yes">Content</span>' });
const { container } = render(<HtmlBlock block={block} />);
expect(container.querySelector('[data-test="yes"]')).toBeInTheDocument();
});
});
// ─── ImageBlock Tests ───
describe('ImageBlock', () => {
it('renders null when url is missing', () => {
const block = makeBlock('b1', 'image', {});
const { container } = render(<ImageBlock block={block} />);
expect(container.firstChild).toBeNull();
});
it('renders image with url and alt text', () => {
const block = makeBlock('b1', 'image', { url: 'https://example.com/img.png', alt: 'Description' });
render(<ImageBlock block={block} />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'https://example.com/img.png');
expect(img).toHaveAttribute('alt', 'Description');
});
it('renders image with empty alt when not provided', () => {
const block = makeBlock('b1', 'image', { url: 'https://example.com/img.png' });
const { container } = render(<ImageBlock block={block} />);
const img = container.querySelector('img');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('alt', '');
});
it('renders alt caption below image when alt is provided', () => {
const block = makeBlock('b1', 'image', { url: 'https://example.com/img.png', alt: 'Caption text' });
render(<ImageBlock block={block} />);
expect(screen.getByText('Caption text')).toBeInTheDocument();
});
it('does not render caption when alt is empty', () => {
const block = makeBlock('b1', 'image', { url: 'https://example.com/img.png', alt: '' });
const { container } = render(<ImageBlock block={block} />);
const captions = container.querySelectorAll('.text-xs.text-secondary-500.italic');
expect(captions).toHaveLength(0);
});
it('renders image inside a link that opens in new tab', () => {
const block = makeBlock('b1', 'image', { url: 'https://example.com/img.png' });
const { container } = render(<ImageBlock block={block} />);
const link = container.querySelector('a');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('applies width attribute when provided as number', () => {
const block = makeBlock('b1', 'image', { url: 'https://example.com/img.png', width: 300 });
const { container } = render(<ImageBlock block={block} />);
const img = container.querySelector('img');
expect(img).toHaveAttribute('width', '300');
});
});
// ─── AudioBlock Tests ───
describe('AudioBlock', () => {
it('renders null when url is missing', () => {
const block = makeBlock('b1', 'audio', {});
const { container } = render(<AudioBlock block={block} />);
expect(container.firstChild).toBeNull();
});
it('renders audio element with url', () => {
const block = makeBlock('b1', 'audio', { url: 'https://example.com/audio.mp3' });
const { container } = render(<AudioBlock block={block} />);
const audio = container.querySelector('audio');
expect(audio).toBeInTheDocument();
expect(audio).toHaveAttribute('src', 'https://example.com/audio.mp3');
expect(audio).toHaveAttribute('controls');
});
it('shows duration label when duration is provided', () => {
const block = makeBlock('b1', 'audio', { url: 'https://example.com/audio.mp3', duration: 125 });
render(<AudioBlock block={block} />);
expect(screen.getByText(/Dauer: 2:05/)).toBeInTheDocument();
});
it('does not show duration label when duration is 0 or invalid', () => {
const block = makeBlock('b1', 'audio', { url: 'https://example.com/audio.mp3', duration: 0 });
const { container } = render(<AudioBlock block={block} />);
expect(container.querySelector('.text-xs.text-secondary-500')).toBeNull();
});
it('formats duration correctly for single-digit seconds', () => {
const block = makeBlock('b1', 'audio', { url: 'https://example.com/audio.mp3', duration: 65 });
render(<AudioBlock block={block} />);
expect(screen.getByText(/Dauer: 1:05/)).toBeInTheDocument();
});
});
// ─── VideoBlock Tests ───
describe('VideoBlock', () => {
it('renders null when url is missing', () => {
const block = makeBlock('b1', 'video', {});
const { container } = render(<VideoBlock block={block} />);
expect(container.firstChild).toBeNull();
});
it('renders video element with url and controls', () => {
const block = makeBlock('b1', 'video', { url: 'https://example.com/video.mp4' });
const { container } = render(<VideoBlock block={block} />);
const video = container.querySelector('video');
expect(video).toBeInTheDocument();
expect(video).toHaveAttribute('src', 'https://example.com/video.mp4');
expect(video).toHaveAttribute('controls');
});
it('sets poster attribute when thumbnail is provided', () => {
const block = makeBlock('b1', 'video', { url: 'https://example.com/video.mp4', thumbnail: 'https://example.com/thumb.jpg' });
const { container } = render(<VideoBlock block={block} />);
const video = container.querySelector('video');
expect(video).toHaveAttribute('poster', 'https://example.com/thumb.jpg');
});
it('does not set poster when thumbnail is not a string', () => {
const block = makeBlock('b1', 'video', { url: 'https://example.com/video.mp4', thumbnail: 123 });
const { container } = render(<VideoBlock block={block} />);
const video = container.querySelector('video');
expect(video).not.toHaveAttribute('poster');
});
});
// ─── FileBlock Tests ───
describe('FileBlock', () => {
it('renders null when both url and name are missing', () => {
const block = makeBlock('b1', 'file', {});
const { container } = render(<FileBlock block={block} />);
expect(container.firstChild).toBeNull();
});
it('renders file name when provided', () => {
const block = makeBlock('b1', 'file', { url: 'https://example.com/doc.pdf', name: 'document.pdf' });
render(<FileBlock block={block} />);
expect(screen.getByText('document.pdf')).toBeInTheDocument();
});
it('uses default name "Datei" when name is not provided', () => {
const block = makeBlock('b1', 'file', { url: 'https://example.com/doc.pdf' });
render(<FileBlock block={block} />);
expect(screen.getByText('Datei')).toBeInTheDocument();
});
it('shows file size when size is provided', () => {
const block = makeBlock('b1', 'file', { url: 'https://example.com/doc.pdf', name: 'doc.pdf', size: 1048576 });
render(<FileBlock block={block} />);
expect(screen.getByText('1.0 MB')).toBeInTheDocument();
});
it('formats file size in KB', () => {
const block = makeBlock('b1', 'file', { url: 'https://example.com/doc.pdf', name: 'doc.pdf', size: 5120 });
render(<FileBlock block={block} />);
expect(screen.getByText('5.0 KB')).toBeInTheDocument();
});
it('formats file size in bytes', () => {
const block = makeBlock('b1', 'file', { url: 'https://example.com/doc.pdf', name: 'doc.pdf', size: 512 });
render(<FileBlock block={block} />);
expect(screen.getByText('512 B')).toBeInTheDocument();
});
it('renders download link with aria-label', () => {
const block = makeBlock('b1', 'file', { url: 'https://example.com/doc.pdf', name: 'doc.pdf' });
render(<FileBlock block={block} />);
const downloadLink = screen.getByLabelText('Datei herunterladen: doc.pdf');
expect(downloadLink).toBeInTheDocument();
expect(downloadLink).toHaveAttribute('href', 'https://example.com/doc.pdf');
});
it('does not render download link when url is missing', () => {
const block = makeBlock('b1', 'file', { name: 'doc.pdf' });
render(<FileBlock block={block} />);
expect(screen.queryByLabelText(/Datei herunterladen/)).not.toBeInTheDocument();
});
});
// ─── ActionCardBlock Tests ───
describe('ActionCardBlock', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('renders title and body', () => {
const block = makeBlock('b1', 'action_card', { title: 'Card Title', body: 'Card Body Text' });
render(<ActionCardBlock block={block} />);
expect(screen.getByText('Card Title')).toBeInTheDocument();
expect(screen.getByText('Card Body Text')).toBeInTheDocument();
});
it('renders without title when not provided', () => {
const block = makeBlock('b1', 'action_card', { body: 'Only body' });
render(<ActionCardBlock block={block} />);
expect(screen.getByText('Only body')).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('renders action buttons', () => {
const block = makeBlock('b1', 'action_card', {
title: 'Title',
body: 'Body',
actions: [
{ label: 'Confirm', action: 'https://example.com', type: 'primary' },
{ label: 'Cancel', action: 'dismiss', type: 'secondary' },
],
});
render(<ActionCardBlock block={block} />);
expect(screen.getByText('Confirm')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
it('opens URL in new tab when action button is clicked', () => {
const mockOpen = vi.spyOn(window, 'open').mockImplementation(() => null);
const block = makeBlock('b1', 'action_card', {
title: 'Title',
body: 'Body',
actions: [{ label: 'Go', action: 'https://example.com', type: 'primary' }],
});
render(<ActionCardBlock block={block} />);
fireEvent.click(screen.getByText('Go'));
expect(mockOpen).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer');
});
it('does not open URL when action is dismiss', () => {
const mockOpen = vi.spyOn(window, 'open').mockImplementation(() => null);
const block = makeBlock('b1', 'action_card', {
title: 'Title',
body: 'Body',
actions: [{ label: 'Dismiss', action: 'dismiss', type: 'secondary' }],
});
render(<ActionCardBlock block={block} />);
fireEvent.click(screen.getByText('Dismiss'));
expect(mockOpen).not.toHaveBeenCalled();
});
it('applies primary styling to primary action buttons', () => {
const block = makeBlock('b1', 'action_card', {
title: 'Title',
actions: [{ label: 'Primary', action: 'https://example.com', type: 'primary' }],
});
render(<ActionCardBlock block={block} />);
const btn = screen.getByText('Primary');
expect(btn.className).toContain('bg-primary-600');
});
it('applies secondary styling to secondary action buttons', () => {
const block = makeBlock('b1', 'action_card', {
title: 'Title',
actions: [{ label: 'Secondary', action: 'dismiss', type: 'secondary' }],
});
render(<ActionCardBlock block={block} />);
const btn = screen.getByText('Secondary');
expect(btn.className).toContain('bg-secondary-100');
});
it('uses default label "Aktion" when action label is empty', () => {
const block = makeBlock('b1', 'action_card', {
title: 'Title',
actions: [{ label: '', action: 'dismiss' }],
});
render(<ActionCardBlock block={block} />);
expect(screen.getByText('Aktion')).toBeInTheDocument();
});
it('renders no action buttons when actions array is empty', () => {
const block = makeBlock('b1', 'action_card', { title: 'Title', body: 'Body', actions: [] });
render(<ActionCardBlock block={block} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
// ─── ContactCardBlock Tests ───
describe('ContactCardBlock', () => {
it('renders contact name', () => {
const block = makeBlock('b1', 'contact_card', { contact_id: '123', name: 'Jane Smith' });
render(<ContactCardBlock block={block} />);
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
it('uses default name when name is not provided', () => {
const block = makeBlock('b1', 'contact_card', { contact_id: '123' });
render(<ContactCardBlock block={block} />);
expect(screen.getByText('Unbekannter Kontakt')).toBeInTheDocument();
});
it('renders link to contact page with contact_id', () => {
const block = makeBlock('b1', 'contact_card', { contact_id: 'abc-456', name: 'Test User' });
render(<ContactCardBlock block={block} />);
const link = screen.getByText('Test User').closest('a');
expect(link).toHaveAttribute('href', '/contacts/abc-456');
});
it('renders link with href="#" when contact_id is missing', () => {
const block = makeBlock('b1', 'contact_card', { name: 'No ID User' });
render(<ContactCardBlock block={block} />);
const link = screen.getByText('No ID User').closest('a');
expect(link).toHaveAttribute('href', '#');
});
it('generates initials from name', () => {
const block = makeBlock('b1', 'contact_card', { contact_id: '1', name: 'John Doe' });
render(<ContactCardBlock block={block} />);
expect(screen.getByText('JD')).toBeInTheDocument();
});
it('generates initials from single name', () => {
const block = makeBlock('b1', 'contact_card', { contact_id: '1', name: 'Max' });
render(<ContactCardBlock block={block} />);
expect(screen.getByText('M')).toBeInTheDocument();
});
it('shows "Kontakt anzeigen" subtitle', () => {
const block = makeBlock('b1', 'contact_card', { contact_id: '1', name: 'Test' });
render(<ContactCardBlock block={block} />);
expect(screen.getByText('Kontakt anzeigen')).toBeInTheDocument();
});
});
// ─── MiniAppBlock Tests ───
describe('MiniAppBlock', () => {
it('renders app_id in label', () => {
const block = makeBlock('b1', 'miniapp', { app_id: 'my-mini-app' });
render(<MiniAppBlock block={block} />);
expect(screen.getByText(/Mini-App: my-mini-app/)).toBeInTheDocument();
});
it('uses default app_id when not provided', () => {
const block = makeBlock('b1', 'miniapp', {});
render(<MiniAppBlock block={block} />);
expect(screen.getByText(/Mini-App: Unbekannt/)).toBeInTheDocument();
});
it('renders info message about mini-apps', () => {
const block = makeBlock('b1', 'miniapp', { app_id: 'test' });
render(<MiniAppBlock block={block} />);
expect(screen.getByText(/Mini-Apps werden in Zukunft/)).toBeInTheDocument();
});
it('shows config details when config is provided', () => {
const block = makeBlock('b1', 'miniapp', {
app_id: 'test',
config: { key: 'value', nested: { data: 123 } },
});
render(<MiniAppBlock block={block} />);
expect(screen.getByText('Konfiguration')).toBeInTheDocument();
});
it('does not show config details when config is empty', () => {
const block = makeBlock('b1', 'miniapp', { app_id: 'test', config: {} });
render(<MiniAppBlock block={block} />);
expect(screen.queryByText('Konfiguration')).not.toBeInTheDocument();
});
it('does not show config details when config is not an object', () => {
const block = makeBlock('b1', 'miniapp', { app_id: 'test', config: 'not-an-object' });
render(<MiniAppBlock block={block} />);
expect(screen.queryByText('Konfiguration')).not.toBeInTheDocument();
});
});