452 lines
25 KiB
TypeScript
452 lines
25 KiB
TypeScript
import React from 'react';
|
|
import '@testing-library/jest-dom';
|
|
import { act, fireEvent, render, screen, waitFor, within } 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';
|
|
import { translations, type TranslationKey } from './i18n/translations';
|
|
|
|
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', () => () => <div>Import Export Stub</div>);
|
|
jest.mock('./components/BackupCard', () => () => <div>Backup Stub</div>);
|
|
|
|
const mockedApi = api as jest.Mocked<typeof api>;
|
|
|
|
function renderView(overrides: Partial<React.ComponentProps<typeof SettingsView>> = {}) {
|
|
return render(
|
|
<MemoryRouter>
|
|
<ToastProvider>
|
|
<I18nProvider>
|
|
<SettingsView
|
|
pageSize={20}
|
|
onPageSizeChange={jest.fn()}
|
|
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }}
|
|
onColumnsChange={jest.fn()}
|
|
themeMode="dark"
|
|
onThemeModeChange={jest.fn()}
|
|
{...overrides}
|
|
/>
|
|
</I18nProvider>
|
|
</ToastProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
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('each selected Settings section has a named panel linked to its tab and remains selectable on phones', async () => {
|
|
renderView();
|
|
await screen.findByText(/AI privacy/i);
|
|
const tabs = screen.getAllByRole('tab');
|
|
for (const tab of tabs) {
|
|
await act(async () => { fireEvent.click(tab); });
|
|
const panel = screen.getByRole('tabpanel', { name: tab.textContent! });
|
|
expect(tab.id).not.toBe('');
|
|
expect(tab).toHaveAttribute('aria-controls', panel.id);
|
|
expect(panel).toHaveAttribute('aria-labelledby', tab.id);
|
|
}
|
|
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Settings section' }));
|
|
fireEvent.click(screen.getByRole('option', { name: 'General' }));
|
|
expect(screen.getByRole('tabpanel', { name: 'General' })).toBeInTheDocument();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
function preferenceCopy(key: TranslationKey) { return translations[document.documentElement.lang === 'nb-NO' ? 'nb' : 'en'][key]; }
|
|
function notificationPanel() { return within(screen.getByRole('tabpanel', { name: preferenceCopy('settingsTabNotifications') })); }
|
|
function notificationReads() { return mockedApi.get.mock.calls.filter(([url]) => url === '/notification-settings'); }
|
|
function notificationWrites() { return mockedApi.put.mock.calls.filter(([url]) => url === '/notification-settings'); }
|
|
async function openNotifications() {
|
|
fireEvent.click(screen.getByRole('tab', { name: preferenceCopy('settingsTabNotifications') }));
|
|
return notificationPanel().findByRole('checkbox', { name: preferenceCopy('settingsNotificationsFollowUpReminders') });
|
|
}
|
|
async function preferenceLanguage(language: 'en' | 'nb') {
|
|
await act(async () => { window.dispatchEvent(new StorageEvent('storage', { key: 'uiLanguage', newValue: language })); });
|
|
}
|
|
function preferenceDeferred() {
|
|
let resolve!: (value: any) => void;
|
|
const promise = new Promise<any>(yes => { resolve = yes; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
test('[P7-09 preferences] initial notification failure offers a single GET Retry and recovers its vanished focused action', async () => {
|
|
const pending = preferenceDeferred(); const ordinaryGet = mockedApi.get.getMockImplementation()!; let reads = 0;
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/notification-settings'
|
|
? ++reads === 1 ? Promise.reject(new Error('offline')) : pending.promise : ordinaryGet(url, config));
|
|
renderView(); fireEvent.click(screen.getByRole('tab', { name: 'Notifications' }));
|
|
expect(await notificationPanel().findByRole('alert')).toHaveTextContent(translations.en.settingsNotificationsLoadFailed);
|
|
const retry = notificationPanel().getByRole('button', { name: 'Retry' });
|
|
act(() => { retry.focus(); fireEvent.click(retry); fireEvent.click(retry); });
|
|
expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled();
|
|
await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } }));
|
|
expect(await notificationPanel().findByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
|
expect(notificationPanel().queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
|
expect(notificationPanel().getByRole('group', { name: translations.en.settingsNotificationsTitle })).toHaveFocus();
|
|
expect(notificationReads()).toHaveLength(2); expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('[P7-09 preferences] notification drafts survive internal sections and locale changes without a GET or save', async () => {
|
|
renderView(); const checkbox = await openNotifications(); expect(checkbox).toBeChecked(); fireEvent.click(checkbox);
|
|
const reads = notificationReads().length;
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' })); await screen.findByLabelText('Applied: follow-up days');
|
|
fireEvent.click(screen.getByRole('tab', { name: 'General' })); await preferenceLanguage('nb');
|
|
const retained = await openNotifications();
|
|
expect(retained).not.toBeChecked(); expect(notificationReads()).toHaveLength(reads);
|
|
expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.nb.settingsNotificationsDraftScope);
|
|
await preferenceLanguage('en'); expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
|
expect(notificationReads()).toHaveLength(reads); expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('[P7-09 preferences] one held notification save acknowledges its snapshot and preserves later edits across sections', async () => {
|
|
const pending = preferenceDeferred(); mockedApi.put.mockImplementationOnce(() => pending.promise);
|
|
renderView(); const checkbox = await openNotifications(); fireEvent.click(checkbox);
|
|
const save = notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave });
|
|
act(() => { fireEvent.click(save); fireEvent.click(save); });
|
|
expect(notificationWrites()).toEqual([['/notification-settings', { emailFollowUpRemindersEnabled: false }]]);
|
|
fireEvent.click(checkbox); expect(checkbox).toBeChecked();
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' })); await screen.findByLabelText('Applied: follow-up days');
|
|
await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } }));
|
|
expect(await openNotifications()).toBeChecked();
|
|
expect(notificationPanel().getByRole('status')).toHaveTextContent(translations.en.assetsUnsavedChanges);
|
|
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges }));
|
|
expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
|
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
|
|
expect(notificationReads()).toHaveLength(1); expect(notificationWrites()).toHaveLength(1);
|
|
});
|
|
|
|
test('[P7-09 preferences] failed notification save keeps its current-locale draft and retries only the original Save', async () => {
|
|
mockedApi.put.mockRejectedValueOnce(new Error('offline'));
|
|
renderView(); fireEvent.click(await openNotifications());
|
|
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave }));
|
|
expect(await notificationPanel().findByRole('alert')).toHaveTextContent(translations.en.settingsNotificationsSaveFailed);
|
|
const reads = notificationReads().length; await preferenceLanguage('nb');
|
|
expect(notificationPanel().getByRole('alert')).toHaveTextContent(translations.nb.settingsNotificationsSaveFailed);
|
|
expect(notificationPanel().getByRole('checkbox', { name: translations.nb.settingsNotificationsFollowUpReminders })).not.toBeChecked();
|
|
expect(notificationPanel().queryByRole('button', { name: translations.nb.retry })).not.toBeInTheDocument();
|
|
fireEvent.click(notificationPanel().getByRole('button', { name: translations.nb.settingsNotificationsSave }));
|
|
await waitFor(() => expect(notificationPanel().queryByRole('alert')).not.toBeInTheDocument());
|
|
expect(notificationWrites()).toEqual([
|
|
['/notification-settings', { emailFollowUpRemindersEnabled: false }],
|
|
['/notification-settings', { emailFollowUpRemindersEnabled: false }],
|
|
]);
|
|
expect(notificationReads()).toHaveLength(reads);
|
|
expect(await screen.findByText(translations.nb.settingsNotificationsSaved)).toBeVisible();
|
|
});
|
|
|
|
test('[P7-09 preferences] discarding a notification draft leaves the independent Rules draft untouched', async () => {
|
|
renderView(); fireEvent.change(await openRules(), { target: { value: '5' } });
|
|
fireEvent.click(await openNotifications());
|
|
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.assetsDiscardChanges }));
|
|
expect(notificationPanel().getByRole('checkbox', { name: translations.en.settingsNotificationsFollowUpReminders })).toBeChecked();
|
|
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' }));
|
|
expect(screen.getByLabelText('Applied: follow-up days')).toHaveValue(5);
|
|
expect(screen.getByRole('status')).toHaveTextContent(translations.en.rulesDraftScope);
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('[P7-09 preferences] a disposed notification save cannot publish into surviving providers or a new Settings session', async () => {
|
|
const pending = preferenceDeferred(); mockedApi.put.mockImplementationOnce(() => pending.promise);
|
|
function Session() {
|
|
const [visible, setVisible] = React.useState(true);
|
|
return <MemoryRouter><ToastProvider><I18nProvider>
|
|
<button onClick={() => setVisible(value => !value)}>Toggle Settings session</button>
|
|
{visible ? <SettingsView pageSize={20} onPageSizeChange={() => undefined} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => undefined} themeMode="dark" onThemeModeChange={() => undefined} /> : null}
|
|
</I18nProvider></ToastProvider></MemoryRouter>;
|
|
}
|
|
render(<Session />); fireEvent.click(await openNotifications());
|
|
fireEvent.click(notificationPanel().getByRole('button', { name: translations.en.settingsNotificationsSave }));
|
|
expect(notificationWrites()).toHaveLength(1); fireEvent.click(screen.getByRole('button', { name: 'Toggle Settings session' }));
|
|
await act(async () => pending.resolve({ data: { emailFollowUpRemindersEnabled: false } }));
|
|
expect(screen.queryByText(translations.en.settingsNotificationsSaved)).not.toBeInTheDocument();
|
|
expect(notificationReads()).toHaveLength(1);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Toggle Settings session' }));
|
|
expect(await openNotifications()).toBeChecked(); expect(notificationReads()).toHaveLength(2);
|
|
expect(notificationPanel().queryByText(translations.en.settingsNotificationsDraftScope)).not.toBeInTheDocument();
|
|
expect(notificationWrites()).toHaveLength(1);
|
|
});
|
|
|
|
test('[P7-09 preferences] Rules load failure follows locale without reloading and Retry retains the six-field contract', async () => {
|
|
const ordinaryGet = mockedApi.get.getMockImplementation()!; let reads = 0;
|
|
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/rules' && ++reads === 1 ? Promise.reject(new Error('offline')) : ordinaryGet(url, config));
|
|
renderView(); fireEvent.click(screen.getByRole('tab', { name: 'Follow-ups' }));
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(translations.en.rulesLoadFailed);
|
|
await preferenceLanguage('nb');
|
|
expect(screen.getByRole('alert')).toHaveTextContent(translations.nb.rulesLoadFailed);
|
|
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/rules')).toHaveLength(1);
|
|
fireEvent.click(screen.getByRole('button', { name: translations.nb.retry }));
|
|
const fields = [
|
|
['rulesAppliedFollowUpDays', 14], ['rulesAppliedGhostDays', 30], ['rulesOfferFollowUpDays', 7],
|
|
['rulesOfferGhostDays', 14], ['rulesFeedbackFollowUpDays', 7], ['rulesFeedbackGhostDays', 14],
|
|
] as const;
|
|
for (const [key, value] of fields) expect(await screen.findByLabelText(translations.nb[key])).toHaveValue(value);
|
|
expect(mockedApi.get.mock.calls.filter(([url]) => url === '/rules')).toHaveLength(2);
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|