Spaces:
Sleeping
Sleeping
| import React from 'react'; | |
| import { render, screen, waitFor } from '../../../tests/helpers/render'; | |
| import userEvent from '@testing-library/user-event'; | |
| import { http, HttpResponse } from 'msw'; | |
| import { server } from '../../../tests/helpers/msw/server'; | |
| import { useAuthStore } from '../../store/authStore'; | |
| import { resetAllStores, seedStore } from '../../../tests/helpers/store'; | |
| import { buildUser } from '../../../tests/helpers/factories'; | |
| import { ToastContainer } from '../shared/Toast'; | |
| import NotificationsTab from './NotificationsTab'; | |
| const minimalMatrix = { | |
| preferences: { | |
| trip_invite: { inapp: true, email: false }, | |
| }, | |
| available_channels: { email: true, webhook: false, inapp: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'email'] }, | |
| }; | |
| beforeEach(() => { | |
| resetAllStores(); | |
| vi.clearAllMocks(); | |
| seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); | |
| server.use( | |
| http.get('/api/notifications/preferences', () => HttpResponse.json(minimalMatrix)), | |
| http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '' } })), | |
| http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })), | |
| ); | |
| }); | |
| describe('NotificationsTab', () => { | |
| it('FE-COMP-NOTIFICATIONS-001: shows loading state initially', () => { | |
| server.use( | |
| http.get('/api/notifications/preferences', () => new Promise(() => {})), | |
| ); | |
| render(<NotificationsTab />); | |
| expect(screen.getByText('Loading...')).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => { | |
| render(<NotificationsTab />); | |
| // The event label is translated; fallback is the key itself | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // Should render a toggle (ToggleSwitch renders a button) | |
| const toggles = await screen.findAllByRole('button'); | |
| expect(toggles.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => { | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // inapp channel header should appear (either translated or raw key) | |
| const headers = screen.getAllByText(/inapp|in.?app/i); | |
| expect(headers.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-004: shows "no channels" message when no channels are available', async () => { | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: {}, | |
| available_channels: { email: false, webhook: false, inapp: false }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'email'] }, | |
| }), | |
| ), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // Should show noChannels message (translated or key) | |
| const noChannelEl = await screen.findByText(/no.*channel|noChannels/i); | |
| expect(noChannelEl).toBeInTheDocument(); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-005: shows a dash for event/channel combos not implemented', async () => { | |
| // Use two events: booking_change only implements email (making email visible), | |
| // but trip_invite only implements inapp — so trip_invite row gets a dash for email | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true }, booking_change: { email: true } }, | |
| available_channels: { email: true, webhook: false, inapp: true }, | |
| event_types: ['trip_invite', 'booking_change'], | |
| implemented_combos: { | |
| trip_invite: ['inapp'], // no email → dash in email column | |
| booking_change: ['email'], // no inapp → dash in inapp column | |
| }, | |
| }), | |
| ), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // A dash should appear for non-implemented combos | |
| const dashes = await screen.findAllByText('—'); | |
| expect(dashes.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-006: clicking a toggle calls the preferences API', async () => { | |
| const user = userEvent.setup(); | |
| let capturedBody: unknown = null; | |
| server.use( | |
| http.put('/api/notifications/preferences', async ({ request }) => { | |
| capturedBody = await request.json(); | |
| return HttpResponse.json({ success: true }); | |
| }), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // minimalMatrix has inapp:true and email:false for trip_invite | |
| // The grid renders email column first, then inapp. We need the inapp toggle. | |
| // The inapp toggle is "on" (background accent), email is "off". | |
| // Find by looking at all buttons — inapp toggle should be 2nd (index 1) since email column comes first. | |
| const toggleButtons = await screen.findAllByRole('button'); | |
| // There are 2 toggles: email (index 0, off) and inapp (index 1, on) | |
| await user.click(toggleButtons[1]); | |
| await waitFor(() => { | |
| expect(capturedBody).not.toBeNull(); | |
| }); | |
| // inapp was true, so after click it should be false | |
| const body = capturedBody as Record<string, Record<string, boolean>>; | |
| expect(body.trip_invite?.inapp).toBe(false); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-007: toggle rolls back on API error', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.put('/api/notifications/preferences', () => HttpResponse.json({ error: 'fail' }, { status: 500 })), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // Find the inapp toggle for trip_invite — it starts as "on" | |
| const toggleButtons = await screen.findAllByRole('button'); | |
| const toggleBtn = toggleButtons[0]; | |
| // Verify the initial state via aria-checked or style; click and wait for rollback | |
| await user.click(toggleBtn); | |
| // After the error, the toggle should revert back (still rendered in the DOM) | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| expect(screen.queryByText('Saving...')).not.toBeInTheDocument(); | |
| }); | |
| // The toggle should still be present (not removed on error) | |
| const buttonsAfter = screen.getAllByRole('button'); | |
| expect(buttonsAfter.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-008: shows "Saving…" indicator while update is in flight', async () => { | |
| const user = userEvent.setup(); | |
| let resolveRequest!: () => void; | |
| server.use( | |
| http.put('/api/notifications/preferences', () => | |
| new Promise<Response>(resolve => { | |
| resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response); | |
| }), | |
| ), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| const toggleButtons = await screen.findAllByRole('button'); | |
| await user.click(toggleButtons[0]); | |
| await waitFor(() => { | |
| expect(screen.getByText('Saving...')).toBeInTheDocument(); | |
| }); | |
| resolveRequest(); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Saving...')).not.toBeInTheDocument(); | |
| }); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-009: webhook URL section renders when webhook channel is available', async () => { | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, webhook: false } }, | |
| available_channels: { email: false, webhook: true, inapp: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'webhook'] }, | |
| }), | |
| ), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // Webhook URL input should be present | |
| const input = await screen.findByRole('textbox'); | |
| expect(input).toBeInTheDocument(); | |
| // Save button should be present | |
| const buttons = screen.getAllByRole('button'); | |
| expect(buttons.some(b => /save/i.test(b.textContent || ''))).toBe(true); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-010: webhook URL input shows masked placeholder when webhook is already set', async () => { | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, webhook: false } }, | |
| available_channels: { email: false, webhook: true, inapp: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'webhook'] }, | |
| }), | |
| ), | |
| http.get('/api/settings', () => | |
| HttpResponse.json({ settings: { webhook_url: '••••••••' } }), | |
| ), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| const input = await screen.findByRole('textbox'); | |
| expect(input).toHaveAttribute('placeholder', '••••••••'); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-011: clicking Save webhook calls settings API', async () => { | |
| const user = userEvent.setup(); | |
| let capturedBody: unknown = null; | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, webhook: false } }, | |
| available_channels: { email: false, webhook: true, inapp: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'webhook'] }, | |
| }), | |
| ), | |
| http.put('/api/settings', async ({ request }) => { | |
| capturedBody = await request.json(); | |
| return HttpResponse.json({ success: true }); | |
| }), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| const input = await screen.findByRole('textbox'); | |
| await user.type(input, 'https://example.com/hook'); | |
| const saveBtn = screen.getAllByRole('button').find(b => /save/i.test(b.textContent || '')); | |
| expect(saveBtn).toBeDefined(); | |
| await user.click(saveBtn!); | |
| await waitFor(() => { | |
| expect(capturedBody).not.toBeNull(); | |
| }); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-012: Test button is disabled when no URL is set and no existing webhook', async () => { | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, webhook: false } }, | |
| available_channels: { email: false, webhook: true, inapp: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'webhook'] }, | |
| }), | |
| ), | |
| http.get('/api/settings', () => | |
| HttpResponse.json({ settings: { webhook_url: '' } }), | |
| ), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| await screen.findByRole('textbox'); | |
| const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || '')); | |
| expect(testBtn).toBeDefined(); | |
| expect(testBtn).toBeDisabled(); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-013: successful test webhook shows success toast', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, webhook: false } }, | |
| available_channels: { email: false, webhook: true, inapp: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'webhook'] }, | |
| }), | |
| ), | |
| http.post('/api/notifications/test-webhook', () => | |
| HttpResponse.json({ success: true }), | |
| ), | |
| ); | |
| render( | |
| <> | |
| <NotificationsTab /> | |
| <ToastContainer /> | |
| </>, | |
| ); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| const input = await screen.findByRole('textbox'); | |
| await user.type(input, 'https://example.com/hook'); | |
| const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || '')); | |
| expect(testBtn).toBeDefined(); | |
| await user.click(testBtn!); | |
| // Success toast should appear | |
| await waitFor(() => { | |
| const toastText = screen.queryByText(/testSuccess|success|sent/i); | |
| expect(toastText).toBeInTheDocument(); | |
| }); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-ntfy-001: ntfy topic input renders when ntfy channel is available', async () => { | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, ntfy: false } }, | |
| available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, | |
| }), | |
| ), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // Ntfy topic input should be present (placeholder text from i18n key or EN default) | |
| const inputs = await screen.findAllByRole('textbox'); | |
| expect(inputs.length).toBeGreaterThan(0); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-ntfy-002: ntfy test button disabled when no topic entered', async () => { | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, ntfy: false } }, | |
| available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, | |
| }), | |
| ), | |
| http.get('/api/settings', () => HttpResponse.json({ settings: { ntfy_topic: '' } })), | |
| ); | |
| render(<NotificationsTab />); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // Test button should be disabled when topic is empty | |
| const allButtons = await screen.findAllByRole('button'); | |
| const testBtn = allButtons.find(b => /test/i.test(b.textContent || '')); | |
| expect(testBtn).toBeDefined(); | |
| expect(testBtn).toBeDisabled(); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-ntfy-003: entering topic and clicking Test calls test-ntfy API', async () => { | |
| const user = userEvent.setup(); | |
| let ntfyCalled = false; | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, ntfy: false } }, | |
| available_channels: { email: false, webhook: false, inapp: true, ntfy: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, | |
| }), | |
| ), | |
| http.post('/api/notifications/test-ntfy', () => { | |
| ntfyCalled = true; | |
| return HttpResponse.json({ success: true }); | |
| }), | |
| ); | |
| render( | |
| <> | |
| <NotificationsTab /> | |
| <ToastContainer /> | |
| </>, | |
| ); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| // Find the topic input (first textbox in the ntfy block) and type a topic | |
| const inputs = await screen.findAllByRole('textbox'); | |
| await user.type(inputs[0], 'my-test-topic'); | |
| // Test button should now be enabled | |
| const allButtons = screen.getAllByRole('button'); | |
| const testBtn = allButtons.find(b => /test/i.test(b.textContent || '')); | |
| expect(testBtn).toBeDefined(); | |
| expect(testBtn).not.toBeDisabled(); | |
| await user.click(testBtn!); | |
| await waitFor(() => { | |
| expect(ntfyCalled).toBe(true); | |
| }); | |
| }); | |
| it('FE-COMP-NOTIFICATIONS-014: failed test webhook shows error toast with message', async () => { | |
| const user = userEvent.setup(); | |
| server.use( | |
| http.get('/api/notifications/preferences', () => | |
| HttpResponse.json({ | |
| preferences: { trip_invite: { inapp: true, webhook: false } }, | |
| available_channels: { email: false, webhook: true, inapp: true }, | |
| event_types: ['trip_invite'], | |
| implemented_combos: { trip_invite: ['inapp', 'webhook'] }, | |
| }), | |
| ), | |
| http.post('/api/notifications/test-webhook', () => | |
| HttpResponse.json({ success: false, error: 'Connection refused' }), | |
| ), | |
| ); | |
| render( | |
| <> | |
| <NotificationsTab /> | |
| <ToastContainer /> | |
| </>, | |
| ); | |
| await waitFor(() => { | |
| expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); | |
| }); | |
| const input = await screen.findByRole('textbox'); | |
| await user.type(input, 'https://example.com/hook'); | |
| const testBtn = screen.getAllByRole('button').find(b => /test/i.test(b.textContent || '')); | |
| expect(testBtn).toBeDefined(); | |
| await user.click(testBtn!); | |
| // Error toast with 'Connection refused' should appear | |
| await waitFor(() => { | |
| expect(screen.getByText('Connection refused')).toBeInTheDocument(); | |
| }); | |
| }); | |
| }); | |