feat(settings): preserve drafts and connection recovery
CI and Deploy / test (push) Failing after 4m27s
CI and Deploy / deploy (push) Has been skipped

This commit is contained in:
cesnimda
2026-10-17 10:50:40 +02:00
parent dac29278db
commit e7dba9a2f7
20 changed files with 3337 additions and 141 deletions
+134 -1
View File
@@ -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();
});