mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test: cover edit, challenge, advanced settings, and gif preview flows
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
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 ChallengeModal from '../challenge-modal';
|
||||
|
||||
(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(() => ({
|
||||
abandonCurrentChallengeMock: vi.fn().mockResolvedValue(undefined),
|
||||
account: {
|
||||
author: {
|
||||
address: '0xabc123',
|
||||
},
|
||||
} as Record<string, any>,
|
||||
challenges: [] as Array<{ challenge: any; id: number }>,
|
||||
commentsByCid: {} as Record<string, { author?: { shortAddress?: string } }>,
|
||||
publicationPreview: 'preview body',
|
||||
publicationType: 'post',
|
||||
removeChallengeMock: vi.fn(),
|
||||
springStartMock: vi.fn(),
|
||||
theme: 'dark',
|
||||
votePreview: 'upvote',
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (key === 'challenge_counter') {
|
||||
return `${options?.index}/${options?.total}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => testState.account,
|
||||
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/utils/challenge-utils', () => ({
|
||||
getPublicationPreview: () => testState.publicationPreview,
|
||||
getPublicationType: () => testState.publicationType,
|
||||
getVotePreview: () => testState.votePreview,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-theme', () => ({
|
||||
default: () => [testState.theme],
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-challenges-store', () => ({
|
||||
default: () => ({
|
||||
abandonCurrentChallenge: testState.abandonCurrentChallengeMock,
|
||||
challenges: testState.challenges,
|
||||
removeChallenge: testState.removeChallengeMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@react-spring/web', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react');
|
||||
return {
|
||||
animated: {
|
||||
div: React.forwardRef(({ children, style, ...props }: any, ref) => createElement('div', { ...props, ref, style: { touchAction: style?.touchAction } }, children)),
|
||||
},
|
||||
useSpring: () => [
|
||||
{
|
||||
x: { get: () => 120 },
|
||||
y: { get: () => 60 },
|
||||
},
|
||||
{ start: testState.springStartMock },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@use-gesture/react', () => ({
|
||||
useDrag: () => () => ({}),
|
||||
}));
|
||||
|
||||
let alertSpy: ReturnType<typeof vi.spyOn>;
|
||||
let container: HTMLDivElement;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let postMessageMock: ReturnType<typeof vi.fn>;
|
||||
let root: Root;
|
||||
|
||||
const createPublication = () => ({
|
||||
author: { displayName: 'Alice' },
|
||||
content: 'Publication content',
|
||||
link: 'https://example.com/link',
|
||||
parentCid: 'parent-1',
|
||||
publishChallengeAnswers: vi.fn(),
|
||||
shortSubplebbitAddress: 'mu',
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
title: 'Subject',
|
||||
});
|
||||
|
||||
const createStoredChallenge = (challenge: any, publication = createPublication(), publicationTarget?: Record<string, unknown>) => ({
|
||||
challenge: [{ challenges: Array.isArray(challenge) ? challenge : [challenge] }, publication, publicationTarget],
|
||||
id: 1,
|
||||
});
|
||||
|
||||
const renderModal = async () => {
|
||||
await act(async () => {
|
||||
root.render(createElement(ChallengeModal));
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchInput = async (element: HTMLInputElement, value: string) => {
|
||||
await act(async () => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
|
||||
descriptor?.set?.call(element, value);
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const clickButton = async (text: string) => {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
describe('ChallengeModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.abandonCurrentChallengeMock.mockReset().mockResolvedValue(undefined);
|
||||
testState.account = {
|
||||
author: {
|
||||
address: '0xabc123',
|
||||
},
|
||||
};
|
||||
testState.challenges = [];
|
||||
testState.commentsByCid = {
|
||||
'parent-1': {
|
||||
author: {
|
||||
shortAddress: '0xparent',
|
||||
},
|
||||
},
|
||||
};
|
||||
testState.publicationPreview = 'preview body';
|
||||
testState.publicationType = 'post';
|
||||
testState.removeChallengeMock.mockReset();
|
||||
testState.springStartMock.mockReset();
|
||||
testState.theme = 'dark';
|
||||
testState.votePreview = 'upvote';
|
||||
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
postMessageMock = vi.fn();
|
||||
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
|
||||
configurable: true,
|
||||
get: () => ({
|
||||
postMessage: postMessageMock,
|
||||
}),
|
||||
});
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
alertSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders nothing when there are no queued challenges', async () => {
|
||||
await renderModal();
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('submits a text challenge answer on Enter and closes the modal', async () => {
|
||||
const publication = createPublication();
|
||||
testState.publicationType = 'reply';
|
||||
testState.challenges = [
|
||||
createStoredChallenge(
|
||||
{
|
||||
challenge: '2 + 2',
|
||||
type: 'text/plain',
|
||||
},
|
||||
publication,
|
||||
),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
expect(container.textContent).toContain('Challenge for reply');
|
||||
expect(container.textContent).toContain('1/1');
|
||||
expect(container.querySelector('textarea')?.textContent ?? container.textContent).toContain('Publication content');
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[placeholder*="TYPE THE ANSWER HERE"]');
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await dispatchInput(input as HTMLInputElement, '4');
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
|
||||
});
|
||||
|
||||
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['4']);
|
||||
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('supports multi-step image challenges with next and previous navigation', async () => {
|
||||
const publication = createPublication();
|
||||
testState.challenges = [
|
||||
createStoredChallenge(
|
||||
[
|
||||
{
|
||||
challenge: 'first answer',
|
||||
type: 'text/plain',
|
||||
},
|
||||
{
|
||||
challenge: 'base64-image',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
publication,
|
||||
),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
expect(container.textContent).toContain('1/2');
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>('input[placeholder*="TYPE THE ANSWER HERE"]');
|
||||
await dispatchInput(input as HTMLInputElement, 'step one');
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('2/2');
|
||||
expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,base64-image');
|
||||
|
||||
await clickButton('previous');
|
||||
expect(container.textContent).toContain('1/2');
|
||||
|
||||
await clickButton('next');
|
||||
await dispatchInput(container.querySelector<HTMLInputElement>('input[placeholder*="TYPE THE ANSWER HERE"]') as HTMLInputElement, 'step two');
|
||||
await clickButton('submit');
|
||||
|
||||
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['step one', 'step two']);
|
||||
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('opens iframe challenges, injects the theme, and completes them', async () => {
|
||||
const publication = createPublication();
|
||||
testState.challenges = [
|
||||
createStoredChallenge(
|
||||
{
|
||||
challenge: 'https://mintpass.org/auth?user={userAddress}',
|
||||
type: 'url/iframe',
|
||||
},
|
||||
publication,
|
||||
),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
expect(container.textContent).toContain('mu wants to open mintpass.org');
|
||||
|
||||
await clickButton('Open');
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute('src')).toContain('https://mintpass.org/auth?user=0xabc123&theme=dark');
|
||||
|
||||
await act(async () => {
|
||||
iframe?.dispatchEvent(new Event('load', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(postMessageMock).toHaveBeenCalledWith(
|
||||
{
|
||||
source: 'plebbit-5chan',
|
||||
theme: 'dark',
|
||||
type: 'plebbit-theme',
|
||||
},
|
||||
'https://mintpass.org',
|
||||
);
|
||||
|
||||
await clickButton('Done');
|
||||
expect(publication.publishChallengeAnswers).toHaveBeenCalledWith(['']);
|
||||
expect(testState.removeChallengeMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('alerts when iframe challenges need a signer address and the account is missing one', async () => {
|
||||
testState.account = { author: { address: '' } };
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'https://mintpass.org/auth?user={userAddress}',
|
||||
type: 'url/iframe',
|
||||
}),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
await clickButton('Open');
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('Error: Unable to load challenge without your address. Please sign in and try again.');
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('abandons invalid iframe challenges and responds to Escape', async () => {
|
||||
testState.challenges = [
|
||||
createStoredChallenge({
|
||||
challenge: 'http://example.com/unsafe',
|
||||
type: 'url/iframe',
|
||||
}),
|
||||
];
|
||||
|
||||
await renderModal();
|
||||
await clickButton('Open');
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('Error: Invalid URL for authentication challenge');
|
||||
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
});
|
||||
|
||||
expect(testState.abandonCurrentChallengeMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
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 EditMenu from '../edit-menu';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: {
|
||||
author: {
|
||||
address: '0xmod',
|
||||
displayName: 'Moderator',
|
||||
shortAddress: '0xmod',
|
||||
},
|
||||
signer: {
|
||||
address: '0xauthor',
|
||||
},
|
||||
} as Record<string, any>,
|
||||
addChallengeMock: vi.fn(),
|
||||
authorOptions: undefined as Record<string, any> | undefined,
|
||||
isMobile: false,
|
||||
modOptions: undefined as Record<string, any> | undefined,
|
||||
privileges: {
|
||||
isAccountCommentAuthor: false,
|
||||
isAccountMod: false,
|
||||
isCommentAuthorMod: false,
|
||||
},
|
||||
publishAuthorEditMock: vi.fn().mockResolvedValue(undefined),
|
||||
publishCommentModerationMock: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ components, i18nKey }: { components?: Record<number, React.ReactElement>; i18nKey: string }) =>
|
||||
createElement(
|
||||
'span',
|
||||
{ 'data-testid': `trans-${i18nKey}` },
|
||||
i18nKey,
|
||||
components?.[1] ? React.cloneElement(components[1], { 'data-testid': 'ban-duration-input' }) : null,
|
||||
),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@floating-ui/react', () => ({
|
||||
FloatingFocusManager: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children),
|
||||
FloatingPortal: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children),
|
||||
autoUpdate: () => undefined,
|
||||
offset: () => ({}),
|
||||
shift: () => ({}),
|
||||
useClick: () => ({}),
|
||||
useDismiss: () => ({}),
|
||||
useFloating: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => ({
|
||||
context: {
|
||||
open,
|
||||
onOpenChange,
|
||||
},
|
||||
floatingStyles: {},
|
||||
refs: {
|
||||
setFloating: () => undefined,
|
||||
setReference: () => undefined,
|
||||
},
|
||||
}),
|
||||
useId: () => 'edit-menu-heading',
|
||||
useInteractions: () => ({
|
||||
getFloatingProps: (props?: Record<string, unknown>) => props || {},
|
||||
getReferenceProps: (props?: Record<string, unknown>) => props || {},
|
||||
}),
|
||||
useRole: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => testState.account,
|
||||
usePublishCommentEdit: (options: Record<string, any>) => {
|
||||
testState.authorOptions = options;
|
||||
return { publishCommentEdit: testState.publishAuthorEditMock };
|
||||
},
|
||||
usePublishCommentModeration: (options: Record<string, any>) => {
|
||||
testState.modOptions = options;
|
||||
return { publishCommentModeration: testState.publishCommentModerationMock };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-author-privileges', () => ({
|
||||
default: () => testState.privileges,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-mobile', () => ({
|
||||
default: () => testState.isMobile,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-challenges-store', () => {
|
||||
const hook = () => ({ challenges: [] });
|
||||
return {
|
||||
default: Object.assign(hook, {
|
||||
getState: () => ({
|
||||
addChallenge: testState.addChallengeMock,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let alertSpy: ReturnType<typeof vi.spyOn>;
|
||||
let confirmSpy: ReturnType<typeof vi.spyOn>;
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const basePost = {
|
||||
author: {
|
||||
address: '0xauthor',
|
||||
displayName: 'Alice',
|
||||
},
|
||||
cid: 'comment-1',
|
||||
content: 'Original content',
|
||||
deleted: false,
|
||||
locked: false,
|
||||
parentCid: undefined as string | undefined,
|
||||
pinned: false,
|
||||
postCid: 'post-1',
|
||||
reason: '',
|
||||
removed: false,
|
||||
spoiler: false,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
} as Record<string, any>;
|
||||
|
||||
const renderMenu = async (post = basePost) => {
|
||||
await act(async () => {
|
||||
root.render(createElement(EditMenu, { post } as any));
|
||||
});
|
||||
};
|
||||
|
||||
const click = async (element: Element | null | undefined) => {
|
||||
await act(async () => {
|
||||
element?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
await act(async () => {
|
||||
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
||||
descriptor?.set?.call(element, value);
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const openMenu = async () => {
|
||||
await click(container.querySelector('span input[type="checkbox"]'));
|
||||
};
|
||||
|
||||
const getCheckbox = (id: string) => container.querySelector<HTMLInputElement>(`#${id}`);
|
||||
|
||||
const getLabelCheckbox = (text: string) =>
|
||||
Array.from(container.querySelectorAll('label input[type="checkbox"]')).find((candidate) => candidate.parentElement?.textContent?.includes(text)) ?? null;
|
||||
|
||||
const clickButton = async (text: string) => {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
||||
await click(button);
|
||||
};
|
||||
|
||||
describe('EditMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-08T00:00:00Z'));
|
||||
testState.account = {
|
||||
author: {
|
||||
address: '0xmod',
|
||||
displayName: 'Moderator',
|
||||
shortAddress: '0xmod',
|
||||
},
|
||||
signer: {
|
||||
address: '0xauthor',
|
||||
},
|
||||
};
|
||||
testState.authorOptions = undefined;
|
||||
testState.isMobile = false;
|
||||
testState.modOptions = undefined;
|
||||
testState.privileges = {
|
||||
isAccountCommentAuthor: false,
|
||||
isAccountMod: false,
|
||||
isCommentAuthorMod: false,
|
||||
};
|
||||
testState.publishAuthorEditMock.mockReset().mockResolvedValue(undefined);
|
||||
testState.publishCommentModerationMock.mockReset().mockResolvedValue(undefined);
|
||||
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true);
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
alertSpy.mockRestore();
|
||||
confirmSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('alerts when a user without privileges tries to edit a thread or reply', async () => {
|
||||
await renderMenu(basePost);
|
||||
await openMenu();
|
||||
expect(alertSpy).toHaveBeenCalledWith('cannot_edit_thread');
|
||||
|
||||
await renderMenu({
|
||||
...basePost,
|
||||
parentCid: 'parent-1',
|
||||
});
|
||||
await openMenu();
|
||||
expect(alertSpy).toHaveBeenLastCalledWith('cannot_edit_reply');
|
||||
});
|
||||
|
||||
it('lets comment authors update content, deletion, and reason', async () => {
|
||||
testState.privileges = {
|
||||
isAccountCommentAuthor: true,
|
||||
isAccountMod: false,
|
||||
isCommentAuthorMod: false,
|
||||
};
|
||||
|
||||
await renderMenu(basePost);
|
||||
await openMenu();
|
||||
|
||||
await click(getCheckbox('deleted'));
|
||||
await click(getLabelCheckbox('Edit?'));
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
const reasonInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(textarea).not.toBeNull();
|
||||
expect(reasonInput).not.toBeNull();
|
||||
|
||||
await dispatchInput(textarea as HTMLTextAreaElement, 'Updated body');
|
||||
await dispatchInput(reasonInput as HTMLInputElement, 'cleanup');
|
||||
await clickButton('save');
|
||||
|
||||
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
|
||||
expect(testState.publishCommentModerationMock).not.toHaveBeenCalled();
|
||||
expect(testState.authorOptions).toMatchObject({
|
||||
author: {
|
||||
address: '0xauthor',
|
||||
displayName: 'Alice',
|
||||
},
|
||||
commentCid: 'comment-1',
|
||||
content: 'Updated body',
|
||||
deleted: true,
|
||||
reason: 'cleanup',
|
||||
spoiler: false,
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
});
|
||||
});
|
||||
|
||||
it('lets moderators change moderation flags, ban duration, and save them', async () => {
|
||||
testState.privileges = {
|
||||
isAccountCommentAuthor: false,
|
||||
isAccountMod: true,
|
||||
isCommentAuthorMod: false,
|
||||
};
|
||||
|
||||
await renderMenu(basePost);
|
||||
await openMenu();
|
||||
|
||||
await click(getCheckbox('removed'));
|
||||
await click(getCheckbox('purged'));
|
||||
await click(getCheckbox('locked'));
|
||||
await click(getCheckbox('spoiler'));
|
||||
await click(getCheckbox('pinned'));
|
||||
await click(getCheckbox('banUser'));
|
||||
|
||||
const banDurationInput = container.querySelector<HTMLInputElement>('[data-testid="ban-duration-input"]');
|
||||
const reasonInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(banDurationInput).not.toBeNull();
|
||||
expect(reasonInput).not.toBeNull();
|
||||
|
||||
await dispatchInput(banDurationInput as HTMLInputElement, '7');
|
||||
await dispatchInput(reasonInput as HTMLInputElement, 'rule violation');
|
||||
await clickButton('save');
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('purge_confirm');
|
||||
expect(testState.publishCommentModerationMock).toHaveBeenCalledOnce();
|
||||
expect(testState.modOptions).toMatchObject({
|
||||
author: {
|
||||
address: '0xmod',
|
||||
displayName: 'Moderator',
|
||||
shortAddress: '0xmod',
|
||||
},
|
||||
commentCid: 'comment-1',
|
||||
subplebbitAddress: 'music-posting.eth',
|
||||
});
|
||||
expect(testState.modOptions?.commentModeration).toMatchObject({
|
||||
reason: 'rule violation',
|
||||
locked: true,
|
||||
pinned: true,
|
||||
purged: true,
|
||||
removed: true,
|
||||
spoiler: true,
|
||||
author: {
|
||||
banExpiresAt: Math.floor(new Date('2026-03-15T00:00:00Z').getTime() / 1000),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not enable purge when the confirmation is rejected', async () => {
|
||||
confirmSpy.mockReturnValue(false);
|
||||
testState.privileges = {
|
||||
isAccountCommentAuthor: false,
|
||||
isAccountMod: true,
|
||||
isCommentAuthorMod: false,
|
||||
};
|
||||
|
||||
await renderMenu(basePost);
|
||||
await openMenu();
|
||||
await click(getCheckbox('purged'));
|
||||
|
||||
expect(getCheckbox('purged')?.checked).toBe(false);
|
||||
expect(testState.modOptions?.commentModeration?.purged).toBe(false);
|
||||
});
|
||||
|
||||
it('runs both the author edit and moderation publication paths when the user has both privileges', async () => {
|
||||
testState.privileges = {
|
||||
isAccountCommentAuthor: true,
|
||||
isAccountMod: true,
|
||||
isCommentAuthorMod: false,
|
||||
};
|
||||
|
||||
await renderMenu(basePost);
|
||||
await openMenu();
|
||||
await clickButton('save');
|
||||
|
||||
expect(testState.publishAuthorEditMock).toHaveBeenCalledOnce();
|
||||
expect(testState.publishCommentModerationMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
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';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
account: {
|
||||
mediaIpfsGatewayUrl: 'https://media.old.example',
|
||||
plebbitOptions: {
|
||||
chainProviders: {
|
||||
eth: { chainId: 1, urls: ['https://eth.old.example'] },
|
||||
sol: { chainId: 101, urls: ['https://sol.old.example'] },
|
||||
},
|
||||
httpRoutersOptions: ['https://router.old.example'],
|
||||
ipfsGatewayUrls: ['https://ipfs.old.example'],
|
||||
plebbitRpcClientsOptions: ['ws://old.example/key'],
|
||||
pubsubHttpClientsOptions: ['https://pubsub.old.example'],
|
||||
},
|
||||
} as Record<string, any>,
|
||||
rpcSettings: {
|
||||
plebbitRpcSettings: {
|
||||
plebbitOptions: {
|
||||
dataPath: '/tmp/plebbit-data',
|
||||
},
|
||||
},
|
||||
state: 'disconnected',
|
||||
} as Record<string, any>,
|
||||
setAccountMock: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
setAccount: (account: unknown) => testState.setAccountMock(account),
|
||||
useAccount: () => testState.account,
|
||||
usePlebbitRpcSettings: () => testState.rpcSettings,
|
||||
}));
|
||||
|
||||
let alertSpy: ReturnType<typeof vi.spyOn>;
|
||||
let container: HTMLDivElement;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
const originalLocation = window.location;
|
||||
let reloadMock: ReturnType<typeof vi.fn>;
|
||||
let root: Root;
|
||||
|
||||
const loadComponent = async (isElectron = false) => {
|
||||
vi.resetModules();
|
||||
(window as unknown as { electronApi?: { isElectron?: boolean } }).electronApi = isElectron ? { isElectron: true } : undefined;
|
||||
return (await import('../advanced-settings')).default;
|
||||
};
|
||||
|
||||
const renderSettings = async (isElectron = false) => {
|
||||
const AdvancedSettings = await loadComponent(isElectron);
|
||||
await act(async () => {
|
||||
root.render(createElement(AdvancedSettings));
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchInput = async (element: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
await act(async () => {
|
||||
const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value');
|
||||
descriptor?.set?.call(element, value);
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const clickButton = async (text: string) => {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text);
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe('AdvancedSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.account = {
|
||||
mediaIpfsGatewayUrl: 'https://media.old.example',
|
||||
plebbitOptions: {
|
||||
chainProviders: {
|
||||
eth: { chainId: 1, urls: ['https://eth.old.example'] },
|
||||
sol: { chainId: 101, urls: ['https://sol.old.example'] },
|
||||
},
|
||||
httpRoutersOptions: ['https://router.old.example'],
|
||||
ipfsGatewayUrls: ['https://ipfs.old.example'],
|
||||
plebbitRpcClientsOptions: ['ws://old.example/key'],
|
||||
pubsubHttpClientsOptions: ['https://pubsub.old.example'],
|
||||
},
|
||||
};
|
||||
testState.rpcSettings = {
|
||||
plebbitRpcSettings: {
|
||||
plebbitOptions: {
|
||||
dataPath: '/tmp/plebbit-data',
|
||||
},
|
||||
},
|
||||
state: 'disconnected',
|
||||
};
|
||||
testState.setAccountMock.mockReset().mockResolvedValue(undefined);
|
||||
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
reloadMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
reload: reloadMock,
|
||||
},
|
||||
});
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
alertSpy?.mockRestore();
|
||||
consoleLogSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('saves trimmed gateway, provider, router, rpc, and chain settings', async () => {
|
||||
await renderSettings(true);
|
||||
|
||||
const textareas = container.querySelectorAll<HTMLTextAreaElement>('textarea');
|
||||
const textInputs = container.querySelectorAll<HTMLInputElement>('input[type="text"]');
|
||||
expect(textareas.length).toBe(5);
|
||||
expect(textInputs.length).toBe(3);
|
||||
|
||||
await dispatchInput(textareas[0], ' https://ipfs.one.example \n\nhttps://ipfs.two.example ');
|
||||
await dispatchInput(textInputs[0], ' https://media.new.example ');
|
||||
await dispatchInput(textareas[1], ' https://pubsub.one.example \n');
|
||||
await dispatchInput(textareas[2], ' https://router.one.example \n');
|
||||
await dispatchInput(textareas[3], ' https://eth.one.example \n');
|
||||
await dispatchInput(textareas[4], ' https://sol.one.example \n');
|
||||
await dispatchInput(textInputs[1], ' ws://127.0.0.1:9138/secret ');
|
||||
await dispatchInput(textInputs[2], ' /tmp/next-plebbit ');
|
||||
await clickButton('save_options');
|
||||
|
||||
expect(testState.setAccountMock).toHaveBeenCalledWith({
|
||||
mediaIpfsGatewayUrl: 'https://media.new.example',
|
||||
plebbitOptions: {
|
||||
chainProviders: {
|
||||
eth: { chainId: 1, urls: ['https://eth.one.example'] },
|
||||
sol: { chainId: 101, urls: ['https://sol.one.example'] },
|
||||
},
|
||||
dataPath: '/tmp/next-plebbit',
|
||||
httpRoutersOptions: ['https://router.one.example'],
|
||||
ipfsGatewayUrls: ['https://ipfs.one.example', 'https://ipfs.two.example'],
|
||||
plebbitRpcClientsOptions: ['ws://127.0.0.1:9138/secret'],
|
||||
pubsubHttpClientsOptions: ['https://pubsub.one.example'],
|
||||
},
|
||||
});
|
||||
expect(alertSpy).toHaveBeenCalledWith('Options saved, reloading...');
|
||||
expect(reloadMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('disables remote-managed textareas and shows the electron data path when RPC is connected', async () => {
|
||||
testState.rpcSettings = {
|
||||
plebbitRpcSettings: {
|
||||
plebbitOptions: {
|
||||
dataPath: '/tmp/connected-node',
|
||||
},
|
||||
},
|
||||
state: 'connected',
|
||||
};
|
||||
|
||||
await renderSettings(true);
|
||||
|
||||
const textareas = container.querySelectorAll<HTMLTextAreaElement>('textarea');
|
||||
const textInputs = container.querySelectorAll<HTMLInputElement>('input[type="text"]');
|
||||
expect(textareas[0]?.disabled).toBe(true);
|
||||
expect(textareas[1]?.disabled).toBe(true);
|
||||
expect(textareas[2]?.disabled).toBe(true);
|
||||
expect(textInputs[0]?.disabled).toBe(true);
|
||||
expect(textInputs[2]?.disabled).toBe(false);
|
||||
expect(textInputs[2]?.value).toBe('/tmp/connected-node');
|
||||
});
|
||||
|
||||
it('toggles the node rpc instructions panel', async () => {
|
||||
await renderSettings(false);
|
||||
|
||||
expect(container.textContent).not.toContain('secret auth key');
|
||||
await clickButton('?');
|
||||
expect(container.textContent).toContain('secret auth key');
|
||||
await clickButton('X');
|
||||
expect(container.textContent).not.toContain('secret auth key');
|
||||
});
|
||||
|
||||
it('alerts with the error message when saving fails with an Error instance', async () => {
|
||||
testState.setAccountMock.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await renderSettings(false);
|
||||
await clickButton('save_options');
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('Error saving options: boom');
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }));
|
||||
});
|
||||
|
||||
it('alerts with a generic message when saving fails with a non-error value', async () => {
|
||||
testState.setAccountMock.mockRejectedValueOnce('bad');
|
||||
|
||||
await renderSettings(false);
|
||||
await clickButton('save_options');
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('Error');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user