import React from 'react'; import '@testing-library/jest-dom'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import SettingsView from './components/SettingsView'; import { I18nProvider } from './i18n/I18nProvider'; import { ToastProvider } from './toast'; import { api } from './api'; jest.mock('./api', () => ({ api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }, getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.', })); jest.mock('./components/ImportExportJobs', () => () =>
Import Export Stub
); jest.mock('./components/BackupCard', () => () =>
Backup Stub
); const mockedApi = api as jest.Mocked; function renderView(overrides: Partial> = {}) { return render( , ); } beforeEach(() => { mockedApi.get.mockImplementation((url: string) => { if (url === '/ai/settings') { return Promise.resolve({ data: { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: 'local', }, } as any); } if (url === '/ai/usage') { return Promise.resolve({ data: { currentMonth: { calls: 0, estimatedTokens: 0 }, plan: 'free', monthlyCallLimit: 0, monthlyTokenLimit: 0, storageUsedBytes: 0, storageLimitBytes: 250_000_000, } } as any); } if (url === '/billing/status') { return Promise.resolve({ data: { enabled: false, canCheckout: false, canManage: false } } as any); } if (url === '/rules') { return Promise.resolve({ data: { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14, }, } as any); } if (url === '/notification-settings') return Promise.resolve({ data: { emailFollowUpRemindersEnabled: true } } as any); return Promise.resolve({ data: {} } as any); }); window.localStorage.clear(); mockedApi.put.mockImplementation((url: string, body: any) => Promise.resolve({ data: { ...body, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: 'local', }, } as any)); }); afterEach(() => { jest.clearAllMocks(); }); test('settings view has no accent picker and uses one follow-up section, one notification section', async () => { renderView(); expect(screen.queryByText(/accent/i)).not.toBeInTheDocument(); expect(screen.getByLabelText(/settings section/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i })); expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument(); expect(screen.queryAllByText(/open reminders/i)).toHaveLength(0); fireEvent.click(screen.getByRole('tab', { name: /notifications/i })); expect(screen.getByText(/^notification settings$/i)).toBeInTheDocument(); // SMTP status now lives under Admin → System → Settings (the old "check system status" link was removed). expect(screen.getAllByText(/smtp delivery and test mail live under/i).length).toBe(1); expect(screen.getByLabelText(/email reminders for follow-ups/i)).toBeInTheDocument(); expect(screen.queryByLabelText(/email alerts for ghosted jobs/i)).not.toBeInTheDocument(); fireEvent.click(screen.getByLabelText(/email reminders for follow-ups/i)); fireEvent.click(screen.getByRole('button', { name: /save notification settings/i })); expect(mockedApi.put).toHaveBeenCalledWith('/notification-settings', { emailFollowUpRemindersEnabled: false }); }); test('AI privacy settings are server-backed and external processing is local-only by default', async () => { renderView(); expect(await screen.findByText(/AI privacy/i)).toBeInTheDocument(); const external = screen.getByLabelText(/allow approved external AI processing/i); expect(external).toBeDisabled(); fireEvent.click(screen.getByLabelText(/enable AI features for my account/i)); fireEvent.click(screen.getByRole('button', { name: /save AI privacy settings/i })); expect(mockedApi.put).toHaveBeenCalledWith('/ai/settings', { enabled: false, externalProcessingAllowed: false, }); }); test('[PRES-S03] failed notification save preserves the draft for an explicit retry', async () => { mockedApi.put.mockRejectedValueOnce(new Error('offline')); renderView(); fireEvent.click(screen.getByRole('tab', { name: /notifications/i })); const preference = await screen.findByLabelText(/email reminders for follow-ups/i); await waitFor(() => expect(preference).toBeChecked()); fireEvent.click(preference); fireEvent.click(screen.getByRole('button', { name: /save notification settings/i })); expect(await screen.findByRole('alert')).toHaveTextContent(/could not|failed/i); expect(preference).not.toBeChecked(); fireEvent.click(screen.getByRole('button', { name: /save notification settings/i })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2)); expect(mockedApi.put).toHaveBeenLastCalledWith('/notification-settings', { emailFollowUpRemindersEnabled: false }); await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); }); test('[PRES-S03 PRES-W11] saving one follow-up rule preserves the other scenarios', async () => { renderView(); fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i })); fireEvent.change(await screen.findByLabelText('Applied: follow-up days'), { target: { value: '5' } }); fireEvent.click(screen.getByRole('button', { name: /save rules/i })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/rules', { id: 1, appliedFollowUpDays: 5, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14, })); }); test('[PRES-S03 PRES-J05] table and theme preferences keep their independent callbacks', async () => { const onPageSizeChange = jest.fn(); const onColumnsChange = jest.fn(); const onThemeModeChange = jest.fn(); renderView({ onPageSizeChange, onColumnsChange, onThemeModeChange }); await screen.findByText(/AI privacy/i); fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Rows per page' })); fireEvent.click(screen.getByRole('option', { name: '25' })); expect(onPageSizeChange).toHaveBeenCalledWith(25); fireEvent.click(screen.getByRole('checkbox', { name: 'Job URL' })); expect(onColumnsChange).toHaveBeenCalledWith({ status: true, dateApplied: true, daysSince: true, jobUrl: true }); fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Theme' })); fireEvent.click(screen.getByRole('option', { name: 'Light' })); expect(onThemeModeChange).toHaveBeenCalledWith('light'); expect(mockedApi.put).not.toHaveBeenCalled(); }); async function openRules() { fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i })); const field = await screen.findByLabelText('Applied: follow-up days'); await waitFor(() => expect(field).toHaveValue(14)); return field; } function rulesRoundTrip() { fireEvent.click(screen.getByRole('tab', { name: /notifications/i })); fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i })); } test('[PRES-S03 W11] the Settings session retains independent pending Rules fields across tab unmounts', async () => { renderView(); fireEvent.change(await openRules(), { target: { value: '5' } }); fireEvent.change(screen.getByLabelText('Offer: follow-up days'), { target: { value: '9' } }); rulesRoundTrip(); expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5); expect(screen.getByLabelText('Offer: follow-up days')).toHaveValue(9); expect(screen.getByLabelText('Feedback: follow-up days')).toHaveValue(7); expect(screen.getByRole('status')).toHaveTextContent(/unsaved/i); expect(mockedApi.put).not.toHaveBeenCalled(); }); test('[PRES-S03] failed Rules save remains actionable after tab return and explicit retry clears dirty state', async () => { renderView(); fireEvent.change(await openRules(), { target: { value: '5' } }); mockedApi.put.mockRejectedValueOnce(new Error('offline')); fireEvent.click(screen.getByRole('button', { name: /save rules/i })); expect(await screen.findByRole('alert')).toHaveTextContent(/failed to save rules/i); rulesRoundTrip(); expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5); expect(screen.getByRole('alert')).toHaveTextContent(/failed to save rules/i); expect(screen.getByRole('status')).toHaveTextContent(/unsaved/i); fireEvent.click(screen.getByRole('button', { name: /save rules/i })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); expect(screen.queryByText('Failed to save rules.')).not.toBeInTheDocument(); }); test('[PRES-S03] invalid and empty Rules input and associated validation survive internal navigation', async () => { renderView(); fireEvent.change(await openRules(), { target: { value: '' } }); fireEvent.change(screen.getByLabelText('Offer: follow-up days'), { target: { value: '366' } }); fireEvent.click(screen.getByRole('button', { name: /save rules/i })); expect(mockedApi.put).not.toHaveBeenCalled(); rulesRoundTrip(); const empty = screen.getByLabelText('Applied: follow-up days'); expect(empty).toHaveValue(null); expect(empty).toHaveAttribute('aria-invalid', 'true'); expect(empty).toHaveAccessibleDescription(/whole number.*1.*365/i); expect(screen.getByLabelText('Offer: follow-up days')).toHaveValue(366); fireEvent.change(empty, { target: { value: '5' } }); fireEvent.change(screen.getByLabelText('Offer: follow-up days'), { target: { value: '9' } }); fireEvent.click(screen.getByRole('button', { name: /save rules/i })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1)); }); test('[PRES-S03] explicit discard resets only the Rules draft to its saved baseline without writing', async () => { renderView(); fireEvent.change(await openRules(), { target: { value: '5' } }); rulesRoundTrip(); fireEvent.click(screen.getByRole('button', { name: /discard changes/i })); expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(14); expect(screen.queryByRole('status')).not.toBeInTheDocument(); expect(mockedApi.put).not.toHaveBeenCalled(); }); test('[PRES-S03] later edits stay dirty when an earlier Rules save completes after a tab switch', async () => { let finish!: (value: any) => void; mockedApi.put.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); renderView(); fireEvent.change(await openRules(), { target: { value: '5' } }); fireEvent.click(screen.getByRole('button', { name: /save rules/i })); fireEvent.change(screen.getByLabelText('Applied: follow-up days'), { target: { value: '6' } }); rulesRoundTrip(); await act(async () => finish({ status: 204 })); expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(6); expect(screen.getByRole('status')).toHaveTextContent(/unsaved/i); fireEvent.click(screen.getByRole('button', { name: /discard changes/i })); expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5); }); test('[PRES-S03 E09] leaving the Settings session disposes its draft and pending responses', async () => { let finish!: (value: any) => void; mockedApi.put.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); const first = renderView(); fireEvent.change(await openRules(), { target: { value: '5' } }); fireEvent.click(screen.getByRole('button', { name: /save rules/i })); first.unmount(); renderView(); await openRules(); await act(async () => finish({ status: 204 })); expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(14); expect(screen.queryByRole('status')).not.toBeInTheDocument(); }); test('[PRES-S03] switching locale while Rules is hidden does not reload or overwrite its pending draft', async () => { renderView(); fireEvent.change(await openRules(), { target: { value: '5' } }); const reads = mockedApi.get.mock.calls.filter(([url]) => url === '/rules').length; fireEvent.click(screen.getByRole('tab', { name: /general/i })); fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Preferred language' })); fireEvent.click(screen.getByRole('option', { name: 'Norwegian Bokmål' })); fireEvent.click(screen.getByRole('tab', { name: 'Oppfølging' })); expect(screen.getByLabelText('Søkt: oppfølgingsdager')).toHaveValue(5); expect(mockedApi.get.mock.calls.filter(([url]) => url === '/rules')).toHaveLength(reads); }); test('[PRES-S03] Rules draft updates do not refetch an unrelated notification preference', async () => { renderView(); const field = await openRules(); const reads = mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings').length; fireEvent.change(field, { target: { value: '5' } }); fireEvent.change(field, { target: { value: '6' } }); await act(async () => undefined); expect(mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings')).toHaveLength(reads); });