diff --git a/src/components/settings-modal/interface-settings/__tests__/interface-settings.test.tsx b/src/components/settings-modal/interface-settings/__tests__/interface-settings.test.tsx index 66de998b..2ce612bd 100644 --- a/src/components/settings-modal/interface-settings/__tests__/interface-settings.test.tsx +++ b/src/components/settings-modal/interface-settings/__tests__/interface-settings.test.tsx @@ -3,21 +3,33 @@ import { createElement } from 'react'; import { createRoot, Root } from 'react-dom/client'; import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import packageJson from '../../../../../package.json'; import InterfaceSettings from '../interface-settings'; import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store'; (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 testState = vi.hoisted(() => ({ + alertMock: vi.fn(), + changeLanguageMock: vi.fn(), + fetchMock: vi.fn(), + fitExpandedImagesToScreen: false, + setFitExpandedImagesToScreenMock: vi.fn(), +})); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => key, - i18n: { changeLanguage: vi.fn(), language: 'en' }, + t: (key: string, opts?: Record) => (opts ? `${key}:${JSON.stringify(opts)}` : key), + i18n: { changeLanguage: testState.changeLanguageMock, language: 'en' }, }), })); vi.mock('../../../../stores/use-expanded-media-store', () => ({ - default: () => ({ fitExpandedImagesToScreen: false, setFitExpandedImagesToScreen: vi.fn() }), + default: () => ({ + fitExpandedImagesToScreen: testState.fitExpandedImagesToScreen, + setFitExpandedImagesToScreen: testState.setFitExpandedImagesToScreenMock, + }), })); vi.mock('../../version', () => ({ @@ -35,6 +47,20 @@ const STORAGE_KEY = 'feed-view-settings-store'; let root: Root; let container: HTMLDivElement; +const createFetchResponse = (body: unknown) => ({ + json: vi.fn().mockResolvedValue(body), +}); + +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +}; + const render = (children: React.ReactNode) => { act(() => { root.render(createElement(MemoryRouter, {}, children)); @@ -47,7 +73,14 @@ describe('InterfaceSettings', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.removeItem(STORAGE_KEY); + testState.alertMock.mockReset(); + testState.changeLanguageMock.mockReset(); + testState.fetchMock.mockReset(); + testState.fitExpandedImagesToScreen = false; + testState.setFitExpandedImagesToScreenMock.mockReset(); useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false); + vi.stubGlobal('alert', testState.alertMock); + vi.stubGlobal('fetch', testState.fetchMock); setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); container = document.createElement('div'); document.body.appendChild(container); @@ -58,6 +91,7 @@ describe('InterfaceSettings', () => { act(() => root.unmount()); container.remove(); setItemSpy.mockRestore(); + vi.unstubAllGlobals(); }); it('renders enable_infinite_scroll_tip under the infinite scroll checkbox', () => { @@ -92,4 +126,109 @@ describe('InterfaceSettings', () => { expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('infinite'); expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true')); }); + + it('toggles fit expanded images through the media store', async () => { + render(createElement(InterfaceSettings)); + + const label = Array.from(container.querySelectorAll('label')).find((candidate) => candidate.textContent?.toLowerCase().includes('fit_expanded_images_to_screen')); + const checkbox = label?.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeTruthy(); + + await act(async () => { + checkbox?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.setFitExpandedImagesToScreenMock).toHaveBeenCalledWith(true); + }); + + it('changes the interface language from the language selector', async () => { + render(createElement(InterfaceSettings)); + + const select = container.querySelector('select'); + expect(select).toBeTruthy(); + + await act(async () => { + if (select) { + select.value = 'fr'; + select.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + + expect(testState.changeLanguageMock).toHaveBeenCalledWith('fr'); + }); + + it('disables the update button while fetching and restores it afterward', async () => { + const pendingFetch = createDeferred>(); + testState.fetchMock.mockReturnValueOnce(pendingFetch.promise); + + render(createElement(InterfaceSettings)); + + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check'); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(button?.disabled).toBe(true); + + pendingFetch.resolve(createFetchResponse({ version: packageJson.version })); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(button?.disabled).toBe(false); + expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version')); + }); + + it('alerts when a newer stable version is available', async () => { + testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: '9.9.9' })); + + render(createElement(InterfaceSettings)); + + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check'); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + const message = String(testState.alertMock.mock.calls.at(-1)?.[0] ?? ''); + expect(message).toContain('new_stable_version'); + expect(message).toContain('refresh_to_update'); + }); + + it('alerts when already on the latest stable version', async () => { + testState.fetchMock.mockResolvedValueOnce(createFetchResponse({ version: packageJson.version })); + + render(createElement(InterfaceSettings)); + + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check'); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(testState.alertMock).toHaveBeenCalledWith(expect.stringContaining('latest_stable_version')); + }); + + it('alerts when fetching the latest version info fails', async () => { + testState.fetchMock.mockRejectedValueOnce(new Error('network down')); + + render(createElement(InterfaceSettings)); + + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'check'); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(testState.alertMock).toHaveBeenCalledWith('Failed to fetch latest version info: Error: network down'); + }); }); diff --git a/src/views/account-data-editor/__tests__/account-data-editor.test.tsx b/src/views/account-data-editor/__tests__/account-data-editor.test.tsx index 97258397..8aab1703 100644 --- a/src/views/account-data-editor/__tests__/account-data-editor.test.tsx +++ b/src/views/account-data-editor/__tests__/account-data-editor.test.tsx @@ -1,13 +1,35 @@ import * as React from 'react'; import { createElement } from 'react'; import { createRoot, Root } from 'react-dom/client'; -import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import AccountDataEditor from '../account-data-editor'; (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 DEFAULT_JSON = '{"account":{"name":"test"}}'; + +const testState = vi.hoisted(() => ({ + account: { id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }, + alertMock: vi.fn(), + buildEditableAccountJsonMock: vi.fn<(account: unknown) => string>(() => DEFAULT_JSON), + buildSavePayloadMock: vi.fn<(parsed: { account: Record }, id: string) => Record>((parsed, id) => ({ + ...parsed.account, + id, + })), + locationState: null as { state?: { returnTo?: string } } | null, + navigateMock: vi.fn(), + safeParseAccountJsonMock: vi.fn<(text: string) => { account: Record } | null>((text: string) => { + try { + const parsed = JSON.parse(text); + return parsed?.account ? parsed : null; + } catch { + return null; + } + }), + setAccountMock: vi.fn(), +})); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -15,36 +37,116 @@ vi.mock('react-i18next', () => ({ }), })); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useLocation: () => testState.locationState ?? { state: null }, + useNavigate: () => testState.navigateMock, + }; +}); + vi.mock('../../../lib/utils/account-editor-utils', () => ({ - buildEditableAccountJson: () => '{"account": {"name": "test"}}', - safeParseAccountJson: vi.fn((text: string) => { - try { - const p = JSON.parse(text); - return p?.account ? p : null; - } catch { - return null; - } - }), - buildSavePayload: vi.fn((parsed: { account: Record }, id: string) => ({ ...parsed.account, id })), + buildEditableAccountJson: (account: unknown) => testState.buildEditableAccountJsonMock(account), + buildSavePayload: (parsed: { account: Record }, id: string) => testState.buildSavePayloadMock(parsed, id), + safeParseAccountJson: (text: string) => testState.safeParseAccountJsonMock(text), })); vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({ - useAccount: () => ({ id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }), - setAccount: vi.fn(), + setAccount: (payload: Record) => testState.setAccountMock(payload), + useAccount: () => testState.account, })); +vi.mock('react-ace', async () => { + const ReactModule = await vi.importActual('react'); + + return { + default: ({ value, onChange }: { value: string; onChange: (nextValue: string) => void }) => + ReactModule.createElement('textarea', { + 'data-testid': 'ace-editor', + onChange: (event: Event) => onChange((event.target as HTMLTextAreaElement).value), + value, + }), + }; +}); + +vi.mock('ace-builds/src-noconflict/mode-json', () => ({})); +vi.mock('ace-builds/src-noconflict/theme-monokai', () => ({})); + let root: Root; let container: HTMLDivElement; -const render = (children: React.ReactNode) => { +const flushEffects = async (count = 10) => { + for (let i = 0; i < count; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +}; + +const waitForEditor = async () => { + for (let i = 0; i < 50; i += 1) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await flushEffects(2); + if (container.querySelector('[data-testid="ace-editor"]')) { + return; + } + } + + expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy(); +}; + +const clickButton = async (label: string) => { + const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === label); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +}; + +const changeEditorValue = async (value: string) => { + const editor = container.querySelector('[data-testid="ace-editor"]'); + expect(editor).toBeTruthy(); + + await act(async () => { + if (editor) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value'); + descriptor?.set?.call(editor, value); + editor.dispatchEvent(new Event('input', { bubbles: true })); + editor.dispatchEvent(new Event('change', { bubbles: true })); + } + }); +}; + +const renderEditor = () => { act(() => { - root.render(createElement(MemoryRouter, { initialEntries: ['/settings/account-data'] }, children)); + root.render(createElement(AccountDataEditor)); }); }; describe('AccountDataEditor', () => { beforeEach(() => { vi.clearAllMocks(); + testState.account = { id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }; + testState.alertMock.mockReset(); + testState.buildEditableAccountJsonMock.mockReturnValue(DEFAULT_JSON); + testState.buildSavePayloadMock.mockImplementation((parsed: { account: Record }, id: string) => ({ ...parsed.account, id })); + testState.locationState = null; + testState.navigateMock.mockReset(); + testState.safeParseAccountJsonMock.mockImplementation((text: string) => { + try { + const parsed = JSON.parse(text); + return parsed?.account ? parsed : null; + } catch { + return null; + } + }); + testState.setAccountMock.mockReset(); + vi.stubGlobal('alert', testState.alertMock); + container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -53,27 +155,86 @@ describe('AccountDataEditor', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + vi.unstubAllGlobals(); }); - it('renders warning gate initially', () => { - render(createElement(AccountDataEditor)); + it('navigates back to the default settings route from the warning gate', async () => { + renderEditor(); expect(container.textContent).toContain('private_key_warning_title'); + + await clickButton('go_back'); + + expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#account-settings'); }); - it('shows go_back and continue buttons in warning gate', () => { - render(createElement(AccountDataEditor)); - const buttons = Array.from(container.querySelectorAll('button')); - const texts = buttons.map((b) => b.textContent ?? ''); - expect(texts.some((t) => t.includes('go_back'))).toBe(true); - expect(texts.some((t) => t.includes('continue'))).toBe(true); + it('loads the editor after continue and respects custom return routes', async () => { + testState.locationState = { state: { returnTo: '/custom/settings#account' } }; + + renderEditor(); + await clickButton('continue'); + + expect(container.textContent).toContain('loading_editor'); + await waitForEditor(); + + expect(container.textContent).not.toContain('loading_editor'); + expect(container.querySelector('[data-testid="ace-editor"]')).toBeTruthy(); + + await clickButton('return_to_settings'); + + expect(testState.navigateMock).toHaveBeenLastCalledWith('/custom/settings#account'); }); - it('does not show editor controls in warning phase', () => { - render(createElement(AccountDataEditor)); - const buttons = Array.from(container.querySelectorAll('button')); - const texts = buttons.map((b) => b.textContent ?? ''); - expect(texts.some((t) => t.includes('save'))).toBe(false); - expect(texts.some((t) => t.includes('reset_changes'))).toBe(false); - expect(texts.some((t) => t.includes('return_to_settings'))).toBe(false); + it('resets edited text back to the account JSON snapshot', async () => { + renderEditor(); + await clickButton('continue'); + await waitForEditor(); + + expect(container.querySelector('[data-testid="ace-editor"]')?.value).toBe(DEFAULT_JSON); + + await changeEditorValue('{"account":{"name":"changed"}}'); + expect(container.querySelector('[data-testid="ace-editor"]')?.value).toBe('{"account":{"name":"changed"}}'); + + await clickButton('reset_changes'); + + expect(container.querySelector('[data-testid="ace-editor"]')?.value).toBe(DEFAULT_JSON); + }); + + it('alerts on invalid JSON without attempting to save', async () => { + renderEditor(); + await clickButton('continue'); + await waitForEditor(); + await changeEditorValue('not valid json'); + await clickButton('save_changes'); + + expect(testState.alertMock).toHaveBeenCalledWith('Invalid JSON'); + expect(testState.setAccountMock).not.toHaveBeenCalled(); + }); + + it('saves valid JSON and navigates back to settings', async () => { + testState.setAccountMock.mockResolvedValueOnce(undefined); + + renderEditor(); + await clickButton('continue'); + await waitForEditor(); + await changeEditorValue('{"account":{"name":"changed"}}'); + await clickButton('save_changes'); + await flushEffects(); + + expect(testState.buildSavePayloadMock).toHaveBeenCalledWith({ account: { name: 'changed' } }, 'test-id'); + expect(testState.setAccountMock).toHaveBeenCalledWith({ id: 'test-id', name: 'changed' }); + expect(testState.navigateMock).toHaveBeenCalledWith('/subs/settings#account-settings'); + }); + + it('surfaces save errors from setAccount', async () => { + testState.setAccountMock.mockRejectedValueOnce(new Error('save failed')); + + renderEditor(); + await clickButton('continue'); + await waitForEditor(); + await changeEditorValue('{"account":{"name":"changed"}}'); + await clickButton('save_changes'); + await flushEffects(); + + expect(testState.alertMock).toHaveBeenCalledWith('save failed'); }); });