mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test: expand hook and utility runtime coverage
This commit is contained in:
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
directoryEntry: { features: {} } as { features?: Record<string, unknown> } | 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();
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
fetchWebpageThumbnailIfNeededMock: vi.fn<(mediaInfo: CommentMediaInfo) => Promise<CommentMediaInfo>>(),
|
||||
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<string, string>,
|
||||
pathname: '/all',
|
||||
thumbnailMediaInfo: undefined as CommentMediaInfo | undefined,
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useLocation: () => ({ pathname: testState.pathname }),
|
||||
useParams: () => testState.params,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../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();
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
initializeMock: vi.fn(),
|
||||
loadingTimestamps: [0] as number[],
|
||||
requestedAddresses: undefined as string[] | undefined,
|
||||
setOfflineStateMock: vi.fn(),
|
||||
subplebbitOfflineState: {} as Record<string, { initialLoad: boolean; state?: string; updatedAt?: number; updatingState?: string }>,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
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<typeof useIsSubplebbitOffline>;
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
community: { address: 'music.eth', features: {} },
|
||||
feedsOptions: {} as Record<string, unknown>,
|
||||
loadedFeeds: {} as Record<string, unknown>,
|
||||
preloadFeed: undefined as Array<{ cid: string }> | undefined,
|
||||
preloadOptions: undefined as Record<string, unknown> | undefined,
|
||||
sizes: {
|
||||
guiPostsPerPage: 2,
|
||||
paginationFeedPostsPerPage: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useFeed: (options: Record<string, unknown> | undefined) => {
|
||||
testState.preloadOptions = options;
|
||||
return { feed: testState.preloadFeed };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/feeds', () => ({
|
||||
default: (selector: (state: { feedsOptions: Record<string, unknown>; loadedFeeds: Record<string, unknown> }) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
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<VirtuosoHandle | null>,
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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)}...`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
cachedThumbnails: new Map<string, string>(),
|
||||
canEmbedHosts: new Set<string>(),
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.cachedThumbnails = new Map<string, string>();
|
||||
testState.canEmbedHosts = new Set<string>();
|
||||
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(`
|
||||
<html>
|
||||
<head><meta property="og:image" content="https://cdn.example/og.png" /></head>
|
||||
<body></body>
|
||||
</html>
|
||||
`),
|
||||
);
|
||||
|
||||
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: `
|
||||
<html>
|
||||
<body><img src="/poster.png" /></body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
|
||||
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('<html></html>', 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
consoleErrorMock: vi.fn(),
|
||||
subplebbits: {} as Record<string, { roles?: Record<string, { role?: string }> }>,
|
||||
}));
|
||||
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user