From 1fbf715aa97cb8db0338c48774c941e6f814d3f3 Mon Sep 17 00:00:00 2001 From: plebeius Date: Sun, 8 Mar 2026 14:29:47 +0800 Subject: [PATCH] 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. --- react-doctor.config.json | 6 + src/__tests__/app.test.tsx | 378 +++++++++++++++ .../boards-bar/__tests__/boards-bar.test.tsx | 261 ++++++++++ .../post-form/__tests__/post-form.test.tsx | 456 ++++++++++++++++++ src/components/post-form/post-form.tsx | 59 ++- src/hooks/__tests__/use-publish-post.test.tsx | 118 +++++ .../__tests__/use-publish-reply.test.tsx | 124 +++++ .../use-catalog-filters-store.test.ts | 171 +++++++ .../__tests__/account-data-editor.test.tsx | 26 +- src/views/board/__tests__/board.test.tsx | 363 ++++++++++++++ src/views/catalog/__tests__/catalog.test.tsx | 370 ++++++++++++++ src/views/home/__tests__/home.test.tsx | 160 ++++++ 12 files changed, 2455 insertions(+), 37 deletions(-) create mode 100644 react-doctor.config.json create mode 100644 src/__tests__/app.test.tsx create mode 100644 src/components/boards-bar/__tests__/boards-bar.test.tsx create mode 100644 src/components/post-form/__tests__/post-form.test.tsx create mode 100644 src/hooks/__tests__/use-publish-post.test.tsx create mode 100644 src/hooks/__tests__/use-publish-reply.test.tsx create mode 100644 src/stores/__tests__/use-catalog-filters-store.test.ts create mode 100644 src/views/board/__tests__/board.test.tsx create mode 100644 src/views/catalog/__tests__/catalog.test.tsx create mode 100644 src/views/home/__tests__/home.test.tsx diff --git a/react-doctor.config.json b/react-doctor.config.json new file mode 100644 index 00000000..3f23fe12 --- /dev/null +++ b/react-doctor.config.json @@ -0,0 +1,6 @@ +{ + "diff": false, + "ignore": { + "files": ["**/__tests__/**", "**/*.test.*"] + } +} diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx new file mode 100644 index 00000000..044d2eaf --- /dev/null +++ b/src/__tests__/app.test.tsx @@ -0,0 +1,378 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../app'; + +(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 ReplyModalShape = { + activeCid: string | null; + closeModal: ReturnType; + parentNumber: number | null; + scrollY: number; + showReplyModal: boolean; + subplebbitAddress: string | null; + threadCid: string | null; + threadNumber: number | null; +}; + +const testState = vi.hoisted(() => ({ + account: { author: { address: '0x123' } } as unknown, + accountComments: {} as Record, + accountSubplebbitAddresses: [] as string[], + closeCreateBoardModalMock: vi.fn(), + directories: [ + { address: 'music-posting.eth', title: '/mu/ - Music', nsfw: false }, + { address: 'tech-posting.eth', title: '/g/ - Technology', nsfw: false }, + ] as Array<{ address: string; title?: string; nsfw?: boolean }>, + initSnowMock: vi.fn(), + isMobile: false, + isSpecialEnabled: false, + removeSnowMock: vi.fn(), + replyModalState: { + activeCid: null, + closeModal: vi.fn(), + parentNumber: null, + scrollY: 0, + showReplyModal: false, + subplebbitAddress: null, + threadCid: null, + threadNumber: null, + } as ReplyModalShape, + resolvedSubplebbitAddress: undefined as string | undefined, + subplebbits: {} as Record, + useThemeMock: vi.fn(), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, + useAccountComment: ({ commentIndex }: { commentIndex?: number }) => (typeof commentIndex === 'number' ? testState.accountComments[commentIndex] : undefined), + useSubplebbit: ({ subplebbitAddress }: { subplebbitAddress?: string }) => (subplebbitAddress ? testState.subplebbits[subplebbitAddress] : undefined), +})); + +vi.mock('../hooks/use-account-subplebbit-addresses', () => ({ + useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses, +})); + +vi.mock('../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, +})); + +vi.mock('../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../hooks/use-resolved-subplebbit-address', () => ({ + useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +})); + +vi.mock('../hooks/use-theme', () => ({ + default: () => testState.useThemeMock(), +})); + +vi.mock('../stores/use-create-board-modal-store', () => ({ + default: () => ({ + closeCreateBoardModal: testState.closeCreateBoardModalMock, + }), +})); + +vi.mock('../stores/use-reply-modal-store', () => ({ + default: () => testState.replyModalState, +})); + +vi.mock('../stores/use-special-theme-store', () => ({ + default: () => ({ + isEnabled: testState.isSpecialEnabled, + }), +})); + +vi.mock('../lib/snow', () => ({ + initSnow: (options: unknown) => testState.initSnowMock(options), + removeSnow: () => testState.removeSnowMock(), +})); + +vi.mock('../lib/utils/preload-utils', () => ({ + preloadThemeAssets: vi.fn(), +})); + +function makeNamedComponent(name: string) { + return () => createElement('div', { 'data-testid': name }, name); +} + +vi.mock('../components/board-buttons', () => ({ + DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'), + MobileBoardButtons: makeNamedComponent('mobile-board-buttons'), +})); + +vi.mock('../components/board-header', () => ({ + default: makeNamedComponent('board-header'), +})); + +vi.mock('../components/feed-cache-container', () => ({ + default: makeNamedComponent('feed-cache-container'), +})); + +vi.mock('../components/post-form', () => ({ + default: makeNamedComponent('post-form'), +})); + +vi.mock('../components/board-blotter', () => ({ + default: makeNamedComponent('board-blotter'), +})); + +vi.mock('../components/boards-bar', () => ({ + default: makeNamedComponent('boards-bar'), +})); + +vi.mock('../views/board', () => ({ + default: makeNamedComponent('board-view'), +})); + +vi.mock('../views/blotter', () => ({ + default: makeNamedComponent('blotter-view'), +})); + +vi.mock('../views/catalog', () => ({ + default: makeNamedComponent('catalog-view'), +})); + +vi.mock('../views/faq', () => ({ + default: makeNamedComponent('faq-view'), +})); + +vi.mock('../views/home', () => ({ + default: makeNamedComponent('home-view'), +})); + +vi.mock('../views/mod-queue', () => ({ + default: makeNamedComponent('mod-queue-view'), +})); + +vi.mock('../views/not-allowed', () => ({ + default: makeNamedComponent('not-allowed-view'), +})); + +vi.mock('../views/not-found', () => ({ + default: makeNamedComponent('not-found-view'), +})); + +vi.mock('../views/pending-post', () => ({ + default: makeNamedComponent('pending-post-view'), +})); + +vi.mock('../views/post', () => ({ + default: makeNamedComponent('post-view'), +})); + +vi.mock('../views/rules', () => ({ + default: makeNamedComponent('rules-view'), +})); + +vi.mock('../views/account-data-editor', () => ({ + default: makeNamedComponent('account-data-editor-view'), +})); + +vi.mock('../components/boards-bar-edit-modal', () => ({ + default: makeNamedComponent('boards-bar-edit-modal'), +})); + +vi.mock('../components/create-board-modal', () => ({ + default: makeNamedComponent('create-board-modal'), +})); + +vi.mock('../components/challenge-modal', () => ({ + default: makeNamedComponent('challenge-modal'), +})); + +vi.mock('../components/directory-modal', () => ({ + default: makeNamedComponent('directory-modal'), +})); + +vi.mock('../components/disclaimer-modal', () => ({ + default: makeNamedComponent('disclaimer-modal'), +})); + +vi.mock('../components/settings-modal', () => ({ + default: makeNamedComponent('settings-modal'), +})); + +vi.mock('../components/reply-modal', () => ({ + default: ({ parentCid, postCid }: { parentCid: string; postCid: string }) => createElement('div', { 'data-testid': 'reply-modal' }, `${parentCid}:${postCid}`), +})); + +let latestLocation = ''; +let container: HTMLDivElement; +let root: Root; + +const LocationProbe = () => { + const location = useLocation(); + React.useLayoutEffect(() => { + latestLocation = `${location.pathname}${location.search}`; + }, [location.pathname, location.search]); + return null; +}; + +const flushEffects = async (count = 8) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +}; + +const renderApp = async (initialEntry: string) => { + latestLocation = initialEntry; + await act(async () => { + root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App), createElement(LocationProbe))); + }); + await flushEffects(); +}; + +describe('App', () => { + beforeEach(() => { + vi.clearAllMocks(); + latestLocation = ''; + testState.account = { author: { address: '0x123' } }; + testState.accountComments = {}; + testState.accountSubplebbitAddresses = []; + testState.isMobile = false; + testState.isSpecialEnabled = false; + testState.replyModalState = { + activeCid: null, + closeModal: vi.fn(), + parentNumber: null, + scrollY: 0, + showReplyModal: false, + subplebbitAddress: null, + threadCid: null, + threadNumber: null, + } as ReplyModalShape; + testState.resolvedSubplebbitAddress = undefined; + testState.subplebbits = {}; + testState.useThemeMock.mockReset(); + testState.closeCreateBoardModalMock.mockReset(); + testState.initSnowMock.mockReset(); + testState.removeSnowMock.mockReset(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders board layout chrome, settings modal, and reply modal wiring on settings routes', async () => { + testState.replyModalState = { + activeCid: 'parent-cid', + closeModal: vi.fn(), + parentNumber: 12, + scrollY: 32, + showReplyModal: true, + subplebbitAddress: 'music-posting.eth', + threadCid: 'thread-cid', + threadNumber: 99, + } as ReplyModalShape; + + await renderApp('/all/settings'); + + expect(container.querySelector('[data-testid="boards-bar"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="board-header"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="post-form"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="feed-cache-container"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="desktop-board-buttons"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="board-blotter"]')).toBeNull(); + expect(latestLocation).toBe('/all/settings'); + }); + + it('redirects board page 1 feeds to not-found', async () => { + await renderApp('/mu/1'); + + expect(latestLocation).toBe('/not-found'); + expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy(); + }); + + it('canonicalizes board address routes to directory codes while preserving query strings', async () => { + await renderApp('/music-posting.eth/thread/comment-1?focus=1'); + + expect(latestLocation).toBe('/mu/thread/comment-1?focus=1'); + expect(container.querySelector('[data-testid="post-view"]')).toBeTruthy(); + }); + + it('routes invalid mod aliases and unknown mod paths to not-found', async () => { + await renderApp('/mu/modqueue'); + expect(latestLocation).toBe('/not-found'); + expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy(); + + await renderApp('/mod/asdf'); + expect(latestLocation).toBe('/mod/asdf'); + expect(container.querySelector('[data-testid="not-found-view"]')).toBeTruthy(); + }); + + it('allows the global mod queue only when the account moderates at least one board', async () => { + testState.accountSubplebbitAddresses = ['music-posting.eth']; + await renderApp('/mod/queue'); + + expect(container.querySelector('[data-testid="mod-queue-view"]')).toBeTruthy(); + + act(() => root.unmount()); + root = createRoot(container); + + testState.accountSubplebbitAddresses = []; + await renderApp('/mod/queue'); + + expect(latestLocation).toBe('/not-allowed'); + expect(container.querySelector('[data-testid="not-allowed-view"]')).toBeTruthy(); + }); + + it('enforces board-scoped mod queue access by account role', async () => { + testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.subplebbits = { + 'music-posting.eth': { + state: 'succeeded', + roles: { + '0x123': { role: 'moderator' }, + }, + }, + }; + + await renderApp('/mu/mod/queue'); + expect(container.querySelector('[data-testid="mod-queue-view"]')).toBeTruthy(); + + act(() => root.unmount()); + root = createRoot(container); + + testState.subplebbits = { + 'music-posting.eth': { + state: 'succeeded', + roles: { + '0x123': { role: 'user' }, + }, + }, + }; + + await renderApp('/mu/mod/queue'); + expect(latestLocation).toBe('/not-allowed'); + expect(container.querySelector('[data-testid="not-allowed-view"]')).toBeTruthy(); + }); + + it('starts and cleans up snow on desktop special-theme board layouts and closes create-board modal on mount', async () => { + testState.isSpecialEnabled = true; + + await renderApp('/mu'); + + expect(testState.initSnowMock).toHaveBeenCalledWith({ flakeCount: 150 }); + expect(testState.closeCreateBoardModalMock).toHaveBeenCalledTimes(1); + + act(() => root.unmount()); + expect(testState.removeSnowMock).toHaveBeenCalled(); + + root = createRoot(container); + }); +}); diff --git a/src/components/boards-bar/__tests__/boards-bar.test.tsx b/src/components/boards-bar/__tests__/boards-bar.test.tsx new file mode 100644 index 00000000..ea8ff2ab --- /dev/null +++ b/src/components/boards-bar/__tests__/boards-bar.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +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(['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('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 }) => 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('../../../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: void>(fn: T) => { + const wrapped = ((...args: Parameters) => 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('*')).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('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('[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)'); + }); +}); diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx new file mode 100644 index 00000000..fad7fd64 --- /dev/null +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +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, + directories: [ + { address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }, + { address: 'mod.eth', features: {}, title: '/mod/ - Moderation' }, + ] as Array<{ address: string; features?: Record; 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, + 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('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('react'); + + return { + default: ({ subplebbitAddress }: { subplebbitAddress?: string }) => { + const [publishPostOptions, setPublishPostOptionsState] = React.useState>(subplebbitAddress ? { subplebbitAddress } : {}); + + return { + postIndex: testState.postIndex, + publishPost: testState.publishPostMock, + publishPostOptions, + resetPublishPostOptions: testState.resetPublishPostOptionsMock, + setPublishPostOptions: (options: Record) => { + testState.setPublishPostOptionsMock(options); + setPublishPostOptionsState((previous) => ({ ...previous, ...options })); + }, + }; + }, + }; +}); + +vi.mock('../../../hooks/use-publish-reply', async () => { + const React = await vi.importActual('react'); + + return { + default: ({ cid, postCid, subplebbitAddress }: { cid: string; postCid?: string; subplebbitAddress: string }) => { + const [publishReplyOptions, setPublishReplyOptionsState] = React.useState>({ + parentCid: cid, + postCid: postCid ?? cid, + subplebbitAddress, + }); + + return { + publishReply: testState.publishReplyMock, + replyIndex: testState.replyIndex, + resetPublishReplyOptions: testState.resetPublishReplyOptionsMock, + setPublishReplyOptions: (options: Record) => { + 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: void>(fn: T) => { + const wrapped = ((...args: Parameters) => 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('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).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).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('input[type="checkbox"]'); + if (spoilerToggle) { + await dispatchChange(spoilerToggle, true); + } + + (globalThis.alert as ReturnType).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'); + }); +}); diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 5eb18bd1..4becdb24 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -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 ( - <> -
- {!(isInAllView || isInSubscriptionsView || isInModView) && showForm && } - {isInModQueueView ? ( -
{t('moderation_queue')}
- ) : isThreadClosed ? ( -
- {t('thread_closed')} -
- {t('may_not_reply')} -
- ) : !showForm ? ( -
- [ - - ] -
- ) : ( - setShowForm(false)} postCid={postCid} /> - )} -
+ const shouldShowOfflineAlert = !(isInAllView || isInSubscriptionsView || isInModView) && showForm; + + if (isMobile) { + return (
- {!(isInAllView || isInSubscriptionsView || isInModView) && showForm && } + {shouldShowOfflineAlert && } {isInModQueueView ? (
{t('moderation_queue')}
) : isThreadClosed ? ( @@ -576,7 +558,32 @@ const PostForm = () => { )} {isInCatalogView &&
}
- + ); + } + + return ( +
+ {shouldShowOfflineAlert && } + {isInModQueueView ? ( +
{t('moderation_queue')}
+ ) : isThreadClosed ? ( +
+ {t('thread_closed')} +
+ {t('may_not_reply')} +
+ ) : !showForm ? ( +
+ [ + + ] +
+ ) : ( + setShowForm(false)} postCid={postCid} /> + )} +
); }; diff --git a/src/hooks/__tests__/use-publish-post.test.tsx b/src/hooks/__tests__/use-publish-post.test.tsx new file mode 100644 index 00000000..6c86cdab --- /dev/null +++ b/src/hooks/__tests__/use-publish-post.test.tsx @@ -0,0 +1,118 @@ +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 usePublishPost from '../use-publish-post'; +import useChallengesStore from '../../stores/use-challenges-store'; +import usePublishPostStore from '../../stores/use-publish-post-store'; + +(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(() => ({ + abandonPublishMock: vi.fn(async () => undefined), + index: 12, + lastPublishOptions: undefined as Record | undefined, + publishCommentMock: vi.fn(), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + usePublishComment: (options: Record) => { + testState.lastPublishOptions = options; + return { + abandonPublish: testState.abandonPublishMock, + index: testState.index, + publishComment: testState.publishCommentMock, + }; + }, +})); + +let container: HTMLDivElement; +let latestValue: ReturnType; +let root: Root; + +const HookHarness = () => { + latestValue = usePublishPost({ subplebbitAddress: 'music.eth' }); + return null; +}; + +const renderHook = () => { + act(() => { + root.render(createElement(HookHarness)); + }); +}; + +describe('usePublishPost', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.index = 12; + testState.lastPublishOptions = undefined; + useChallengesStore.setState({ challenges: [] }); + usePublishPostStore.getState().resetPublishPostStore(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + renderHook(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('sanitizes publish options into portable store state and exposes the publish action', async () => { + await act(async () => { + latestValue.setPublishPostOptions({ + author: { displayName: 'Alice' }, + content: '', + link: '', + spoiler: true, + title: 'Hello world', + } as never); + }); + + expect(latestValue.postIndex).toBe(12); + expect(latestValue.publishPost).toBe(testState.publishCommentMock); + expect(latestValue.publishPostOptions).toMatchObject({ + author: { displayName: 'Alice' }, + content: undefined, + link: undefined, + spoiler: true, + subplebbitAddress: 'music.eth', + title: 'Hello world', + }); + expect(typeof latestValue.publishPostOptions.onChallengeVerification).toBe('function'); + expect(typeof latestValue.publishPostOptions.onError).toBe('function'); + }); + + it('routes publish challenges through the challenge store and abandons the current publish when requested', async () => { + await act(async () => { + latestValue.setPublishPostOptions({ + content: 'Thread body', + } as never); + }); + + expect(typeof testState.lastPublishOptions?.onChallenge).toBe('function'); + + await act(async () => { + await testState.lastPublishOptions?.onChallenge('captcha', 'nonce'); + }); + + const challenges = useChallengesStore.getState().challenges; + expect(challenges).toHaveLength(1); + expect(challenges[0]?.challenge).toEqual(['captcha', 'nonce']); + + await act(async () => { + await useChallengesStore.getState().abandonCurrentChallenge(); + }); + + expect(testState.abandonPublishMock).toHaveBeenCalledTimes(1); + + await act(async () => { + latestValue.resetPublishPostOptions(); + }); + + expect(latestValue.publishPostOptions).toEqual({}); + }); +}); diff --git a/src/hooks/__tests__/use-publish-reply.test.tsx b/src/hooks/__tests__/use-publish-reply.test.tsx new file mode 100644 index 00000000..fd6ed712 --- /dev/null +++ b/src/hooks/__tests__/use-publish-reply.test.tsx @@ -0,0 +1,124 @@ +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 usePublishReply from '../use-publish-reply'; +import useChallengesStore from '../../stores/use-challenges-store'; +import usePostNumberStore from '../../stores/use-post-number-store'; +import usePublishReplyStore from '../../stores/use-publish-reply-store'; + +(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(() => ({ + abandonPublishMock: vi.fn(async () => undefined), + index: 7, + lastPublishOptions: undefined as Record | undefined, + publishCommentMock: vi.fn(), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + usePublishComment: (options: Record) => { + testState.lastPublishOptions = options; + return { + abandonPublish: testState.abandonPublishMock, + index: testState.index, + publishComment: testState.publishCommentMock, + }; + }, +})); + +let container: HTMLDivElement; +let latestValue: ReturnType; +let root: Root; + +const HookHarness = () => { + latestValue = usePublishReply({ cid: 'parent-cid', subplebbitAddress: 'music.eth' }); + return null; +}; + +const renderHook = () => { + act(() => { + root.render(createElement(HookHarness)); + }); +}; + +describe('usePublishReply', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.index = 7; + testState.lastPublishOptions = undefined; + useChallengesStore.setState({ challenges: [] }); + usePostNumberStore.setState({ cidToNumber: {}, numberToCid: { 'music.eth': { 12: 'quoted-cid' } } }); + usePublishReplyStore.setState({ + author: {}, + content: {}, + displayName: {}, + link: {}, + publishCommentOptions: {}, + spoiler: {}, + }); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + renderHook(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('builds reply publish options with derived quoted cids and exposes the publish action', async () => { + await act(async () => { + latestValue.setPublishReplyOptions({ + author: { displayName: 'Bob' }, + content: 'Replying to >>12', + link: '', + spoiler: true, + } as never); + }); + + expect(latestValue.replyIndex).toBe(7); + expect(latestValue.publishReply).toBe(testState.publishCommentMock); + expect(testState.lastPublishOptions).toMatchObject({ + author: { displayName: 'Bob' }, + content: 'Replying to >>12', + link: undefined, + parentCid: 'parent-cid', + postCid: 'parent-cid', + quotedCids: ['quoted-cid'], + spoiler: true, + subplebbitAddress: 'music.eth', + }); + }); + + it('queues reply challenges and clears the scoped reply store on reset', async () => { + await act(async () => { + latestValue.setPublishReplyOptions({ + content: 'Body', + } as never); + }); + + expect(typeof testState.lastPublishOptions?.onChallenge).toBe('function'); + + await act(async () => { + await testState.lastPublishOptions?.onChallenge('captcha'); + }); + + expect(useChallengesStore.getState().challenges).toHaveLength(1); + + await act(async () => { + await useChallengesStore.getState().abandonCurrentChallenge(); + }); + + expect(testState.abandonPublishMock).toHaveBeenCalledTimes(1); + + await act(async () => { + latestValue.resetPublishReplyOptions(); + }); + + expect(usePublishReplyStore.getState().publishCommentOptions['parent-cid']).toBeUndefined(); + }); +}); diff --git a/src/stores/__tests__/use-catalog-filters-store.test.ts b/src/stores/__tests__/use-catalog-filters-store.test.ts new file mode 100644 index 00000000..d4e25b31 --- /dev/null +++ b/src/stores/__tests__/use-catalog-filters-store.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const testState = vi.hoisted(() => ({ + commentMatchesPatternMock: vi.fn((comment: { content?: string; title?: string; link?: string }, pattern: string) => { + const haystack = [comment?.title, comment?.content, comment?.link].filter(Boolean).join(' ').toLowerCase(); + return haystack.includes(pattern.toLowerCase()); + }), +})); + +type CatalogFiltersStoreModule = typeof import('../use-catalog-filters-store'); +type CatalogFiltersStore = Awaited>; + +const STORAGE_KEY = 'catalog-filters-storage'; + +const loadStore = async () => { + vi.resetModules(); + vi.doMock('../../lib/utils/pattern-utils', () => ({ + commentMatchesPattern: (comment: unknown, pattern: string) => testState.commentMatchesPatternMock(comment as never, pattern), + })); + + const module = (await import('../use-catalog-filters-store')) as CatalogFiltersStoreModule; + await Promise.resolve(); + return module.default; +}; + +const createFilterItem = (text: string, overrides: Record = {}) => ({ + text, + enabled: true, + count: 0, + filteredCids: new Set(), + subplebbitCounts: new Map(), + subplebbitFilteredCids: new Map>(), + hide: true, + top: false, + color: '', + ...overrides, +}); + +describe('useCatalogFiltersStore', () => { + let consoleWarnSpy: ReturnType; + + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + vi.clearAllMocks(); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.doUnmock('../../lib/utils/pattern-utils'); + consoleWarnSpy.mockRestore(); + }); + + it('sanitizes filter items and persists only portable filter fields', async () => { + const useCatalogFiltersStore = await loadStore(); + + useCatalogFiltersStore + .getState() + .setFilterItems([ + createFilterItem('spam', { color: 'red' }), + createFilterItem(' '), + createFilterItem('news', { enabled: false, hide: false, top: true }), + ] as never); + + const { filterItems } = useCatalogFiltersStore.getState(); + expect(filterItems).toHaveLength(2); + expect(filterItems[0]).toMatchObject({ + text: 'spam', + enabled: true, + count: 0, + hide: true, + top: false, + color: 'red', + }); + expect(filterItems[0].filteredCids).toEqual(new Set()); + expect(filterItems[0].subplebbitCounts).toEqual(new Map()); + expect(filterItems[1]).toMatchObject({ + text: 'news', + enabled: false, + hide: false, + top: true, + }); + + const persisted = localStorage.getItem(STORAGE_KEY) ?? ''; + expect(persisted).toContain('"text":"spam"'); + expect(persisted).toContain('"hide":true'); + expect(persisted).not.toContain('"count"'); + expect(persisted).not.toContain('"filteredCids"'); + }); + + it('applies search and content filters, hides matching comments, and counts hidden cids only once per board', async () => { + const useCatalogFiltersStore = await loadStore(); + + useCatalogFiltersStore.getState().setFilterItems([createFilterItem('spam', { hide: true }), createFilterItem('highlight', { hide: false, top: true })] as never); + useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('music.eth'); + useCatalogFiltersStore.getState().setSearchFilter('topic'); + + const filter = useCatalogFiltersStore.getState().filter; + expect(filter).toBeTypeOf('function'); + + expect(filter?.({ cid: 'cid-1', content: 'topic spam', subplebbitAddress: 'music.eth' } as never)).toBe(false); + expect(filter?.({ cid: 'cid-1', content: 'topic spam', subplebbitAddress: 'music.eth' } as never)).toBe(false); + expect(filter?.({ cid: 'cid-2', content: 'topic spam', subplebbitAddress: 'tech.eth' } as never)).toBe(false); + expect(filter?.({ cid: 'cid-3', content: 'topic highlight', subplebbitAddress: 'music.eth' } as never)).toBe(true); + expect(filter?.({ cid: 'cid-4', content: 'ordinary update', subplebbitAddress: 'music.eth' } as never)).toBe(false); + + vi.runAllTimers(); + + const state = useCatalogFiltersStore.getState(); + expect(testState.commentMatchesPatternMock).toHaveBeenCalled(); + expect(state.filterItems[0].count).toBe(1); + expect(state.filterItems[0].filteredCids).toEqual(new Set(['cid-1'])); + expect(state.filterItems[0].subplebbitCounts.get('music.eth')).toBe(1); + expect(state.filteredCount).toBe(1); + expect(state.getFilteredCountForCurrentSubplebbit()).toBe(1); + }); + + it('preserves counts for unchanged filters when saving and clears matched filters', async () => { + const useCatalogFiltersStore = await loadStore(); + + useCatalogFiltersStore.getState().setFilterItems([createFilterItem('spam')] as never); + useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('music.eth'); + useCatalogFiltersStore.getState().incrementFilterCount(0, 'cid-1', 'music.eth'); + useCatalogFiltersStore.getState().setMatchedFilter('cid-1', 'red'); + + useCatalogFiltersStore + .getState() + .saveAndApplyFilters([createFilterItem('spam', { color: 'orange' }), createFilterItem('eggs', { enabled: false, top: true })] as never); + + const { filterItems, filteredCids, matchedFilters } = useCatalogFiltersStore.getState(); + expect(filterItems).toHaveLength(2); + expect(filterItems[0].text).toBe('spam'); + expect(filterItems[0].count).toBe(1); + expect(filterItems[0].subplebbitCounts.get('music.eth')).toBe(1); + expect(filterItems[0].color).toBe('orange'); + expect(filterItems[1]).toMatchObject({ + text: 'eggs', + count: 0, + enabled: false, + top: true, + }); + expect(filterItems[1].subplebbitCounts).toEqual(new Map()); + expect(filteredCids).toEqual(new Set()); + expect(matchedFilters.size).toBe(0); + }); + + it('switches current boards, recalculates counts, and resets only the active board counters', async () => { + const useCatalogFiltersStore = await loadStore(); + + useCatalogFiltersStore.getState().setFilterItems([createFilterItem('spam')] as never); + useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('music.eth'); + useCatalogFiltersStore.getState().incrementFilterCount(0, 'cid-1', 'music.eth'); + useCatalogFiltersStore.getState().incrementFilterCount(0, 'cid-2', 'tech.eth'); + + expect(useCatalogFiltersStore.getState().getFilteredCountForCurrentSubplebbit()).toBe(1); + + useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('tech.eth'); + expect(useCatalogFiltersStore.getState().currentSubplebbitAddress).toBe('tech.eth'); + expect(useCatalogFiltersStore.getState().getFilteredCountForCurrentSubplebbit()).toBe(1); + + useCatalogFiltersStore.getState().resetCountsForCurrentSubplebbit(); + expect(useCatalogFiltersStore.getState().getFilteredCountForCurrentSubplebbit()).toBe(0); + + useCatalogFiltersStore.getState().setCurrentSubplebbitAddress('music.eth'); + expect(useCatalogFiltersStore.getState().getFilteredCountForCurrentSubplebbit()).toBe(1); + + useCatalogFiltersStore.getState().clearSearchFilter(); + expect(useCatalogFiltersStore.getState().searchText).toBe(''); + }); +}); diff --git a/src/views/account-data-editor/__tests__/account-data-editor.test.tsx b/src/views/account-data-editor/__tests__/account-data-editor.test.tsx index 8aab1703..699227d5 100644 --- a/src/views/account-data-editor/__tests__/account-data-editor.test.tsx +++ b/src/views/account-data-editor/__tests__/account-data-editor.test.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { createElement } from 'react'; import { createRoot, Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import AccountDataEditor from '../account-data-editor'; (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; @@ -75,6 +74,9 @@ vi.mock('ace-builds/src-noconflict/theme-monokai', () => ({})); let root: Root; let container: HTMLDivElement; +let AccountDataEditor: React.ComponentType; + +const queryEditor = () => container.querySelector('[data-testid="ace-editor"]') ?? container.querySelector('textarea'); const flushEffects = async (count = 10) => { for (let i = 0; i < count; i += 1) { @@ -85,17 +87,17 @@ const flushEffects = async (count = 10) => { }; const waitForEditor = async () => { - for (let i = 0; i < 50; i += 1) { + for (let i = 0; i < 200; i += 1) { await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 5)); }); await flushEffects(2); - if (container.querySelector('[data-testid="ace-editor"]')) { + if (queryEditor()) { return; } } - expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy(); + expect(queryEditor()).toBeTruthy(); }; const clickButton = async (label: string) => { @@ -108,7 +110,7 @@ const clickButton = async (label: string) => { }; const changeEditorValue = async (value: string) => { - const editor = container.querySelector('[data-testid="ace-editor"]'); + const editor = queryEditor(); expect(editor).toBeTruthy(); await act(async () => { @@ -128,8 +130,10 @@ const renderEditor = () => { }; describe('AccountDataEditor', () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); + AccountDataEditor = (await import('../account-data-editor')).default; testState.account = { id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }; testState.alertMock.mockReset(); testState.buildEditableAccountJsonMock.mockReturnValue(DEFAULT_JSON); @@ -177,7 +181,7 @@ describe('AccountDataEditor', () => { await waitForEditor(); expect(container.textContent).not.toContain('loading_editor'); - expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy(); + expect(queryEditor()).toBeTruthy(); await clickButton('return_to_settings'); @@ -189,14 +193,14 @@ describe('AccountDataEditor', () => { await clickButton('continue'); await waitForEditor(); - expect(container.querySelector('[data-testid="ace-editor"]')?.value).toBe(DEFAULT_JSON); + expect(queryEditor()?.value).toBe(DEFAULT_JSON); await changeEditorValue('{"account":{"name":"changed"}}'); - expect(container.querySelector('[data-testid="ace-editor"]')?.value).toBe('{"account":{"name":"changed"}}'); + expect(queryEditor()?.value).toBe('{"account":{"name":"changed"}}'); await clickButton('reset_changes'); - expect(container.querySelector('[data-testid="ace-editor"]')?.value).toBe(DEFAULT_JSON); + expect(queryEditor()?.value).toBe(DEFAULT_JSON); }); it('alerts on invalid JSON without attempting to save', async () => { diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx new file mode 100644 index 00000000..a8eecfca --- /dev/null +++ b/src/views/board/__tests__/board.test.tsx @@ -0,0 +1,363 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Board, { type BoardProps } from '../board'; + +(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; + pinned?: boolean; + subplebbitAddress?: string; + deleted?: boolean; + postCid?: string; + removed?: boolean; + state?: string; + timestamp?: number; +}; + +const testState = vi.hoisted(() => ({ + account: { subscriptions: [] as string[] }, + accountComments: [] as TestComment[], + accountSubplebbitAddresses: [] as string[], + directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, + directoryByAddress: { + 'music-posting.eth': { + address: 'music-posting.eth', + features: { postsPerPage: 2 }, + }, + } as Record }>, + feed: [] as TestComment[], + feedStateString: 'syncing', + filteredDirectoryAddresses: ['music-posting.eth'] as string[], + hasMore: false, + loadMoreMock: vi.fn(), + pageSizes: { + guiPostsPerPage: 2, + infiniteFeedPostsPerPage: 2, + maxGuiPages: 3, + paginationFeedPostsPerPage: 6, + }, + resetMock: vi.fn(), + resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, + setEnableInfiniteScrollMock: vi.fn(), + setResetFunctionMock: vi.fn(), + subplebbit: { + error: undefined as Error | undefined, + shortAddress: 'music-posting.eth', + state: 'ready', + title: '/mu/ - Music', + }, + subplebbitSnapshot: { + shortAddress: 'music-posting.eth', + title: '/mu/ - Music', + } as { shortAddress?: string; title?: string }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, + useAccountComments: () => ({ accountComments: testState.accountComments }), + useFeed: () => ({ + feed: testState.feed, + hasMore: testState.hasMore, + loadMore: testState.loadMoreMock, + reset: testState.resetMock, + }), + useSubplebbit: () => testState.subplebbit, +})); + +vi.mock('../../../hooks/use-stable-subplebbit', () => ({ + useSubplebbitField: (_address: string | undefined, selector: (subplebbit: typeof testState.subplebbitSnapshot) => unknown) => selector(testState.subplebbitSnapshot), +})); + +vi.mock('react-virtuoso', () => ({ + Virtuoso: React.forwardRef( + ( + { + components, + data = [], + endReached, + itemContent, + }: { + components?: { Footer?: React.ComponentType }; + data?: TestComment[]; + endReached?: ((index: number) => void) | undefined; + itemContent: (index: number, item: TestComment) => React.ReactNode; + }, + ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>, + ) => { + React.useImperativeHandle(ref, () => ({ + getState: (cb) => cb({ ranges: [0], scrollTop: 42 }), + })); + + return createElement( + 'div', + { 'data-testid': 'virtuoso' }, + data.map((item, index) => createElement('div', { key: item.cid }, itemContent(index, item))), + endReached ? createElement('button', { 'data-testid': 'end-reached', onClick: () => endReached(data.length) }, 'end-reached') : null, + components?.Footer ? createElement(components.Footer) : null, + ); + }, + ), +})); + +vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({ + useAccountSubplebbitAddresses: () => testState.accountSubplebbitAddresses, +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, + useDirectoryAddresses: () => testState.directories.map((entry) => entry.address), + useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined), +})); + +vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({ + useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses, +})); + +vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ + useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +})); + +vi.mock('../../../hooks/use-state-string', () => ({ + useFeedStateString: () => testState.feedStateString, +})); + +vi.mock('../../../stores/use-feed-reset-store', () => ({ + default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) => + selector({ + setResetFunction: testState.setResetFunctionMock, + }), +})); + +vi.mock('../../../stores/use-feed-view-settings-store', () => ({ + default: (selector: (state: { enableInfiniteScroll: boolean; setEnableInfiniteScroll: typeof testState.setEnableInfiniteScrollMock }) => unknown) => + selector({ + enableInfiniteScroll: false, + setEnableInfiniteScroll: testState.setEnableInfiniteScrollMock, + }), +})); + +vi.mock('../../../hooks/use-board-feed-page-size', () => ({ + useBoardFeedPageSize: () => testState.pageSizes, +})); + +vi.mock('../../../components/error-display/error-display', () => ({ + default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'), +})); + +vi.mock('../../../components/loading-ellipsis', () => ({ + default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string), +})); + +vi.mock('../../../components/board-pagination', () => ({ + default: ({ basePath, currentPage, totalPages }: { basePath: string; currentPage: number; totalPages: number }) => + createElement('div', { 'data-testid': 'board-pagination' }, `${basePath}:${currentPage}:${totalPages}`), +})); + +vi.mock('../../../components/board-buttons/board-buttons', () => ({ + CatalogButton: ({ address }: { address?: string }) => createElement('div', { 'data-testid': 'catalog-button' }, address || 'catalog'), +})); + +vi.mock('../../../components/footer', () => ({ + PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-desktop' }, firstRow), + PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-mobile' }, children), +})); + +vi.mock('../../post', () => ({ + Post: ({ post }: { post?: TestComment }) => createElement('div', { 'data-testid': 'post' }, post?.cid || 'missing-post'), +})); + +vi.mock('../../../lib/snow', () => ({ + shouldShowSnow: () => false, +})); + +let container: HTMLDivElement; +let latestLocation = ''; +let root: Root; + +const LocationProbe = () => { + const location = useLocation(); + React.useLayoutEffect(() => { + latestLocation = location.pathname; + }, [location.pathname]); + return null; +}; + +const flushEffects = async (count = 5) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +}; + +const renderBoard = async ({ boardProps, initialEntry, routePath }: { boardProps?: BoardProps; initialEntry: string; routePath: string }) => { + latestLocation = initialEntry; + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [initialEntry] }, + createElement( + Routes, + {}, + createElement(Route, { path: routePath, element: createElement(Board, boardProps) }), + createElement(Route, { path: '*', element: createElement(Board, boardProps) }), + ), + createElement(LocationProbe), + ), + ); + }); + await flushEffects(); +}; + +describe('Board', () => { + beforeEach(() => { + vi.clearAllMocks(); + latestLocation = ''; + testState.account = { subscriptions: [] }; + testState.accountComments = []; + testState.accountSubplebbitAddresses = []; + testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; + testState.directoryByAddress = { + 'music-posting.eth': { + address: 'music-posting.eth', + features: { postsPerPage: 2 }, + }, + }; + testState.feed = []; + testState.feedStateString = 'syncing'; + testState.filteredDirectoryAddresses = ['music-posting.eth']; + testState.hasMore = false; + testState.pageSizes = { + guiPostsPerPage: 2, + infiniteFeedPostsPerPage: 2, + maxGuiPages: 3, + paginationFeedPostsPerPage: 6, + }; + testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.subplebbit = { + error: undefined, + shortAddress: 'music-posting.eth', + state: 'ready', + title: '/mu/ - Music', + }; + testState.subplebbitSnapshot = { + shortAddress: 'music-posting.eth', + title: '/mu/ - Music', + }; + testState.loadMoreMock.mockReset(); + testState.resetMock.mockReset(); + testState.setEnableInfiniteScrollMock.mockReset(); + testState.setResetFunctionMock.mockReset(); + document.title = 'before'; + Object.defineProperty(window, 'scrollTo', { + 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('renders the current page feed, inserts recent account comments, and wires footer actions', async () => { + const currentTimestamp = Math.floor(Date.now() / 1000); + testState.feed = [ + { cid: 'pinned-post', pinned: true, subplebbitAddress: 'music-posting.eth' }, + { cid: 'older-post', subplebbitAddress: 'music-posting.eth' }, + { cid: 'oldest-post', subplebbitAddress: 'music-posting.eth' }, + ]; + testState.accountComments = [ + { + cid: 'fresh-post', + postCid: 'fresh-post', + state: 'succeeded', + subplebbitAddress: 'music-posting.eth', + timestamp: currentTimestamp, + }, + ]; + testState.hasMore = true; + + await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); + + expect(document.title).toBe('/mu/ - 5chan'); + expect(testState.setResetFunctionMock).toHaveBeenCalledWith(testState.resetMock); + expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pinned-post', 'fresh-post']); + expect(container.querySelector('[data-testid="board-pagination"]')?.textContent).toBe('/mu:1:2'); + + await act(async () => { + const topButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'top'); + topButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'instant', left: 0, top: 0 }); + + await act(async () => { + const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh'); + refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + const loadMoreButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'load_more'); + loadMoreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.resetMock).toHaveBeenCalledTimes(2); + expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true); + }); + + it('redirects oversized board pages back to the last available page', async () => { + testState.feed = [ + { cid: 'first-post', subplebbitAddress: 'music-posting.eth' }, + { cid: 'second-post', subplebbitAddress: 'music-posting.eth' }, + { cid: 'third-post', subplebbitAddress: 'music-posting.eth' }, + ]; + + await renderBoard({ initialEntry: '/mu/4', routePath: '/:boardIdentifier/*' }); + + expect(latestLocation).toBe('/mu/2'); + }); + + it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => { + testState.account = { subscriptions: [] }; + testState.filteredDirectoryAddresses = []; + + await renderBoard({ + boardProps: { viewType: 'subs' }, + initialEntry: '/subs/9', + routePath: '/subs/*', + }); + + expect(latestLocation).toBe('/subs'); + expect(container.textContent).toContain('not_subscribed_to_any_board'); + }); + + it('surfaces board load errors when the feed is empty', async () => { + testState.subplebbit = { + error: new Error('board failed'), + shortAddress: 'music-posting.eth', + state: 'failed', + title: '/mu/ - Music', + }; + + await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); + + expect(container.querySelector('[data-testid="error-display"]')?.textContent).toBe('board failed'); + expect(container.textContent).toContain('failed'); + }); +}); diff --git a/src/views/catalog/__tests__/catalog.test.tsx b/src/views/catalog/__tests__/catalog.test.tsx new file mode 100644 index 00000000..6888cfe1 --- /dev/null +++ b/src/views/catalog/__tests__/catalog.test.tsx @@ -0,0 +1,370 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Catalog, { type CatalogProps } from '../catalog'; + +(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; + content?: string; + title?: string; + pinned?: boolean; + subplebbitAddress?: string; + deleted?: boolean; + postCid?: string; + removed?: boolean; + state?: string; + timestamp?: number; +}; + +type FilterItem = { + color?: string; + count: number; + enabled: boolean; + filteredCids: Set; + hide: boolean; + text: string; + top: boolean; +}; + +const testState = vi.hoisted(() => ({ + account: { subscriptions: [] as string[] }, + accountComments: [] as TestComment[], + clearMatchedFiltersMock: vi.fn(), + directoryByAddress: { + 'music-posting.eth': { + address: 'music-posting.eth', + features: { postsPerPage: 2 }, + }, + } as Record }>, + directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, + feed: [] as TestComment[], + filterItems: [] as FilterItem[], + filteredDirectoryAddresses: ['music-posting.eth'] as string[], + hasMore: false, + incrementFilterCountMock: vi.fn(), + loadMoreMock: vi.fn(), + pageSizes: { + guiPostsPerPage: 2, + maxGuiPages: 3, + paginationFeedPostsPerPage: 6, + }, + resetMock: vi.fn(), + resolvedSubplebbitAddress: 'music-posting.eth' as string | undefined, + searchText: '', + setCurrentSubplebbitAddressMock: vi.fn(), + setMatchedFilterMock: vi.fn(), + setResetFunctionMock: vi.fn(), + sortType: 'new' as 'active' | 'new', + subplebbit: { + error: undefined as Error | undefined, + shortAddress: 'music-posting.eth', + state: 'ready', + title: '/mu/ - Music', + }, +})); + +function getCatalogFiltersState() { + return { + clearMatchedFilters: testState.clearMatchedFiltersMock, + filterItems: testState.filterItems, + incrementFilterCount: testState.incrementFilterCountMock, + searchText: testState.searchText, + setCurrentSubplebbitAddress: testState.setCurrentSubplebbitAddressMock, + setMatchedFilter: testState.setMatchedFilterMock, + }; +} + +function useCatalogFiltersStoreMock(selector?: (state: ReturnType) => T) { + const state = getCatalogFiltersState(); + return selector ? selector(state) : (state as T); +} + +useCatalogFiltersStoreMock.getState = getCatalogFiltersState; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, + useAccountComments: () => ({ accountComments: testState.accountComments }), + useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean } }) => ({ + feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed, + hasMore: testState.hasMore, + loadMore: testState.loadMoreMock, + reset: testState.resetMock, + }), + useSubplebbit: () => testState.subplebbit, +})); + +vi.mock('react-virtuoso', () => ({ + Virtuoso: React.forwardRef( + ( + { + components, + data = [], + endReached, + itemContent, + }: { + components?: { Footer?: React.ComponentType }; + data?: Array; + endReached?: ((index: number) => void) | undefined; + itemContent: (index: number, item: TestComment[]) => React.ReactNode; + }, + ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>, + ) => { + React.useImperativeHandle(ref, () => ({ + getState: (cb) => cb({ ranges: [0], scrollTop: 24 }), + })); + + return createElement( + 'div', + { 'data-testid': 'virtuoso' }, + data.map((row, index) => createElement('div', { key: `row-${index}` }, itemContent(index, row))), + endReached ? createElement('button', { 'data-testid': 'end-reached', onClick: () => endReached(data.length) }, 'end-reached') : null, + components?.Footer ? createElement(components.Footer) : null, + ); + }, + ), +})); + +vi.mock('../../../hooks/use-catalog-feed-rows', () => ({ + default: (_columnCount: number, processedFeed: TestComment[]) => processedFeed.map((comment) => [comment]), +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, + useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined), +})); + +vi.mock('../../../hooks/use-board-feed-page-size', () => ({ + useBoardFeedPageSize: () => testState.pageSizes, +})); + +vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({ + useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses, +})); + +vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ + useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress, +})); + +vi.mock('../../../hooks/use-state-string', () => ({ + useFeedStateString: () => 'loading_feed', +})); + +vi.mock('../../../hooks/use-window-width', () => ({ + default: () => 900, +})); + +vi.mock('../../../stores/use-catalog-style-store', () => ({ + default: () => ({ + imageSize: 'Small', + }), +})); + +vi.mock('../../../stores/use-feed-reset-store', () => ({ + default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) => + selector({ + setResetFunction: testState.setResetFunctionMock, + }), +})); + +vi.mock('../../../stores/use-sorting-store', () => ({ + default: () => ({ + sortType: testState.sortType, + }), +})); + +vi.mock('../../../stores/use-catalog-filters-store', () => ({ + default: useCatalogFiltersStoreMock, +})); + +vi.mock('../../../components/catalog-row', () => ({ + default: ({ row }: { row: TestComment[] }) => createElement('div', { 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`), +})); + +vi.mock('../../../components/footer', () => ({ + CatalogFooterFirstRow: ({ subplebbitAddress }: { subplebbitAddress?: string }) => + createElement('div', { 'data-testid': 'catalog-first-row' }, subplebbitAddress || 'multi'), + PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-desktop' }, firstRow), +})); + +vi.mock('../../../components/loading-ellipsis', () => ({ + default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string), +})); + +vi.mock('../../../components/error-display/error-display', () => ({ + default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'), +})); + +vi.mock('../../../lib/utils/pattern-utils', () => ({ + commentMatchesPattern: (comment: TestComment, pattern: string) => { + const loweredPattern = pattern.toLowerCase(); + return `${comment.title || ''} ${comment.content || ''}`.toLowerCase().includes(loweredPattern); + }, +})); + +vi.mock('../../../lib/utils/catalog-sort', () => ({ + sortCatalogFeedForDisplay: (feed: TestComment[]) => feed, +})); + +let container: HTMLDivElement; +let latestLocation = ''; +let root: Root; + +const LocationProbe = () => { + const location = useLocation(); + React.useLayoutEffect(() => { + latestLocation = location.pathname; + }, [location.pathname]); + return null; +}; + +const flushEffects = async (count = 5) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +}; + +const renderCatalog = async ({ catalogProps, initialEntry, routePath }: { catalogProps?: CatalogProps; initialEntry: string; routePath: string }) => { + latestLocation = initialEntry; + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: [initialEntry] }, + createElement( + Routes, + {}, + createElement(Route, { path: routePath, element: createElement(Catalog, catalogProps) }), + createElement(Route, { path: '*', element: createElement(Catalog, catalogProps) }), + ), + createElement(LocationProbe), + ), + ); + }); + await flushEffects(); +}; + +describe('Catalog', () => { + beforeEach(() => { + vi.clearAllMocks(); + latestLocation = ''; + testState.account = { subscriptions: [] }; + testState.accountComments = []; + testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; + testState.directoryByAddress = { + 'music-posting.eth': { + address: 'music-posting.eth', + features: { postsPerPage: 2 }, + }, + }; + testState.feed = []; + testState.filterItems = []; + testState.filteredDirectoryAddresses = ['music-posting.eth']; + testState.hasMore = false; + testState.pageSizes = { + guiPostsPerPage: 2, + maxGuiPages: 3, + paginationFeedPostsPerPage: 6, + }; + testState.resolvedSubplebbitAddress = 'music-posting.eth'; + testState.searchText = ''; + testState.sortType = 'new'; + testState.subplebbit = { + error: undefined, + shortAddress: 'music-posting.eth', + state: 'ready', + title: '/mu/ - Music', + }; + testState.clearMatchedFiltersMock.mockReset(); + testState.incrementFilterCountMock.mockReset(); + testState.loadMoreMock.mockReset(); + testState.resetMock.mockReset(); + testState.setCurrentSubplebbitAddressMock.mockReset(); + testState.setMatchedFilterMock.mockReset(); + testState.setResetFunctionMock.mockReset(); + document.title = 'before'; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('applies catalog filters, promotes top matches, and clears board filter state on unmount', async () => { + testState.feed = [ + { cid: 'boring-post', title: 'plain talk', content: 'nothing special', subplebbitAddress: 'music-posting.eth' }, + { cid: 'hidden-post', title: 'cats and spoilers', content: 'spoiler content', subplebbitAddress: 'music-posting.eth' }, + { cid: 'top-post', title: 'cats forever', content: 'hello world', subplebbitAddress: 'music-posting.eth' }, + ]; + testState.filterItems = [ + { count: 0, enabled: true, filteredCids: new Set(), hide: true, text: 'spoiler', top: false }, + { color: 'red', count: 0, enabled: true, filteredCids: new Set(), hide: false, text: 'cats', top: true }, + ]; + + await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' }); + + expect(document.title).toBe('/mu/ - catalog - 5chan'); + expect(testState.setCurrentSubplebbitAddressMock).toHaveBeenCalledWith('music-posting.eth'); + expect(testState.clearMatchedFiltersMock).toHaveBeenCalled(); + expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:top-post', 'row:boring-post']); + expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(0, 'hidden-post', 'music-posting.eth'); + expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(1, 'top-post', 'music-posting.eth'); + expect(testState.setMatchedFilterMock).toHaveBeenCalledWith('top-post', 'red'); + + act(() => root.unmount()); + + expect(testState.setCurrentSubplebbitAddressMock).toHaveBeenLastCalledWith(null); + expect(testState.clearMatchedFiltersMock).toHaveBeenCalledTimes(3); + + root = createRoot(container); + }); + + it('canonicalizes multiboard catalog paths and keeps load-more wired for infinite scrolling', async () => { + testState.feed = [{ cid: 'all-post', title: 'one', subplebbitAddress: 'music-posting.eth' }]; + testState.hasMore = true; + + await renderCatalog({ + catalogProps: { viewType: 'all' }, + initialEntry: '/all/catalog/7', + routePath: '/all/*', + }); + + expect(latestLocation).toBe('/all/catalog'); + + await act(async () => { + container.querySelector('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.loadMoreMock).toHaveBeenCalledTimes(1); + }); + + it('shows the empty subscriptions state when there are no subscribed boards to browse', async () => { + testState.account = { subscriptions: [] }; + + await renderCatalog({ + catalogProps: { viewType: 'subs' }, + initialEntry: '/subs/catalog', + routePath: '/subs/*', + }); + + expect(container.textContent).toContain('not_subscribed_to_any_board'); + expect(container.querySelector('[data-testid="catalog-first-row"]')?.textContent).toBe('music-posting.eth'); + }); +}); diff --git a/src/views/home/__tests__/home.test.tsx b/src/views/home/__tests__/home.test.tsx new file mode 100644 index 00000000..6c12e615 --- /dev/null +++ b/src/views/home/__tests__/home.test.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Home from '../home'; + +(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(() => ({ + closeDirectoryModalMock: vi.fn(), + directories: [] as Array<{ address: string; title?: string }>, + directoryAddresses: [] as string[], + navigateMock: vi.fn(), + subplebbits: {} as Record, + subplebbitsStats: {} as Record, +})); + +vi.mock('react-i18next', () => ({ + Trans: ({ i18nKey }: { i18nKey: string }) => createElement('span', { 'data-testid': `trans-${i18nKey}` }, i18nKey), + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => testState.navigateMock, + }; +}); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useSubplebbits: () => ({ subplebbits: testState.subplebbits }), +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, + useDirectoryAddresses: () => testState.directoryAddresses, +})); + +vi.mock('../../../hooks/use-subplebbits-stats', () => ({ + SubplebbitStatsCollector: ({ subplebbitAddress }: { subplebbitAddress: string }) => + createElement('div', { 'data-testid': 'stats-collector', 'data-address': subplebbitAddress }), + useSubplebbitsStatsStore: (selector: (state: { subplebbitsStats: typeof testState.subplebbitsStats }) => unknown) => + selector({ subplebbitsStats: testState.subplebbitsStats }), +})); + +vi.mock('../../../stores/use-directory-modal-store', () => ({ + default: () => ({ + closeDirectoryModal: testState.closeDirectoryModalMock, + }), +})); + +vi.mock('../boards-list', () => ({ + default: ({ multisub }: { multisub: unknown[] }) => createElement('div', { 'data-testid': 'boards-list' }, `boards:${multisub.length}`), +})); + +vi.mock('../popular-threads-box', () => ({ + default: ({ directories, subplebbits }: { directories: unknown[]; subplebbits: Record }) => + createElement('div', { 'data-testid': 'popular-threads-box' }, `popular:${directories.length}:${Object.keys(subplebbits).length}`), +})); + +vi.mock('../../../components/site-legal-meta', () => ({ + default: () => createElement('div', { 'data-testid': 'site-legal-meta' }, 'site-legal-meta'), +})); + +vi.mock('../../../components/disclaimer-modal', () => ({ + default: () => createElement('div', { 'data-testid': 'disclaimer-modal' }, 'disclaimer-modal'), +})); + +vi.mock('../../../components/directory-modal', () => ({ + default: () => createElement('div', { 'data-testid': 'directory-modal' }, 'directory-modal'), +})); + +let container: HTMLDivElement; +let root: Root; + +const renderHome = () => { + act(() => { + root.render(createElement(MemoryRouter, {}, createElement(Home))); + }); +}; + +describe('Home', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.title = 'before'; + testState.closeDirectoryModalMock.mockReset(); + testState.navigateMock.mockReset(); + testState.directories = [ + { address: 'music-posting.eth', title: '/mu/ - Music' }, + { address: 'tech-posting.eth', title: '/g/ - Technology' }, + ]; + testState.directoryAddresses = ['music-posting.eth', 'tech-posting.eth']; + testState.subplebbits = { + 'music-posting.eth': { address: 'music-posting.eth' }, + 'tech-posting.eth': { address: 'tech-posting.eth' }, + }; + testState.subplebbitsStats = { + 'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 }, + 'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 5 }, + }; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders the home view chrome, child sections, collectors, and aggregated stats', () => { + renderHome(); + + expect(document.title).toBe('5chan'); + expect(container.querySelector('[data-testid="disclaimer-modal"]')?.textContent).toBe('disclaimer-modal'); + expect(container.querySelector('[data-testid="directory-modal"]')?.textContent).toBe('directory-modal'); + expect(container.querySelector('[data-testid="boards-list"]')?.textContent).toBe('boards:2'); + expect(container.querySelector('[data-testid="popular-threads-box"]')?.textContent).toBe('popular:2:2'); + expect(container.querySelectorAll('[data-testid="stats-collector"]')).toHaveLength(2); + expect(container.textContent).toContain('total_posts 12'); + expect(container.textContent).toContain('current_users 7'); + expect(container.textContent).toContain('boards_tracked 2'); + expect(container.querySelector('[data-testid="site-legal-meta"]')?.textContent).toBe('site-legal-meta'); + }); + + it('navigates to the canonical board path when the search form is submitted', async () => { + renderHome(); + + const input = container.querySelector('input[type="text"]'); + const form = container.querySelector('form'); + expect(input).toBeTruthy(); + expect(form).toBeTruthy(); + + await act(async () => { + if (input) { + input.value = 'music-posting.eth'; + } + form?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); + + expect(testState.navigateMock).toHaveBeenCalledWith('/mu'); + }); + + it('closes the directory modal when the home view unmounts', () => { + renderHome(); + expect(testState.closeDirectoryModalMock).not.toHaveBeenCalled(); + + act(() => root.unmount()); + + expect(testState.closeDirectoryModalMock).toHaveBeenCalledTimes(1); + + root = createRoot(container); + }); +});