diff --git a/src/components/settings-modal/__tests__/settings-modal.test.tsx b/src/components/settings-modal/__tests__/settings-modal.test.tsx new file mode 100644 index 00000000..2d52f01e --- /dev/null +++ b/src/components/settings-modal/__tests__/settings-modal.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('../account-settings', () => ({ + default: () =>
account-settings
, +})); + +vi.mock('../crypto-address-setting', () => ({ + default: () =>
crypto-address-setting
, +})); + +vi.mock('../crypto-wallets-setting', () => ({ + default: () =>
crypto-wallets-setting
, +})); + +vi.mock('../interface-settings', () => ({ + default: () =>
interface-settings
, +})); + +vi.mock('../media-hosting-settings', () => ({ + default: () =>
media-hosting-settings
, +})); + +vi.mock('../advanced-settings', () => ({ + default: () =>
advanced-settings
, +})); + +vi.mock('../subscriptions-setting', () => ({ + default: () =>
subscriptions-settings
, +})); + +const LocationProbe = () => { + const location = useLocation(); + return
{location.pathname + location.hash}
; +}; + +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'); + }); +}); diff --git a/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx b/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx index 0ae8e3e7..462229ce 100644 --- a/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx +++ b/src/components/settings-modal/account-settings/__tests__/account-settings.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; +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
{location.pathname + location.hash}
; +}; + let root: Root; let container: HTMLDivElement; +let alertSpy: ReturnType; +let confirmSpy: ReturnType; +let createElementSpy: ReturnType; +let anchorClickSpy: ReturnType; +let inputClickSpy: ReturnType; +let consoleLogSpy: ReturnType; +let consoleErrorSpy: ReturnType; +let createdInput: HTMLInputElement | null; +let createdAnchor: HTMLAnchorElement | null; +let createObjectUrlSpy: ReturnType; +let revokeObjectUrlSpy: ReturnType; -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'); }); }); diff --git a/src/components/settings-modal/account-settings/account-settings.tsx b/src/components/settings-modal/account-settings/account-settings.tsx index 2e63b4db..e277f5cb 100644 --- a/src/components/settings-modal/account-settings/account-settings.tsx +++ b/src/components/settings-modal/account-settings/account-settings.tsx @@ -130,6 +130,7 @@ const AccountSettingsEditor = ({ if (accountData.account?.name) { await setActiveAccount(accountData.account.name); } + return true; }, (error) => { if (error instanceof Error) { diff --git a/src/components/settings-modal/subscriptions-setting/__tests__/subscriptions-setting.test.tsx b/src/components/settings-modal/subscriptions-setting/__tests__/subscriptions-setting.test.tsx new file mode 100644 index 00000000..6375e6db --- /dev/null +++ b/src/components/settings-modal/subscriptions-setting/__tests__/subscriptions-setting.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const accountState = vi.hoisted(() => ({ + value: { + name: 'Account 1', + subscriptions: ['music-posting.bso'], + } as { name: string; subscriptions: string[] }, +})); + +const subscriptionMocks = vi.hoisted(() => ({ + byAddress: new Map; unsubscribe: ReturnType }>(), + 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; + +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'); + }); +}); diff --git a/src/lib/utils/__tests__/route-utils.test.ts b/src/lib/utils/__tests__/route-utils.test.ts index 815a623d..5f9561d3 100644 --- a/src/lib/utils/__tests__/route-utils.test.ts +++ b/src/lib/utils/__tests__/route-utils.test.ts @@ -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(); + }); });