diff --git a/src/components/footer/__tests__/footer.test.tsx b/src/components/footer/__tests__/footer.test.tsx new file mode 100644 index 00000000..733e3113 --- /dev/null +++ b/src/components/footer/__tests__/footer.test.tsx @@ -0,0 +1,246 @@ +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 { + CatalogFooterFirstRow, + PageFooterDesktop, + PageFooterMobile, + StyleOnlyFooterFirstRow, + ThreadFooterFirstRow, + ThreadFooterMobile, + ThreadFooterStyleRow, +} from '../footer'; + +(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(() => ({ + directoryEntry: { features: {} } as { features?: Record } | undefined, + linkCount: 2, + openReplyModalEmptyMock: vi.fn(), + pageNumber: 4 as number | undefined, + post: { replyCount: 7 } as { replyCount?: number } | undefined, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useComment: () => testState.post, +})); + +vi.mock('../../boards-bar', () => ({ + default: () => createElement('div', { 'data-testid': 'boards-bar' }, 'boards-bar'), +})); + +vi.mock('../../site-legal-meta', () => ({ + default: ({ order }: { order?: string }) => createElement('div', { 'data-order': order, 'data-testid': 'site-legal-meta' }, `site-legal-meta:${order}`), +})); + +vi.mock('../../style-selector/style-selector', () => ({ + default: () => createElement('div', { 'data-testid': 'style-selector' }, 'style-selector'), +})); + +vi.mock('../../board-buttons/board-buttons', () => ({ + AutoButton: () => createElement('button', { type: 'button' }, 'auto-button'), + CatalogButton: ({ + address, + isInAllView, + isInModView, + isInSubscriptionsView, + }: { + address?: string; + isInAllView?: boolean; + isInModView?: boolean; + isInSubscriptionsView?: boolean; + }) => createElement('button', { 'data-testid': 'catalog-button', type: 'button' }, `${address}|${isInAllView}|${isInSubscriptionsView}|${isInModView}`), + PostPageStats: () => createElement('div', { 'data-testid': 'post-page-stats' }, 'post-page-stats'), + RefreshButton: () => createElement('button', { type: 'button' }, 'refresh-button'), + ReturnButton: ({ + address, + isInAllView, + isInModView, + isInSubscriptionsView, + }: { + address?: string; + isInAllView?: boolean; + isInModView?: boolean; + isInSubscriptionsView?: boolean; + }) => createElement('button', { 'data-testid': 'return-button', type: 'button' }, `${address}|${isInAllView}|${isInSubscriptionsView}|${isInModView}`), + TopButton: () => createElement('button', { type: 'button' }, 'top-button'), + UpdateButton: () => createElement('button', { type: 'button' }, 'update-button'), +})); + +vi.mock('../../../stores/use-reply-modal-store', () => ({ + default: () => ({ + openReplyModalEmpty: testState.openReplyModalEmptyMock, + }), +})); + +vi.mock('../../../hooks/use-count-links-in-replies', () => ({ + default: () => testState.linkCount, +})); + +vi.mock('../../../hooks/use-post-page-number', () => ({ + usePostPageNumber: () => testState.pageNumber, +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectoryByAddress: () => testState.directoryEntry, +})); + +let container: HTMLDivElement; +let root: Root; + +const renderWithRouter = async (element: React.ReactNode, initialEntry = '/mu/thread/post-1') => { + await act(async () => { + root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, element)); + }); +}; + +describe('footer', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.directoryEntry = { features: {} }; + testState.linkCount = 2; + testState.openReplyModalEmptyMock.mockReset(); + testState.pageNumber = 4; + testState.post = { replyCount: 7 }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders the desktop footer shell with optional style row, boards bar, and legal metadata', async () => { + await renderWithRouter( + createElement(PageFooterDesktop, { + firstRow: createElement('div', { 'data-testid': 'first-row' }, 'first-row'), + styleRow: createElement('div', { 'data-testid': 'style-row' }, 'style-row'), + }), + ); + + expect(container.querySelector('[data-testid="first-row"]')?.textContent).toBe('first-row'); + expect(container.querySelector('[data-testid="style-row"]')?.textContent).toBe('style-row'); + expect(container.querySelector('[data-testid="boards-bar"]')?.textContent).toBe('boards-bar'); + expect(container.querySelector('[data-testid="site-legal-meta"]')?.getAttribute('data-order')).toBe('license-first'); + }); + + it('renders style-only, catalog, and mobile footer wrappers with shared controls', async () => { + await renderWithRouter( + createElement( + React.Fragment, + {}, + createElement(StyleOnlyFooterFirstRow), + createElement(CatalogFooterFirstRow, { + isInAllView: true, + subplebbitAddress: 'music-posting.eth', + }), + createElement(ThreadFooterStyleRow), + createElement(PageFooterMobile, { + children: createElement('div', { 'data-testid': 'mobile-child' }, 'mobile-child'), + }), + ), + '/all/catalog', + ); + + expect(container.textContent).toContain('style:'); + expect(container.querySelectorAll('[data-testid="style-selector"]')).toHaveLength(3); + expect(container.querySelector('[data-testid="return-button"]')?.textContent).toBe('music-posting.eth|true|false|false'); + expect(container.querySelector('[data-testid="catalog-button"]')?.textContent).toBe('music-posting.eth|true|false|false'); + expect(container.textContent).toContain('refresh-button'); + expect(container.querySelector('[data-testid="mobile-child"]')?.textContent).toBe('mobile-child'); + }); + + it('opens the reply modal from the desktop thread footer unless the thread is closed', async () => { + await renderWithRouter( + createElement(ThreadFooterFirstRow, { + postCid: 'post-cid', + subplebbitAddress: 'music-posting.eth', + threadNumber: 42, + }), + '/all/thread/post-cid', + ); + + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.getAttribute('aria-label') === 'post_a_reply'); + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.openReplyModalEmptyMock).toHaveBeenCalledWith('post-cid', 42, 'music-posting.eth'); + expect(container.querySelector('[data-testid="post-page-stats"]')?.textContent).toBe('post-page-stats'); + + testState.openReplyModalEmptyMock.mockReset(); + await renderWithRouter( + createElement(ThreadFooterFirstRow, { + isThreadClosed: true, + postCid: 'post-cid', + subplebbitAddress: 'music-posting.eth', + threadNumber: 42, + }), + '/all/thread/post-cid', + ); + + const closedButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.getAttribute('aria-label') === 'post_a_reply'); + expect(closedButton?.hasAttribute('disabled')).toBe(true); + await act(async () => { + closedButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.openReplyModalEmptyMock).not.toHaveBeenCalled(); + }); + + it('renders mobile thread stats using directory media requirements and opens replies when available', async () => { + testState.directoryEntry = { features: { requirePostLinkIsMedia: true } }; + + await renderWithRouter( + createElement(ThreadFooterMobile, { + postCid: 'post-cid', + subplebbitAddress: 'music-posting.eth', + threadNumber: 55, + }), + ); + + expect(container.textContent).toContain('Replies: 7 / Images: 2 / pagination.pageLabel: 4'); + + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'post_a_reply'); + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.openReplyModalEmptyMock).toHaveBeenCalledWith('post-cid', 55, 'music-posting.eth'); + }); + + it('falls back to link stats and unknown counts when thread data is unavailable or closed on mobile', async () => { + testState.directoryEntry = { features: {} }; + testState.linkCount = undefined as unknown as number; + testState.pageNumber = undefined; + testState.post = undefined; + + await renderWithRouter( + createElement(ThreadFooterMobile, { + isThreadClosed: true, + postCid: 'post-cid', + subplebbitAddress: 'music-posting.eth', + threadNumber: 55, + }), + ); + + expect(container.textContent).toContain('Replies: ? / Links: ? / pagination.pageLabel: ?'); + + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'post_a_reply'); + expect(button?.hasAttribute('disabled')).toBe(true); + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testState.openReplyModalEmptyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/use-comment-media-info.test.tsx b/src/hooks/__tests__/use-comment-media-info.test.tsx new file mode 100644 index 00000000..997f805d --- /dev/null +++ b/src/hooks/__tests__/use-comment-media-info.test.tsx @@ -0,0 +1,173 @@ +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 type { CommentMediaInfo } from '../../lib/utils/media-utils'; +import { useCommentMediaInfo } from '../use-comment-media-info'; + +(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(() => ({ + fetchWebpageThumbnailIfNeededMock: vi.fn<(mediaInfo: CommentMediaInfo) => Promise>(), + getCommentMediaInfoMock: vi.fn<(link: string, thumbnailUrl: string, linkWidth: number, linkHeight: number) => CommentMediaInfo | undefined>(), + imageHeight: 360, + imageWidth: 640, + mediaInfo: undefined as CommentMediaInfo | undefined, + params: {} as Record, + pathname: '/all', + thumbnailMediaInfo: undefined as CommentMediaInfo | undefined, +})); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: () => ({ pathname: testState.pathname }), + useParams: () => testState.params, + }; +}); + +vi.mock('../../lib/utils/media-utils', () => ({ + fetchWebpageThumbnailIfNeeded: (mediaInfo: CommentMediaInfo) => testState.fetchWebpageThumbnailIfNeededMock(mediaInfo), + getCommentMediaInfo: (link: string, thumbnailUrl: string, linkWidth: number, linkHeight: number) => + testState.getCommentMediaInfoMock(link, thumbnailUrl, linkWidth, linkHeight), +})); + +let latestValue: CommentMediaInfo | undefined; +let container: HTMLDivElement; +let root: Root; +let originalImage: typeof globalThis.Image; + +class MockImage { + height = testState.imageHeight; + onerror: ((this: GlobalEventHandlers, ev: Event | string) => any) | null = null; + onload: ((this: GlobalEventHandlers, ev: Event) => any) | null = null; + width = testState.imageWidth; + + set src(_value: string) { + setTimeout(() => { + this.onload?.call(this as never, new Event('load')); + }, 0); + } +} + +const HookHarness = ({ link, linkHeight, linkWidth, thumbnailUrl }: { link: string; linkHeight: number; linkWidth: number; thumbnailUrl: string }) => { + latestValue = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); + 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 renderHook = async (props = { link: 'https://example.com', linkHeight: 360, linkWidth: 640, thumbnailUrl: '' }) => { + await act(async () => { + root.render(createElement(HookHarness, props)); + }); + await flushEffects(); +}; + +describe('useCommentMediaInfo', () => { + beforeEach(() => { + vi.clearAllMocks(); + latestValue = undefined; + testState.imageHeight = 360; + testState.imageWidth = 640; + testState.mediaInfo = undefined; + testState.params = {}; + testState.pathname = '/all'; + testState.thumbnailMediaInfo = undefined; + testState.getCommentMediaInfoMock.mockImplementation(() => testState.mediaInfo); + testState.fetchWebpageThumbnailIfNeededMock.mockImplementation(async () => testState.thumbnailMediaInfo ?? (testState.mediaInfo as CommentMediaInfo)); + + originalImage = globalThis.Image; + globalThis.Image = MockImage as never; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + globalThis.Image = originalImage; + }); + + it('returns the current media info without fetching thumbnails outside post and pending views', async () => { + testState.mediaInfo = { + thumbnail: 'https://cdn.example.com/thumb.jpg', + type: 'image', + url: 'https://cdn.example.com/image.jpg', + }; + + await renderHook({ + link: 'https://cdn.example.com/image.jpg', + linkHeight: 360, + linkWidth: 640, + thumbnailUrl: 'https://cdn.example.com/thumb.jpg', + }); + + expect(latestValue).toEqual(testState.mediaInfo); + expect(testState.fetchWebpageThumbnailIfNeededMock).not.toHaveBeenCalled(); + }); + + it('fetches webpage thumbnails on thread routes and stores the loaded dimensions', async () => { + testState.pathname = '/biz/thread/post-1'; + testState.params = { + boardIdentifier: 'biz', + commentCid: 'post-1', + }; + testState.mediaInfo = { + type: 'webpage', + url: 'https://example.com', + }; + testState.thumbnailMediaInfo = { + ...testState.mediaInfo, + thumbnail: 'https://cdn.example.com/thumb.jpg', + }; + + await renderHook({ + link: 'https://example.com', + linkHeight: 360, + linkWidth: 640, + thumbnailUrl: '', + }); + + expect(testState.fetchWebpageThumbnailIfNeededMock).toHaveBeenCalledWith(testState.mediaInfo); + expect(latestValue).toMatchObject({ + thumbnailHeight: 360, + thumbnailWidth: 640, + type: 'webpage', + url: 'https://example.com', + }); + }); + + it('skips webpage thumbnail fetching when the route is pending but a thumbnail already exists', async () => { + testState.pathname = '/pending/42'; + testState.params = { + accountCommentIndex: '42', + }; + testState.mediaInfo = { + thumbnail: 'https://cdn.example.com/existing-thumb.jpg', + type: 'webpage', + url: 'https://example.com/already-thumbnailed', + }; + + await renderHook({ + link: 'https://example.com/already-thumbnailed', + linkHeight: 360, + linkWidth: 640, + thumbnailUrl: 'https://cdn.example.com/existing-thumb.jpg', + }); + + expect(latestValue).toEqual(testState.mediaInfo); + expect(testState.fetchWebpageThumbnailIfNeededMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/use-is-subplebbit-offline.test.tsx b/src/hooks/__tests__/use-is-subplebbit-offline.test.tsx new file mode 100644 index 00000000..48474262 --- /dev/null +++ b/src/hooks/__tests__/use-is-subplebbit-offline.test.tsx @@ -0,0 +1,174 @@ +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 useIsSubplebbitOffline from '../use-is-subplebbit-offline'; + +(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(() => ({ + initializeMock: vi.fn(), + loadingTimestamps: [0] as number[], + requestedAddresses: undefined as string[] | undefined, + setOfflineStateMock: vi.fn(), + subplebbitOfflineState: {} as Record, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === 'posts_last_synced_info') { + return `posts_last_synced_info:${options?.time}`; + } + return key; + }, + }), +})); + +vi.mock('../../stores/use-subplebbit-offline-store', () => ({ + default: () => ({ + initializesubplebbitOfflineState: testState.initializeMock, + setSubplebbitOfflineState: testState.setOfflineStateMock, + subplebbitOfflineState: testState.subplebbitOfflineState, + }), +})); + +vi.mock('../../stores/use-subplebbits-loading-start-timestamps-store', () => ({ + default: (addresses?: string[]) => { + testState.requestedAddresses = addresses; + return testState.loadingTimestamps; + }, +})); + +vi.mock('../../lib/utils/time-utils', () => ({ + getFormattedTimeAgo: (timestamp: number) => `ago:${timestamp}`, +})); + +let latestValue: ReturnType; +let container: HTMLDivElement; +let root: Root; + +const HookHarness = ({ subplebbit }: { subplebbit?: { address?: string; state?: string; updatedAt?: number; updatingState?: string } }) => { + latestValue = useIsSubplebbitOffline(subplebbit as never); + return null; +}; + +const flushEffects = async (count = 3) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +}; + +const renderHook = async (subplebbit?: { address?: string; state?: string; updatedAt?: number; updatingState?: string }) => { + await act(async () => { + root.render(createElement(HookHarness, { subplebbit })); + }); + await flushEffects(); +}; + +describe('useIsSubplebbitOffline', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:10Z')); + + latestValue = { + isOffline: false, + isOnlineStatusLoading: false, + offlineIconClass: '', + offlineTitle: '', + }; + testState.loadingTimestamps = [1_704_067_200]; + testState.requestedAddresses = undefined; + testState.subplebbitOfflineState = {}; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + }); + + it('initializes unseen boards and reports a loading state during the first sync window', async () => { + await renderHook({ address: 'music.eth', state: 'updating', updatingState: 'fetching' }); + + expect(testState.requestedAddresses).toEqual(['music.eth']); + expect(testState.initializeMock).toHaveBeenCalledWith('music.eth'); + expect(testState.setOfflineStateMock).toHaveBeenCalledWith('music.eth', { + state: 'updating', + updatedAt: undefined, + updatingState: 'fetching', + }); + expect(latestValue).toEqual({ + isOffline: false, + isOnlineStatusLoading: true, + offlineIconClass: 'yellowOfflineIcon', + offlineTitle: 'downloading board...', + }); + }); + + it('reports boards with stale updates as offline and includes the last synced time', async () => { + const staleUpdatedAt = 1_704_052_000; + testState.subplebbitOfflineState = { + 'music.eth': { + initialLoad: false, + updatedAt: staleUpdatedAt, + }, + }; + testState.loadingTimestamps = [1_704_067_000]; + + await renderHook({ address: 'music.eth', state: 'stopped', updatedAt: staleUpdatedAt }); + + expect(testState.initializeMock).not.toHaveBeenCalled(); + expect(latestValue).toEqual({ + isOffline: true, + isOnlineStatusLoading: false, + offlineIconClass: 'redOfflineIcon', + offlineTitle: `posts_last_synced_info:ago:${staleUpdatedAt}`, + }); + }); + + it('marks boards without an update timestamp as offline once the loading timeout has elapsed', async () => { + testState.subplebbitOfflineState = { + 'music.eth': { + initialLoad: false, + }, + }; + testState.loadingTimestamps = [1_704_067_100]; + + await renderHook({ address: 'music.eth' }); + + expect(latestValue).toEqual({ + isOffline: true, + isOnlineStatusLoading: false, + offlineIconClass: 'redOfflineIcon', + offlineTitle: 'subplebbit_offline_info', + }); + }); + + it('treats recently updated boards as online', async () => { + const freshUpdatedAt = 1_704_067_205; + testState.subplebbitOfflineState = { + 'music.eth': { + initialLoad: false, + updatedAt: freshUpdatedAt, + }, + }; + + await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt }); + + expect(latestValue).toEqual({ + isOffline: false, + isOnlineStatusLoading: false, + offlineIconClass: '', + offlineTitle: false, + }); + }); +}); diff --git a/src/hooks/__tests__/use-post-page-number.test.tsx b/src/hooks/__tests__/use-post-page-number.test.tsx new file mode 100644 index 00000000..3be792f4 --- /dev/null +++ b/src/hooks/__tests__/use-post-page-number.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 { usePostPageNumber } from '../use-post-page-number'; + +(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(() => ({ + community: { address: 'music.eth', features: {} }, + feedsOptions: {} as Record, + loadedFeeds: {} as Record, + preloadFeed: undefined as Array<{ cid: string }> | undefined, + preloadOptions: undefined as Record | undefined, + sizes: { + guiPostsPerPage: 2, + paginationFeedPostsPerPage: 20, + }, +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ + useFeed: (options: Record | undefined) => { + testState.preloadOptions = options; + return { feed: testState.preloadFeed }; + }, +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/feeds', () => ({ + default: (selector: (state: { feedsOptions: Record; loadedFeeds: Record }) => unknown) => + selector({ + feedsOptions: testState.feedsOptions, + loadedFeeds: testState.loadedFeeds, + }), +})); + +vi.mock('../use-directories', () => ({ + useDirectoryByAddress: () => testState.community, +})); + +vi.mock('../use-board-feed-page-size', () => ({ + useBoardFeedPageSize: () => testState.sizes, +})); + +let latestValue: number | undefined; +let container: HTMLDivElement; +let root: Root; + +const HookHarness = ({ enabled = true, postCid, subplebbitAddress }: { enabled?: boolean; postCid?: string; subplebbitAddress?: string }) => { + latestValue = usePostPageNumber({ enabled, postCid, subplebbitAddress }); + return null; +}; + +const renderHook = (props: { enabled?: boolean; postCid?: string; subplebbitAddress?: string }) => { + act(() => { + root.render(createElement(HookHarness, props)); + }); + + return latestValue; +}; + +describe('usePostPageNumber', () => { + beforeEach(() => { + latestValue = undefined; + testState.community = { address: 'music.eth', features: {} }; + testState.feedsOptions = {}; + testState.loadedFeeds = {}; + testState.preloadFeed = undefined; + testState.preloadOptions = undefined; + testState.sizes = { + guiPostsPerPage: 2, + paginationFeedPostsPerPage: 20, + }; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('returns the cached board page when the post already exists in loaded feeds', () => { + testState.feedsOptions = { + boardFeed: { + sortType: 'active', + subplebbitAddresses: ['music.eth'], + }, + }; + testState.loadedFeeds = { + boardFeed: [{ cid: 'post-1' }, { cid: 'post-2' }, { cid: 'post-3' }], + }; + + expect(renderHook({ postCid: 'post-3', subplebbitAddress: 'music.eth' })).toBe(2); + expect(testState.preloadOptions).toEqual({ + postsPerPage: 20, + sortType: 'active', + subplebbitAddresses: ['music.eth'], + }); + }); + + it('falls back to the preloaded feed when cached feeds do not contain the post yet', () => { + testState.preloadFeed = [{ cid: 'post-1' }, { cid: 'post-2' }, { cid: 'post-3' }, { cid: 'post-4' }]; + + expect(renderHook({ postCid: 'post-4', subplebbitAddress: 'music.eth' })).toBe(2); + expect(testState.preloadOptions).toEqual({ + postsPerPage: 20, + sortType: 'active', + subplebbitAddresses: ['music.eth'], + }); + }); + + it('skips resolution entirely when the hook is disabled or required inputs are missing', () => { + testState.preloadFeed = [{ cid: 'post-1' }]; + + expect(renderHook({ enabled: false, postCid: 'post-1', subplebbitAddress: 'music.eth' })).toBeUndefined(); + expect(testState.preloadOptions).toBeUndefined(); + + expect(renderHook({ enabled: true, postCid: undefined, subplebbitAddress: 'music.eth' })).toBeUndefined(); + expect(testState.preloadOptions).toBeUndefined(); + }); +}); diff --git a/src/hooks/__tests__/use-scroll-to-reply.test.tsx b/src/hooks/__tests__/use-scroll-to-reply.test.tsx new file mode 100644 index 00000000..45901ebc --- /dev/null +++ b/src/hooks/__tests__/use-scroll-to-reply.test.tsx @@ -0,0 +1,152 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { VirtuosoHandle } from 'react-virtuoso'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useScrollToReply from '../use-scroll-to-reply'; + +(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 warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + +const testState = vi.hoisted(() => ({ + loadMoreMock: vi.fn(), + scrollToIndexMock: vi.fn(), +})); + +let container: HTMLDivElement; +let root: Root; + +const HookHarness = ({ + enabled = true, + hasMore, + replies, + targetReplyCid, +}: { + enabled?: boolean; + hasMore: boolean; + replies: Array<{ cid?: string | null }>; + targetReplyCid?: string; +}) => { + useScrollToReply({ + enabled, + hasMore, + loadMore: testState.loadMoreMock, + replies, + targetReplyCid, + virtuosoRef: { + current: { + scrollToIndex: testState.scrollToIndexMock, + } as unknown as VirtuosoHandle, + } as React.RefObject, + }); + + return null; +}; + +const renderHook = async (props: { enabled?: boolean; hasMore: boolean; replies: Array<{ cid?: string | null }>; targetReplyCid?: string }) => { + await act(async () => { + root.render(createElement(HookHarness, props)); + }); +}; + +describe('useScrollToReply', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.querySelectorAll('[data-cid]').forEach((node) => node.remove()); + vi.useRealTimers(); + }); + + it('scrolls directly to the target reply when it is already loaded', async () => { + await renderHook({ + hasMore: true, + replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }], + targetReplyCid: 'reply-2', + }); + + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(testState.scrollToIndexMock).toHaveBeenCalledWith({ + align: 'center', + behavior: 'smooth', + index: 1, + }); + expect(testState.loadMoreMock).not.toHaveBeenCalled(); + }); + + it('scrolls to the latest loaded reply and schedules loadMore while searching', async () => { + await renderHook({ + hasMore: true, + replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }], + targetReplyCid: 'reply-3', + }); + + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(testState.scrollToIndexMock).toHaveBeenCalledWith({ + align: 'end', + behavior: 'smooth', + index: 1, + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(testState.loadMoreMock).toHaveBeenCalledTimes(1); + }); + + it('uses DOM scrolling once all replies are loaded and the target element exists', async () => { + const replyElement = document.createElement('div'); + const scrollIntoViewMock = vi.fn(); + replyElement.dataset.cid = 'reply-3'; + replyElement.scrollIntoView = scrollIntoViewMock; + document.body.appendChild(replyElement); + + await renderHook({ + hasMore: false, + replies: [{ cid: 'reply-1' }], + targetReplyCid: 'reply-3', + }); + + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(scrollIntoViewMock).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + }); + expect(testState.scrollToIndexMock).not.toHaveBeenCalled(); + }); + + it('warns once the target reply cannot be found after all pages are loaded', async () => { + await renderHook({ + hasMore: false, + replies: [{ cid: 'reply-1' }], + targetReplyCid: 'reply-404', + }); + + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(warnSpy).toHaveBeenCalledWith('[scroll-to-reply] Could not find reply with CID "reply-404" in the feed.'); + expect(testState.loadMoreMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/utils/__tests__/challenge-utils.test.ts b/src/lib/utils/__tests__/challenge-utils.test.ts new file mode 100644 index 00000000..1018b0bf --- /dev/null +++ b/src/lib/utils/__tests__/challenge-utils.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { alertChallengeVerificationFailed, getPublicationPreview, getPublicationType, getVotePreview } from '../challenge-utils'; + +const alertMock = vi.fn(); +const originalAlert = globalThis.alert; +const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); +const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + +describe('challenge-utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.alert = alertMock; + }); + + afterEach(() => { + globalThis.alert = originalAlert; + }); + + it('alerts with merged object challenge errors, reason, and resolved board path', () => { + alertChallengeVerificationFailed( + { + challengeErrors: { + captcha: 'invalid captcha', + ignored: 42, + }, + challengeSuccess: false, + reason: 'try again later', + } as never, + { subplebbitAddress: 'business-and-finance.bso' }, + ); + + expect(warnSpy).toHaveBeenCalledWith( + 'Challenge Verification Failed:', + expect.objectContaining({ challengeSuccess: false }), + 'Publication:', + expect.objectContaining({ subplebbitAddress: 'business-and-finance.bso' }), + ); + expect(alertMock).toHaveBeenCalledWith('Error from /biz/: invalid captcha try again later'); + }); + + it('alerts with joined array challenge errors and falls back to the raw board address when unmapped', () => { + alertChallengeVerificationFailed( + { + challengeErrors: ['first error', 'second error'], + challengeSuccess: false, + } as never, + { subplebbitAddress: 'unknown-board.eth' }, + ); + + expect(alertMock).toHaveBeenCalledWith('Error from unknown-board.eth: first error second error'); + }); + + it('warns about invalid challenge error payloads and falls back to an unknown error', () => { + alertChallengeVerificationFailed( + { + challengeErrors: 'bad-shape', + challengeSuccess: false, + } as never, + {}, + ); + + expect(warnSpy).toHaveBeenCalledWith('challengeVerification.challengeErrors is not an object or array:', 'bad-shape'); + expect(alertMock).toHaveBeenCalledWith('Error from unknown board: unknown error'); + }); + + it('logs successful challenge verification instead of alerting', () => { + alertChallengeVerificationFailed({ challengeSuccess: true } as never, { subplebbitAddress: 'business-and-finance.bso' }); + + expect(logSpy).toHaveBeenCalledWith('Challenge verification succeeded:', expect.objectContaining({ challengeSuccess: true })); + expect(alertMock).not.toHaveBeenCalled(); + }); + + it('classifies publication types and vote previews', () => { + expect(getPublicationType(undefined)).toBeUndefined(); + expect(getPublicationType({ vote: 1 })).toBe('vote'); + expect(getPublicationType({ parentCid: 'reply-parent' })).toBe('reply'); + expect(getPublicationType({ commentCid: 'comment-cid' })).toBe('edit'); + expect(getPublicationType({ title: 'new thread' })).toBe('post'); + + expect(getVotePreview(undefined)).toBe(''); + expect(getVotePreview({ vote: 1 })).toBe(' +1'); + expect(getVotePreview({ vote: -1 })).toBe(' -1'); + }); + + it('builds publication previews from title, content, links, and truncation rules', () => { + expect(getPublicationPreview(undefined)).toBe(''); + expect(getPublicationPreview({ link: 'https://example.com/only-link' })).toBe('https://example.com/only-link'); + expect(getPublicationPreview({ title: 'Announcement', content: 'Now live' })).toBe('Announcement: Now live'); + expect( + getPublicationPreview({ + content: 'a'.repeat(80), + }), + ).toBe(`${'a'.repeat(50)}...`); + }); +}); diff --git a/src/lib/utils/__tests__/media-utils.test.ts b/src/lib/utils/__tests__/media-utils.test.ts new file mode 100644 index 00000000..f8c9c975 --- /dev/null +++ b/src/lib/utils/__tests__/media-utils.test.ts @@ -0,0 +1,284 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const testState = vi.hoisted(() => ({ + cachedThumbnails: new Map(), + canEmbedHosts: new Set(), + capacitorHttpGetMock: vi.fn(), + consoleErrorMock: vi.fn(), + fetchMock: vi.fn(), + isNativePlatform: false, + localForageGetItemMock: vi.fn(), + localForageSetItemMock: vi.fn(), +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({ + default: { + createInstance: () => ({ + getItem: (url: string) => testState.localForageGetItemMock(url), + setItem: (url: string, thumbnail: string) => testState.localForageSetItemMock(url, thumbnail), + }), + }, +})); + +vi.mock('../../../components/embed', () => ({ + canEmbed: (url: URL) => testState.canEmbedHosts.has(url.hostname), +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: { + isNativePlatform: () => testState.isNativePlatform, + }, + CapacitorHttp: { + get: (options: unknown) => testState.capacitorHttpGetMock(options), + }, +})); + +import { fetchWebpageThumbnailIfNeeded, getCommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getLinkMediaInfo, getMediaDimensions } from '../media-utils'; + +const clearMemoizedCache = (fn: unknown) => { + const memoized = fn as { clear?: () => void }; + memoized.clear?.(); +}; + +const createFetchResponse = (html: string, ok = true) => { + let sent = false; + + return { + body: { + getReader: () => ({ + read: async () => { + if (sent) { + return { done: true, value: undefined }; + } + + sent = true; + return { + done: false, + value: new TextEncoder().encode(html), + }; + }, + }), + }, + ok, + }; +}; + +describe('media-utils', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + testState.cachedThumbnails = new Map(); + testState.canEmbedHosts = new Set(); + testState.isNativePlatform = false; + testState.localForageGetItemMock.mockImplementation(async (url: string) => testState.cachedThumbnails.get(url) ?? null); + testState.localForageSetItemMock.mockImplementation(async (url: string, thumbnail: string) => { + testState.cachedThumbnails.set(url, thumbnail); + }); + testState.fetchMock.mockReset(); + testState.capacitorHttpGetMock.mockReset(); + vi.stubGlobal('fetch', testState.fetchMock); + clearMemoizedCache(getHasThumbnail); + clearMemoizedCache(getLinkMediaInfo); + clearMemoizedCache(getMediaDimensions); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(testState.consoleErrorMock); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + vi.unstubAllGlobals(); + }); + + it('maps media types to translated labels', () => { + const t = (key: string) => `translated:${key}`; + + expect(getDisplayMediaInfoType('image', t)).toBe('translated:image'); + expect(getDisplayMediaInfoType('gif', t)).toBe('translated:gif'); + expect(getDisplayMediaInfoType('animated gif', t)).toBe('translated:animated_gif'); + expect(getDisplayMediaInfoType('iframe', t)).toBe('translated:iframe'); + expect(getDisplayMediaInfoType('video', t)).toBe('translated:video'); + expect(getDisplayMediaInfoType('audio', t)).toBe('translated:audio'); + expect(getDisplayMediaInfoType('unknown', t)).toBe('translated:webpage'); + }); + + it('recognizes which media types expose thumbnails', () => { + expect(getHasThumbnail(undefined, 'https://example.com/file.png')).toBe(false); + expect(getHasThumbnail({ type: 'image', url: 'https://example.com/file.png' }, 'https://example.com/file.png')).toBe(true); + expect(getHasThumbnail({ type: 'video', url: 'https://example.com/file.mp4' }, 'https://example.com/file.mp4')).toBe(true); + expect(getHasThumbnail({ type: 'audio', url: 'https://example.com/file.mp3' }, 'https://example.com/file.mp3')).toBe(true); + expect(getHasThumbnail({ type: 'gif', url: 'https://example.com/file.gif' }, 'https://example.com/file.gif')).toBe(true); + expect(getHasThumbnail({ thumbnail: 'https://example.com/thumb.png', type: 'webpage', url: 'https://example.com' }, 'https://example.com')).toBe(true); + expect( + getHasThumbnail( + { patternThumbnailUrl: 'https://img.youtube.com/vi/abc/0.jpg', type: 'iframe', url: 'https://www.youtube.com/watch?v=abc' }, + 'https://www.youtube.com/watch?v=abc', + ), + ).toBe(true); + expect(getHasThumbnail({ type: 'iframe', url: 'https://example.com/embed' }, 'https://example.com/embed')).toBe(false); + }); + + it('classifies direct media, embeds, imgbb pages, and unknown links', () => { + testState.canEmbedHosts = new Set(['www.youtube.com', 'streamable.com']); + + expect(getLinkMediaInfo('not-a-url')).toBeUndefined(); + expect(getLinkMediaInfo('https://example.com/_next/image?url=%2Fposter.png')).toMatchObject({ type: 'image' }); + expect(getLinkMediaInfo('https://ibb.co/abc123')).toEqual({ + thumbnail: 'https://i.ibb.co/abc123/thumbnail.jpg', + type: 'webpage', + url: 'https://ibb.co/abc123', + }); + expect(getLinkMediaInfo('https://example.com/file.gif')).toMatchObject({ type: 'gif' }); + expect(getLinkMediaInfo('https://example.com/file.png')).toMatchObject({ type: 'image' }); + expect(getLinkMediaInfo('https://example.com/file.mp4')).toMatchObject({ type: 'video' }); + expect(getLinkMediaInfo('https://example.com/file.mp3')).toMatchObject({ type: 'audio' }); + expect(getLinkMediaInfo('https://example.com/path')).toMatchObject({ type: 'webpage' }); + expect(getLinkMediaInfo('https://www.youtube.com/watch?v=abc123')).toEqual({ + patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', + type: 'iframe', + url: 'https://www.youtube.com/watch?v=abc123', + }); + expect(getLinkMediaInfo('https://streamable.com/clip123')).toEqual({ + patternThumbnailUrl: 'https://cdn-cf-east.streamable.com/image/clip123.jpg', + type: 'iframe', + url: 'https://streamable.com/clip123', + }); + expect(getLinkMediaInfo('https://yt.example/watch?v=yt123')).toEqual({ + patternThumbnailUrl: 'https://img.youtube.com/vi/yt123/0.jpg', + type: 'iframe', + url: 'https://yt.example/watch?v=yt123', + }); + }); + + it('builds comment media info and strips thumbnails for blacklisted domains', () => { + testState.canEmbedHosts = new Set(['www.youtube.com']); + + expect(getCommentMediaInfo('', '', 0, 0)).toBeUndefined(); + expect(getCommentMediaInfo('https://example.com/file.png', 'https://example.com/thumb.png', 320, 240)).toEqual({ + linkHeight: 240, + linkWidth: 320, + thumbnail: 'https://example.com/thumb.png', + type: 'image', + url: 'https://example.com/file.png', + }); + expect(getCommentMediaInfo('https://x.com/post/123', 'https://example.com/thumb.png', 100, 50)).toEqual({ + linkHeight: 50, + linkWidth: 100, + patternThumbnailUrl: undefined, + thumbnail: undefined, + type: 'webpage', + url: 'https://x.com/post/123', + }); + expect(getCommentMediaInfo('https://www.youtube.com/watch?v=abc123', '', 800, 450)).toEqual({ + linkHeight: 450, + linkWidth: 800, + patternThumbnailUrl: 'https://img.youtube.com/vi/abc123/0.jpg', + thumbnail: undefined, + type: 'iframe', + url: 'https://www.youtube.com/watch?v=abc123', + }); + }); + + it('returns expected media dimensions for embeds, audio, and sized media', () => { + testState.canEmbedHosts = new Set(['www.youtube.com', 'www.reddit.com']); + + expect(getMediaDimensions({ type: 'iframe', url: 'https://www.youtube.com/watch?v=abc123' })).toBe('800x450'); + expect(getMediaDimensions({ type: 'iframe', url: 'https://www.reddit.com/r/example/comments/abc123' })).toBe('500x520'); + expect(getMediaDimensions({ type: 'audio', url: 'https://example.com/file.mp3' })).toBe('700x240'); + expect(getMediaDimensions({ linkHeight: 480, linkWidth: 640, type: 'image', url: 'https://example.com/file.png' })).toBe('640x480'); + expect(getMediaDimensions({ linkHeight: 720, linkWidth: 1280, type: 'video', url: 'https://example.com/file.mp4' })).toBe('1280x720'); + expect(getMediaDimensions({ type: 'webpage', url: 'https://example.com' })).toBe(''); + }); + + it('uses cached webpage thumbnails before fetching the network', async () => { + testState.cachedThumbnails.set('https://example.com/cached', 'https://cdn.example/cached.png'); + + const result = await fetchWebpageThumbnailIfNeeded({ + type: 'webpage', + url: 'https://example.com/cached', + }); + + expect(result).toEqual({ + thumbnail: 'https://cdn.example/cached.png', + type: 'webpage', + url: 'https://example.com/cached', + }); + expect(testState.fetchMock).not.toHaveBeenCalled(); + }); + + it('fetches og:image thumbnails on web and persists them', async () => { + testState.fetchMock.mockResolvedValue( + createFetchResponse(` + + + + + `), + ); + + const result = await fetchWebpageThumbnailIfNeeded({ + type: 'webpage', + url: 'https://example.com/og-page', + }); + + expect(testState.fetchMock).toHaveBeenCalledWith('https://example.com/og-page', expect.objectContaining({ headers: { Accept: 'text/html' } })); + expect(testState.localForageSetItemMock).toHaveBeenCalledWith('https://example.com/og-page', 'https://cdn.example/og.png'); + expect(result).toEqual({ + thumbnail: 'https://cdn.example/og.png', + type: 'webpage', + url: 'https://example.com/og-page', + }); + }); + + it('fetches first-image thumbnails on native and resolves relative urls', async () => { + testState.isNativePlatform = true; + testState.capacitorHttpGetMock.mockResolvedValue({ + data: ` + + + + `, + }); + + const result = await fetchWebpageThumbnailIfNeeded({ + type: 'webpage', + url: 'https://example.com/native-page', + }); + + expect(testState.capacitorHttpGetMock).toHaveBeenCalledWith( + expect.objectContaining({ + connectTimeout: 5000, + headers: { Accept: 'text/html', Range: 'bytes=0-1048575' }, + readTimeout: 5000, + responseType: 'text', + url: 'https://example.com/native-page', + }), + ); + expect(result).toEqual({ + thumbnail: 'https://example.com/poster.png', + type: 'webpage', + url: 'https://example.com/native-page', + }); + }); + + it('returns unchanged media when thumbnails already exist or fetching fails', async () => { + const existing = { + thumbnail: 'https://cdn.example/existing.png', + type: 'webpage', + url: 'https://example.com/ready', + } as const; + expect(await fetchWebpageThumbnailIfNeeded(existing)).toBe(existing); + + testState.fetchMock.mockResolvedValue(createFetchResponse('', false)); + const result = await fetchWebpageThumbnailIfNeeded({ + type: 'webpage', + url: 'https://example.com/failure', + }); + + expect(result).toEqual({ + thumbnail: undefined, + type: 'webpage', + url: 'https://example.com/failure', + }); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/utils/__tests__/pattern-utils.test.ts b/src/lib/utils/__tests__/pattern-utils.test.ts new file mode 100644 index 00000000..367fc5b7 --- /dev/null +++ b/src/lib/utils/__tests__/pattern-utils.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const testState = vi.hoisted(() => ({ + consoleErrorMock: vi.fn(), + subplebbits: {} as Record }>, +})); + +vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({ + default: { + getState: () => ({ + subplebbits: testState.subplebbits, + }), + }, +})); + +import { commentMatchesPattern, displayNameMatchesPattern, matchesPattern, parsePattern, userHasRole, userIdMatchesPattern } from '../pattern-utils'; + +describe('pattern-utils', () => { + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + testState.subplebbits = { + 'music-posting.eth': { + roles: { + 'author-1': { role: 'moderator' }, + 'author-2': { role: 'owner' }, + }, + }, + }; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(testState.consoleErrorMock); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('matches whole words, exact phrases, regexes, wildcards, and logical operators', () => { + expect(matchesPattern('That feel when the girlfriend texts back', 'feel')).toBe(true); + expect(matchesPattern('That feel when the girlfriend texts back', 'feels')).toBe(false); + expect(matchesPattern('That feel when the girlfriend texts back', 'feel girlfriend')).toBe(true); + expect(matchesPattern('That feel when the girlfriend texts back', 'girlfriend|boyfriend feel')).toBe(true); + expect(matchesPattern('That feel when the girlfriend texts back', '"feel when the girlfriend"')).toBe(true); + expect(matchesPattern('That feeling stays forever', 'feel*')).toBe(true); + expect(matchesPattern('MIXED Case Example', '/mixed case example/i')).toBe(true); + }); + + it('falls back to a simple include when pattern parsing throws', () => { + expect(matchesPattern('the broken marker foo(', 'foo(')).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + + it('matches user ids through full and short addresses', () => { + const comment = { + author: { + address: '12D3KooWabcdef', + shortAddress: '12D3KooWabc', + }, + }; + + expect(userIdMatchesPattern(comment as never, 'abcdef')).toBe(true); + expect(userIdMatchesPattern(comment as never, '12D3KooWabc')).toBe(true); + expect(userIdMatchesPattern(comment as never, 'missing')).toBe(false); + }); + + it('matches display names case-insensitively and treats anonymous as undefined display names', () => { + expect(displayNameMatchesPattern({ author: { displayName: 'Alice' } } as never, 'alice')).toBe(true); + expect(displayNameMatchesPattern({ author: {} } as never, 'anonymous')).toBe(true); + expect(displayNameMatchesPattern({ author: { displayName: 'Bob' } } as never, 'anonymous')).toBe(false); + }); + + it('matches roles with moderator aliases and rejects missing role metadata', () => { + const modComment = { + author: { address: 'author-1' }, + subplebbitAddress: 'music-posting.eth', + }; + const ownerComment = { + author: { address: 'author-2' }, + subplebbitAddress: 'music-posting.eth', + }; + + expect(userHasRole(modComment as never, 'moderator')).toBe(true); + expect(userHasRole(modComment as never, 'mod')).toBe(true); + expect(userHasRole(ownerComment as never, 'owner')).toBe(true); + expect(userHasRole(ownerComment as never, 'admin')).toBe(false); + expect(userHasRole({ author: { address: 'missing' }, subplebbitAddress: 'unknown.eth' } as never, 'moderator')).toBe(false); + }); + + it('parses mixed special filters and content filters', () => { + expect(parsePattern('#abc ##Alice #!#mod exact phrase')).toEqual({ + contentFilter: 'exact phrase', + specialFilters: [ + { type: 'userId', value: 'abc' }, + { type: 'displayName', value: 'Alice' }, + { type: 'role', value: 'mod' }, + ], + }); + expect(parsePattern('')).toEqual({ + contentFilter: '', + specialFilters: [], + }); + }); + + it('matches comments against combined special filters and content filters', () => { + const comment = { + author: { + address: 'author-1', + displayName: 'Alice', + shortAddress: 'auth1', + }, + content: 'That feel when the girlfriend texts back', + subplebbitAddress: 'music-posting.eth', + title: 'TFW', + }; + + expect(commentMatchesPattern(comment as never, '#auth1 ##Alice #!#moderator girlfriend')).toBe(true); + expect(commentMatchesPattern(comment as never, '#auth1 ##Bob #!#moderator girlfriend')).toBe(false); + expect(commentMatchesPattern(comment as never, '#auth1')).toBe(true); + expect(commentMatchesPattern(comment as never, '##Alice')).toBe(true); + expect(commentMatchesPattern(comment as never, '#!#mod')).toBe(true); + expect(commentMatchesPattern(comment as never, 'tfw girlfriend')).toBe(true); + }); +}); diff --git a/src/lib/utils/__tests__/post-page-resolution.test.ts b/src/lib/utils/__tests__/post-page-resolution.test.ts new file mode 100644 index 00000000..8911e1a7 --- /dev/null +++ b/src/lib/utils/__tests__/post-page-resolution.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { findPostPageInFeed, findPostPageInLoadedBoardFeeds, isBoardFeedOptions } from '../post-page-resolution'; + +describe('post-page-resolution', () => { + it('finds the GUI page for a post inside a feed and rejects invalid inputs', () => { + const feed = [{ cid: 'post-1' }, { cid: 'post-2' }, { cid: 'post-3' }, { cid: 'post-4' }]; + + expect(findPostPageInFeed(feed, 'post-3', 2)).toBe(2); + expect(findPostPageInFeed(feed, 'missing-post', 2)).toBeUndefined(); + expect(findPostPageInFeed(feed, 'post-1', 0)).toBeUndefined(); + expect(findPostPageInFeed(feed, '', 2)).toBeUndefined(); + }); + + it('only accepts strict board feed options for the active single-board feed', () => { + const baseOptions = { + sortType: 'active', + subplebbitAddresses: ['music.eth'], + }; + + expect(isBoardFeedOptions(baseOptions, 'music.eth')).toBe(true); + expect(isBoardFeedOptions({ ...baseOptions, sortType: 'new' }, 'music.eth')).toBe(false); + expect(isBoardFeedOptions({ ...baseOptions, subplebbitAddresses: ['music.eth', 'tech.eth'] }, 'music.eth')).toBe(false); + expect(isBoardFeedOptions({ ...baseOptions, subplebbitAddresses: ['tech.eth'] }, 'music.eth')).toBe(false); + expect(isBoardFeedOptions({ ...baseOptions, filter: { title: 'test' } }, 'music.eth')).toBe(false); + expect(isBoardFeedOptions({ ...baseOptions, newerThan: 3600 }, 'music.eth')).toBe(false); + expect(isBoardFeedOptions({ ...baseOptions, modQueue: true }, 'music.eth')).toBe(false); + expect(isBoardFeedOptions({ ...baseOptions, accountComments: true }, 'music.eth')).toBe(false); + }); + + it('resolves a post page from matching loaded board feeds and ignores unrelated feeds', () => { + const feedsOptions = { + allFeed: { + sortType: 'active', + subplebbitAddresses: ['all.eth'], + }, + catalogFilterFeed: { + filter: { title: 'match' }, + sortType: 'active', + subplebbitAddresses: ['music.eth'], + }, + boardFeed: { + sortType: 'active', + subplebbitAddresses: ['music.eth'], + }, + }; + const loadedFeeds = { + allFeed: [{ cid: 'post-4' }], + boardFeed: [{ cid: 'post-1' }, { cid: 'post-2' }, { cid: 'post-3' }, { cid: 'post-4' }], + }; + + expect(findPostPageInLoadedBoardFeeds(feedsOptions, loadedFeeds, 'music.eth', 'post-4', 2)).toBe(2); + expect(findPostPageInLoadedBoardFeeds(feedsOptions, loadedFeeds, 'music.eth', 'missing-post', 2)).toBeUndefined(); + expect(findPostPageInLoadedBoardFeeds(feedsOptions, { boardFeed: 'not-an-array' as never }, 'music.eth', 'post-4', 2)).toBeUndefined(); + }); +});