test: cover board, catalog, and publish flows

Board browsing, catalog filtering, post publishing, and reply publishing had little or no honest whole-repo coverage, which let route-level regressions and duplicated form side effects slip through. This pass adds targeted tests for those user flows and fixes the duplicate `PostFormTable` mounting bug they exposed.
This commit is contained in:
plebeius
2026-03-08 14:29:47 +08:00
parent 76eb4b537e
commit 1fbf715aa9
12 changed files with 2455 additions and 37 deletions
@@ -0,0 +1,118 @@
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 usePublishPost from '../use-publish-post';
import useChallengesStore from '../../stores/use-challenges-store';
import usePublishPostStore from '../../stores/use-publish-post-store';
(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(() => ({
abandonPublishMock: vi.fn(async () => undefined),
index: 12,
lastPublishOptions: undefined as Record<string, any> | undefined,
publishCommentMock: vi.fn(),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
usePublishComment: (options: Record<string, any>) => {
testState.lastPublishOptions = options;
return {
abandonPublish: testState.abandonPublishMock,
index: testState.index,
publishComment: testState.publishCommentMock,
};
},
}));
let container: HTMLDivElement;
let latestValue: ReturnType<typeof usePublishPost>;
let root: Root;
const HookHarness = () => {
latestValue = usePublishPost({ subplebbitAddress: 'music.eth' });
return null;
};
const renderHook = () => {
act(() => {
root.render(createElement(HookHarness));
});
};
describe('usePublishPost', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.index = 12;
testState.lastPublishOptions = undefined;
useChallengesStore.setState({ challenges: [] });
usePublishPostStore.getState().resetPublishPostStore();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderHook();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('sanitizes publish options into portable store state and exposes the publish action', async () => {
await act(async () => {
latestValue.setPublishPostOptions({
author: { displayName: 'Alice' },
content: '',
link: '',
spoiler: true,
title: 'Hello world',
} as never);
});
expect(latestValue.postIndex).toBe(12);
expect(latestValue.publishPost).toBe(testState.publishCommentMock);
expect(latestValue.publishPostOptions).toMatchObject({
author: { displayName: 'Alice' },
content: undefined,
link: undefined,
spoiler: true,
subplebbitAddress: 'music.eth',
title: 'Hello world',
});
expect(typeof latestValue.publishPostOptions.onChallengeVerification).toBe('function');
expect(typeof latestValue.publishPostOptions.onError).toBe('function');
});
it('routes publish challenges through the challenge store and abandons the current publish when requested', async () => {
await act(async () => {
latestValue.setPublishPostOptions({
content: 'Thread body',
} as never);
});
expect(typeof testState.lastPublishOptions?.onChallenge).toBe('function');
await act(async () => {
await testState.lastPublishOptions?.onChallenge('captcha', 'nonce');
});
const challenges = useChallengesStore.getState().challenges;
expect(challenges).toHaveLength(1);
expect(challenges[0]?.challenge).toEqual(['captcha', 'nonce']);
await act(async () => {
await useChallengesStore.getState().abandonCurrentChallenge();
});
expect(testState.abandonPublishMock).toHaveBeenCalledTimes(1);
await act(async () => {
latestValue.resetPublishPostOptions();
});
expect(latestValue.publishPostOptions).toEqual({});
});
});
@@ -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 usePublishReply from '../use-publish-reply';
import useChallengesStore from '../../stores/use-challenges-store';
import usePostNumberStore from '../../stores/use-post-number-store';
import usePublishReplyStore from '../../stores/use-publish-reply-store';
(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(() => ({
abandonPublishMock: vi.fn(async () => undefined),
index: 7,
lastPublishOptions: undefined as Record<string, any> | undefined,
publishCommentMock: vi.fn(),
}));
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
usePublishComment: (options: Record<string, any>) => {
testState.lastPublishOptions = options;
return {
abandonPublish: testState.abandonPublishMock,
index: testState.index,
publishComment: testState.publishCommentMock,
};
},
}));
let container: HTMLDivElement;
let latestValue: ReturnType<typeof usePublishReply>;
let root: Root;
const HookHarness = () => {
latestValue = usePublishReply({ cid: 'parent-cid', subplebbitAddress: 'music.eth' });
return null;
};
const renderHook = () => {
act(() => {
root.render(createElement(HookHarness));
});
};
describe('usePublishReply', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.index = 7;
testState.lastPublishOptions = undefined;
useChallengesStore.setState({ challenges: [] });
usePostNumberStore.setState({ cidToNumber: {}, numberToCid: { 'music.eth': { 12: 'quoted-cid' } } });
usePublishReplyStore.setState({
author: {},
content: {},
displayName: {},
link: {},
publishCommentOptions: {},
spoiler: {},
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderHook();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('builds reply publish options with derived quoted cids and exposes the publish action', async () => {
await act(async () => {
latestValue.setPublishReplyOptions({
author: { displayName: 'Bob' },
content: 'Replying to >>12',
link: '',
spoiler: true,
} as never);
});
expect(latestValue.replyIndex).toBe(7);
expect(latestValue.publishReply).toBe(testState.publishCommentMock);
expect(testState.lastPublishOptions).toMatchObject({
author: { displayName: 'Bob' },
content: 'Replying to >>12',
link: undefined,
parentCid: 'parent-cid',
postCid: 'parent-cid',
quotedCids: ['quoted-cid'],
spoiler: true,
subplebbitAddress: 'music.eth',
});
});
it('queues reply challenges and clears the scoped reply store on reset', async () => {
await act(async () => {
latestValue.setPublishReplyOptions({
content: 'Body',
} as never);
});
expect(typeof testState.lastPublishOptions?.onChallenge).toBe('function');
await act(async () => {
await testState.lastPublishOptions?.onChallenge('captcha');
});
expect(useChallengesStore.getState().challenges).toHaveLength(1);
await act(async () => {
await useChallengesStore.getState().abandonCurrentChallenge();
});
expect(testState.abandonPublishMock).toHaveBeenCalledTimes(1);
await act(async () => {
latestValue.resetPublishReplyOptions();
});
expect(usePublishReplyStore.getState().publishCommentOptions['parent-cid']).toBeUndefined();
});
});