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