mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
12962480d5
When the notification preferences failed to load, the matrix on both the
desktop tab and the phone screen stayed on its loading line for good and
nothing told the user anything went wrong. A failed load of the webhook and
ntfy settings was just as silent and left empty fields that looked like
nothing was saved.
Both loads in useNotificationSettings ended in .catch(() => {}) and had no
cancelled flag. Each failure now shows the common error toast, a failed
preferences load sets loadFailed so both views replace the loading line
with the error text, and both loads ignore a response that arrives after
the screen is gone.
Regression test FE-COMP-NOTIFSETTINGS-010 (useNotificationSettings.test.ts)
was red on the old code: "expected "vi.fn()" to be called 2 times, but got
0 times". FE-COMP-NOTIFSETTINGS-011 covers the cancelled flag and
FE-COMP-NOTIFICATIONS-032 the desktop view.
Changed assertions:
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-002: a failed load used
to expect the "Loading..." line to stay; it now expects "Error" and no
loading line.
670 lines
29 KiB
TypeScript
670 lines
29 KiB
TypeScript
// FE-MOB-SETNOTIF-001 onwards
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { useLocation } from 'react-router';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { render, screen, waitFor, within } from '../../../helpers/render';
|
|
import { resetAllStores, seedStore } from '../../../helpers/store';
|
|
import { buildUser } from '../../../helpers/factories';
|
|
import { server } from '../../../helpers/msw/server';
|
|
import { useAuthStore } from '../../../../src/store/authStore';
|
|
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
|
import MSettingsNotifications from '../../../../src/mobile/screens/settings/MSettingsNotifications';
|
|
|
|
const MASKED = '••••••••';
|
|
|
|
interface Channel {
|
|
id: string;
|
|
source: 'builtin' | 'plugin';
|
|
labelKey?: string;
|
|
label?: string;
|
|
settingsPath?: string;
|
|
active: boolean;
|
|
configured: boolean;
|
|
}
|
|
|
|
const builtin = (id: string, over: Partial<Channel> = {}): Channel => ({
|
|
id,
|
|
source: 'builtin',
|
|
labelKey: `settings.notificationPreferences.${id}`,
|
|
active: true,
|
|
configured: true,
|
|
...over,
|
|
});
|
|
|
|
function matrix(over: Record<string, unknown> = {}) {
|
|
return {
|
|
preferences: { trip_invite: { inapp: true, webhook: false } },
|
|
channels: [builtin('inapp'), builtin('webhook')],
|
|
event_types: ['trip_invite'],
|
|
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function usePrefs(payload: unknown) {
|
|
server.use(http.get('/api/notifications/preferences', () => HttpResponse.json(payload)));
|
|
}
|
|
|
|
function useSettings(settings: Record<string, unknown>) {
|
|
server.use(http.get('/api/settings', () => HttpResponse.json({ settings })));
|
|
}
|
|
|
|
/** Renders the current router location so navigate() calls are observable. */
|
|
function LocationProbe() {
|
|
const loc = useLocation();
|
|
return <span data-testid="loc">{`${loc.pathname}${loc.search}`}</span>;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
|
usePrefs(matrix());
|
|
useSettings({});
|
|
server.use(
|
|
http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })),
|
|
http.put('/api/settings', () => HttpResponse.json({ success: true })),
|
|
http.post('/api/settings/bulk', () => HttpResponse.json({ success: true })),
|
|
http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: true })),
|
|
http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: true })),
|
|
);
|
|
});
|
|
|
|
describe('MSettingsNotifications', () => {
|
|
it('FE-MOB-SETNOTIF-001: shows the loading line until the matrix arrives', async () => {
|
|
usePrefs(new Promise(() => {}));
|
|
render(<MSettingsNotifications />);
|
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-002: a failing preferences load shows an error instead of the loading line', async () => {
|
|
server.use(
|
|
http.get('/api/notifications/preferences', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
|
http.get('/api/settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
expect(await screen.findByText('Error')).toBeInTheDocument();
|
|
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
|
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-003: reports when no active channel implements any event', async () => {
|
|
usePrefs(matrix({ implemented_combos: { trip_invite: [] } }));
|
|
render(<MSettingsNotifications />);
|
|
expect(await screen.findByText(/No notification channels are configured/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-004: an inactive channel is neither a card nor a chip', async () => {
|
|
usePrefs(matrix({ channels: [builtin('inapp'), builtin('webhook', { active: false })] }));
|
|
render(<MSettingsNotifications />);
|
|
|
|
await screen.findByText('Trip invitations');
|
|
expect(screen.getByRole('button', { name: 'In-App' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Webhook' })).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Webhook URL')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-005: the webhook card renders with an unset placeholder and a disabled Test', async () => {
|
|
render(<MSettingsNotifications />);
|
|
|
|
expect(await screen.findByText('Webhook URL')).toBeInTheDocument();
|
|
expect(screen.getByText(/Enter your Discord, Slack, or custom webhook URL/)).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('https://discord.com/api/webhooks/...')).toHaveValue('');
|
|
expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-006: an already-stored webhook is masked, never echoed into the field', async () => {
|
|
useSettings({ webhook_url: MASKED });
|
|
render(<MSettingsNotifications />);
|
|
|
|
const input = await screen.findByPlaceholderText(MASKED);
|
|
expect(input).toHaveValue('');
|
|
// A stored URL is enough to test against, even with the field left blank.
|
|
expect(screen.getByRole('button', { name: 'Test' })).toBeEnabled();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-007: saving the webhook writes the setting and toasts', async () => {
|
|
const user = userEvent.setup();
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.put('/api/settings', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
const input = await screen.findByPlaceholderText('https://discord.com/api/webhooks/...');
|
|
await user.type(input, 'https://hooks.example.com/x');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
expect(await screen.findByText('Webhook URL saved')).toBeInTheDocument();
|
|
expect(body).toEqual({ key: 'webhook_url', value: 'https://hooks.example.com/x' });
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-008: a rejected webhook save toasts the generic error', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(http.put('/api/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await screen.findByPlaceholderText('https://discord.com/api/webhooks/...');
|
|
const save = screen.getByRole('button', { name: 'Save' });
|
|
await user.click(save);
|
|
|
|
expect(await screen.findByText('Error')).toBeInTheDocument();
|
|
await waitFor(() => expect(save).toBeEnabled());
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-009: testing the webhook posts the typed URL and toasts on success', async () => {
|
|
const user = userEvent.setup();
|
|
let body: Record<string, unknown> | null = null;
|
|
server.use(
|
|
http.post('/api/notifications/test-webhook', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await user.type(await screen.findByPlaceholderText('https://discord.com/api/webhooks/...'), 'https://hooks.example.com/x');
|
|
await user.click(screen.getByRole('button', { name: 'Test' }));
|
|
|
|
expect(await screen.findByText('Test webhook sent successfully')).toBeInTheDocument();
|
|
expect(body).toEqual({ url: 'https://hooks.example.com/x' });
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-010: a webhook test answering success:false shows the server error', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: false, error: 'Connection refused' })));
|
|
useSettings({ webhook_url: 'https://hooks.example.com/x' });
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await screen.findByDisplayValue('https://hooks.example.com/x');
|
|
await user.click(screen.getByRole('button', { name: 'Test' }));
|
|
|
|
expect(await screen.findByText('Connection refused')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-011: a webhook test request failure falls back to the generic message', async () => {
|
|
const user = userEvent.setup();
|
|
server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
|
|
useSettings({ webhook_url: 'https://hooks.example.com/x' });
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await screen.findByDisplayValue('https://hooks.example.com/x');
|
|
const test = screen.getByRole('button', { name: 'Test' });
|
|
await user.click(test);
|
|
|
|
expect(await screen.findByText('Test webhook failed')).toBeInTheDocument();
|
|
await waitFor(() => expect(test).toBeEnabled());
|
|
});
|
|
|
|
describe('ntfy channel', () => {
|
|
beforeEach(() => {
|
|
usePrefs(
|
|
matrix({
|
|
preferences: { trip_invite: { inapp: true, ntfy: true } },
|
|
channels: [builtin('inapp'), builtin('ntfy')],
|
|
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
|
|
defaults: { ntfyServer: 'https://ntfy.admin.example' },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-012: hydrates topic and server and offers the admin default as placeholder', async () => {
|
|
useSettings({ ntfy_topic: 'my-topic', ntfy_server: '' });
|
|
render(<MSettingsNotifications />);
|
|
|
|
expect(await screen.findByDisplayValue('my-topic')).toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText('https://ntfy.admin.example')).toHaveValue('');
|
|
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-013: a stored access token is masked and can be cleared', async () => {
|
|
const user = userEvent.setup();
|
|
let body: Record<string, unknown> | null = null;
|
|
useSettings({ ntfy_topic: 'my-topic', ntfy_token: MASKED });
|
|
server.use(
|
|
http.put('/api/settings', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
expect(await screen.findByPlaceholderText(MASKED)).toHaveValue('');
|
|
await user.click(screen.getByRole('button', { name: 'Clear' }));
|
|
|
|
expect(await screen.findByText('Access token cleared')).toBeInTheDocument();
|
|
expect(body).toEqual({ key: 'ntfy_token', value: '' });
|
|
await waitFor(() => expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-014: a failing token clear keeps the button and toasts', async () => {
|
|
const user = userEvent.setup();
|
|
useSettings({ ntfy_topic: 'my-topic', ntfy_token: MASKED });
|
|
server.use(http.put('/api/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Clear' }));
|
|
|
|
expect(await screen.findByText('Error')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-015: saving sends topic, server and a freshly typed token in one bulk write', async () => {
|
|
const user = userEvent.setup();
|
|
let body: { settings?: Record<string, unknown> } | null = null;
|
|
useSettings({ ntfy_topic: 'my-topic', ntfy_server: 'https://ntfy.sh' });
|
|
server.use(
|
|
http.post('/api/settings/bulk', async ({ request }) => {
|
|
body = (await request.json()) as { settings?: Record<string, unknown> };
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await screen.findByDisplayValue('my-topic');
|
|
await user.type(screen.getByPlaceholderText(''), 'tk_abc');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
expect(await screen.findByText('Ntfy settings saved')).toBeInTheDocument();
|
|
expect(body?.settings).toEqual({ ntfy_topic: 'my-topic', ntfy_server: 'https://ntfy.sh', ntfy_token: 'tk_abc' });
|
|
// The token is now stored, so the Clear action appears.
|
|
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-016: saving without a token omits it from the payload', async () => {
|
|
const user = userEvent.setup();
|
|
let body: { settings?: Record<string, unknown> } | null = null;
|
|
useSettings({ ntfy_topic: 'my-topic' });
|
|
server.use(
|
|
http.post('/api/settings/bulk', async ({ request }) => {
|
|
body = (await request.json()) as { settings?: Record<string, unknown> };
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
await screen.findByDisplayValue('my-topic');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
await waitFor(() => expect(body).not.toBeNull());
|
|
expect(body?.settings).toEqual({ ntfy_topic: 'my-topic', ntfy_server: '' });
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-017: a rejected ntfy save toasts the generic error', async () => {
|
|
const user = userEvent.setup();
|
|
useSettings({ ntfy_topic: 'my-topic' });
|
|
server.use(http.post('/api/settings/bulk', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await screen.findByDisplayValue('my-topic');
|
|
await user.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
expect(await screen.findByText('Error')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-018: Test needs a topic and sends the current topic, server and token', async () => {
|
|
const user = userEvent.setup();
|
|
let body: Record<string, unknown> | null = null;
|
|
useSettings({});
|
|
server.use(
|
|
http.post('/api/notifications/test-ntfy', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, unknown>;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
const test = await screen.findByRole('button', { name: 'Test' });
|
|
expect(test).toBeDisabled();
|
|
|
|
await user.type(screen.getByPlaceholderText('my-trek-alerts'), 'alerts');
|
|
await user.click(test);
|
|
|
|
expect(await screen.findByText('Test ntfy notification sent successfully')).toBeInTheDocument();
|
|
expect(body).toEqual({ topic: 'alerts', server: null, token: null });
|
|
|
|
// A typed-in server overrides the admin default for the next test run.
|
|
await user.type(screen.getByPlaceholderText('https://ntfy.admin.example'), 'https://ntfy.example.org');
|
|
await user.click(test);
|
|
await waitFor(() => expect(body).toEqual({ topic: 'alerts', server: 'https://ntfy.example.org', token: null }));
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-019: an ntfy test answering success:false shows the server error', async () => {
|
|
const user = userEvent.setup();
|
|
useSettings({ ntfy_topic: 'alerts' });
|
|
server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: false, error: 'Topic unreachable' })));
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await screen.findByDisplayValue('alerts');
|
|
await user.click(screen.getByRole('button', { name: 'Test' }));
|
|
|
|
expect(await screen.findByText('Topic unreachable')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-020: an ntfy test request failure falls back to the generic message', async () => {
|
|
const user = userEvent.setup();
|
|
useSettings({ ntfy_topic: 'alerts' });
|
|
server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await screen.findByDisplayValue('alerts');
|
|
const test = screen.getByRole('button', { name: 'Test' });
|
|
await user.click(test);
|
|
|
|
expect(await screen.findByText('Test ntfy notification failed')).toBeInTheDocument();
|
|
await waitFor(() => expect(test).toBeEnabled());
|
|
});
|
|
});
|
|
|
|
describe('plugin channels', () => {
|
|
const pluginChannel = (over: Partial<Channel> = {}): Channel => ({
|
|
id: 'plugin:trek-gotify',
|
|
source: 'plugin',
|
|
label: 'Gotify',
|
|
settingsPath: '/settings?tab=plugins',
|
|
active: true,
|
|
configured: true,
|
|
...over,
|
|
});
|
|
|
|
const pluginMatrix = (over: Partial<Channel> = {}) =>
|
|
matrix({
|
|
preferences: { trip_invite: { inapp: true, 'plugin:trek-gotify': false } },
|
|
channels: [builtin('inapp'), pluginChannel(over)],
|
|
implemented_combos: { trip_invite: ['inapp', 'plugin:trek-gotify'] },
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-021: a configured plugin channel offers a test send and no configure link', async () => {
|
|
usePrefs(pluginMatrix());
|
|
render(<MSettingsNotifications />);
|
|
|
|
// Once as its own card, once as a chip in the event row.
|
|
await waitFor(() => expect(screen.getAllByText('Gotify')).toHaveLength(2));
|
|
expect(screen.getByText(/Configured\. Manage credentials/)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Send test' })).toBeEnabled();
|
|
expect(screen.queryByRole('button', { name: 'Configure' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-022: an unconfigured plugin channel links to its settings page and blocks the test', async () => {
|
|
const user = userEvent.setup();
|
|
usePrefs(pluginMatrix({ configured: false }));
|
|
render(<><MSettingsNotifications /><LocationProbe /></>);
|
|
|
|
expect(await screen.findByText(/Not configured yet/)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Configure' }));
|
|
expect(screen.getByTestId('loc')).toHaveTextContent('/settings?tab=plugins');
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-023: Send test hits the generic channel route and toasts', async () => {
|
|
const user = userEvent.setup();
|
|
let called = '';
|
|
usePrefs(pluginMatrix());
|
|
server.use(
|
|
http.post('/api/notifications/test/:channelId', ({ params }) => {
|
|
called = String(params.channelId);
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Send test' }));
|
|
|
|
expect(await screen.findByText('Test notification sent.')).toBeInTheDocument();
|
|
expect(called).toBe('plugin:trek-gotify');
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-024: a channel test answering success:false surfaces its error', async () => {
|
|
const user = userEvent.setup();
|
|
usePrefs(pluginMatrix());
|
|
server.use(http.post('/api/notifications/test/:channelId', () => HttpResponse.json({ success: false, error: 'Gotify said no' })));
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Send test' }));
|
|
expect(await screen.findByText('Gotify said no')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-025: a channel test request failure falls back to the generic message', async () => {
|
|
const user = userEvent.setup();
|
|
usePrefs(pluginMatrix());
|
|
server.use(http.post('/api/notifications/test/:channelId', () => HttpResponse.json({ error: 'x' }, { status: 500 })));
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
const send = await screen.findByRole('button', { name: 'Send test' });
|
|
await user.click(send);
|
|
|
|
expect(await screen.findByText('Test failed.')).toBeInTheDocument();
|
|
await waitFor(() => expect(send).toBeEnabled());
|
|
});
|
|
});
|
|
|
|
describe('event matrix', () => {
|
|
it('FE-MOB-SETNOTIF-026: tapping a chip flips the preference and PUTs only that cell', async () => {
|
|
const user = userEvent.setup();
|
|
let body: Record<string, Record<string, boolean>> | null = null;
|
|
server.use(
|
|
http.put('/api/notifications/preferences', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, Record<string, boolean>>;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
const chip = await screen.findByRole('button', { name: 'In-App' });
|
|
await user.click(chip);
|
|
|
|
await waitFor(() => expect(body).not.toBeNull());
|
|
expect(body).toEqual({ trip_invite: { inapp: false } });
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-027: a channel with no stored preference defaults to on', async () => {
|
|
const user = userEvent.setup();
|
|
let body: Record<string, Record<string, boolean>> | null = null;
|
|
usePrefs(matrix({ preferences: {} }));
|
|
server.use(
|
|
http.put('/api/notifications/preferences', async ({ request }) => {
|
|
body = (await request.json()) as Record<string, Record<string, boolean>>;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Webhook' }));
|
|
|
|
await waitFor(() => expect(body).not.toBeNull());
|
|
expect(body).toEqual({ trip_invite: { webhook: false } });
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-028: a failing update rolls the matrix back', async () => {
|
|
const user = userEvent.setup();
|
|
let reject!: () => void;
|
|
server.use(
|
|
http.put(
|
|
'/api/notifications/preferences',
|
|
() => new Promise<Response>(resolve => { reject = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response); }),
|
|
),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
const chip = await screen.findByRole('button', { name: 'In-App' });
|
|
// The webhook chip is off in this matrix — its look is the "inactive" reference.
|
|
const inactiveClass = screen.getByRole('button', { name: 'Webhook' }).className;
|
|
const activeClass = chip.className;
|
|
expect(activeClass).not.toBe(inactiveClass);
|
|
|
|
await user.click(chip);
|
|
// Flipped optimistically while the request is still in flight…
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(inactiveClass));
|
|
|
|
reject();
|
|
// …and restored once the server rejects it.
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(activeClass));
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-032: a failing update toasts and leaves a later toggle alone', async () => {
|
|
const user = userEvent.setup();
|
|
let rejectInapp!: () => void;
|
|
server.use(
|
|
http.put('/api/notifications/preferences', async ({ request }) => {
|
|
const body = (await request.json()) as Record<string, Record<string, boolean>>;
|
|
// The first write is the in-app flip; it hangs until the test rejects it.
|
|
if ('inapp' in body.trip_invite) {
|
|
return new Promise<Response>(resolve => {
|
|
rejectInapp = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response);
|
|
});
|
|
}
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<><ToastContainer /><MSettingsNotifications /></>);
|
|
|
|
const inapp = await screen.findByRole('button', { name: 'In-App' });
|
|
// The webhook chip is off in this matrix — its look is the "inactive" reference.
|
|
const inactiveClass = screen.getByRole('button', { name: 'Webhook' }).className;
|
|
const activeClass = inapp.className;
|
|
|
|
await user.click(inapp);
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(inactiveClass));
|
|
|
|
// Flipped while the in-app write is still hanging.
|
|
await user.click(screen.getByRole('button', { name: 'Webhook' }));
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Webhook' }).className).toBe(activeClass));
|
|
|
|
rejectInapp();
|
|
|
|
expect(await screen.findByText('Error')).toBeInTheDocument();
|
|
await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(activeClass));
|
|
// The webhook toggle the user made meanwhile is not undone.
|
|
expect(screen.getByRole('button', { name: 'Webhook' }).className).toBe(activeClass);
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-035: a second toggle while the first is in flight sends only its own cell', async () => {
|
|
const user = userEvent.setup();
|
|
const bodies: Record<string, Record<string, boolean>>[] = [];
|
|
let rejectFirst!: () => void;
|
|
server.use(
|
|
http.put('/api/notifications/preferences', async ({ request }) => {
|
|
bodies.push((await request.json()) as Record<string, Record<string, boolean>>);
|
|
if (bodies.length === 1) {
|
|
return new Promise<Response>(resolve => {
|
|
rejectFirst = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response);
|
|
});
|
|
}
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'In-App' }));
|
|
await waitFor(() => expect(bodies).toHaveLength(1));
|
|
await user.click(screen.getByRole('button', { name: 'Webhook' }));
|
|
await waitFor(() => expect(bodies).toHaveLength(2));
|
|
rejectFirst();
|
|
|
|
// The failed in-app flip is rolled back on screen, so the server must not get it
|
|
// through the webhook write either.
|
|
expect(bodies).toEqual([{ trip_invite: { inapp: false } }, { trip_invite: { webhook: true } }]);
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-029: the saving hint shows while the update is in flight', async () => {
|
|
const user = userEvent.setup();
|
|
let release!: () => void;
|
|
server.use(
|
|
http.put(
|
|
'/api/notifications/preferences',
|
|
() => new Promise<Response>(resolve => { release = () => resolve(HttpResponse.json({ success: true }) as unknown as Response); }),
|
|
),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'In-App' }));
|
|
expect(await screen.findByText('Saving...')).toBeInTheDocument();
|
|
|
|
release();
|
|
await waitFor(() => expect(screen.queryByText('Saving...')).not.toBeInTheDocument());
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-030: events without a matching channel are skipped, unknown ones keep their raw key', async () => {
|
|
usePrefs(
|
|
matrix({
|
|
event_types: ['trip_invite', 'booking_change', 'weird_event'],
|
|
implemented_combos: {
|
|
trip_invite: ['inapp'],
|
|
booking_change: ['email'],
|
|
weird_event: ['webhook'],
|
|
},
|
|
}),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
await screen.findByText('Trip invitations');
|
|
// booking_change only implements the inactive email channel → no row at all.
|
|
expect(screen.queryByText('Booking changes')).not.toBeInTheDocument();
|
|
// No label key for weird_event → the raw event type is shown.
|
|
expect(screen.getByText('weird_event')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-031: a plugin channel without a label falls back to its id', async () => {
|
|
usePrefs(
|
|
matrix({
|
|
preferences: { trip_invite: { 'plugin:x': true } },
|
|
channels: [{ id: 'plugin:x', source: 'plugin', active: true, configured: true }],
|
|
implemented_combos: { trip_invite: ['plugin:x'] },
|
|
}),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
const row = (await screen.findByText('Trip invitations')).parentElement as HTMLElement;
|
|
expect(within(row).getByRole('button', { name: 'plugin:x' })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Web Push (#894): the per-device card joins the other channel cards while the
|
|
// admin has push switched on. jsdom has no push APIs, so the real hook reads
|
|
// this "browser" as unable to receive push, and the card has to say so.
|
|
describe('MSettingsNotifications: Web Push', () => {
|
|
it('FE-MOB-SETNOTIF-033: an active push channel gets its chip and the device card', async () => {
|
|
usePrefs(
|
|
matrix({
|
|
preferences: { trip_invite: { inapp: true, push: true } },
|
|
channels: [builtin('inapp'), builtin('push', { configured: false })],
|
|
implemented_combos: { trip_invite: ['inapp', 'push'] },
|
|
}),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
expect(await screen.findByText('Push notifications on this device')).toBeInTheDocument();
|
|
const row = (await screen.findByText('Trip invitations')).parentElement as HTMLElement;
|
|
expect(within(row).getByRole('button', { name: 'Push' })).toBeInTheDocument();
|
|
expect(await screen.findByRole('status')).toHaveTextContent(/push/i);
|
|
});
|
|
|
|
it('FE-MOB-SETNOTIF-034: no card while the admin has push off', async () => {
|
|
usePrefs(
|
|
matrix({
|
|
channels: [builtin('inapp'), builtin('push', { active: false })],
|
|
implemented_combos: { trip_invite: ['inapp', 'push'] },
|
|
}),
|
|
);
|
|
render(<MSettingsNotifications />);
|
|
|
|
await screen.findByText('Trip invitations');
|
|
expect(screen.queryByText('Push notifications on this device')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('MSettingsNotifications — cells the admin blocked (#1536)', () => {
|
|
it('FE-MOB-SETNOTIF-LOCK-001: a blocked channel shows as a locked chip, not a toggle', async () => {
|
|
usePrefs(matrix({ locked: { trip_invite: ['webhook'] } }));
|
|
render(<MSettingsNotifications />);
|
|
const chip = await screen.findByLabelText(/Turned off for everyone by the admin/);
|
|
expect(chip).toHaveAttribute('aria-disabled', 'true');
|
|
expect(chip.tagName).toBe('SPAN');
|
|
});
|
|
});
|