import SettingsPage from '@/features/Settings/SettingsPage' import { api } from '@/utils/apiUtils' import * as timezoneUtils from '@/utils/timezone' import '@testing-library/jest-dom' import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithProviders } from '../../utils/Renderwithproviders' jest.mock('@/utils/apiUtils') describe('SettingsPage', () => { beforeEach(() => { jest.clearAllMocks() localStorage.clear() }) const preloadedState = { user: { userData: { sub: 'test', email: 'test@test.com', family_name: 'Doe', name: 'John', sid: 'mockSid', openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, coreConfig: { language: 'en', datetime: { timeZone: 'UTC' } }, alarmEmailsEnabled: false, loading: false, error: null }, settings: { language: 'en', timeZone: 'UTC', isBrowserDefaultTimeZone: false, view: 'settings' } } it('renders settings page with sidebar and content layout', () => { renderWithProviders(, preloadedState) expect(screen.getByRole('main')).toHaveClass( 'main-layout', 'settings-layout' ) }) it('displays sidebar navigation with Settings and Notifications items', () => { const { container } = renderWithProviders(, preloadedState) // Check sidebar navigation items // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access const sidebar = container.querySelector('.settings-sidebar') expect(sidebar).toBeInTheDocument() expect(screen.getAllByText(/settings.title/i).length).toBeGreaterThan(0) expect(screen.getByText(/settings.notifications/i)).toBeInTheDocument() }) it('highlights active navigation item', () => { const { container } = renderWithProviders(, preloadedState) // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access const settingsNavItem = container.querySelector('.settings-nav-item.active') expect(settingsNavItem).toBeInTheDocument() }) it('navigates back to calendar view when clicking back button', async () => { const { store } = renderWithProviders(, preloadedState) const backButton = screen.getByLabelText('settings.back') fireEvent.click(backButton) await waitFor(() => { const state = store.getState() expect(state.settings.view).toBe('calendar') }) }) it('switches between Settings and Notifications tabs', () => { renderWithProviders(, preloadedState) const notificationsTab = screen.getByRole('tab', { name: /settings.notifications/i }) fireEvent.click(notificationsTab) expect( screen.getByText(/settings.notifications.deliveryMethod/i) ).toBeInTheDocument() }) it('displays language select in Settings tab', () => { renderWithProviders(, preloadedState) expect( screen.getByLabelText('settings.languageSelector') ).toBeInTheDocument() }) it('displays all available language options and uses language from user state', async () => { const stateWithUserLanguage = { user: { userData: { sub: 'test', email: 'test@test.com', family_name: 'Doe', name: 'John', sid: 'mockSid', openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, coreConfig: { language: 'fr', datetime: { timeZone: 'UTC' } }, loading: false, error: null }, settings: { language: 'en', timeZone: 'UTC', isBrowserDefaultTimeZone: false, view: 'settings' } } renderWithProviders(, stateWithUserLanguage) const languageSelect = screen.getByLabelText('settings.languageSelector') // Verify Select exists expect(languageSelect).toBeInTheDocument() // Verify that the underlying native input reflects the user language ("fr") // eslint-disable-next-line testing-library/no-node-access const nativeInput = languageSelect.querySelector( 'input[aria-hidden="true"]' ) as HTMLInputElement | null expect(nativeInput).not.toBeNull() expect(nativeInput?.value).toBe('fr') }) it('updates language immediately (optimistic update) and calls API in background', async () => { ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) const { store } = renderWithProviders(, preloadedState) const languageSelect = screen.getByLabelText('settings.languageSelector') // MUI Select uses a native input element - find and change it // eslint-disable-next-line testing-library/no-node-access const nativeInput = languageSelect.querySelector( 'input[aria-hidden="true"]' ) as HTMLInputElement expect(nativeInput).toBeInTheDocument() // Simulate change event on the native input Object.defineProperty(nativeInput, 'value', { writable: true, value: 'fr' }) fireEvent.change(nativeInput, { target: { value: 'fr' } }) // Language should be updated immediately (optimistic update) await waitFor(() => { const state = store.getState() expect(state.user?.coreConfig.language).toBe('fr') }) await waitFor(() => { const state = store.getState() expect(state.settings.language).toBe('fr') }) // API should be called in background await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ name: 'language', value: 'fr' }) ]) }) ]) }) ) }) }) it('saves language change to localStorage immediately (optimistic update)', async () => { ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) renderWithProviders(, preloadedState) const languageSelect = screen.getByLabelText('settings.languageSelector') // MUI Select uses a native input element - find and change it // eslint-disable-next-line testing-library/no-node-access const nativeInput = languageSelect.querySelector( 'input[aria-hidden="true"]' ) as HTMLInputElement expect(nativeInput).toBeInTheDocument() // Simulate change event on the native input Object.defineProperty(nativeInput, 'value', { writable: true, value: 'fr' }) fireEvent.change(nativeInput, { target: { value: 'fr' } }) // localStorage should be updated immediately (optimistic update) await waitFor(() => { expect(localStorage.getItem('lang')).toBe('fr') }) }) it('rolls back language change if API call fails', async () => { ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) const { store } = renderWithProviders(, preloadedState) const languageSelect = screen.getByLabelText('settings.languageSelector') // MUI Select uses a native input element - find and change it // eslint-disable-next-line testing-library/no-node-access const nativeInput = languageSelect.querySelector( 'input[aria-hidden="true"]' ) as HTMLInputElement expect(nativeInput).toBeInTheDocument() // Simulate change event on the native input Object.defineProperty(nativeInput, 'value', { writable: true, value: 'fr' }) fireEvent.change(nativeInput, { target: { value: 'fr' } }) // Wait for rollback - language should be rolled back to "en" after error await waitFor(() => { const state = store.getState() expect(state.user?.coreConfig.language).toBe('en') }) await waitFor( () => { const state = store.getState() expect(state.settings.language).toBe('en') }, { timeout: 3000 } ) }) it('shows Email toggle in Notifications tab', () => { renderWithProviders(, preloadedState) const notificationsTab = screen.getByRole('tab', { name: /settings.notifications/i }) fireEvent.click(notificationsTab) expect( screen.getByText(/settings.notifications.deliveryMethod/i) ).toBeInTheDocument() expect( screen.getByRole('switch', { name: /settings.notifications.email/i }) ).toBeInTheDocument() }) describe('Timezone Settings', () => { beforeEach(() => { ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) }) it('displays timezone selector in Settings tab', () => { renderWithProviders(, preloadedState) expect(screen.getAllByRole('combobox')).toHaveLength(2) }) it('displays timezone from user state', () => { const stateWithUserTimeZone = { user: { userData: { sub: 'test', email: 'test@test.com', family_name: 'Doe', name: 'John', sid: 'mockSid', openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, coreConfig: { language: 'en', datetime: { timeZone: 'America/New_York' } }, loading: false, error: null }, settings: { language: 'en', timeZone: 'UTC', isBrowserDefaultTimeZone: false, view: 'settings' } } renderWithProviders(, stateWithUserTimeZone) expect(screen.getByDisplayValue(/America\/New York/i)).toBeDefined() }) it('updates timezone immediately (optimistic update) and calls API', async () => { const { store } = renderWithProviders(, preloadedState) const timezoneInput = screen.getAllByRole('combobox')[1] // Clear the input and type new timezone fireEvent.change(timezoneInput, { target: { value: 'Europe/Paris' } }) // Find and click the timezone option from the dropdown const option = await screen.findByText(/Europe\/Paris/i) fireEvent.click(option) // Timezone should be updated immediately (optimistic update) await waitFor(() => { const state = store.getState() expect(state.user?.coreConfig.datetime.timeZone).toBe('Europe/Paris') }) await waitFor(() => { const state = store.getState() expect(state.settings.timeZone).toBe('Europe/Paris') }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ name: 'datetime', value: expect.objectContaining({ timeZone: 'Europe/Paris' }) }) ]) }) ]) }) ) }) }) it('handles timezone change with different timezone values', async () => { const { store } = renderWithProviders(, preloadedState) const timezoneInput = screen.getAllByRole('combobox')[1] // Test with Asia/Tokyo fireEvent.change(timezoneInput, { target: { value: 'Asia/Tokyo' } }) const tokyoOption = await screen.findByText(/Asia\/Tokyo/i) fireEvent.click(tokyoOption) await waitFor(() => { const state = store.getState() expect(state.user?.coreConfig.datetime.timeZone).toBe('Asia/Tokyo') }) await waitFor(() => { const state = store.getState() expect(state.settings.timeZone).toBe('Asia/Tokyo') }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ name: 'datetime', value: expect.objectContaining({ timeZone: 'Asia/Tokyo' }) }) ]) }) ]) }) ) }) jest.clearAllMocks() ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) fireEvent.change(timezoneInput, { target: { value: 'America/Los_Angeles' } }) const laOption = await screen.findByText(/America\/Los Angeles/i) fireEvent.click(laOption) await waitFor(() => { const state = store.getState() expect(state.user?.coreConfig.datetime.timeZone).toBe( 'America/Los_Angeles' ) }) await waitFor(() => { const state = store.getState() expect(state.settings.timeZone).toBe('America/Los_Angeles') }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ name: 'datetime', value: expect.objectContaining({ timeZone: 'America/Los_Angeles' }) }) ]) }) ]) }) ) }) }) it('enables browser default timezone and calls API with null', async () => { const { store } = renderWithProviders(, preloadedState) const browserDefaultSwitch = screen.getByRole('switch', { name: /settings.timeZoneBrowserDefault/i }) expect(browserDefaultSwitch).not.toBeChecked() fireEvent.click(browserDefaultSwitch) await waitFor(() => { expect(browserDefaultSwitch).toBeChecked() }) await waitFor(() => { const state = store.getState() expect(state.settings.isBrowserDefaultTimeZone).toBe(true) }) await waitFor(() => { const state = store.getState() expect(state.user?.coreConfig.datetime.timeZone).toBeNull() }) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ name: 'datetime', value: expect.objectContaining({ timeZone: null }) }) ]) }) ]) }) ) }) }) it('disables browser default timezone and shows timezone selector', async () => { const stateWithBrowserDefault = { ...preloadedState, user: { ...preloadedState.user, coreConfig: { language: 'en', datetime: { timeZone: null } } }, settings: { ...preloadedState.settings, isBrowserDefaultTimeZone: true } } renderWithProviders(, stateWithBrowserDefault) const browserDefaultSwitch = screen.getByRole('switch', { name: /settings.timeZoneBrowserDefault/i }) expect(browserDefaultSwitch).toBeChecked() expect(screen.getAllByRole('combobox')).toHaveLength(1) fireEvent.click(browserDefaultSwitch) await waitFor(() => { expect(browserDefaultSwitch).not.toBeChecked() }) await waitFor(() => { expect(screen.getAllByRole('combobox')).toHaveLength(2) }) }) it('rolls back timezone change if API call fails', async () => { ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) const { store } = renderWithProviders(, preloadedState) const timezoneInput = screen.getAllByRole('combobox')[1] fireEvent.change(timezoneInput, { target: { value: 'Europe/Paris' } }) const option = await screen.findByText(/Europe\/Paris/i) fireEvent.click(option) await waitFor(() => { const state = store.getState() expect(state.user?.coreConfig.datetime.timeZone).toBe('UTC') }) await waitFor( () => { const state = store.getState() expect(state.settings.timeZone).toBe('UTC') }, { timeout: 3000 } ) await waitFor(() => { expect( screen.getByText('settings.timeZoneUpdateError') ).toBeInTheDocument() }) }) it('rolls back browser default change if API call fails', async () => { ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) const { store } = renderWithProviders(, preloadedState) const browserDefaultSwitch = screen.getByRole('switch', { name: /settings.timeZoneBrowserDefault/i }) fireEvent.click(browserDefaultSwitch) await waitFor( () => { const state = store.getState() expect(state.settings.isBrowserDefaultTimeZone).toBe(false) }, { timeout: 3000 } ) await waitFor(() => { const state = store.getState() expect(state.user?.coreConfig.datetime.timeZone).toBe('UTC') }) await waitFor(() => { expect( screen.getByText('settings.timeZoneUpdateError') ).toBeInTheDocument() }) }) it('uses UTC as default timezone when no timezone is set', () => { const stateWithoutTimeZone = { user: { userData: { sub: 'test', email: 'test@test.com', family_name: 'Doe', name: 'John', sid: 'mockSid', openpaasId: '667037022b752d0026472254' }, organiserData: null, tokens: null, coreConfig: { language: 'en', datetime: { timeZone: undefined } }, loading: false, error: null }, settings: { language: 'en', timeZone: undefined, isBrowserDefaultTimeZone: false, view: 'settings' } } jest.spyOn(timezoneUtils, 'getTimezoneOffset').mockReturnValue('UTC') renderWithProviders(, stateWithoutTimeZone) const timezoneInput = screen.getByDisplayValue('UTC (UTC)') expect(timezoneInput).toBeDefined() }) it('preserves other datetime properties when updating timezone', async () => { const stateWithDatetimeConfig = { ...preloadedState, user: { ...preloadedState.user, coreConfig: { language: 'en', datetime: { timeZone: 'UTC', format: '24h', firstDayOfWeek: 1 } } } } renderWithProviders(, stateWithDatetimeConfig) const timezoneInput = screen.getAllByRole('combobox')[1] fireEvent.change(timezoneInput, { target: { value: 'Europe/Paris' } }) const option = await screen.findByText(/Europe\/Paris/i) fireEvent.click(option) await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ name: 'core', configurations: expect.arrayContaining([ expect.objectContaining({ name: 'datetime', value: { timeZone: 'Europe/Paris', format: '24h', firstDayOfWeek: 1 } }) ]) }) ]) }) ) }) }) describe('Alarm Emails Settings', () => { beforeEach(() => { ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) }) it('displays alarmEmails toggle with correct initial state', () => { const stateWithAlarmEmailsEnabled = { ...preloadedState, user: { ...preloadedState.user, alarmEmailsEnabled: true } } renderWithProviders(, stateWithAlarmEmailsEnabled) const notificationsTab = screen.getByRole('tab', { name: /settings.notifications/i }) fireEvent.click(notificationsTab) const toggle = screen.getByRole('switch', { name: /settings.notifications.email/i }) as HTMLInputElement expect(toggle).toBeInTheDocument() expect(toggle.checked).toBe(true) }) it('displays alarmEmails toggle as true when alarmEmailsEnabled is null', () => { const stateWithNullAlarmEmails = { ...preloadedState, user: { ...preloadedState.user, alarmEmailsEnabled: null } } renderWithProviders(, stateWithNullAlarmEmails) const notificationsTab = screen.getByRole('tab', { name: /settings.notifications/i }) fireEvent.click(notificationsTab) const toggle = screen.getByRole('switch', { name: /settings.notifications.email/i }) as HTMLInputElement expect(toggle).toBeInTheDocument() expect(toggle.checked).toBe(true) }) it('updates alarmEmails immediately (optimistic update) and calls API in background', async () => { ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) const { store } = renderWithProviders(, preloadedState) const notificationsTab = screen.getByRole('tab', { name: /settings.notifications/i }) fireEvent.click(notificationsTab) const toggle = screen.getByRole('switch', { name: /settings.notifications.email/i }) as HTMLInputElement expect(toggle.checked).toBe(false) fireEvent.click(toggle) // AlarmEmails should be updated immediately (optimistic update) await waitFor(() => { const state = store.getState() expect(state.user?.alarmEmailsEnabled).toBe(true) }) // API should be called in background await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ name: 'calendar', configurations: expect.arrayContaining([ expect.objectContaining({ name: 'alarmEmails', value: true }) ]) }) ]) }) ) }) }) it('rolls back alarmEmails change if API call fails', async () => { ;(api.patch as jest.Mock).mockRejectedValue(new Error('API Error')) const { store } = renderWithProviders(, preloadedState) const notificationsTab = screen.getByRole('tab', { name: /settings.notifications/i }) fireEvent.click(notificationsTab) const toggle = screen.getByRole('switch', { name: /settings.notifications.email/i }) as HTMLInputElement expect(toggle.checked).toBe(false) fireEvent.click(toggle) // Wait for rollback - alarmEmails should be rolled back to false after error await waitFor( () => { const state = store.getState() expect(state.user?.alarmEmailsEnabled).toBe(false) }, { timeout: 3000 } ) }) it('sends false value when toggle is turned off', async () => { ;(api.patch as jest.Mock).mockResolvedValue({ status: 204 }) const stateWithAlarmEmailsEnabled = { ...preloadedState, user: { ...preloadedState.user, alarmEmailsEnabled: true } } const { store } = renderWithProviders( , stateWithAlarmEmailsEnabled ) const notificationsTab = screen.getByRole('tab', { name: /settings.notifications/i }) fireEvent.click(notificationsTab) const toggle = screen.getByRole('switch', { name: /settings.notifications.email/i }) as HTMLInputElement expect(toggle.checked).toBe(true) fireEvent.click(toggle) // AlarmEmails should be updated immediately (optimistic update) await waitFor(() => { const state = store.getState() expect(state.user?.alarmEmailsEnabled).toBe(false) }) // API should be called with false value await waitFor(() => { expect(api.patch).toHaveBeenCalledWith( 'api/configurations?scope=user', expect.objectContaining({ json: expect.arrayContaining([ expect.objectContaining({ name: 'calendar', configurations: expect.arrayContaining([ expect.objectContaining({ name: 'alarmEmails', value: false }) ]) }) ]) }) ) }) }) }) }) })