// FE-MOB-AMATRIX-001 to FE-MOB-AMATRIX-012 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import userEvent from '@testing-library/user-event'; import { act, render, screen, waitFor } from '../../../helpers/render'; import { server } from '../../../helpers/msw/server'; import { resetAllStores } from '../../../helpers/store'; import { useTranslation } from '../../../../src/i18n'; import type { useToast } from '../../../../src/components/shared/Toast'; import MAdminNotifyMatrix from '../../../../src/mobile/screens/admin/MAdminNotifyMatrix'; const MATRIX = { event_types: ['version_available', 'trip_reminder'], channels: [ { id: 'inapp', active: true }, { id: 'email', active: true }, { id: 'webhook', active: false }, { id: 'ntfy', active: true }, ], implemented_combos: { version_available: ['inapp', 'email'], trip_reminder: ['inapp'], }, preferences: { version_available: { inapp: true, email: false }, }, }; function buildToast() { return { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), }; } function Harness({ toast }: { toast: ReturnType }) { const { t } = useTranslation(); return ; } function matrixRespondsWith(payload: unknown) { server.use(http.get('/api/admin/notification-preferences', () => HttpResponse.json(payload))); } beforeEach(() => { resetAllStores(); }); afterEach(() => { server.resetHandlers(); }); describe('MAdminNotifyMatrix', () => { it('FE-MOB-AMATRIX-001: shows the loading card until the matrix arrives', async () => { server.use( http.get('/api/admin/notification-preferences', async () => { await new Promise(() => {}); return HttpResponse.json(MATRIX); }), ); render(); expect(screen.getByText('Loading...')).toBeInTheDocument(); }); it('FE-MOB-AMATRIX-002: stays on the loading card when the request fails', async () => { server.use( http.get('/api/admin/notification-preferences', () => HttpResponse.json({}, { status: 500 })), ); render(); await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument()); expect(screen.queryByRole('switch')).not.toBeInTheDocument(); }); it('FE-MOB-AMATRIX-003: shows the empty hint when the server reports no event types', async () => { matrixRespondsWith({ event_types: [], implemented_combos: {}, preferences: {} }); render(); expect( await screen.findByText( 'No notification channels are configured. Ask an admin to set up email or webhook notifications.', ), ).toBeInTheDocument(); }); it('FE-MOB-AMATRIX-004: renders only channels that are active and implemented somewhere', async () => { matrixRespondsWith(MATRIX); render(); await screen.findByText('New version available'); expect(screen.getByText('In-App')).toBeInTheDocument(); expect(screen.getByText('Email')).toBeInTheDocument(); // webhook is inactive, ntfy is active but not implemented for any event expect(screen.queryByText('Webhook')).not.toBeInTheDocument(); expect(screen.queryByText('Ntfy')).not.toBeInTheDocument(); }); it('FE-MOB-AMATRIX-005: falls back to the raw event id when no label key exists', async () => { matrixRespondsWith(MATRIX); render(); expect(await screen.findByText('trip_reminder')).toBeInTheDocument(); }); it('FE-MOB-AMATRIX-006: reflects stored preferences and defaults missing ones to on', async () => { matrixRespondsWith(MATRIX); render(); const versionInapp = await screen.findByRole('switch', { name: 'version_available inapp' }); expect(versionInapp).toHaveAttribute('aria-checked', 'true'); expect(screen.getByRole('switch', { name: 'version_available email' })).toHaveAttribute( 'aria-checked', 'false', ); // trip_reminder has no stored preference at all expect(screen.getByRole('switch', { name: 'trip_reminder inapp' })).toHaveAttribute('aria-checked', 'true'); }); it('FE-MOB-AMATRIX-007: renders a dash for combos the backend does not implement', async () => { matrixRespondsWith(MATRIX); render(); await screen.findByText('trip_reminder'); expect(screen.queryByRole('switch', { name: 'trip_reminder email' })).not.toBeInTheDocument(); expect(screen.getByText('—')).toBeInTheDocument(); }); it('FE-MOB-AMATRIX-008: toggling a cell persists the whole preference map', async () => { matrixRespondsWith(MATRIX); let body: Record> | undefined; server.use( http.put('/api/admin/notification-preferences', async ({ request }) => { body = (await request.json()) as Record>; return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('switch', { name: 'version_available email' })); await waitFor(() => expect(body).toBeDefined()); expect(body).toEqual({ version_available: { inapp: true, email: true } }); expect(screen.getByRole('switch', { name: 'version_available email' })).toHaveAttribute( 'aria-checked', 'true', ); }); it('FE-MOB-AMATRIX-009: a failed save reverts the toggle and reports the error', async () => { matrixRespondsWith(MATRIX); server.use( http.put('/api/admin/notification-preferences', () => HttpResponse.json({}, { status: 500 })), ); const toast = buildToast(); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('switch', { name: 'version_available inapp' })); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); expect(screen.getByRole('switch', { name: 'version_available inapp' })).toHaveAttribute( 'aria-checked', 'true', ); }); it('FE-MOB-AMATRIX-010: toggling an event without stored preferences seeds it from the default', async () => { matrixRespondsWith(MATRIX); let body: Record> | undefined; server.use( http.put('/api/admin/notification-preferences', async ({ request }) => { body = (await request.json()) as Record>; return HttpResponse.json({ success: true }); }), ); const user = userEvent.setup(); render(); await user.click(await screen.findByRole('switch', { name: 'trip_reminder inapp' })); await waitFor(() => expect(body).toBeDefined()); expect(body).toEqual({ version_available: { inapp: true, email: false }, trip_reminder: { inapp: false }, }); }); it('FE-MOB-AMATRIX-011: a response without a channel list renders the events but no toggles', async () => { matrixRespondsWith({ event_types: ['version_available'], implemented_combos: { version_available: ['inapp'] }, preferences: {}, }); render(); expect(await screen.findByText('New version available')).toBeInTheDocument(); expect(screen.queryByRole('switch')).not.toBeInTheDocument(); expect(screen.queryByText('In-App')).not.toBeInTheDocument(); }); it('FE-MOB-AMATRIX-012: a failing earlier save reverts only its own cell, not a later one', async () => { matrixRespondsWith(MATRIX); const bodies: Record[] = []; let failFirst: () => void = () => {}; server.use( http.put('/api/admin/notification-preferences', async ({ request }) => { bodies.push((await request.json()) as Record); if (bodies.length === 1) { await new Promise((resolve) => { failFirst = resolve; }); return HttpResponse.json({}, { status: 500 }); } return HttpResponse.json({ success: true }); }) ); const toast = buildToast(); const user = userEvent.setup(); render(); const inappToggle = await screen.findByRole('switch', { name: 'version_available inapp' }); const emailToggle = screen.getByRole('switch', { name: 'version_available email' }); // The first save hangs while the screen renders the optimistic value. await user.click(inappToggle); // on -> off, will fail await waitFor(() => expect(bodies).toHaveLength(1)); expect(inappToggle).toHaveAttribute('aria-checked', 'false'); // A later tap on another cell goes through. await user.click(emailToggle); // off -> on, succeeds await waitFor(() => expect(bodies).toHaveLength(2)); expect(bodies[1]).toEqual({ version_available: { inapp: false, email: true } }); expect(emailToggle).toHaveAttribute('aria-checked', 'true'); // Now the first save fails: only its own cell goes back. await act(async () => { failFirst(); }); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); expect(inappToggle).toHaveAttribute('aria-checked', 'true'); expect(emailToggle).toHaveAttribute('aria-checked', 'true'); }); });