mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
test(settings): expand settings and route coverage
Added focused tests for settings flows, subscription behavior, and route helpers, and fixed the account import success path those tests exposed in account-settings.tsx.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import SettingsModal from '../settings-modal';
|
||||
|
||||
(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>;
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../account-settings', () => ({
|
||||
default: () => <div data-testid='account-settings'>account-settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../crypto-address-setting', () => ({
|
||||
default: () => <div data-testid='crypto-address-setting'>crypto-address-setting</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../crypto-wallets-setting', () => ({
|
||||
default: () => <div data-testid='crypto-wallets-setting'>crypto-wallets-setting</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../interface-settings', () => ({
|
||||
default: () => <div data-testid='interface-settings-panel'>interface-settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../media-hosting-settings', () => ({
|
||||
default: () => <div data-testid='media-hosting-settings-panel'>media-hosting-settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../advanced-settings', () => ({
|
||||
default: () => <div data-testid='advanced-settings-panel'>advanced-settings</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../subscriptions-setting', () => ({
|
||||
default: () => <div data-testid='subscriptions-settings-panel'>subscriptions-settings</div>,
|
||||
}));
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
return <div data-testid='location'>{location.pathname + location.hash}</div>;
|
||||
};
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
const render = (initialEntry = '/all/settings') => {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(React.Fragment, {}, createElement(SettingsModal), createElement(LocationProbe))),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getLocationText = () => container.querySelector('[data-testid="location"]')?.textContent ?? '';
|
||||
|
||||
const getLabelByText = (text: string) => {
|
||||
const label = Array.from(container.querySelectorAll('label')).find((candidate) => (candidate.textContent ?? '').includes(text));
|
||||
if (!label) {
|
||||
throw new Error(`Label containing "${text}" not found`);
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('opens the account section for crypto subsection hashes', () => {
|
||||
render('/all/settings#crypto-wallet-settings');
|
||||
|
||||
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="crypto-address-setting"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="crypto-wallets-setting"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('updates the hash when sections open and close', async () => {
|
||||
render('/all/settings#account-settings');
|
||||
|
||||
expect(getLocationText()).toBe('/all/settings#account-settings');
|
||||
|
||||
await act(async () => {
|
||||
getLabelByText('interface').click();
|
||||
});
|
||||
|
||||
expect(getLocationText()).toBe('/all/settings#interface-settings');
|
||||
expect(container.querySelector('[data-testid="interface-settings-panel"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
getLabelByText('interface').click();
|
||||
});
|
||||
|
||||
expect(getLocationText()).toBe('/all/settings#account-settings');
|
||||
expect(container.querySelector('[data-testid="interface-settings-panel"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
getLabelByText('bitsocial_account').click();
|
||||
});
|
||||
|
||||
expect(getLocationText()).toBe('/all/settings');
|
||||
expect(container.querySelector('[data-testid="account-settings"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('expands and collapses all settings sections', async () => {
|
||||
render('/all/settings');
|
||||
|
||||
const expandAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => (candidate.textContent ?? '').includes('expand_all_settings'));
|
||||
if (!expandAllControl) {
|
||||
throw new Error('expand_all_settings control not found');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
expandAllControl.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="interface-settings-panel"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="media-hosting-settings-panel"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="account-settings"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).not.toBeNull();
|
||||
|
||||
const collapseAllControl = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) =>
|
||||
(candidate.textContent ?? '').includes('collapse_all_settings'),
|
||||
);
|
||||
if (!collapseAllControl) {
|
||||
throw new Error('collapse_all_settings control not found');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
collapseAllControl.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="interface-settings-panel"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="media-hosting-settings-panel"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="account-settings"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="subscriptions-settings-panel"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="advanced-settings-panel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the modal when the overlay is clicked', async () => {
|
||||
render('/all/settings#interface-settings');
|
||||
|
||||
const overlay = container.querySelector('[role="button"]');
|
||||
if (!overlay) {
|
||||
throw new Error('overlay not found');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(getLocationText()).toBe('/all');
|
||||
});
|
||||
|
||||
it('closes the modal when Escape is pressed', async () => {
|
||||
render('/all/settings');
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
});
|
||||
|
||||
expect(getLocationText()).toBe('/all');
|
||||
});
|
||||
});
|
||||
+297
-35
@@ -1,13 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AccountSettings from '../account-settings';
|
||||
|
||||
(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 hookMocks = vi.hoisted(() => ({
|
||||
deleteAccount: vi.fn(),
|
||||
exportAccount: vi.fn(),
|
||||
importAccount: vi.fn(),
|
||||
setActiveAccount: vi.fn(),
|
||||
useAccount: vi.fn(),
|
||||
useAccounts: vi.fn(),
|
||||
}));
|
||||
|
||||
const fileReaderState = vi.hoisted(() => ({
|
||||
result: '',
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
@@ -16,71 +29,320 @@ vi.mock('react-i18next', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => ({ id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }),
|
||||
useAccounts: () => ({ accounts: [{ id: 'test-id', name: 'Account 1', author: { shortAddress: '0x1...3' } }] }),
|
||||
deleteAccount: vi.fn(),
|
||||
exportAccount: vi.fn(),
|
||||
importAccount: vi.fn(),
|
||||
setActiveAccount: vi.fn(),
|
||||
useAccount: hookMocks.useAccount,
|
||||
useAccounts: hookMocks.useAccounts,
|
||||
deleteAccount: hookMocks.deleteAccount,
|
||||
exportAccount: hookMocks.exportAccount,
|
||||
importAccount: hookMocks.importAccount,
|
||||
setActiveAccount: hookMocks.setActiveAccount,
|
||||
}));
|
||||
|
||||
vi.mock('@capacitor/core', () => ({
|
||||
Capacitor: { getPlatform: () => 'web' },
|
||||
}));
|
||||
|
||||
class MockFileReader {
|
||||
onload: ((event: { target: { result: unknown } }) => void) | null = null;
|
||||
|
||||
readAsText() {
|
||||
this.onload?.({ target: { result: fileReaderState.result } });
|
||||
}
|
||||
}
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
return <div data-testid='location'>{location.pathname + location.hash}</div>;
|
||||
};
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let alertSpy: ReturnType<typeof vi.spyOn>;
|
||||
let confirmSpy: ReturnType<typeof vi.spyOn>;
|
||||
let createElementSpy: ReturnType<typeof vi.spyOn>;
|
||||
let anchorClickSpy: ReturnType<typeof vi.spyOn>;
|
||||
let inputClickSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let createdInput: HTMLInputElement | null;
|
||||
let createdAnchor: HTMLAnchorElement | null;
|
||||
let createObjectUrlSpy: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectUrlSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
const render = (children: React.ReactNode) => {
|
||||
act(() => {
|
||||
root.render(createElement(MemoryRouter, { initialEntries: ['/subs/settings'] }, children));
|
||||
const flushMicrotasks = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
const render = (initialEntry = '/subs/settings') => {
|
||||
act(() => {
|
||||
root.render(
|
||||
createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(React.Fragment, {}, createElement(AccountSettings), createElement(LocationProbe))),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getButtons = () => Array.from(container.querySelectorAll('button'));
|
||||
|
||||
const getButtonByText = (text: string) => {
|
||||
const button = getButtons().find((candidate) => (candidate.textContent ?? '').includes(text));
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
return button;
|
||||
};
|
||||
|
||||
const getLocationText = () => container.querySelector('[data-testid="location"]')?.textContent ?? '';
|
||||
|
||||
describe('AccountSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
fileReaderState.result = '';
|
||||
createdInput = null;
|
||||
createdAnchor = null;
|
||||
|
||||
hookMocks.useAccount.mockReturnValue({
|
||||
id: 'test-id',
|
||||
name: 'Account 1',
|
||||
author: { address: '0x123', shortAddress: '0x1...3' },
|
||||
subscriptions: ['business.eth'],
|
||||
});
|
||||
hookMocks.useAccounts.mockReturnValue({
|
||||
accounts: [{ id: 'test-id', name: 'Account 1', author: { shortAddress: '0x1...3' } }],
|
||||
});
|
||||
|
||||
createObjectUrlSpy = vi.fn(() => 'blob:test-account');
|
||||
revokeObjectUrlSpy = vi.fn();
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectUrlSpy,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: revokeObjectUrlSpy,
|
||||
});
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader);
|
||||
|
||||
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true);
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
anchorClickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
|
||||
inputClickSpy = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => undefined);
|
||||
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
createElementSpy = vi.spyOn(document, 'createElement').mockImplementation(((tagName: string, options?: ElementCreationOptions) => {
|
||||
const element = originalCreateElement(tagName, options);
|
||||
if (tagName === 'input') {
|
||||
createdInput = element as HTMLInputElement;
|
||||
}
|
||||
if (tagName === 'a') {
|
||||
createdAnchor = element as HTMLAnchorElement;
|
||||
}
|
||||
return element;
|
||||
}) as typeof document.createElement);
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
if (root) {
|
||||
act(() => root.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
alertSpy?.mockRestore();
|
||||
confirmSpy?.mockRestore();
|
||||
consoleLogSpy?.mockRestore();
|
||||
consoleErrorSpy?.mockRestore();
|
||||
anchorClickSpy?.mockRestore();
|
||||
inputClickSpy?.mockRestore();
|
||||
createElementSpy?.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not render an inline textarea', () => {
|
||||
render(createElement(AccountSettings));
|
||||
it('renders the expected account actions without inline editor controls', () => {
|
||||
render();
|
||||
expect(container.querySelector('textarea')).toBeNull();
|
||||
|
||||
const buttonTexts = getButtons().map((button) => button.textContent ?? '');
|
||||
expect(buttonTexts.some((text) => text.includes('save_changes'))).toBe(false);
|
||||
expect(buttonTexts.some((text) => text.includes('reset_changes'))).toBe(false);
|
||||
expect(buttonTexts.some((text) => text.includes('edit'))).toBe(true);
|
||||
expect(buttonTexts.some((text) => text.includes('download_backup'))).toBe(true);
|
||||
expect(buttonTexts.some((text) => text.includes('import_account_backup'))).toBe(true);
|
||||
expect(buttonTexts.some((text) => text.includes('delete_account'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render save_changes or reset_changes buttons', () => {
|
||||
render(createElement(AccountSettings));
|
||||
const buttons = Array.from(container.querySelectorAll('button'));
|
||||
const buttonTexts = buttons.map((b) => b.textContent ?? '');
|
||||
expect(buttonTexts.some((t) => t.includes('save_changes'))).toBe(false);
|
||||
expect(buttonTexts.some((t) => t.includes('reset_changes'))).toBe(false);
|
||||
it('deletes the account only after both confirmations succeed', async () => {
|
||||
confirmSpy.mockReturnValueOnce(true).mockReturnValueOnce(true);
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('delete_account').click();
|
||||
});
|
||||
|
||||
expect(hookMocks.deleteAccount).toHaveBeenCalledWith('Account 1');
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders an edit button', () => {
|
||||
render(createElement(AccountSettings));
|
||||
const buttons = Array.from(container.querySelectorAll('button'));
|
||||
expect(buttons.some((b) => (b.textContent ?? '').includes('edit'))).toBe(true);
|
||||
it('does not delete the account when the first confirmation is rejected', async () => {
|
||||
confirmSpy.mockReturnValueOnce(false);
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('delete_account').click();
|
||||
});
|
||||
|
||||
expect(hookMocks.deleteAccount).not.toHaveBeenCalled();
|
||||
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders download_backup and import_account_backup buttons', () => {
|
||||
render(createElement(AccountSettings));
|
||||
const buttons = Array.from(container.querySelectorAll('button'));
|
||||
const texts = buttons.map((b) => b.textContent ?? '');
|
||||
expect(texts.some((t) => t.includes('download_backup'))).toBe(true);
|
||||
expect(texts.some((t) => t.includes('import_account_backup'))).toBe(true);
|
||||
expect(texts.some((t) => t.includes('create'))).toBe(false);
|
||||
it('exports a formatted account backup download', async () => {
|
||||
hookMocks.exportAccount.mockResolvedValue(JSON.stringify({ account: { name: 'Account 1' } }));
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('download_backup').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hookMocks.exportAccount).toHaveBeenCalledOnce();
|
||||
expect(createObjectUrlSpy).toHaveBeenCalledOnce();
|
||||
expect(anchorClickSpy).toHaveBeenCalledOnce();
|
||||
expect(createdAnchor?.download).toBe('Account 1.json');
|
||||
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:test-account');
|
||||
});
|
||||
|
||||
it('renders delete_account button', () => {
|
||||
render(createElement(AccountSettings));
|
||||
const buttons = Array.from(container.querySelectorAll('button'));
|
||||
expect(buttons.some((b) => (b.textContent ?? '').includes('delete_account'))).toBe(true);
|
||||
it('alerts when export returns malformed JSON', async () => {
|
||||
hookMocks.exportAccount.mockResolvedValue('not-json');
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('download_backup').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('Failed to parse account');
|
||||
expect(createObjectUrlSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('alerts when exportAccount throws', async () => {
|
||||
hookMocks.exportAccount.mockRejectedValue(new Error('export failed'));
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('download_backup').click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('export failed');
|
||||
expect(createObjectUrlSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('alerts when no import file is selected', async () => {
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('import_account_backup').click();
|
||||
});
|
||||
|
||||
expect(createdInput).not.toBeNull();
|
||||
expect(inputClickSpy).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () => {
|
||||
createdInput?.onchange?.({ target: { files: [] } } as unknown as Event);
|
||||
});
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('No file selected.');
|
||||
expect(hookMocks.importAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('alerts when the imported file contains invalid JSON', async () => {
|
||||
fileReaderState.result = '{bad json';
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('import_account_backup').click();
|
||||
});
|
||||
|
||||
const file = new File(['{}'], 'account.json', { type: 'application/json' });
|
||||
await act(async () => {
|
||||
createdInput?.onchange?.({ target: { files: [file] } } as unknown as Event);
|
||||
});
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('Invalid JSON in file.');
|
||||
expect(hookMocks.importAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('imports an account backup, merges owned boards into subscriptions, and redirects back to account settings', async () => {
|
||||
fileReaderState.result = JSON.stringify({
|
||||
account: {
|
||||
name: 'Imported',
|
||||
author: { address: '0x999' },
|
||||
subscriptions: ['business.eth'],
|
||||
subplebbits: {
|
||||
'business.eth': { title: '/biz/' },
|
||||
'music-posting.bso': { title: '/mu/' },
|
||||
},
|
||||
},
|
||||
});
|
||||
hookMocks.importAccount.mockResolvedValue(undefined);
|
||||
hookMocks.setActiveAccount.mockResolvedValue(undefined);
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('import_account_backup').click();
|
||||
});
|
||||
|
||||
const file = new File(['{}'], 'account.json', { type: 'application/json' });
|
||||
await act(async () => {
|
||||
createdInput?.onchange?.({ target: { files: [file] } } as unknown as Event);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(hookMocks.importAccount).toHaveBeenCalledOnce();
|
||||
const importedPayload = JSON.parse(hookMocks.importAccount.mock.calls[0][0]);
|
||||
expect(importedPayload.account.subscriptions).toEqual(['business.eth', 'music-posting.bso']);
|
||||
expect(localStorage.getItem('importedAccountAddress')).toBe('0x999');
|
||||
expect(hookMocks.setActiveAccount).toHaveBeenCalledWith('Imported');
|
||||
expect(alertSpy).toHaveBeenCalledWith('Imported Imported');
|
||||
expect(getLocationText()).toBe('/subs/settings#account-settings');
|
||||
});
|
||||
|
||||
it('surfaces import errors without navigating or reloading', async () => {
|
||||
fileReaderState.result = JSON.stringify({
|
||||
account: {
|
||||
name: 'Imported',
|
||||
author: { address: '0x999' },
|
||||
},
|
||||
});
|
||||
hookMocks.importAccount.mockRejectedValue(new Error('import failed'));
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('import_account_backup').click();
|
||||
});
|
||||
|
||||
const file = new File(['{}'], 'account.json', { type: 'application/json' });
|
||||
await act(async () => {
|
||||
createdInput?.onchange?.({ target: { files: [file] } } as unknown as Event);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(alertSpy).toHaveBeenCalledWith('import failed');
|
||||
expect(getLocationText()).toBe('/subs/settings');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +130,7 @@ const AccountSettingsEditor = ({
|
||||
if (accountData.account?.name) {
|
||||
await setActiveAccount(accountData.account.name);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
(error) => {
|
||||
if (error instanceof Error) {
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
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 SubscriptionsSetting from '../subscriptions-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 accountState = vi.hoisted(() => ({
|
||||
value: {
|
||||
name: 'Account 1',
|
||||
subscriptions: ['music-posting.bso'],
|
||||
} as { name: string; subscriptions: string[] },
|
||||
}));
|
||||
|
||||
const subscriptionMocks = vi.hoisted(() => ({
|
||||
byAddress: new Map<string, { subscribed: boolean; subscribe: ReturnType<typeof vi.fn>; unsubscribe: ReturnType<typeof vi.fn> }>(),
|
||||
setAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
|
||||
useAccount: () => accountState.value,
|
||||
useSubscribe: ({ subplebbitAddress }: { subplebbitAddress: string }) =>
|
||||
subscriptionMocks.byAddress.get(subplebbitAddress) ?? {
|
||||
subscribed: false,
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
setAccount: subscriptionMocks.setAccount,
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let confirmSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
const render = () => {
|
||||
act(() => {
|
||||
root.render(createElement(SubscriptionsSetting));
|
||||
});
|
||||
};
|
||||
|
||||
const getButtonByText = (text: string) => {
|
||||
const button = Array.from(container.querySelectorAll('[role="button"]')).find((candidate) => (candidate.textContent ?? '').includes(text));
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
return button;
|
||||
};
|
||||
|
||||
describe('SubscriptionsSetting', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
accountState.value = {
|
||||
name: 'Account 1',
|
||||
subscriptions: ['music-posting.bso'],
|
||||
};
|
||||
subscriptionMocks.byAddress = new Map();
|
||||
subscriptionMocks.setAccount.mockReset();
|
||||
confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true);
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('shows the empty-state message when there are no subscriptions', () => {
|
||||
accountState.value = {
|
||||
name: 'Account 1',
|
||||
subscriptions: [],
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
expect(container.textContent).toContain('not_subscribed_to_any_board');
|
||||
});
|
||||
|
||||
it('unsubscribes from all boards after confirmation', async () => {
|
||||
accountState.value = {
|
||||
name: 'Account 1',
|
||||
subscriptions: ['music-posting.bso', 'business.eth'],
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('unsubscribe_all').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('unsubscribe_all_confirm');
|
||||
expect(subscriptionMocks.setAccount).toHaveBeenCalledWith({
|
||||
name: 'Account 1',
|
||||
subscriptions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not unsubscribe from all boards when confirmation is cancelled', async () => {
|
||||
confirmSpy.mockReturnValueOnce(false);
|
||||
accountState.value = {
|
||||
name: 'Account 1',
|
||||
subscriptions: ['music-posting.bso', 'business.eth'],
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
await act(async () => {
|
||||
getButtonByText('unsubscribe_all').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(subscriptionMocks.setAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toggles a board subscription with click and keyboard interaction', async () => {
|
||||
const subscribe = vi.fn();
|
||||
const unsubscribe = vi.fn();
|
||||
subscriptionMocks.byAddress.set('music-posting.bso', {
|
||||
subscribed: true,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
const subscriptionButton = getButtonByText('unsubscribe');
|
||||
expect(subscriptionButton.textContent).toContain('unsubscribe');
|
||||
|
||||
await act(async () => {
|
||||
subscriptionButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(subscriptionButton.textContent).toContain('subscribe');
|
||||
|
||||
await act(async () => {
|
||||
subscriptionButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
});
|
||||
|
||||
expect(subscribe).toHaveBeenCalledOnce();
|
||||
expect(subscriptionButton.textContent).toContain('unsubscribe');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isBoardModRoute, isFeedRoute, isLegacyBoardModQueueRoute, isModQueueRoute, isValidBoardModRoute, normalizeMultiboardFeedPath } from '../route-utils';
|
||||
import {
|
||||
areSameBoardAddress,
|
||||
extractDirectoryFromTitle,
|
||||
getBoardPath,
|
||||
getFeedCacheKey,
|
||||
getFeedType,
|
||||
getPageFromFeedPath,
|
||||
getSubplebbitAddress,
|
||||
isBoardModRoute,
|
||||
isDirectoryBoard,
|
||||
isFeedRoute,
|
||||
isLegacyBoardModQueueRoute,
|
||||
isModQueueRoute,
|
||||
isPendingPostRoute,
|
||||
isPostRoute,
|
||||
isValidBoardModRoute,
|
||||
isValidModRoute,
|
||||
normalizeMultiboardFeedPath,
|
||||
stripPageFromFeedPath,
|
||||
} from '../route-utils';
|
||||
|
||||
const communities = [
|
||||
{ address: 'business.eth', title: '/biz/ - Business & Finance' },
|
||||
{ address: 'music-posting.bso', title: '/mu/ - Music' },
|
||||
{ address: 'random.eth', directoryCode: 'b', title: 'Random' },
|
||||
];
|
||||
|
||||
describe('directory mapping helpers', () => {
|
||||
it('extracts short codes from titled directories', () => {
|
||||
expect(extractDirectoryFromTitle('/biz/ - Business & Finance')).toBe('biz');
|
||||
expect(extractDirectoryFromTitle('Business & Finance')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps addresses to canonical board paths and back', () => {
|
||||
expect(getBoardPath('business.eth', communities)).toBe('biz');
|
||||
expect(getBoardPath('music-posting.eth', communities)).toBe('mu');
|
||||
expect(getBoardPath('unknown.example', communities)).toBe('unknown.example');
|
||||
|
||||
expect(getSubplebbitAddress('biz', communities)).toBe('business.eth');
|
||||
expect(getSubplebbitAddress('b', communities)).toBe('random.eth');
|
||||
expect(getSubplebbitAddress('unknown.example', communities)).toBe('unknown.example');
|
||||
});
|
||||
|
||||
it('compares aliases and directory identifiers correctly', () => {
|
||||
expect(areSameBoardAddress('music-posting.eth', 'music-posting.bso')).toBe(true);
|
||||
expect(areSameBoardAddress('music-posting.eth', 'business.eth')).toBe(false);
|
||||
expect(areSameBoardAddress(undefined, 'business.eth')).toBe(false);
|
||||
|
||||
expect(isDirectoryBoard('biz', communities)).toBe(true);
|
||||
expect(isDirectoryBoard('business.eth', communities)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMultiboardFeedPath', () => {
|
||||
it('normalizes /all/3 -> /all', () => {
|
||||
@@ -46,6 +97,11 @@ describe('isFeedRoute', () => {
|
||||
expect(isFeedRoute('/biz/mod')).toBe(false);
|
||||
expect(isFeedRoute('/biz/mod/queue')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for posts and pending items', () => {
|
||||
expect(isFeedRoute('/biz/thread/abc')).toBe(false);
|
||||
expect(isFeedRoute('/pending/4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('board mod routes', () => {
|
||||
@@ -78,4 +134,58 @@ describe('board mod routes', () => {
|
||||
expect(isValidBoardModRoute('/biz/mod/log')).toBe(false);
|
||||
expect(isValidBoardModRoute('/biz/modqueue')).toBe(false);
|
||||
});
|
||||
|
||||
it('validates allowed top-level mod routes', () => {
|
||||
expect(isValidModRoute('/mod')).toBe(true);
|
||||
expect(isValidModRoute('/mod/catalog/settings')).toBe(true);
|
||||
expect(isValidModRoute('/mod/modqueue')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route kind helpers', () => {
|
||||
it('recognizes post and pending routes with optional settings suffixes', () => {
|
||||
expect(isPostRoute('/biz/thread/abc')).toBe(true);
|
||||
expect(isPostRoute('/biz/thread/abc/settings')).toBe(true);
|
||||
expect(isPostRoute('/biz')).toBe(false);
|
||||
|
||||
expect(isPendingPostRoute('/pending/3')).toBe(true);
|
||||
expect(isPendingPostRoute('/pending/3/settings')).toBe(true);
|
||||
expect(isPendingPostRoute('/biz')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feed pagination helpers', () => {
|
||||
it('strips trailing page numbers from feed paths', () => {
|
||||
expect(stripPageFromFeedPath('/biz/3')).toBe('/biz');
|
||||
expect(stripPageFromFeedPath('/biz/catalog/4')).toBe('/biz/catalog');
|
||||
expect(stripPageFromFeedPath('/biz/catalog')).toBe('/biz/catalog');
|
||||
});
|
||||
|
||||
it('parses page numbers and defaults to page 1', () => {
|
||||
expect(getPageFromFeedPath('/biz/3')).toBe(3);
|
||||
expect(getPageFromFeedPath('/biz/catalog/4/settings')).toBe(4);
|
||||
expect(getPageFromFeedPath('/biz/11')).toBe(1);
|
||||
expect(getPageFromFeedPath('/biz')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('feed cache helpers', () => {
|
||||
it('derives cache keys for feeds and threads', () => {
|
||||
expect(getFeedCacheKey('/biz')).toBe('/biz');
|
||||
expect(getFeedCacheKey('/biz/3/settings')).toBe('/biz');
|
||||
expect(getFeedCacheKey('/biz/catalog/4')).toBe('/biz/catalog');
|
||||
expect(getFeedCacheKey('/biz/thread/abc')).toBe('/biz');
|
||||
});
|
||||
|
||||
it('returns null cache keys for non-feed routes', () => {
|
||||
expect(getFeedCacheKey('/pending/3')).toBeNull();
|
||||
expect(getFeedCacheKey('/biz/mod/queue')).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies board, catalog, and non-feed routes', () => {
|
||||
expect(getFeedType('/biz')).toBe('board');
|
||||
expect(getFeedType('/biz/thread/abc')).toBe('board');
|
||||
expect(getFeedType('/biz/catalog/settings')).toBe('catalog');
|
||||
expect(getFeedType('/pending/3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user