diff --git a/src/components/search/IntelligentAutoComplete.tsx b/src/components/search/IntelligentAutoComplete.tsx index 58c7f392..f5cb6463 100644 --- a/src/components/search/IntelligentAutoComplete.tsx +++ b/src/components/search/IntelligentAutoComplete.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Search, X, Clock, ChevronRight, Sparkles, User, Tag, Type } from 'lucide-react'; import { getSearchSuggestions, highlightMatch } from '../../utils/searchUtils'; @@ -17,7 +17,7 @@ export const IntelligentAutoComplete = React.memo( const [activeIndex, setActiveIndex] = useState(-1); const dropdownRef = useRef(null); const inputRef = useRef(null); - const suggestions = getSearchSuggestions(value); + const suggestions = useMemo(() => getSearchSuggestions(value), [value]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { diff --git a/src/components/shared/ImageUploader.tsx b/src/components/shared/ImageUploader.tsx index 04f971fd..4e013f60 100644 --- a/src/components/shared/ImageUploader.tsx +++ b/src/components/shared/ImageUploader.tsx @@ -16,6 +16,12 @@ function ImageUploader({ onImageSelect, initialImageUrl, className = '' }: Image const fileInputRef = useRef(null); const objectUrlRef = useRef(null); + useEffect(() => { + if (initialImageUrl) { + setPreviewUrl(initialImageUrl); + } + }, [initialImageUrl]); + useEffect(() => { return () => { if (objectUrlRef.current) { @@ -24,6 +30,72 @@ function ImageUploader({ onImageSelect, initialImageUrl, className = '' }: Image }; }, []); +<<<<<<< perforrmance-search-functionality-code-quality-improvement + const setObjectPreviewUrl = useCallback((objectUrl: string) => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + } + + objectUrlRef.current = objectUrl; + setPreviewUrl(objectUrl); + }, []); + + const handleFileChange = useCallback(async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.type.startsWith('video/')) { + try { + const video = document.createElement('video'); + const videoObjectUrl = URL.createObjectURL(file); + video.src = videoObjectUrl; + video.crossOrigin = 'anonymous'; + video.muted = true; + + await new Promise((resolve, reject) => { + video.onloadeddata = () => { + video.currentTime = Math.min(1, video.duration / 2); + }; + video.onseeked = () => resolve(); + video.onerror = () => reject(new Error('Failed to load video')); + }); + + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.toBlob( + (blob) => { + if (blob) { + const objectUrl = URL.createObjectURL(blob); + setObjectPreviewUrl(objectUrl); + const optimizedFile = new File([blob], file.name.replace(/\.[^/.]+$/, '.jpg'), { + type: 'image/jpeg', + }); + onImageSelect(optimizedFile); + } + }, + 'image/jpeg', + 0.85, + ); + } + URL.revokeObjectURL(videoObjectUrl); + } catch (error) { + console.error('Video optimization failed:', error); + } + } else if (file.type.startsWith('image/')) { + const objectUrl = URL.createObjectURL(file); + setObjectPreviewUrl(objectUrl); + onImageSelect(file); + } + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [onImageSelect, setObjectPreviewUrl]); +======= const handleFileChange = useCallback( (event: ChangeEvent) => { const file = event.target.files?.[0]; @@ -41,6 +113,7 @@ function ImageUploader({ onImageSelect, initialImageUrl, className = '' }: Image }, [onImageSelect], ); +>>>>>>> main const handleClick = useCallback(() => { fileInputRef.current?.click(); @@ -60,7 +133,7 @@ function ImageUploader({ onImageSelect, initialImageUrl, className = '' }: Image alt="Profile Preview" fill sizes="(max-width: 768px) 100vw, 33vw" - unoptimized={previewUrl.startsWith('blob:')} + unoptimized={previewUrl.startsWith('data:') || previewUrl.startsWith('blob:')} className="object-cover" /> ) : ( @@ -92,7 +165,7 @@ function ImageUploader({ onImageSelect, initialImageUrl, className = '' }: Image diff --git a/src/components/shared/__tests__/ImageUploader.test.tsx b/src/components/shared/__tests__/ImageUploader.test.tsx new file mode 100644 index 00000000..f5e47ee1 --- /dev/null +++ b/src/components/shared/__tests__/ImageUploader.test.tsx @@ -0,0 +1,95 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; +import ImageUploader from '../ImageUploader'; + +describe('ImageUploader', () => { + let originalCreateElement: typeof document.createElement; + + beforeAll(() => { + global.URL.createObjectURL = vi.fn(() => 'blob:mock-url'); + global.URL.revokeObjectURL = vi.fn(); + + originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation( + (tagName: string, options?: ElementCreationOptions) => { + if (tagName === 'video') { + const video = originalCreateElement('video'); + Object.defineProperty(video, 'duration', { value: 10 }); + Object.defineProperty(video, 'videoWidth', { value: 640 }); + Object.defineProperty(video, 'videoHeight', { value: 480 }); + + // Simulate video load and seek events automatically + setTimeout(() => { + if (video.onloadeddata) (video.onloadeddata as Function)(); + setTimeout(() => { + if (video.onseeked) (video.onseeked as Function)(); + }, 0); + }, 0); + return video; + } + + if (tagName === 'canvas') { + const canvas = originalCreateElement('canvas'); + canvas.getContext = vi.fn( + () => + ({ + drawImage: vi.fn(), + } as any), + ); + canvas.toBlob = vi.fn((callback) => { + callback(new Blob(['mock-image-data'], { type: 'image/jpeg' })); + }); + return canvas; + } + return originalCreateElement(tagName, options); + }, + ); + }); + + afterAll(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly with default state', () => { + render(); + expect(screen.getByText('Upload New Picture')).toBeInTheDocument(); + }); + + it('handles image upload correctly without video extraction', async () => { + const onImageSelect = vi.fn(); + const user = userEvent.setup(); + render(); + + const file = new File(['hello'], 'hello.png', { type: 'image/png' }); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, file); + + expect(global.URL.createObjectURL).toHaveBeenCalledWith(file); + expect(onImageSelect).toHaveBeenCalledWith(file); + }); + + it('extracts frame when video is uploaded', async () => { + const onImageSelect = vi.fn(); + const user = userEvent.setup(); + render(); + + const file = new File(['video-data'], 'video.mp4', { type: 'video/mp4' }); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + + await user.upload(input, file); + + await waitFor(() => { + expect(onImageSelect).toHaveBeenCalled(); + }); + + const selectedFile = onImageSelect.mock.calls[0][0]; + expect(selectedFile.name).toBe('video.jpg'); + expect(selectedFile.type).toBe('image/jpeg'); + }); +}); diff --git a/src/components/social/__tests__/commentSection.test.tsx b/src/components/social/__tests__/commentSection.test.tsx new file mode 100644 index 00000000..f2123cbb --- /dev/null +++ b/src/components/social/__tests__/commentSection.test.tsx @@ -0,0 +1,819 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderHook } from '@testing-library/react'; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('@/lib/api', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('next/router', () => ({ + useRouter: () => ({ push: vi.fn(), query: {} }), +})); + +vi.mock('next/image', () => ({ + // Simplified Image stub so jsdom can render it without errors + default: ({ src, alt }: { src: string; alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})); + +// ─── Imports after mocks ─────────────────────────────────────────────────────── + +import { apiClient } from '@/lib/api'; +import { useSocialInteractions } from '@/hooks/useSocialFeatures'; +import { useTopicFeed } from '@/hooks/useTopicFeed'; +import SocialInteractions from '@/components/social/SocialInteractions'; +import FollowingSystem from '@/components/social/FollowingSystem'; +import TopicFeed from '@/components/social/TopicFeed'; +import { getRelativeTime, formatFollowerCount, groupActivitiesByDate } from '@/utils/socialUtils'; +import type { Activity, Topic, TopicPost } from '@/utils/socialUtils'; +import type { Comment } from '@/hooks/useSocialFeatures'; + +// ─── Shared test data ────────────────────────────────────────────────────────── + +const makeComment = (overrides: Partial = {}): Comment => ({ + id: 'c1', + authorId: 'u1', + authorName: 'Alice', + body: 'Great post!', + createdAt: new Date(Date.now() - 60_000), // 1 minute ago + ...overrides, +}); + +const makeTopic = (overrides: Partial = {}): Topic => ({ + slug: 'react', + name: 'React', + description: 'All things React', + postCount: 42, + followerCount: 1200, + ...overrides, +}); + +const makeTopicPost = (overrides: Partial = {}): TopicPost => ({ + id: 'p1', + authorId: 'u1', + authorName: 'Alice', + title: 'Understanding hooks', + body: 'Hooks are great because…', + topicSlug: 'react', + likes: 10, + commentCount: 3, + createdAt: new Date(Date.now() - 3_600_000), // 1 hour ago + ...overrides, +}); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +/** Stub navigator.clipboard so share tests don't crash in jsdom. */ +function stubClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + writable: true, + configurable: true, + }); + return writeText; +} + +/** Stub IntersectionObserver (required by TopicFeed / ActivityFeed). */ +function stubIntersectionObserver() { + global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + disconnect: vi.fn(), + })); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// socialUtils – extended coverage +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('getRelativeTime – extended ranges', () => { + it('returns days ago', () => { + expect(getRelativeTime(new Date(Date.now() - 2 * 86_400_000))).toBe('2 days ago'); + }); + + it('returns singular day', () => { + expect(getRelativeTime(new Date(Date.now() - 1 * 86_400_000))).toBe('1 day ago'); + }); + + it('returns weeks ago', () => { + expect(getRelativeTime(new Date(Date.now() - 14 * 86_400_000))).toBe('2 weeks ago'); + }); + + it('returns singular week', () => { + expect(getRelativeTime(new Date(Date.now() - 7 * 86_400_000))).toBe('1 week ago'); + }); + + it('returns months ago', () => { + expect(getRelativeTime(new Date(Date.now() - 60 * 86_400_000))).toBe('2 months ago'); + }); + + it('returns years ago', () => { + expect(getRelativeTime(new Date(Date.now() - 400 * 86_400_000))).toBe('1 year ago'); + }); +}); + +describe('formatFollowerCount – edge cases', () => { + it('handles 0', () => expect(formatFollowerCount(0)).toBe('0')); + it('handles exactly 1000', () => expect(formatFollowerCount(1000)).toBe('1K')); + it('handles exactly 1_000_000', () => expect(formatFollowerCount(1_000_000)).toBe('1M')); + it('handles large millions', () => expect(formatFollowerCount(2_500_000)).toBe('2.5M')); +}); + +describe('groupActivitiesByDate – multi-day grouping', () => { + it('places activities from different days into separate groups', () => { + const today = new Date(); + const twoDaysAgo = new Date(today); + twoDaysAgo.setDate(today.getDate() - 2); + + const activities: Activity[] = [ + { id: '1', actorId: 'u1', actorName: 'Alice', action: 'liked', createdAt: today }, + { id: '2', actorId: 'u2', actorName: 'Bob', action: 'commented', createdAt: twoDaysAgo }, + ]; + + const groups = groupActivitiesByDate(activities); + expect(groups['Today']).toHaveLength(1); + expect(groups['Today'][0].id).toBe('1'); + // The second group key is a formatted date, not 'Yesterday' + const keys = Object.keys(groups); + expect(keys).toHaveLength(2); + }); + + it('returns an empty object for an empty array', () => { + expect(groupActivitiesByDate([])).toEqual({}); + }); + + it('preserves insertion order within a group', () => { + const now = new Date(); + const a1: Activity = { + id: 'a', + actorId: 'u1', + actorName: 'Alice', + action: 'liked', + createdAt: now, + }; + const a2: Activity = { + id: 'b', + actorId: 'u1', + actorName: 'Alice', + action: 'commented', + createdAt: now, + }; + const groups = groupActivitiesByDate([a1, a2]); + expect(groups['Today'][0].id).toBe('a'); + expect(groups['Today'][1].id).toBe('b'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// useSocialInteractions – Comment Section hook +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('useSocialInteractions – comment section', () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [] }); + vi.mocked(apiClient.post).mockResolvedValue(makeComment()); + vi.mocked(apiClient.delete).mockResolvedValue({}); + }); + + afterEach(() => vi.clearAllMocks()); + + it('starts with an empty comment list', async () => { + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.comments).toHaveLength(0); + }); + + it('loads pre-existing comments from the API', async () => { + const existing = makeComment({ id: 'existing', body: 'Hello!' }); + vi.mocked(apiClient.get).mockResolvedValue({ likes: 2, liked: false, comments: [existing] }); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.comments).toHaveLength(1)); + expect(result.current.comments[0].body).toBe('Hello!'); + }); + + it('addComment posts to the correct API endpoint', async () => { + const { result } = renderHook(() => useSocialInteractions('post-42')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.addComment('Nice!')); + expect(apiClient.post).toHaveBeenCalledWith('/api/social/interactions/post-42/comments', { + body: 'Nice!', + }); + }); + + it('addComment appends comment to the end of the list', async () => { + const first = makeComment({ id: 'c1', body: 'First!' }); + const second = makeComment({ id: 'c2', body: 'Second!' }); + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [first] }); + vi.mocked(apiClient.post).mockResolvedValue(second); + + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.comments).toHaveLength(1)); + await act(() => result.current.addComment('Second!')); + expect(result.current.comments).toHaveLength(2); + expect(result.current.comments[1].body).toBe('Second!'); + }); + + it('sets loading true during addComment and false after', async () => { + let resolvePost!: (v: unknown) => void; + vi.mocked(apiClient.post).mockReturnValue(new Promise((res) => (resolvePost = res))); + + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { + result.current.addComment('Async…'); + }); + expect(result.current.loading).toBe(true); + + await act(async () => resolvePost(makeComment({ body: 'Async…' }))); + expect(result.current.loading).toBe(false); + }); + + it('handles addComment API failure gracefully (loading resets)', async () => { + vi.mocked(apiClient.post).mockRejectedValue(new Error('Network error')); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(() => result.current.addComment('Will fail').catch(() => {})); + expect(result.current.loading).toBe(false); + }); + + it('converts createdAt strings to Date objects for loaded comments', async () => { + const rawComment = { + id: 'c1', + authorId: 'u1', + authorName: 'Alice', + body: 'Hi', + createdAt: '2024-01-15T10:00:00.000Z', + }; + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [rawComment] }); + const { result } = renderHook(() => useSocialInteractions('post-1')); + await waitFor(() => expect(result.current.comments).toHaveLength(1)); + expect(result.current.comments[0].createdAt).toBeInstanceOf(Date); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// SocialInteractions – Comment Section UI +// ═══════════════════════════════════════════════════════════════════════════════ + +describe('SocialInteractions – Comment Section UI', () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockResolvedValue({ likes: 5, liked: false, comments: [] }); + vi.mocked(apiClient.post).mockResolvedValue(makeComment()); + vi.mocked(apiClient.delete).mockResolvedValue({}); + stubClipboard(); + }); + + afterEach(() => vi.clearAllMocks()); + + // ── Visibility toggle ─────────────────────────────────────────────────────── + + it('hides the comment section by default', () => { + render(); + expect(screen.queryByPlaceholderText('Add a comment…')).not.toBeInTheDocument(); + }); + + it('shows the comment section after clicking the Toggle comments button', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + expect(screen.getByPlaceholderText('Add a comment…')).toBeInTheDocument(); + }); + + it('hides the comment section when toggled a second time', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + await user.click(screen.getByLabelText('Toggle comments')); + expect(screen.queryByPlaceholderText('Add a comment…')).not.toBeInTheDocument(); + }); + + // ── Empty state ───────────────────────────────────────────────────────────── + + it('shows "No comments yet." when the comment list is empty', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('5')); // wait for initial load + await user.click(screen.getByLabelText('Toggle comments')); + expect(screen.getByText('No comments yet.')).toBeInTheDocument(); + }); + + // ── Comment count badge ───────────────────────────────────────────────────── + + it('displays the correct comment count on the toggle button', async () => { + const comments = [makeComment({ id: 'c1' }), makeComment({ id: 'c2' })]; + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments }); + render(); + await waitFor(() => expect(screen.getByText('2')).toBeInTheDocument()); + }); + + // ── Comment list rendering ────────────────────────────────────────────────── + + it('renders a comment with author name and body', async () => { + const comment = makeComment({ authorName: 'Bob', body: 'Fantastic article!' }); + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [comment] }); + + const user = userEvent.setup(); + render(); + await waitFor(() => expect(screen.getByText('1')).toBeInTheDocument()); + await user.click(screen.getByLabelText('Toggle comments')); + + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('Fantastic article!')).toBeInTheDocument(); + }); + + it('renders the relative timestamp for each comment', async () => { + const comment = makeComment({ createdAt: new Date(Date.now() - 120_000) }); // 2 min ago + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [comment] }); + + const user = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('1')); + await user.click(screen.getByLabelText('Toggle comments')); + + expect(screen.getByText('2 minutes ago')).toBeInTheDocument(); + }); + + it('renders a fallback avatar icon when authorAvatar is absent', async () => { + const comment = makeComment({ authorAvatar: undefined }); + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [comment] }); + + const user = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('1')); + await user.click(screen.getByLabelText('Toggle comments')); + + // Lucide UserCircle renders an SVG – assert no tag for avatar + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('renders an tag when authorAvatar is provided', async () => { + const comment = makeComment({ authorAvatar: 'https://example.com/avatar.png' }); + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [comment] }); + + const user = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('1')); + await user.click(screen.getByLabelText('Toggle comments')); + + const img = screen.getByRole('img', { name: comment.authorName }); + expect(img).toHaveAttribute('src', 'https://example.com/avatar.png'); + }); + + it('renders multiple comments in the correct order', async () => { + const comments = [ + makeComment({ id: 'c1', body: 'First comment' }), + makeComment({ id: 'c2', body: 'Second comment' }), + makeComment({ id: 'c3', body: 'Third comment' }), + ]; + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments }); + + const user = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('3')); + await user.click(screen.getByLabelText('Toggle comments')); + + const allCommentBodies = screen.getAllByText(/comment$/i).map((el) => el.textContent); + expect(allCommentBodies).toEqual(['First comment', 'Second comment', 'Third comment']); + }); + + // ── Comment form – input validation ──────────────────────────────────────── + + it('disables the Post button when the draft input is empty', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + expect(screen.getByRole('button', { name: /post/i })).toBeDisabled(); + }); + + it('enables the Post button when the draft input has text', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + await user.type(screen.getByPlaceholderText('Add a comment…'), 'Hello'); + expect(screen.getByRole('button', { name: /post/i })).not.toBeDisabled(); + }); + + it('keeps Post button disabled for whitespace-only input', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + await user.type(screen.getByPlaceholderText('Add a comment…'), ' '); + expect(screen.getByRole('button', { name: /post/i })).toBeDisabled(); + }); + + // ── Comment submission ────────────────────────────────────────────────────── + + it('submitting the form calls addComment with trimmed text', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + + const input = screen.getByPlaceholderText('Add a comment…'); + await user.type(input, ' Nice work! '); + await user.click(screen.getByRole('button', { name: /post/i })); + + await waitFor(() => + expect(apiClient.post).toHaveBeenCalledWith('/api/social/interactions/post-1/comments', { + body: 'Nice work!', + }), + ); + }); + + it('clears the draft input after a successful submission', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + + const input = screen.getByPlaceholderText('Add a comment…') as HTMLInputElement; + await user.type(input, 'My comment'); + await user.click(screen.getByRole('button', { name: /post/i })); + + await waitFor(() => expect(input.value).toBe('')); + }); + + it('appends the new comment to the visible list after submission', async () => { + const newComment = makeComment({ id: 'new', body: 'Brand new comment' }); + vi.mocked(apiClient.post).mockResolvedValue(newComment); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + + await user.type(screen.getByPlaceholderText('Add a comment…'), 'Brand new comment'); + await user.click(screen.getByRole('button', { name: /post/i })); + + await waitFor(() => expect(screen.getByText('Brand new comment')).toBeInTheDocument()); + }); + + it('does not submit when the form is submitted with an empty draft', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + + // Press Enter in an empty input + const input = screen.getByPlaceholderText('Add a comment…'); + await user.type(input, '{enter}'); + + expect(apiClient.post).not.toHaveBeenCalled(); + }); + + // ── Accessibility ──────────────────────────────────────────────────────────── + + it('comment input has a placeholder that serves as a visible label cue', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByLabelText('Toggle comments')); + expect(screen.getByPlaceholderText('Add a comment…')).toBeInTheDocument(); + }); + + it('Toggle comments button is keyboard accessible', async () => { + const user = userEvent.setup(); + render(); + await user.tab(); + // The first focusable button is the Like button; tab again to reach comments + await user.tab(); + expect(screen.getByLabelText('Toggle comments')).toHaveFocus(); + }); + + // ── Security: XSS prevention ──────────────────────────────────────────────── + + it('renders comment body as text, not raw HTML (XSS prevention)', async () => { + const xssPayload = ''; + const comment = makeComment({ body: xssPayload }); + vi.mocked(apiClient.get).mockResolvedValue({ likes: 0, liked: false, comments: [comment] }); + + const user = userEvent.setup(); + render(); + await waitFor(() => screen.getByText('1')); + await user.click(screen.getByLabelText('Toggle comments')); + + // The payload must appear as text content, not executed HTML + expect(screen.getByText(xssPayload)).toBeInTheDocument(); + // No