diff --git a/frontend/src/__tests__/comm/BlockRenderer.test.tsx b/frontend/src/__tests__/comm/BlockRenderer.test.tsx new file mode 100644 index 0000000..b5c80cd --- /dev/null +++ b/frontend/src/__tests__/comm/BlockRenderer.test.tsx @@ -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, + 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(); + expect(container.firstChild).toBeNull(); + }); + + it('renders null when blocks is undefined', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders a text block with content', () => { + const blocks = [makeBlock('b1', 'text', { text: 'Hello World' })]; + render(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText(/Unbekannter Block-Typ: unknown_type/)).toBeInTheDocument(); + }); + + it('renders markdown block via BlockRenderer', () => { + const blocks = [makeBlock('b1', 'markdown', { markdown: '# Heading' })]; + render(); + expect(screen.getByText('Heading')).toBeInTheDocument(); + }); + + it('renders html block via BlockRenderer', () => { + const blocks = [makeBlock('b1', 'html', { html: '

HTML content

' })]; + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + + it('renders miniapp block via BlockRenderer', () => { + const blocks = [makeBlock('b1', 'miniapp', { app_id: 'my-app' })]; + render(); + 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(); + expect(container.firstChild).toBeNull(); + }); + + it('renders heading from markdown', () => { + const block = makeBlock('b1', 'markdown', { markdown: '# My Heading' }); + render(); + expect(screen.getByText('My Heading')).toBeInTheDocument(); + }); + + it('renders paragraph from markdown', () => { + const block = makeBlock('b1', 'markdown', { markdown: 'This is a paragraph.' }); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(container.firstChild).toBeNull(); + }); + + it('renders basic HTML content', () => { + const block = makeBlock('b1', 'html', { html: '

Simple HTML

' }); + render(); + expect(screen.getByText('Simple HTML')).toBeInTheDocument(); + }); + + it('strips script tags from HTML', () => { + const block = makeBlock('b1', 'html', { + html: '

Safe content

', + }); + const { container } = render(); + expect(screen.getByText('Safe content')).toBeInTheDocument(); + expect(container.querySelector('script')).toBeNull(); + }); + + it('strips onclick event handlers', () => { + const block = makeBlock('b1', 'html', { + html: '

Clickable

', + }); + const { container } = render(); + const p = container.querySelector('p'); + expect(p).not.toHaveAttribute('onclick'); + }); + + it('replaces javascript: URLs in href', () => { + const block = makeBlock('b1', 'html', { + html: 'Bad Link', + }); + const { container } = render(); + const link = container.querySelector('a'); + expect(link?.getAttribute('href')).not.toContain('javascript:'); + }); + + it('renders div with dangerouslySetInnerHTML', () => { + const block = makeBlock('b1', 'html', { html: 'Content' }); + const { container } = render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(container.firstChild).toBeNull(); + }); + + it('renders audio element with url', () => { + const block = makeBlock('b1', 'audio', { url: 'https://example.com/audio.mp3' }); + const { container } = render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('JD')).toBeInTheDocument(); + }); + + it('generates initials from single name', () => { + const block = makeBlock('b1', 'contact_card', { contact_id: '1', name: 'Max' }); + render(); + expect(screen.getByText('M')).toBeInTheDocument(); + }); + + it('shows "Kontakt anzeigen" subtitle', () => { + const block = makeBlock('b1', 'contact_card', { contact_id: '1', name: 'Test' }); + render(); + 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(); + expect(screen.getByText(/Mini-App: my-mini-app/)).toBeInTheDocument(); + }); + + it('uses default app_id when not provided', () => { + const block = makeBlock('b1', 'miniapp', {}); + render(); + expect(screen.getByText(/Mini-App: Unbekannt/)).toBeInTheDocument(); + }); + + it('renders info message about mini-apps', () => { + const block = makeBlock('b1', 'miniapp', { app_id: 'test' }); + render(); + 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(); + 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(); + 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(); + expect(screen.queryByText('Konfiguration')).not.toBeInTheDocument(); + }); +});