mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
2acef40c35
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.
248 lines
9.3 KiB
TypeScript
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');
|
|
});
|
|
});
|