mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test: cover comment rendering and edge-route states
This commit is contained in:
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
|
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<typeof import('react-router-dom')>('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: <T extends (...args: any[]) => void>(fn: T) => {
|
||||||
|
const wrapped = ((...args: Parameters<T>) => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
|
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<string, TestComment>,
|
||||||
|
formattedDate: '2024-01-01 12:00:00',
|
||||||
|
formattedTimeAgo: '2 hours ago',
|
||||||
|
isMobile: false,
|
||||||
|
params: {} as Record<string, string>,
|
||||||
|
pathname: '/mu',
|
||||||
|
postNumbers: {} as Record<string, number>,
|
||||||
|
stateString: 'Publishing',
|
||||||
|
unavailableCids: new Set<string>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
Trans: ({ components, i18nKey, values }: { components?: Record<number, React.ReactElement>; i18nKey: string; values?: Record<string, unknown> }) =>
|
||||||
|
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<string, unknown>) => {
|
||||||
|
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<typeof import('react-router-dom')>('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<string, TestComment> }) => unknown) =>
|
||||||
|
selector({
|
||||||
|
comments: testState.commentsByCid,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../stores/use-post-number-store', () => ({
|
||||||
|
default: (selector: (state: { cidToNumber: Record<string, number> }) => 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<string>();
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -271,7 +271,7 @@ const CommentContent = ({ comment: post }: { comment: Comment }) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!cid && !hasFailedState && (
|
{!cid && (
|
||||||
<>
|
<>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
|
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<typeof vi.fn>;
|
||||||
|
|
||||||
|
const renderMedia = async (props: Record<string, unknown>) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
|
const testState = vi.hoisted(() => ({
|
||||||
|
copyToClipboardMock: vi.fn<(value: string) => Promise<void>>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
|
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<typeof import('react-router-dom')>('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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||||
|
|
||||||
|
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<typeof import('react-router-dom')>('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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user