test(ci): align strict community mocks

This commit is contained in:
Tommaso Casaburi
2026-04-15 13:18:02 +07:00
parent e5aed2c7db
commit 45567013fc
9 changed files with 372 additions and 97 deletions
+4 -1
View File
@@ -48,7 +48,10 @@ const testState = vi.hoisted(() => ({
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useAccountComment: ({ commentIndex }: { commentIndex?: number }) => (typeof commentIndex === 'number' ? testState.accountComments[commentIndex] : undefined),
useCommunity: ({ communityAddress }: { communityAddress?: string }) => (communityAddress ? testState.subplebbits[communityAddress] : undefined),
useCommunity: (options?: { communityAddress?: string; community?: { name?: string; publicKey?: string } }) => {
const communityAddress = options?.communityAddress ?? options?.community?.name ?? options?.community?.publicKey;
return communityAddress ? testState.subplebbits[communityAddress] : undefined;
},
useAccountCommunities: () => ({
accountCommunities: Object.fromEntries(testState.accountSubplebbitAddresses.map((address) => [address, { address }])),
}),
@@ -0,0 +1,213 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import CryptoAddressSetting from '../crypto-address-setting';
(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 SIGNER_ADDRESS = '12D3KooWSignerPublicKey';
const hookMocks = vi.hoisted(() => ({
setAccount: vi.fn(),
useAccount: vi.fn(),
useResolvedAuthorAddress: vi.fn(),
}));
const TestedCryptoAddressSetting = ((CryptoAddressSetting as unknown as { type?: React.ComponentType }).type ??
(CryptoAddressSetting as unknown as React.ComponentType)) as React.ComponentType;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) =>
(
({
check: 'check',
crypto_address_not_resolved: 'Crypto address is not resolved yet.',
crypto_address_not_yours: 'Crypto address is not yours.',
crypto_address_verification: 'if the crypto address is resolved p2p',
crypto_address_yours: 'Crypto address belongs to this account.',
enter_crypto_address: 'Enter crypto address.',
loading: 'loading',
save: 'save',
saved: 'saved',
}) as Record<string, string>
)[key] ?? key,
}),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
setAccount: hookMocks.setAccount,
useAccount: hookMocks.useAccount,
useResolvedAuthorAddress: hookMocks.useResolvedAuthorAddress,
}));
let root: Root;
let container: HTMLDivElement;
let alertSpy: ReturnType<typeof vi.spyOn>;
let lastResolveOptions: unknown;
let resolvedAuthorState: {
chainProvider?: { urls?: string[] };
error?: unknown;
resolvedAddress?: string | null;
state?: string;
};
const render = async () => {
await act(async () => {
root.render(createElement(TestedCryptoAddressSetting));
await Promise.resolve();
});
};
const rerender = render;
const getInput = () => {
const input = container.querySelector('input[placeholder="myaddress.bso"]') as HTMLInputElement | null;
if (!input) {
throw new Error('crypto address input not found');
}
return input;
};
const getButtonByText = (text: string) => {
const button = Array.from(container.querySelectorAll('button')).find((candidate) => (candidate.textContent ?? '').trim() === text);
if (!button) {
throw new Error(`button "${text}" not found`);
}
return button as HTMLButtonElement;
};
const setInputValue = async (value: string) => {
await act(async () => {
const input = getInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
if (!valueSetter) {
throw new Error('input value setter not found');
}
valueSetter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
};
describe('CryptoAddressSetting', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
resolvedAuthorState = {
chainProvider: undefined,
error: undefined,
resolvedAddress: undefined,
state: 'initializing',
};
lastResolveOptions = undefined;
hookMocks.useAccount.mockReturnValue({
author: {
address: 'legacy-name.eth',
shortAddress: 'legacy-name.eth',
},
signer: {
address: SIGNER_ADDRESS,
},
});
hookMocks.setAccount.mockResolvedValue({});
hookMocks.useResolvedAuthorAddress.mockImplementation((options: unknown) => {
lastResolveOptions = options;
return resolvedAuthorState;
});
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
vi.runOnlyPendingTimers();
root.unmount();
});
container.remove();
alertSpy.mockRestore();
vi.useRealTimers();
});
it('uses the full author address as the initial field value', async () => {
hookMocks.useAccount.mockReturnValue({
author: {
address: 'resolved-alias.eth',
shortAddress: 'different-short-address',
},
signer: {
address: SIGNER_ADDRESS,
},
});
await render();
expect(getInput().value).toBe('resolved-alias.eth');
});
it('updates the displayed status after async resolution completes', async () => {
await render();
await setInputValue('music-posting.bso');
expect(container.textContent).toContain('if the crypto address is resolved p2p');
await act(async () => {
getButtonByText('check').click();
});
expect(container.textContent).toContain('loading');
expect((lastResolveOptions as { author?: { address?: string } } | undefined)?.author?.address).toBe('music-posting.bso');
resolvedAuthorState = {
...resolvedAuthorState,
resolvedAddress: null,
state: 'succeeded',
};
await rerender();
expect(container.textContent).toContain('Crypto address is not resolved yet.');
});
it('saves a verified alias when it resolves to the signer address', async () => {
await render();
await setInputValue('resolved-name.bso');
await act(async () => {
getButtonByText('check').click();
});
resolvedAuthorState = {
...resolvedAuthorState,
resolvedAddress: SIGNER_ADDRESS,
state: 'succeeded',
};
await rerender();
await act(async () => {
getButtonByText('save').click();
await Promise.resolve();
});
expect(hookMocks.setAccount).toHaveBeenCalledWith(
expect.objectContaining({
author: expect.objectContaining({
address: 'resolved-name.bso',
}),
}),
);
expect(container.textContent).toContain('saved');
expect(getInput().value).toBe('resolved-name.bso');
expect(alertSpy).not.toHaveBeenCalled();
});
});
@@ -12,114 +12,153 @@ const withErrorHandling = async <T,>(fn: () => Promise<T>, onError: (e: unknown)
}
};
const CryptoAddressSetting = () => {
const { t } = useTranslation();
const account = useAccount();
const getInitialCryptoAddress = (address?: string) => (address?.includes('.') ? address : '');
const [cryptoState, setCryptoState] = useState({
cryptoAddress: account?.author?.shortAddress.includes('.') ? account.author.shortAddress : '',
checkingCryptoAddress: false,
showResolvingMessage: true,
resolveString: t('crypto_address_verification'),
resolveClass: '',
const getDefaultResolutionStatus = (t: (key: string) => string) => ({
resolveClass: '',
resolveString: t('crypto_address_verification'),
});
const getResolutionStatus = ({
checkedAddress,
chainProviderUrls,
error,
resolvedAddress,
signerAddress,
state,
t,
}: {
chainProviderUrls?: string[];
checkedAddress?: string;
error?: unknown;
resolvedAddress?: string | null;
signerAddress?: string;
state?: string;
t: (key: string) => string;
}) => {
if (!checkedAddress) {
return getDefaultResolutionStatus(t);
}
if (state === 'failed') {
return {
resolveClass: styles.red,
resolveString: error instanceof Error ? `failed to resolve crypto address, error: ${error.message}` : 'cannot resolve crypto address, unknown error',
};
}
if (state === 'resolving' || state === 'ready' || state === 'initializing') {
return {
resolveClass: styles.yellow,
resolveString: chainProviderUrls ? `resolving from ${chainProviderUrls.join(', ')}` : t('loading'),
};
}
if (resolvedAddress && resolvedAddress === signerAddress) {
return {
resolveClass: styles.green,
resolveString: t('crypto_address_yours'),
};
}
if (resolvedAddress && resolvedAddress !== signerAddress) {
return {
resolveClass: styles.red,
resolveString: t('crypto_address_not_yours'),
};
}
if (resolvedAddress === null || state === 'succeeded') {
return {
resolveClass: styles.red,
resolveString: t('crypto_address_not_resolved'),
};
}
return getDefaultResolutionStatus(t);
};
const showSavedIndicator = (setSavedCryptoAddress: (value: boolean) => void) => {
setSavedCryptoAddress(true);
setTimeout(() => {
setSavedCryptoAddress(false);
}, 2000);
};
const CryptoAddressSettingContent = ({ account }: { account: ReturnType<typeof useAccount> }) => {
const { t } = useTranslation();
const [cryptoAddress, setCryptoAddress] = useState(() => getInitialCryptoAddress(account?.author?.address));
const [checkedAddress, setCheckedAddress] = useState<string>();
const [savedCryptoAddress, setSavedCryptoAddress] = useState(false);
const [showCryptoAddressInfo, setShowCryptoAddressInfo] = useState(false);
const signerAddress = account?.signer?.address;
const authorToResolve = checkedAddress ? { ...account?.author, address: checkedAddress } : undefined;
const { resolvedAddress, state, error, chainProvider } = useResolvedAuthorAddress({ author: authorToResolve, cache: false });
const resolutionStatus = getResolutionStatus({
chainProviderUrls: chainProvider?.urls,
checkedAddress,
error,
resolvedAddress,
signerAddress,
state,
t,
});
const [savedCryptoAddress, setSavedCryptoAddress] = useState(false);
const [shouldResolve, setShouldResolve] = useState(false);
const authorToResolve = shouldResolve ? { ...account?.author, address: cryptoState.cryptoAddress } : undefined;
const { resolvedAddress, state, error, chainProvider } = useResolvedAuthorAddress({ author: authorToResolve, cache: false });
const [inputValue, setInputValue] = useState(account?.author?.shortAddress.includes('.') ? account.author.shortAddress : '');
const checkCryptoAddress = () => {
setShouldResolve(true);
const addressToCheck = inputValue || cryptoState.cryptoAddress;
const addressToCheck = cryptoAddress.trim();
if (!addressToCheck || !addressToCheck.includes('.')) {
alert(t('enter_crypto_address'));
return;
}
let resolveString = '';
let resolveClass = '';
if (state === 'failed') {
resolveString = error instanceof Error ? `failed to resolve crypto address, error: ${error.message}` : 'cannot resolve crypto address, unknown error';
resolveClass = styles.red;
} else if (state === 'resolving') {
resolveString = `resolving from ${chainProvider?.urls}`;
resolveClass = styles.yellow;
} else if (resolvedAddress && resolvedAddress === account?.signer?.address) {
resolveString = t('crypto_address_yours');
resolveClass = styles.green;
} else if (resolvedAddress && resolvedAddress !== account?.signer?.address) {
resolveString = t('crypto_address_not_yours');
resolveClass = styles.red;
} else {
resolveString = t('crypto_address_verification');
resolveClass = '';
}
setCryptoState((prevState) => ({
...prevState,
cryptoAddress: addressToCheck,
showResolvingMessage: true,
resolveString,
resolveClass,
}));
setCryptoAddress(addressToCheck);
setCheckedAddress(addressToCheck);
};
const saveCryptoAddress = async () => {
if (!cryptoState.cryptoAddress || !cryptoState.cryptoAddress.includes('.')) {
const addressToSave = cryptoAddress.trim();
if (!addressToSave || !addressToSave.includes('.')) {
alert(t('enter_crypto_address'));
return;
} else if (cryptoState.cryptoAddress === account?.author?.address) {
setSavedCryptoAddress(true);
setTimeout(() => {
setSavedCryptoAddress(false);
}, 2000);
}
if (addressToSave === account?.author?.address) {
showSavedIndicator(setSavedCryptoAddress);
return;
} else if (resolvedAddress && resolvedAddress !== account?.signer?.address) {
alert(t('crypto_address_not_yours'));
return;
} else if (cryptoState.cryptoAddress && !resolvedAddress) {
}
if (checkedAddress !== addressToSave || !resolvedAddress) {
alert(t('crypto_address_not_resolved'));
return;
} else if (resolvedAddress && resolvedAddress === account?.signer?.address) {
const result = await withErrorHandling(
() => setAccount({ ...account, author: { ...account?.author, address: cryptoState.cryptoAddress } }),
(error) => {
if (error instanceof Error) {
alert(error.message);
console.log(error);
} else {
console.error('An unknown error occurred:', error);
}
},
);
if (result !== undefined) {
setShouldResolve(false);
setSavedCryptoAddress(true);
setTimeout(() => setSavedCryptoAddress(false), 2000);
setCryptoState((prevState) => ({
...prevState,
savedCryptoAddress: true,
cryptoAddress: '',
checkingCryptoAddress: false,
}));
}
setSavedCryptoAddress(true);
setCryptoState((prevState) => ({
...prevState,
checkingCryptoAddress: false,
showResolvingMessage: false,
resolveString: t('crypto_address_verification'),
resolveClass: '',
}));
}
};
const [showCryptoAddressInfo, setShowCryptoAddressInfo] = useState(false);
if (resolvedAddress !== signerAddress) {
alert(t('crypto_address_not_yours'));
return;
}
const result = await withErrorHandling(
() => setAccount({ ...account, author: { ...account?.author, address: addressToSave } }),
(publishError) => {
if (publishError instanceof Error) {
alert(publishError.message);
console.log(publishError);
} else {
console.error('An unknown error occurred:', publishError);
}
},
);
if (result === undefined) {
return;
}
setCheckedAddress(undefined);
setCryptoAddress(addressToSave);
showSavedIndicator(setSavedCryptoAddress);
};
return (
<div className={styles.setting}>
@@ -127,10 +166,10 @@ const CryptoAddressSetting = () => {
<input
type='text'
placeholder='myaddress.bso'
value={cryptoState.cryptoAddress}
value={cryptoAddress}
onChange={(e) => {
setInputValue(e.target.value);
setCryptoState((prevState) => ({ ...prevState, cryptoAddress: e.target.value }));
setCheckedAddress(undefined);
setCryptoAddress(e.target.value);
}}
/>
<button className={styles.saveButton} onClick={saveCryptoAddress}>
@@ -162,10 +201,17 @@ const CryptoAddressSetting = () => {
<button className={styles.button} onClick={checkCryptoAddress}>
{t('check')}
</button>{' '}
<span className={cryptoState.resolveClass}>{cryptoState.resolveString}</span>
<span className={resolutionStatus.resolveClass}>{resolutionStatus.resolveString}</span>
</div>
</div>
);
};
const CryptoAddressSetting = () => {
const account = useAccount();
const accountResetKey = account?.id ?? account?.name ?? account?.signer?.address ?? account?.author?.address ?? 'default-account';
return <CryptoAddressSettingContent key={accountResetKey} account={account} />;
};
export default memo(CryptoAddressSetting);
@@ -35,7 +35,9 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/feeds', () => ({
}));
vi.mock('../use-directories', () => ({
useDirectories: () => [],
useDirectoryByAddress: () => testState.community,
findDirectoryByAddress: () => undefined,
}));
vi.mock('../use-board-feed-page-size', () => ({
@@ -95,9 +97,9 @@ describe('usePostPageNumber', () => {
expect(renderHook({ postCid: 'post-3', subplebbitAddress: 'music.eth' })).toBe(2);
expect(testState.preloadOptions).toEqual({
communities: [{ name: 'music.eth' }],
postsPerPage: 20,
sortType: 'active',
communityAddresses: ['music.eth'],
});
});
@@ -106,9 +108,9 @@ describe('usePostPageNumber', () => {
expect(renderHook({ postCid: 'post-4', subplebbitAddress: 'music.eth' })).toBe(2);
expect(testState.preloadOptions).toEqual({
communities: [{ name: 'music.eth' }],
postsPerPage: 20,
sortType: 'active',
communityAddresses: ['music.eth'],
});
});
@@ -58,6 +58,8 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) =>
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
}));
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
+2
View File
@@ -159,6 +159,8 @@ vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
useDirectoryAddresses: () => testState.directories.map((entry) => entry.address),
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined),
findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) =>
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
}));
vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({
@@ -173,6 +173,8 @@ vi.mock('react-virtuoso', () => ({
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined),
findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) =>
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
}));
vi.mock('../../../hooks/use-board-feed-page-size', () => ({
+2
View File
@@ -39,6 +39,8 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
useDirectoryAddresses: () => testState.directoryAddresses,
findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) =>
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
}));
vi.mock('../../../hooks/use-communities-stats', () => ({
+4 -1
View File
@@ -35,7 +35,10 @@ vi.mock('react-router-dom', async () => {
});
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useCommunity: ({ communityAddress }: { communityAddress?: string }) => (communityAddress ? testState.communities[communityAddress] : undefined),
useCommunity: (options?: { communityAddress?: string; community?: { name?: string; publicKey?: string } }) => {
const communityAddress = options?.communityAddress ?? options?.community?.name ?? options?.community?.publicKey;
return communityAddress ? testState.communities[communityAddress] : undefined;
},
}));
vi.mock('../../../hooks/use-directories', async () => {