mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test: cover board, catalog, and publish flows
Board browsing, catalog filtering, post publishing, and reply publishing had little or no honest whole-repo coverage, which let route-level regressions and duplicated form side effects slip through. This pass adds targeted tests for those user flows and fixes the duplicate `PostFormTable` mounting bug they exposed.
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import BoardsBar from '../boards-bar';
|
||||
|
||||
(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(() => ({
|
||||
accountComment: undefined as { subplebbitAddress?: string } | undefined,
|
||||
accountSubplebbitAddresses: ['music-posting.eth'] as string[],
|
||||
directories: [
|
||||
{ address: 'music-posting.eth', title: '/mu/ - Music' },
|
||||
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
|
||||
] as Array<{ address: string; title?: string }>,
|
||||
directoriesMetadata: { title: '/all/ - All Boards' } as { title?: string } | null,
|
||||
initializeVisibilityMock: vi.fn(),
|
||||
navigateMock: vi.fn(),
|
||||
openBoardsBarEditModalMock: vi.fn(),
|
||||
openCreateBoardModalMock: vi.fn(),
|
||||
openDirectoryModalMock: vi.fn(),
|
||||
resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined,
|
||||
showSubscriptionsInBoardsBar: true,
|
||||
subscriptions: ['custom.eth'] as string[],
|
||||
visibleDirectories: new Set<string>(['mu']),
|
||||
}));
|
||||
|
||||
function useBoardsBarVisibilityStoreMock() {
|
||||
return {
|
||||
visibleDirectories: testState.visibleDirectories,
|
||||
showSubscriptionsInBoardsBar: testState.showSubscriptionsInBoardsBar,
|
||||
};
|
||||
}
|
||||
|
||||
useBoardsBarVisibilityStoreMock.getState = () => ({
|
||||
initialize: testState.initializeVisibilityMock,
|
||||
});
|
||||
|
||||
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,
|
||||
useNavigate: () => testState.navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useAccountComment: () => testState.accountComment,
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/accounts', () => ({
|
||||
default: (selector: (state: { activeAccountId: string; accounts: Record<string, { subscriptions: string[] }> }) => unknown) =>
|
||||
selector({
|
||||
activeAccountId: 'account-1',
|
||||
accounts: {
|
||||
'account-1': {
|
||||
subscriptions: testState.subscriptions,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({
|
||||
useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../hooks/use-directories')>('../../../hooks/use-directories');
|
||||
return {
|
||||
...actual,
|
||||
useDirectories: () => testState.directories,
|
||||
useDirectoriesMetadata: () => testState.directoriesMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||
useBoardPath: (subplebbitAddress: string | undefined) => {
|
||||
if (subplebbitAddress === 'music-posting.eth') return 'mu';
|
||||
if (subplebbitAddress === 'tech-posting.eth') return 'g';
|
||||
return subplebbitAddress;
|
||||
},
|
||||
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-create-board-modal-store', () => ({
|
||||
default: () => ({
|
||||
openCreateBoardModal: testState.openCreateBoardModalMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-boards-bar-edit-modal-store', () => ({
|
||||
default: () => ({
|
||||
openBoardsBarEditModal: testState.openBoardsBarEditModalMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-boards-bar-visibility-store', () => ({
|
||||
default: useBoardsBarVisibilityStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-directory-modal-store', () => ({
|
||||
default: () => ({
|
||||
openDirectoryModal: testState.openDirectoryModalMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
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 = () => undefined;
|
||||
return wrapped;
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const findExactText = (text: string) => Array.from(container.querySelectorAll<HTMLElement>('*')).find((element) => element.textContent?.trim() === text);
|
||||
|
||||
const renderBoardsBar = async (initialEntry: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialEntry] },
|
||||
createElement(
|
||||
Routes,
|
||||
{},
|
||||
createElement(Route, { path: '/:boardIdentifier/*', element: createElement(BoardsBar) }),
|
||||
createElement(Route, { path: '/pending/:accountCommentIndex/*', element: createElement(BoardsBar) }),
|
||||
createElement(Route, { path: '*', element: createElement(BoardsBar) }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe('BoardsBar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.accountComment = undefined;
|
||||
testState.accountSubplebbitAddresses = ['music-posting.eth'];
|
||||
testState.directories = [
|
||||
{ address: 'music-posting.eth', title: '/mu/ - Music' },
|
||||
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
|
||||
];
|
||||
testState.directoriesMetadata = { title: '/all/ - All Boards' };
|
||||
testState.navigateMock.mockReset();
|
||||
testState.openBoardsBarEditModalMock.mockReset();
|
||||
testState.openCreateBoardModalMock.mockReset();
|
||||
testState.openDirectoryModalMock.mockReset();
|
||||
testState.initializeVisibilityMock.mockReset();
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
testState.showSubscriptionsInBoardsBar = true;
|
||||
testState.subscriptions = ['custom.eth'];
|
||||
testState.visibleDirectories = new Set(['mu']);
|
||||
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
configurable: true,
|
||||
value: 0,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders desktop board groups and subscription links, then opens edit/create/directory actions', async () => {
|
||||
await renderBoardsBar('/mu');
|
||||
|
||||
expect(testState.initializeVisibilityMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain('all');
|
||||
expect(container.textContent).toContain('subs');
|
||||
expect(container.textContent).toContain('mod');
|
||||
expect(container.textContent).toContain('custom.eth');
|
||||
expect(container.textContent).toContain('mu');
|
||||
expect(container.textContent).not.toContain('g /');
|
||||
|
||||
await act(async () => {
|
||||
findExactText('...')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('g');
|
||||
|
||||
await act(async () => {
|
||||
findExactText('Edit')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
findExactText('create_board')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(testState.openBoardsBarEditModalMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.openCreateBoardModalMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
findExactText('biz')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(testState.openDirectoryModalMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the desktop search bar and submits entered board addresses', async () => {
|
||||
await renderBoardsBar('/mu');
|
||||
|
||||
await act(async () => {
|
||||
findExactText('search')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
const inputs = container.querySelectorAll<HTMLInputElement>('input[type="text"]');
|
||||
const desktopSearchInput = inputs[0];
|
||||
expect(desktopSearchInput).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
desktopSearchInput.value = 'new-board.eth';
|
||||
desktopSearchInput.form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/new-board.eth');
|
||||
});
|
||||
|
||||
it('keeps catalog navigation on mobile board changes and hides the navbar on downward scroll', async () => {
|
||||
await renderBoardsBar('/mu/catalog');
|
||||
|
||||
const select = container.querySelector('select');
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
if (select) {
|
||||
select.value = 'all';
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/all/catalog');
|
||||
|
||||
const mobileNav = container.querySelector<HTMLElement>('[style*="translateY"]');
|
||||
expect(mobileNav?.style.transform).toBe('translateY(0)');
|
||||
|
||||
await act(async () => {
|
||||
window.scrollY = 100;
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
|
||||
expect(mobileNav?.style.transform).toBe('translateY(-23px)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import PostForm, { LinkTypePreviewer } from '../post-form';
|
||||
|
||||
(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(() => ({
|
||||
account: {
|
||||
author: { displayName: 'Alice' },
|
||||
subscriptions: ['music-posting.eth'],
|
||||
},
|
||||
accountComment: undefined as { subplebbitAddress?: string } | undefined,
|
||||
accountSubplebbitAddresses: ['mod.eth'] as string[],
|
||||
comments: {} as Record<string, { deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean }>,
|
||||
directories: [
|
||||
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
||||
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
||||
] as Array<{ address: string; features?: Record<string, unknown>; title?: string }>,
|
||||
editedComment: undefined as { deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
|
||||
gifFrameStatus: 'idle' as 'idle' | 'ready',
|
||||
handleUploadMock: vi.fn(),
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
navigateMock: vi.fn(),
|
||||
offlineTitle: 'offline board',
|
||||
postIndex: undefined as number | undefined,
|
||||
publishPostMock: vi.fn(),
|
||||
publishReplyMock: vi.fn(),
|
||||
replyIndex: undefined as number | undefined,
|
||||
resetPublishPostOptionsMock: vi.fn(),
|
||||
resetPublishReplyOptionsMock: vi.fn(),
|
||||
resolvedSubplebbitAddress: undefined as string | undefined,
|
||||
setAccountMock: vi.fn(),
|
||||
setPublishPostOptionsMock: vi.fn(),
|
||||
setPublishReplyOptionsMock: vi.fn(),
|
||||
showUploadControls: true,
|
||||
subplebbits: {
|
||||
'music-posting.eth': { address: 'music-posting.eth' },
|
||||
} as Record<string, unknown>,
|
||||
uploadComplete: undefined as ((uploadedUrl: string) => void) | undefined,
|
||||
uploadMode: 'always',
|
||||
uploadedFileName: 'picked.png' as string | null,
|
||||
}));
|
||||
|
||||
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,
|
||||
useNavigate: () => testState.navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
setAccount: (account: unknown) => testState.setAccountMock(account),
|
||||
useAccount: () => testState.account,
|
||||
useAccountComment: () => testState.accountComment,
|
||||
useEditedComment: () => ({ editedComment: testState.editedComment }),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
|
||||
default: (selector: (state: { subplebbits: typeof testState.subplebbits }) => unknown) => selector({ subplebbits: testState.subplebbits }),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages', () => ({
|
||||
default: (selector: (state: { comments: typeof testState.comments }) => unknown) => selector({ comments: testState.comments }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({
|
||||
useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
|
||||
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
|
||||
default: () => ({
|
||||
status: testState.gifFrameStatus,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({
|
||||
default: () => ({
|
||||
isOffline: testState.isOffline,
|
||||
isOnlineStatusLoading: testState.isOnlineStatusLoading,
|
||||
offlineTitle: testState.offlineTitle,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-publish-post', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react');
|
||||
|
||||
return {
|
||||
default: ({ subplebbitAddress }: { subplebbitAddress?: string }) => {
|
||||
const [publishPostOptions, setPublishPostOptionsState] = React.useState<Record<string, unknown>>(subplebbitAddress ? { subplebbitAddress } : {});
|
||||
|
||||
return {
|
||||
postIndex: testState.postIndex,
|
||||
publishPost: testState.publishPostMock,
|
||||
publishPostOptions,
|
||||
resetPublishPostOptions: testState.resetPublishPostOptionsMock,
|
||||
setPublishPostOptions: (options: Record<string, unknown>) => {
|
||||
testState.setPublishPostOptionsMock(options);
|
||||
setPublishPostOptionsState((previous) => ({ ...previous, ...options }));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-publish-reply', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react');
|
||||
|
||||
return {
|
||||
default: ({ cid, postCid, subplebbitAddress }: { cid: string; postCid?: string; subplebbitAddress: string }) => {
|
||||
const [publishReplyOptions, setPublishReplyOptionsState] = React.useState<Record<string, unknown>>({
|
||||
parentCid: cid,
|
||||
postCid: postCid ?? cid,
|
||||
subplebbitAddress,
|
||||
});
|
||||
|
||||
return {
|
||||
publishReply: testState.publishReplyMock,
|
||||
replyIndex: testState.replyIndex,
|
||||
resetPublishReplyOptions: testState.resetPublishReplyOptionsMock,
|
||||
setPublishReplyOptions: (options: Record<string, unknown>) => {
|
||||
testState.setPublishReplyOptionsMock(options);
|
||||
setPublishReplyOptionsState((previous) => ({ ...previous, ...options }));
|
||||
},
|
||||
_publishReplyOptions: publishReplyOptions,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-file-upload', () => ({
|
||||
useFileUpload: ({ onUploadComplete }: { onUploadComplete: (uploadedUrl: string) => void }) => {
|
||||
testState.uploadComplete = onUploadComplete;
|
||||
return {
|
||||
handleUpload: testState.handleUploadMock,
|
||||
isUploading: false,
|
||||
uploadedFileName: testState.uploadedFileName,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/utils/media-utils', () => ({
|
||||
getLinkMediaInfo: (link: string) => {
|
||||
if (link.endsWith('.gif')) {
|
||||
return { type: 'gif', url: link };
|
||||
}
|
||||
if (link.endsWith('.png')) {
|
||||
return { type: 'image', url: link };
|
||||
}
|
||||
return { type: 'link', url: link };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||||
getShowUploadControls: () => testState.showUploadControls,
|
||||
isWebRuntime: () => true,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
||||
default: (selector: (state: { uploadMode: string }) => unknown) =>
|
||||
selector({
|
||||
uploadMode: testState.uploadMode,
|
||||
}),
|
||||
}));
|
||||
|
||||
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 = () => undefined;
|
||||
return wrapped;
|
||||
},
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const flushEffects = async (count = 4) => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderPostForm = async (initialEntry: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
MemoryRouter,
|
||||
{ initialEntries: [initialEntry] },
|
||||
createElement(
|
||||
Routes,
|
||||
{},
|
||||
createElement(Route, { path: '/all/*', element: createElement(PostForm) }),
|
||||
createElement(Route, { path: '/subs/*', element: createElement(PostForm) }),
|
||||
createElement(Route, { path: '/mod/*', element: createElement(PostForm) }),
|
||||
createElement(Route, { path: '/:boardIdentifier/thread/:commentCid/*', element: createElement(PostForm) }),
|
||||
createElement(Route, { path: '/:boardIdentifier/*', element: createElement(PostForm) }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
await flushEffects();
|
||||
};
|
||||
|
||||
const clickByText = async (scope: ParentNode, text: string, index = 0) => {
|
||||
const button = Array.from(scope.querySelectorAll('button')).filter((candidate) => candidate.textContent === text)[index] as HTMLButtonElement | undefined;
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
await act(async () => {
|
||||
element.value = value;
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, value: string | boolean) => {
|
||||
await act(async () => {
|
||||
if (typeof value === 'boolean' && 'checked' in element) {
|
||||
element.checked = value;
|
||||
} else {
|
||||
element.value = String(value);
|
||||
}
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
describe('PostForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.account = {
|
||||
author: { displayName: 'Alice' },
|
||||
subscriptions: ['music-posting.eth'],
|
||||
};
|
||||
testState.accountComment = undefined;
|
||||
testState.accountSubplebbitAddresses = ['mod.eth'];
|
||||
testState.comments = {};
|
||||
testState.directories = [
|
||||
{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' },
|
||||
{ address: 'mod.eth', features: {}, title: '/mod/ - Moderation' },
|
||||
];
|
||||
testState.editedComment = undefined;
|
||||
testState.gifFrameStatus = 'idle';
|
||||
testState.isOffline = false;
|
||||
testState.isOnlineStatusLoading = false;
|
||||
testState.offlineTitle = 'offline board';
|
||||
testState.postIndex = undefined;
|
||||
testState.replyIndex = undefined;
|
||||
testState.resolvedSubplebbitAddress = undefined;
|
||||
testState.showUploadControls = true;
|
||||
testState.uploadComplete = undefined;
|
||||
testState.uploadMode = 'always';
|
||||
testState.uploadedFileName = 'picked.png';
|
||||
testState.subplebbits = {
|
||||
'music-posting.eth': { address: 'music-posting.eth' },
|
||||
};
|
||||
testState.handleUploadMock.mockReset();
|
||||
testState.navigateMock.mockReset();
|
||||
testState.publishPostMock.mockReset();
|
||||
testState.publishReplyMock.mockReset();
|
||||
testState.resetPublishPostOptionsMock.mockReset();
|
||||
testState.resetPublishReplyOptionsMock.mockReset();
|
||||
testState.setAccountMock.mockReset();
|
||||
testState.setPublishPostOptionsMock.mockReset();
|
||||
testState.setPublishReplyOptionsMock.mockReset();
|
||||
Object.defineProperty(globalThis, 'alert', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('shows the closed-thread notice when the current post can no longer receive replies', async () => {
|
||||
testState.comments = {
|
||||
'thread-cid': {
|
||||
locked: true,
|
||||
postCid: 'thread-cid',
|
||||
},
|
||||
};
|
||||
|
||||
await renderPostForm('/mu/thread/thread-cid');
|
||||
|
||||
expect(container.textContent).toContain('thread_closed');
|
||||
expect(container.textContent).toContain('may_not_reply');
|
||||
});
|
||||
|
||||
it('opens the new-thread form, validates all-view requirements, and publishes a board post', async () => {
|
||||
await renderPostForm('/all');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
|
||||
const table = container.querySelector('table');
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
await clickByText(table as HTMLTableElement, 'choose_file');
|
||||
expect(testState.handleUploadMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('empty_comment_alert');
|
||||
|
||||
const textInputs = table?.querySelectorAll<HTMLInputElement>('input[type="text"]') || [];
|
||||
const nameInput = textInputs[0];
|
||||
const subjectInput = textInputs[1];
|
||||
const linkInput = textInputs[2];
|
||||
const textarea = table?.querySelector('textarea');
|
||||
const select = table?.querySelector('select');
|
||||
|
||||
expect(nameInput).toBeTruthy();
|
||||
expect(subjectInput).toBeTruthy();
|
||||
expect(linkInput).toBeTruthy();
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
(globalThis.alert as ReturnType<typeof vi.fn>).mockClear();
|
||||
await dispatchInput(linkInput as HTMLInputElement, 'not-a-url');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('invalid_url_alert');
|
||||
|
||||
(globalThis.alert as ReturnType<typeof vi.fn>).mockClear();
|
||||
await dispatchInput(linkInput as HTMLInputElement, '');
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'A valid body');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('no_board_selected_warning');
|
||||
|
||||
await dispatchChange(select as HTMLSelectElement, 'music-posting.eth');
|
||||
await dispatchInput(nameInput as HTMLInputElement, 'Alice Cooper');
|
||||
await dispatchInput(subjectInput as HTMLInputElement, 'A thread');
|
||||
|
||||
const spoilerToggle = table?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
if (spoilerToggle) {
|
||||
await dispatchChange(spoilerToggle, true);
|
||||
}
|
||||
|
||||
(globalThis.alert as ReturnType<typeof vi.fn>).mockClear();
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
|
||||
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ subplebbitAddress: 'music-posting.eth' });
|
||||
});
|
||||
|
||||
it('redirects to the pending route when a post publish index is already available on mount', async () => {
|
||||
testState.postIndex = 7;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
|
||||
await renderPostForm('/mu');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
await flushEffects();
|
||||
|
||||
expect(testState.resetPublishPostOptionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/pending/7');
|
||||
});
|
||||
|
||||
it('resets the reply form after a completed reply publish', async () => {
|
||||
testState.comments = {
|
||||
'thread-cid': {
|
||||
postCid: 'thread-cid',
|
||||
},
|
||||
};
|
||||
testState.replyIndex = 4;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
|
||||
await renderPostForm('/mu/thread/thread-cid');
|
||||
await clickByText(container, 'post_a_reply');
|
||||
await flushEffects();
|
||||
|
||||
expect(testState.resetPublishReplyOptionsMock).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector('table')).toBeNull();
|
||||
});
|
||||
|
||||
it('publishes replies from the open reply form', async () => {
|
||||
testState.comments = {
|
||||
'thread-cid': {
|
||||
postCid: 'thread-cid',
|
||||
},
|
||||
};
|
||||
testState.isOffline = true;
|
||||
testState.resolvedSubplebbitAddress = 'music-posting.eth';
|
||||
|
||||
await renderPostForm('/mu/thread/thread-cid');
|
||||
await clickByText(container, 'post_a_reply');
|
||||
|
||||
const table = container.querySelector('table');
|
||||
const textarea = table?.querySelector('textarea');
|
||||
expect(table).toBeTruthy();
|
||||
expect(textarea).toBeTruthy();
|
||||
expect(container.textContent).toContain('offline board');
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'Reply body');
|
||||
await clickByText(table as HTMLTableElement, 'post');
|
||||
|
||||
expect(testState.publishReplyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinkTypePreviewer', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('describes gif previews and invalid links for the post form link helper', async () => {
|
||||
testState.gifFrameStatus = 'ready';
|
||||
await act(async () => {
|
||||
root.render(createElement(LinkTypePreviewer, { link: 'https://example.com/file.gif' }));
|
||||
});
|
||||
expect(container.textContent).toBe('animated_gif');
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(LinkTypePreviewer, { link: 'not-a-url' }));
|
||||
});
|
||||
expect(container.textContent).toBe('invalid_url');
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { isValidURL } from '../../lib/utils/url-utils';
|
||||
import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import { useAccountSubplebbitAddresses } from '../../hooks/use-account-subplebbit-addresses';
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
|
||||
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
|
||||
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
||||
@@ -513,6 +514,7 @@ const PostForm = () => {
|
||||
const isInModQueueView = isModQueueView(location.pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||
const isInCatalogView = isCatalogView(location.pathname, params);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const commentCid = params?.commentCid;
|
||||
const post = useSubplebbitsPagesStore((state) => state.comments[commentCid as string]);
|
||||
@@ -532,32 +534,12 @@ const PostForm = () => {
|
||||
const resolvedAddress = useResolvedSubplebbitAddress();
|
||||
const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.postFormDesktop}>
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
||||
{isInModQueueView ? (
|
||||
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
||||
) : isThreadClosed ? (
|
||||
<div className={styles.closed}>
|
||||
{t('thread_closed')}
|
||||
<br />
|
||||
{t('may_not_reply')}
|
||||
</div>
|
||||
) : !showForm ? (
|
||||
<div>
|
||||
[
|
||||
<button className='button' onClick={() => setShowForm(true)}>
|
||||
{isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
||||
</button>
|
||||
]
|
||||
</div>
|
||||
) : (
|
||||
<PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />
|
||||
)}
|
||||
</div>
|
||||
const shouldShowOfflineAlert = !(isInAllView || isInSubscriptionsView || isInModView) && showForm;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={styles.postFormMobile}>
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
||||
{shouldShowOfflineAlert && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
||||
{isInModQueueView ? (
|
||||
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
||||
) : isThreadClosed ? (
|
||||
@@ -576,7 +558,32 @@ const PostForm = () => {
|
||||
)}
|
||||
{isInCatalogView && <hr />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.postFormDesktop}>
|
||||
{shouldShowOfflineAlert && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
|
||||
{isInModQueueView ? (
|
||||
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
|
||||
) : isThreadClosed ? (
|
||||
<div className={styles.closed}>
|
||||
{t('thread_closed')}
|
||||
<br />
|
||||
{t('may_not_reply')}
|
||||
</div>
|
||||
) : !showForm ? (
|
||||
<div>
|
||||
[
|
||||
<button className='button' onClick={() => setShowForm(true)}>
|
||||
{isInPostView ? t('post_a_reply') : t('start_new_thread')}
|
||||
</button>
|
||||
]
|
||||
</div>
|
||||
) : (
|
||||
<PostFormTable closeForm={() => setShowForm(false)} postCid={postCid} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user