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,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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user