Files
TREK/client/tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx
T
Maurice 2acef40c35 fix(admin): keep a later notification toggle when an earlier one fails on the phone
When a toggle on the phone matrix failed after a later toggle had already rendered, its catch put back the preferences from its own click and so also undid the later cell. The live preferences are now mirrored in a ref and a failure reverts only its own cell, as the desktop panel already does. The test hangs the first save, lets the second one through and then fails the first.
2026-10-09 15:19:46 +02:00

248 lines
9.3 KiB
TypeScript

// 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<typeof useToast> }) {
const { t } = useTranslation();
return <MAdminNotifyMatrix t={t} toast={toast} />;
}
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(<Harness toast={buildToast()} />);
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(<Harness toast={buildToast()} />);
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(<Harness toast={buildToast()} />);
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(<Harness toast={buildToast()} />);
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(<Harness toast={buildToast()} />);
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(<Harness toast={buildToast()} />);
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(<Harness toast={buildToast()} />);
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<string, Record<string, boolean>> | undefined;
server.use(
http.put('/api/admin/notification-preferences', async ({ request }) => {
body = (await request.json()) as Record<string, Record<string, boolean>>;
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
render(<Harness toast={buildToast()} />);
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(<Harness toast={toast} />);
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<string, Record<string, boolean>> | undefined;
server.use(
http.put('/api/admin/notification-preferences', async ({ request }) => {
body = (await request.json()) as Record<string, Record<string, boolean>>;
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
render(<Harness toast={buildToast()} />);
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(<Harness toast={buildToast()} />);
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<string, unknown>[] = [];
let failFirst: () => void = () => {};
server.use(
http.put('/api/admin/notification-preferences', async ({ request }) => {
bodies.push((await request.json()) as Record<string, unknown>);
if (bodies.length === 1) {
await new Promise<void>((resolve) => {
failFirst = resolve;
});
return HttpResponse.json({}, { status: 500 });
}
return HttpResponse.json({ success: true });
})
);
const toast = buildToast();
const user = userEvent.setup();
render(<Harness toast={toast} />);
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');
});
});