/** * Tests for TopicView component. */ import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { axe } from 'vitest-axe' import { TopicView } from './topic-view' import { mockTopics, mockUsers, mockAuthorDeletedTopic, mockModDeletedTopic } from '@/mocks/data' import { createMockOnboardingContext } from '@/test/mock-onboarding' vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: vi.fn() }), })) vi.mock('@/context/onboarding-context', () => ({ useOnboardingContext: () => createMockOnboardingContext(), })) vi.mock('@/hooks/use-auth', () => ({ useAuth: () => ({ user: { did: 'did:plc:user-test-001', handle: 'test.bsky.social' }, isAuthenticated: true, isLoading: false, getAccessToken: () => 'mock-access-token', authFetch: vi.fn(), login: vi.fn(), logout: vi.fn(), setSessionFromCallback: vi.fn(), crossPostScopesGranted: false, requestCrossPostAuth: vi.fn(), }), })) vi.mock('@/lib/api/client', () => ({ getReactions: vi.fn().mockResolvedValue({ reactions: [], cursor: null }), createReaction: vi.fn().mockResolvedValue({ uri: 'at://test', cid: 'bafyrei-test' }), deleteReaction: vi.fn().mockResolvedValue(undefined), })) const topic = mockTopics[0]! const baseTopic = topic const editedTopic = { ...baseTopic, indexedAt: new Date(new Date(baseTopic.publishedAt).getTime() + 60_000).toISOString(), } const mockReactions = [ { type: 'like', count: 5, reacted: false }, { type: 'celebrate', count: 2, reacted: true }, ] describe('TopicView', () => { it('renders topic title as h2', () => { render() const heading = screen.getByRole('heading', { level: 2, name: topic.title }) expect(heading).toBeInTheDocument() }) it('renders topic content via markdown', () => { render() expect(screen.getByText(topic.content)).toBeInTheDocument() }) it('renders author display name with link', () => { render() expect(screen.getByText('Jay')).toBeInTheDocument() const authorLink = screen.getByRole('link', { name: /Jay/ }) expect(authorLink).toHaveAttribute('href', `/profile/${mockUsers[0]!.handle}`) }) it('falls back to DID when author profile is missing', () => { const topicWithoutAuthor = { ...topic, author: undefined } render() expect(screen.getByText(mockUsers[0]!.did)).toBeInTheDocument() }) it('renders category link', () => { render() const link = screen.getByRole('link', { name: topic.category }) expect(link).toHaveAttribute('href', `/c/${topic.category}`) }) it('renders tags', () => { render() for (const tag of topic.tags ?? []) { expect(screen.getByText(`#${tag}`)).toBeInTheDocument() } }) it('renders reply count as static text when onReply is not provided', () => { render() expect(screen.getByLabelText(`${topic.replyCount} replies`)).toBeInTheDocument() expect(screen.getByLabelText(`${topic.replyCount} replies`).tagName).toBe('SPAN') }) it('renders reply count as clickable button when onReply is provided', () => { render() const replyButton = screen.getByRole('button', { name: /reply to this topic/i }) expect(replyButton).toBeInTheDocument() }) it('calls onReply when reply button is clicked', async () => { const user = userEvent.setup() const onReply = vi.fn() render() await user.click(screen.getByRole('button', { name: /reply to this topic/i })) expect(onReply).toHaveBeenCalledTimes(1) }) it('renders reaction count', () => { render() expect(screen.getByLabelText(`${topic.reactionCount} reactions`)).toBeInTheDocument() }) it('uses article element with aria-labelledby', () => { const { container } = render() const article = container.querySelector('article') expect(article).toBeInTheDocument() expect(article).toHaveAttribute('aria-labelledby') }) it('includes anchor link for post', () => { const { container } = render() const article = container.querySelector('article') expect(article).toHaveAttribute('id', 'post-1') }) it('passes axe accessibility check', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) it('renders reaction bar when reactions are provided', () => { render() expect(screen.getByRole('group', { name: 'Reactions' })).toBeInTheDocument() }) it('does not render reactions when not provided', () => { render() expect(screen.queryByRole('group', { name: 'Reactions' })).not.toBeInTheDocument() }) it('renders moderation controls for moderators', () => { render() expect(screen.getByRole('group', { name: /moderation/i })).toBeInTheDocument() }) it('renders report button when canReport is true', () => { render() expect(screen.getByRole('button', { name: /report/i })).toBeInTheDocument() }) it('renders self-label indicator when selfLabels are provided', () => { render() expect(screen.getByText(/content warning/i)).toBeInTheDocument() }) it('calls onReactionToggle when reaction is clicked', async () => { const user = userEvent.setup() const onToggle = vi.fn() render() await user.click(screen.getByRole('button', { name: /like/i })) expect(onToggle).toHaveBeenCalledWith('like') }) describe('edit button', () => { it('renders edit button when canEdit is true and onEdit is provided', () => { render() expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument() }) it('does not render edit button when canEdit is false', () => { render() expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() }) it('does not render edit button when canEdit is undefined', () => { render() expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() }) it('does not render edit button when onEdit is not provided', () => { render() expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument() }) it('calls onEdit callback when edit button is clicked', async () => { const user = userEvent.setup() const onEdit = vi.fn() render() await user.click(screen.getByRole('button', { name: /edit/i })) expect(onEdit).toHaveBeenCalledTimes(1) }) it('passes axe accessibility check with edit button visible', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) }) describe('edited indicator', () => { it('shows "(edited)" when indexedAt is more than 30s after publishedAt', () => { render() expect(screen.getByText('(edited)')).toBeInTheDocument() }) it('does not show "(edited)" when timestamps are close', () => { render() expect(screen.queryByText('(edited)')).not.toBeInTheDocument() }) }) describe('tombstone: author-deleted topics', () => { it('shows author-deleted placeholder text', () => { render() expect(screen.getByText('This topic was removed by the author.')).toBeInTheDocument() }) it('does not render topic content for author-deleted topics', () => { const { container } = render() expect(container.querySelector('.prose')).not.toBeInTheDocument() }) it('does not render the API placeholder title for author-deleted topics', () => { render() expect(screen.queryByText('[Deleted by author]')).not.toBeInTheDocument() }) it('shows [deleted] instead of author identity for author-deleted topics', () => { render() expect(screen.getByText('[deleted]')).toBeInTheDocument() }) it('does not render category or tags for author-deleted topics', () => { render() expect(screen.queryByText(mockAuthorDeletedTopic.category)).not.toBeInTheDocument() }) it('does not render reactions footer for author-deleted topics', () => { render( ) expect(screen.queryByRole('group', { name: 'Reactions' })).not.toBeInTheDocument() }) it('does not render report button for author-deleted topics', () => { render() expect(screen.queryByRole('button', { name: /report/i })).not.toBeInTheDocument() }) it('uses muted styling for author-deleted topics', () => { const { container } = render() const article = container.querySelector('article') expect(article?.className).toContain('bg-muted/50') }) it('passes axe accessibility check for author-deleted topics', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) }) describe('tombstone: moderator-deleted topics', () => { it('shows moderator-deleted placeholder text', () => { render() expect(screen.getByText('This topic was removed by a moderator.')).toBeInTheDocument() }) it('does not render the API placeholder title for mod-deleted topics', () => { render() expect(screen.queryByText('[Removed by moderator]')).not.toBeInTheDocument() }) it('shows [deleted] instead of author identity for mod-deleted topics', () => { render() expect(screen.getByText('[deleted]')).toBeInTheDocument() }) it('uses muted styling for mod-deleted topics', () => { const { container } = render() const article = container.querySelector('article') expect(article?.className).toContain('bg-muted/50') }) it('passes axe accessibility check for mod-deleted topics', async () => { const { container } = render() const results = await axe(container) expect(results).toHaveNoViolations() }) }) })