From 9444d3e01ac19afbf71c46d378bfdcbf20ab313c Mon Sep 17 00:00:00 2001 From: plebeius Date: Sun, 8 Mar 2026 16:00:54 +0800 Subject: [PATCH] test: cover comment rendering and edge-route states --- .../__tests__/catalog-search.test.tsx | 183 ++++++++++ .../__tests__/comment-content.test.tsx | 344 ++++++++++++++++++ .../comment-content/comment-content.tsx | 2 +- .../__tests__/comment-media.test.tsx | 255 +++++++++++++ .../__tests__/error-display.test.tsx | 114 ++++++ .../not-found/__tests__/not-found.test.tsx | 106 ++++++ .../__tests__/pending-post.test.tsx | 135 +++++++ 7 files changed, 1138 insertions(+), 1 deletion(-) create mode 100644 src/components/catalog-search/__tests__/catalog-search.test.tsx create mode 100644 src/components/comment-content/__tests__/comment-content.test.tsx create mode 100644 src/components/comment-media/__tests__/comment-media.test.tsx create mode 100644 src/components/error-display/__tests__/error-display.test.tsx create mode 100644 src/views/not-found/__tests__/not-found.test.tsx create mode 100644 src/views/pending-post/__tests__/pending-post.test.tsx diff --git a/src/components/catalog-search/__tests__/catalog-search.test.tsx b/src/components/catalog-search/__tests__/catalog-search.test.tsx new file mode 100644 index 00000000..d36150bb --- /dev/null +++ b/src/components/catalog-search/__tests__/catalog-search.test.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import CatalogSearch from '../catalog-search'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + clearSearchFilterMock: vi.fn(), + debounceCancelMock: vi.fn(), + isMobile: false, + location: { + pathname: '/mu/catalog', + search: '', + }, + navigateMock: vi.fn(), + setSearchFilterMock: vi.fn(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: () => testState.location, + useNavigate: () => testState.navigateMock, + }; +}); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../../stores/use-catalog-filters-store', () => ({ + default: () => ({ + clearSearchFilter: testState.clearSearchFilterMock, + setSearchFilter: testState.setSearchFilterMock, + }), +})); + +vi.mock('lodash/debounce', () => ({ + default: void>(fn: T) => { + const wrapped = ((...args: Parameters) => fn(...args)) as T & { cancel: () => void }; + wrapped.cancel = () => testState.debounceCancelMock(); + return wrapped; + }, +})); + +let container: HTMLDivElement; +let root: Root; + +const renderSearch = async () => { + await act(async () => { + root.render(createElement(CatalogSearch)); + }); + await act(async () => { + await Promise.resolve(); + }); +}; + +const clickElement = async (element: Element | null) => { + expect(element).toBeTruthy(); + await act(async () => { + element?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +const querySearchButton = () => Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'search') ?? null; +const queryCloseButton = () => Array.from(container.querySelectorAll('span')).find((node) => node.textContent === '✖') ?? null; +const queryInput = () => container.querySelector('input'); + +const dispatchInput = async (element: HTMLInputElement, value: string) => { + await act(async () => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); + }); +}; + +describe('CatalogSearch', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.clearSearchFilterMock.mockReset(); + testState.debounceCancelMock.mockReset(); + testState.isMobile = false; + testState.location = { + pathname: '/mu/catalog', + search: '', + }; + testState.navigateMock.mockReset(); + testState.setSearchFilterMock.mockReset(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('opens from the query param and seeds the catalog search filter', async () => { + testState.location = { + pathname: '/mu/catalog', + search: '?q=linux', + }; + + await renderSearch(); + + expect(testState.setSearchFilterMock).toHaveBeenCalledWith('linux'); + expect(queryInput()).toBeTruthy(); + expect(queryInput()?.getAttribute('value')).toBe('linux'); + }); + + it('toggles the search UI, updates the filter and URL, and closes via Escape', async () => { + await renderSearch(); + + await clickElement(querySearchButton()); + const input = queryInput(); + expect(input).toBeTruthy(); + + if (!input) { + throw new Error('Expected catalog search input'); + } + + await dispatchInput(input, 'web3'); + + expect(testState.setSearchFilterMock).toHaveBeenCalledWith('web3'); + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog?q=web3', { replace: true }); + + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' })); + }); + + expect(testState.clearSearchFilterMock).toHaveBeenCalled(); + expect(testState.navigateMock).toHaveBeenLastCalledWith('/mu/catalog', { replace: true }); + expect(queryInput()).toBeNull(); + }); + + it('closes through the toggle button and cancels the debounced updater on unmount', async () => { + testState.isMobile = true; + + await renderSearch(); + await clickElement(querySearchButton()); + expect(queryInput()).toBeTruthy(); + + await clickElement(querySearchButton()); + + expect(testState.clearSearchFilterMock).toHaveBeenCalled(); + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/catalog', { replace: true }); + expect(queryInput()).toBeNull(); + + act(() => root.unmount()); + expect(testState.debounceCancelMock).toHaveBeenCalled(); + }); + + it('closes with the explicit close button after typing', async () => { + await renderSearch(); + await clickElement(querySearchButton()); + + const input = queryInput(); + expect(input).toBeTruthy(); + if (!input) { + throw new Error('Expected catalog search input'); + } + + await dispatchInput(input, 'cats'); + await clickElement(queryCloseButton()); + + expect(testState.clearSearchFilterMock).toHaveBeenCalled(); + expect(testState.navigateMock).toHaveBeenLastCalledWith('/mu/catalog', { replace: true }); + expect(queryInput()).toBeNull(); + }); +}); diff --git a/src/components/comment-content/__tests__/comment-content.test.tsx b/src/components/comment-content/__tests__/comment-content.test.tsx new file mode 100644 index 00000000..45c50abd --- /dev/null +++ b/src/components/comment-content/__tests__/comment-content.test.tsx @@ -0,0 +1,344 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import CommentContent from '../comment-content'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +type TestComment = { + author?: { + subplebbit?: { + banExpiresAt?: number; + }; + }; + cid?: string; + commentModeration?: { + purged?: boolean; + }; + content?: string; + deleted?: boolean; + edit?: { + timestamp: number; + }; + number?: number; + original?: { + content?: string; + }; + parentCid?: string; + pendingApproval?: boolean; + postCid?: string; + quotedCids?: string[]; + reason?: string; + removed?: boolean; + state?: string; + subplebbitAddress?: string; +}; + +const testState = vi.hoisted(() => ({ + commentsByCid: {} as Record, + formattedDate: '2024-01-01 12:00:00', + formattedTimeAgo: '2 hours ago', + isMobile: false, + params: {} as Record, + pathname: '/mu', + postNumbers: {} as Record, + stateString: 'Publishing', + unavailableCids: new Set(), +})); + +vi.mock('react-i18next', () => ({ + Trans: ({ components, i18nKey, values }: { components?: Record; i18nKey: string; values?: Record }) => + createElement( + 'span', + { 'data-testid': `trans-${i18nKey}` }, + values?.timestamp ? `${i18nKey}:${values.timestamp}` : i18nKey, + components?.[1] + ? React.cloneElement(components[1], { + 'data-testid': `trans-action-${i18nKey}`, + children: i18nKey, + }) + : null, + ), + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'reason_reason') { + return `reason:${options?.reason}`; + } + if (key === 'ban_expires_at') { + return `ban:${options?.address}:${options?.timestamp}`; + } + return key; + }, + }), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: () => ({ pathname: testState.pathname }), + useParams: () => testState.params, + }; +}); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({ + default: (selector: (state: { comments: Record }) => unknown) => + selector({ + comments: testState.commentsByCid, + }), +})); + +vi.mock('../../../stores/use-post-number-store', () => ({ + default: (selector: (state: { cidToNumber: Record }) => unknown) => + selector({ + cidToNumber: testState.postNumbers, + }), +})); + +vi.mock('../../../lib/get-short-address', () => ({ + default: (address?: string) => `short:${address}`, +})); + +vi.mock('../../../lib/utils/time-utils', () => ({ + getFormattedDate: () => testState.formattedDate, + getFormattedTimeAgo: () => testState.formattedTimeAgo, +})); + +vi.mock('../../../lib/utils/quote-link-utils', () => ({ + isUnavailableQuoteTarget: (comment?: TestComment) => Boolean(comment?.cid && testState.unavailableCids.has(comment.cid)), +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../../hooks/use-state-string', () => ({ + default: () => testState.stateString, +})); + +vi.mock('../../loading-ellipsis', () => ({ + default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string), +})); + +vi.mock('../../reply-quote-preview', () => ({ + default: ({ + isOP, + isQuotelinkReply, + isQuotelinkUnavailable, + quotelinkNumber, + quotelinkReply, + }: { + isOP?: boolean; + isQuotelinkReply?: boolean; + isQuotelinkUnavailable?: boolean; + quotelinkNumber?: number; + quotelinkReply?: TestComment; + }) => + createElement( + 'span', + { + 'data-number': quotelinkNumber, + 'data-op': String(Boolean(isOP)), + 'data-testid': isQuotelinkReply ? 'reply-quote-preview' : 'quote-preview', + 'data-unavailable': String(Boolean(isQuotelinkUnavailable)), + }, + quotelinkReply?.cid || `quote:${quotelinkNumber}`, + ), +})); + +vi.mock('../../markdown', () => ({ + default: ({ content }: { content?: string }) => createElement('div', { 'data-testid': 'markdown' }, content), +})); + +vi.mock('../../tooltip', () => ({ + default: ({ children, content }: { children?: React.ReactNode; content: string }) => createElement('span', { 'data-testid': 'tooltip', title: content }, children), +})); + +let container: HTMLDivElement; +let root: Root; + +const renderContent = async (comment: TestComment) => { + await act(async () => { + root.render(createElement(CommentContent, { comment } as any)); + }); +}; + +const queryMarkdownText = () => Array.from(container.querySelectorAll('[data-testid="markdown"]')).map((node) => node.textContent ?? ''); + +describe('CommentContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.commentsByCid = {}; + testState.formattedDate = '2024-01-01 12:00:00'; + testState.formattedTimeAgo = '2 hours ago'; + testState.isMobile = false; + testState.params = {}; + testState.pathname = '/mu'; + testState.postNumbers = {}; + testState.stateString = 'Publishing'; + testState.unavailableCids = new Set(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders quote previews for replies and filters out inline quoted numbers', async () => { + testState.commentsByCid = { + 'quoted-1': { cid: 'quoted-1', number: 11 }, + 'quoted-2': { cid: 'quoted-2', number: 12 }, + }; + testState.postNumbers = { + 'quoted-1': 11, + 'quoted-2': 12, + }; + + await renderContent({ + cid: 'reply-1', + content: '>>11 already referenced inline', + parentCid: 'quoted-1', + postCid: 'post-1', + quotedCids: ['quoted-1', 'quoted-2'], + }); + + const previews = container.querySelectorAll('[data-testid="reply-quote-preview"]'); + expect(previews).toHaveLength(1); + expect(previews[0]?.textContent).toBe('quoted-2'); + }); + + it('truncates long comments outside the post view and expands them on demand', async () => { + const longComment = 'x'.repeat(1105); + + await renderContent({ + cid: 'post-1', + content: longComment, + postCid: 'post-1', + }); + + expect(queryMarkdownText()[0]).toHaveLength(1000); + + const expandButton = container.querySelector('[data-testid="trans-action-comment_too_long"]'); + expect(expandButton).toBeTruthy(); + + await act(async () => { + expandButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(queryMarkdownText()[0]).toHaveLength(1105); + }); + + it('shows and hides the original content for edited comments', async () => { + await renderContent({ + cid: 'post-1', + content: 'edited body', + edit: { + timestamp: 1_704_067_200, + }, + original: { + content: 'original body', + }, + postCid: 'post-1', + reason: 'typo', + }); + + expect(queryMarkdownText()).toEqual(['edited body']); + expect(container.textContent).toContain('comment_edited_at_timestamp:2024-01-01 12:00:00'); + expect(container.textContent).toContain('reason:typo'); + + const showOriginal = container.querySelector('[data-testid="trans-action-click_here_to_show_original"]'); + expect(showOriginal).toBeTruthy(); + + await act(async () => { + showOriginal?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(queryMarkdownText()).toEqual(['original body']); + + const hideOriginal = container.querySelector('[data-testid="trans-action-click_here_to_hide_original"]'); + await act(async () => { + hideOriginal?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(queryMarkdownText()).toEqual(['edited body']); + }); + + it('renders moderation and deletion states with the expected messaging', async () => { + await renderContent({ + cid: 'post-1', + commentModeration: { + purged: true, + }, + content: 'ignored', + postCid: 'post-1', + }); + expect(container.textContent).toContain('This_post_was_purged'); + + await renderContent({ + cid: 'post-2', + content: 'ignored', + postCid: 'post-2', + reason: 'spam', + removed: true, + }); + expect(container.textContent).toContain('this_post_was_removed'); + expect(container.textContent).toContain('Reason: "spam"'); + + await renderContent({ + cid: 'post-3', + content: 'ignored', + deleted: true, + postCid: 'post-3', + reason: 'self-delete', + }); + expect(container.textContent).toContain('user_deleted_this_post'); + expect(container.textContent).toContain('Reason: "self-delete"'); + }); + + it('renders pending approval, ban details, and loading or failed states', async () => { + await renderContent({ + author: { + subplebbit: { + banExpiresAt: 1_704_067_200, + }, + }, + cid: 'post-1', + content: 'queued body', + pendingApproval: true, + postCid: 'post-1', + reason: 'rules violation', + subplebbitAddress: 'music-posting.eth', + }); + + expect(container.textContent).toContain('pending_mod_approval'); + const tooltip = container.querySelector('[data-testid="tooltip"]'); + expect(tooltip?.getAttribute('title')).toContain('ban:short:music-posting.eth:2024-01-01 12:00:00'); + + testState.stateString = 'Failed to publish'; + await renderContent({ + content: 'still pending', + postCid: 'post-2', + state: 'failed', + }); + expect(container.textContent).toContain('Failed to publish'); + expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); + + testState.stateString = 'Publishing'; + await renderContent({ + content: 'still pending', + postCid: 'post-3', + state: 'publishing', + }); + expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Publishing'); + }); +}); diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index 3efc10b5..3b8b9f11 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -271,7 +271,7 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => { )} - {!cid && !hasFailedState && ( + {!cid && ( <>

diff --git a/src/components/comment-media/__tests__/comment-media.test.tsx b/src/components/comment-media/__tests__/comment-media.test.tsx new file mode 100644 index 00000000..9b6050e2 --- /dev/null +++ b/src/components/comment-media/__tests__/comment-media.test.tsx @@ -0,0 +1,255 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import CommentMedia from '../comment-media'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + canEmbed: false, + fitExpandedImagesToScreen: false, + getHasThumbnailResult: true, + gifFrameStatus: 'idle' as 'failed' | 'idle' | 'loading' | 'ready', + gifFrameUrl: null as string | null, + hostname: 'example.com', + isMobile: false, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('../../../lib/utils/media-utils', () => ({ + getDisplayMediaInfoType: (type: string) => type, + getHasThumbnail: () => testState.getHasThumbnailResult, + getMediaDimensions: () => '640x480', +})); + +vi.mock('../../../lib/utils/url-utils', () => ({ + getHostname: () => testState.hostname, +})); + +vi.mock('../../../stores/use-expanded-media-store', () => ({ + default: () => ({ + fitExpandedImagesToScreen: testState.fitExpandedImagesToScreen, + }), +})); + +vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({ + default: () => ({ + frameUrl: testState.gifFrameUrl, + status: testState.gifFrameStatus, + }), +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../embed', () => ({ + __esModule: true, + canEmbed: () => testState.canEmbed, + default: ({ url }: { url: string }) => createElement('div', { 'data-testid': 'embed' }, url), +})); + +let container: HTMLDivElement; +let root: Root; +let setShowThumbnailMock: ReturnType; + +const renderMedia = async (props: Record) => { + await act(async () => { + root.render(createElement(CommentMedia, props as any)); + }); +}; + +describe('CommentMedia', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.canEmbed = false; + testState.fitExpandedImagesToScreen = false; + testState.getHasThumbnailResult = true; + testState.gifFrameStatus = 'idle'; + testState.gifFrameUrl = null; + testState.hostname = 'example.com'; + testState.isMobile = false; + setShowThumbnailMock = vi.fn(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('toggles image expansion on mobile and renders the media metadata', async () => { + testState.isMobile = true; + + await renderMedia({ + commentMediaInfo: { + linkHeight: 300, + linkWidth: 600, + type: 'image', + url: 'https://cdn.example.com/image.jpg', + }, + setShowThumbnail: setShowThumbnailMock, + showThumbnail: true, + }); + + const image = container.querySelector('img[src="https://cdn.example.com/image.jpg"]'); + expect(image).toBeTruthy(); + expect(container.textContent).toContain('image'); + + await act(async () => { + image?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(container.textContent).toContain('https://cdn.example.com/image'); + expect(container.textContent).toContain('640x480'); + }); + + it('falls back to the deleted-file placeholder when an image fails to load', async () => { + await renderMedia({ + commentMediaInfo: { + type: 'image', + url: 'https://cdn.example.com/missing.jpg', + }, + setShowThumbnail: setShowThumbnailMock, + showThumbnail: true, + }); + + const image = container.querySelector('img[src="https://cdn.example.com/missing.jpg"]'); + expect(image).toBeTruthy(); + + await act(async () => { + image?.dispatchEvent(new Event('error', { bubbles: true })); + }); + + expect(container.querySelector('img[alt="File deleted"]')).toBeTruthy(); + }); + + it('renders GIF thumbnail states and toggles the media view from the placeholder', async () => { + testState.isMobile = true; + testState.gifFrameStatus = 'loading'; + + await renderMedia({ + commentMediaInfo: { + type: 'gif', + url: 'https://cdn.example.com/animated.gif', + }, + setShowThumbnail: setShowThumbnailMock, + showThumbnail: true, + }); + + const placeholder = container.querySelector('[aria-label="Loading GIF thumbnail"]'); + expect(placeholder).toBeTruthy(); + + await act(async () => { + placeholder?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(setShowThumbnailMock).toHaveBeenCalledWith(false); + + testState.gifFrameStatus = 'ready'; + testState.gifFrameUrl = 'https://cdn.example.com/frame.png'; + await renderMedia({ + commentMediaInfo: { + type: 'gif', + url: 'https://cdn.example.com/animated.gif', + }, + setShowThumbnail: setShowThumbnailMock, + showThumbnail: true, + }); + + expect(container.textContent).toContain('animated gif'); + expect(container.querySelector('img[src="https://cdn.example.com/frame.png"]')).toBeTruthy(); + }); + + it('renders fallback embedded webpage links when there is no thumbnail', async () => { + testState.canEmbed = true; + testState.getHasThumbnailResult = false; + testState.isMobile = true; + + await renderMedia({ + commentMediaInfo: { + type: 'webpage', + url: 'https://example.com/article', + }, + setShowThumbnail: setShowThumbnailMock, + showThumbnail: true, + }); + + expect(container.textContent).toContain('example.com'); + + const fallbackButton = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'example.com' && node.getAttribute('role') === 'button'); + await act(async () => { + fallbackButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(setShowThumbnailMock).toHaveBeenCalledWith(false); + }); + + it('renders the expanded embed view with a close button on mobile', async () => { + testState.isMobile = true; + + await renderMedia({ + commentMediaInfo: { + patternThumbnailUrl: 'https://cdn.example.com/thumb.jpg', + type: 'iframe', + url: 'https://youtu.be/test', + }, + setShowThumbnail: setShowThumbnailMock, + showThumbnail: false, + }); + + expect(container.querySelector('[data-testid="embed"]')?.textContent).toContain('https://youtu.be/test'); + + const closeButton = Array.from(container.querySelectorAll('span')).find((node) => node.textContent === 'close'); + expect(closeButton).toBeTruthy(); + + await act(async () => { + closeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(setShowThumbnailMock).toHaveBeenCalledWith(true); + }); + + it('renders deleted and spoiler thumbnails instead of the original media', async () => { + await renderMedia({ + commentMediaInfo: { + type: 'video', + url: 'https://cdn.example.com/video.mp4', + }, + deleted: true, + setShowThumbnail: setShowThumbnailMock, + showThumbnail: true, + }); + + expect(container.querySelector('img[alt="File deleted"]')).toBeTruthy(); + + await renderMedia({ + commentMediaInfo: { + type: 'video', + url: 'https://cdn.example.com/video.mp4', + }, + setShowThumbnail: setShowThumbnailMock, + showThumbnail: true, + spoiler: true, + }); + + const spoiler = container.querySelector('img[src="assets/spoiler.png"]'); + expect(spoiler).toBeTruthy(); + + await act(async () => { + spoiler?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(setShowThumbnailMock).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/error-display/__tests__/error-display.test.tsx b/src/components/error-display/__tests__/error-display.test.tsx new file mode 100644 index 00000000..ecfc5421 --- /dev/null +++ b/src/components/error-display/__tests__/error-display.test.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ErrorDisplay from '../error-display'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + copyToClipboardMock: vi.fn<(value: string) => Promise>(), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + }), +})); + +vi.mock('../../../lib/utils/clipboard-utils', () => ({ + copyToClipboard: (value: string) => testState.copyToClipboardMock(value), +})); + +let container: HTMLDivElement; +let root: Root; +const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + +const renderDisplay = async (error: unknown) => { + await act(async () => { + root.render(createElement(ErrorDisplay, { error })); + }); +}; + +describe('ErrorDisplay', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + testState.copyToClipboardMock.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + it('waits before rendering, then copies structured errors and shows feedback', async () => { + testState.copyToClipboardMock.mockResolvedValue(undefined); + const error = { + details: { code: 500 }, + message: 'network down', + }; + + await renderDisplay(error); + expect(container.textContent).toBe(''); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + const button = container.querySelector('button'); + expect(button?.textContent).toContain('error: network down'); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.copyToClipboardMock).toHaveBeenCalledWith(JSON.stringify(error, null, 2)); + expect(container.textContent).toContain('full error copied to the clipboard'); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect(container.textContent).toContain('error: network down'); + }); + + it('shows copy failure feedback and logs the clipboard error', async () => { + testState.copyToClipboardMock.mockRejectedValue(new Error('denied')); + + await renderDisplay({ message: 'boom' }); + act(() => { + vi.advanceTimersByTime(1000); + }); + + const button = container.querySelector('button'); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to copy error: ', expect.any(Error)); + expect(container.textContent).toContain('copy failed'); + }); + + it('renders plain string errors after the delay and hides again when the error clears', async () => { + await renderDisplay('plain failure'); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(container.textContent).toContain('plain failure'); + + await renderDisplay(null); + await act(async () => { + await Promise.resolve(); + }); + + expect(container.textContent).toBe(''); + }); +}); diff --git a/src/views/not-found/__tests__/not-found.test.tsx b/src/views/not-found/__tests__/not-found.test.tsx new file mode 100644 index 00000000..d7be08dc --- /dev/null +++ b/src/views/not-found/__tests__/not-found.test.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import NotFound from '../not-found'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, + location: { + pathname: '/mu/thread/missing-post', + }, + resolvedAddress: 'music-posting.eth', + shortAddress: 'mu', + subplebbitAddress: 'music-posting.eth', +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + Link: ({ children, to }: { children: React.ReactNode; to: string }) => createElement('a', { href: to }, children), + useLocation: () => testState.location, + }; +}); + +vi.mock('../../../hooks/use-stable-subplebbit', () => ({ + useSubplebbitField: (_address: string, selector: (subplebbit: { address?: string; shortAddress?: string }) => string | undefined) => + selector({ + address: testState.resolvedAddress, + shortAddress: testState.shortAddress, + }), +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, +})); + +vi.mock('../../../lib/utils/route-utils', () => ({ + getSubplebbitAddress: () => testState.subplebbitAddress, +})); + +vi.mock('../../home', () => ({ + HomeLogo: () => createElement('div', { 'data-testid': 'home-logo' }, 'home-logo'), +})); + +vi.mock('../../../components/not-found-image', () => ({ + default: () => createElement('div', { 'data-testid': 'not-found-image' }, 'image'), +})); + +let container: HTMLDivElement; +let root: Root; + +const renderNotFound = async () => { + await act(async () => { + root.render(createElement(NotFound)); + }); +}; + +describe('NotFound', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; + testState.location = { + pathname: '/mu/thread/missing-post', + }; + testState.resolvedAddress = 'music-posting.eth'; + testState.shortAddress = 'mu'; + testState.subplebbitAddress = 'music-posting.eth'; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders the board-specific back link when the missing route belongs to a known board', async () => { + await renderNotFound(); + + expect(container.querySelector('[data-testid="home-logo"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="not-found-image"]')).toBeTruthy(); + expect(container.textContent).toContain('Not Found'); + expect(container.textContent).toContain('Back to p/mu'); + expect(container.querySelector('a')?.getAttribute('href')).toBe('/mu'); + }); + + it('omits the board link for generic not-found routes', async () => { + testState.location = { + pathname: '/not-found', + }; + testState.resolvedAddress = ''; + testState.shortAddress = ''; + testState.subplebbitAddress = ''; + + await renderNotFound(); + + expect(container.textContent).not.toContain('Back to p/'); + expect(container.querySelector('a')).toBeNull(); + }); +}); diff --git a/src/views/pending-post/__tests__/pending-post.test.tsx b/src/views/pending-post/__tests__/pending-post.test.tsx new file mode 100644 index 00000000..09860943 --- /dev/null +++ b/src/views/pending-post/__tests__/pending-post.test.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import PendingPost from '../pending-post'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +type TestComment = { + cid?: string; + subplebbitAddress?: string; +}; + +const testState = vi.hoisted(() => ({ + accountCommentIndex: undefined as string | undefined, + accountComments: [] as TestComment[], + directories: [] as Array<{ address: string; title?: string }>, + getBoardPathMock: vi.fn<(address: string) => string>(), + navigateMock: vi.fn(), + post: undefined as TestComment | undefined, +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => testState.navigateMock, + useParams: () => ({ + accountCommentIndex: testState.accountCommentIndex, + }), + }; +}); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccountComment: () => testState.post, + useAccountComments: () => ({ + accountComments: testState.accountComments, + }), +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, +})); + +vi.mock('../../../lib/utils/route-utils', () => ({ + getBoardPath: (address: string) => testState.getBoardPathMock(address), +})); + +vi.mock('../../post', () => ({ + Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post-view' }, post?.cid ?? 'no-post'), +})); + +let container: HTMLDivElement; +let root: Root; +const scrollToMock = vi.fn(); +const originalScrollTo = window.scrollTo; + +const flushEffects = async (count = 4) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +}; + +const renderPendingPost = async () => { + await act(async () => { + root.render(createElement(PendingPost)); + }); + await flushEffects(); +}; + +describe('PendingPost', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.accountCommentIndex = undefined; + testState.accountComments = []; + testState.directories = []; + testState.getBoardPathMock.mockReset(); + testState.navigateMock.mockReset(); + testState.post = undefined; + + window.scrollTo = scrollToMock; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + window.scrollTo = originalScrollTo; + }); + + it('renders the pending post and scrolls to the top for valid indices', async () => { + testState.accountCommentIndex = '0'; + testState.accountComments = [{}]; + testState.post = { + subplebbitAddress: 'music-posting.eth', + }; + + await renderPendingPost(); + + expect(scrollToMock).toHaveBeenCalledWith(0, 0); + expect(container.querySelector('[data-testid="post-view"]')?.textContent).toBe('no-post'); + expect(testState.navigateMock).not.toHaveBeenCalledWith('/not-found', { replace: true }); + }); + + it('redirects invalid pending indices to not found', async () => { + testState.accountCommentIndex = '-1'; + testState.accountComments = [{}, {}]; + + await renderPendingPost(); + + expect(testState.navigateMock).toHaveBeenCalledWith('/not-found', { replace: true }); + }); + + it('redirects resolved pending posts to the canonical thread route', async () => { + testState.accountCommentIndex = '1'; + testState.accountComments = [{}, {}]; + testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; + testState.getBoardPathMock.mockReturnValue('mu'); + testState.post = { + cid: 'post-cid', + subplebbitAddress: 'music-posting.eth', + }; + + await renderPendingPost(); + + expect(testState.getBoardPathMock).toHaveBeenCalledWith('music-posting.eth'); + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/thread/post-cid', { replace: true }); + }); +});