feat(settings): preserve drafts and connection recovery
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
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: {
|
||||
@@ -316,3 +317,135 @@ test('[PRES-S03] Rules draft updates do not refetch an unrelated notification pr
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user