mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
* refactor(community-api): migrate 5chan to community hooks * fix(review): address PR feedback * fix(review): preserve legacy board context fallbacks * fix(review): address latest bot feedback * fix(review): use communityAddress in edit menu privileges
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
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,
|
|
authorPrivilegesOptions: 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('@bitsocialnet/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: (options: Record<string, any>) => {
|
|
testState.authorPrivilegesOptions = options;
|
|
return 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,
|
|
communityAddress: '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.authorPrivilegesOptions = 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,
|
|
communityAddress: 'music-posting.eth',
|
|
});
|
|
expect(testState.authorPrivilegesOptions).toMatchObject({
|
|
commentAuthorAddress: '0xauthor',
|
|
communityAddress: 'music-posting.eth',
|
|
postCid: 'post-1',
|
|
});
|
|
expect(testState.authorPrivilegesOptions).not.toHaveProperty('subplebbitAddress');
|
|
});
|
|
|
|
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',
|
|
communityAddress: '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();
|
|
});
|
|
});
|