test: cover edit, challenge, advanced settings, and gif preview flows

This commit is contained in:
plebeius
2026-03-08 16:48:01 +08:00
parent 90a01057f2
commit 2b32866b6b
4 changed files with 1144 additions and 0 deletions
@@ -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();
});
});