import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import NotificationsTab from './NotificationsTab';
const minimalMatrix = {
preferences: {
trip_invite: { inapp: true, email: false },
},
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: true,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: 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();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => {
render();
// 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();
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: {},
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: false,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'email'] },
})
)
);
render();
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: true,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: 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();
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();
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>;
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();
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((resolve) => {
resolveRequest = () => resolve(HttpResponse.json({ success: true }) as unknown as Response);
})
)
);
render();
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
})
)
);
render();
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
})
),
http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '••••••••' } }))
);
render();
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: 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();
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
})
),
http.get('/api/settings', () => HttpResponse.json({ settings: { webhook_url: '' } }))
);
render();
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'webhook'] },
})
),
http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: true }))
);
render(
<>
>
);
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
{
id: 'ntfy',
source: 'builtin',
labelKey: 'settings.notificationPreferences.ntfy',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
})
)
);
render();
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
{
id: 'ntfy',
source: 'builtin',
labelKey: 'settings.notificationPreferences.ntfy',
active: true,
configured: true,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'ntfy'] },
})
),
http.get('/api/settings', () => HttpResponse.json({ settings: { ntfy_topic: '' } }))
);
render();
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: false,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: true,
},
{
id: 'ntfy',
source: 'builtin',
labelKey: 'settings.notificationPreferences.ntfy',
active: true,
configured: 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(
<>
>
);
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 } },
channels: [
{
id: 'email',
source: 'builtin',
labelKey: 'settings.notificationPreferences.email',
active: false,
configured: true,
},
{
id: 'webhook',
source: 'builtin',
labelKey: 'settings.notificationPreferences.webhook',
active: true,
configured: true,
},
{
id: 'inapp',
source: 'builtin',
labelKey: 'settings.notificationPreferences.inapp',
active: true,
configured: 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(
<>
>
);
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();
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Plugin notification channels (e.g. a Gotify plugin)
// ─────────────────────────────────────────────────────────────────────────────
//
// A plugin channel must be a first-class citizen here alongside in-app/email/ntfy:
// its own column in the matrix, its state visible, and a way to act on it. The
// credentials themselves live on the plugin's settings page, so an UNconfigured
// channel links there rather than just naming the place.
const pluginMatrix = (over: Record = {}) => ({
preferences: { trip_invite: { inapp: true, 'plugin:trek-gotify': true } },
channels: [
{ id: 'inapp', source: 'builtin', labelKey: 'settings.notificationPreferences.inapp', active: true, configured: true },
{
id: 'plugin:trek-gotify',
source: 'plugin',
label: 'Gotify',
settingsPath: '/settings?tab=plugins',
active: true,
configured: true,
...over,
},
],
event_types: ['trip_invite'],
implemented_combos: { trip_invite: ['inapp', 'plugin:trek-gotify'] },
});
function mockMatrix(matrix: unknown) {
server.use(
http.get('*/api/notifications/preferences', () => HttpResponse.json(matrix)),
http.get('*/api/settings', () => HttpResponse.json({ settings: {} })),
);
}
describe('NotificationsTab — plugin channels', () => {
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-001: a configured plugin channel gets its own column, by its own name', async () => {
mockMatrix(pluginMatrix());
render();
// The server sends the display name outright — plugin channels have no i18n key.
// It appears TWICE by design: once as the channel's own card, once as the matrix
// column header — i.e. it really is a first-class column, not just a side panel.
const shown = await screen.findAllByText('Gotify');
expect(shown.length).toBe(2);
// …and it is togglable per event, exactly like a built-in.
await waitFor(() => expect(screen.getAllByRole('button').length).toBeGreaterThan(0));
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-002: configured → offers a test send, no Configure link', async () => {
mockMatrix(pluginMatrix({ configured: true }));
render();
const test = await screen.findByRole('button', { name: /send test/i });
expect(test).toBeEnabled();
expect(screen.queryByRole('link', { name: /configure/i })).not.toBeInTheDocument();
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-003: NOT configured → links to the plugin settings tab, test disabled', async () => {
mockMatrix(pluginMatrix({ configured: false }));
render();
const link = await screen.findByRole('link', { name: /configure/i });
// A real route — settings is one page with a ?tab= param, not a route per plugin.
expect(link).toHaveAttribute('href', '/settings?tab=plugins');
expect(screen.getByRole('button', { name: /send test/i })).toBeDisabled();
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-004: Send test calls the generic channel-test route', async () => {
mockMatrix(pluginMatrix());
let called = '';
server.use(
http.post('*/api/notifications/test/:channelId', ({ params }) => {
called = String(params.channelId);
return HttpResponse.json({ success: true });
}),
);
render(<>>);
await userEvent.click(await screen.findByRole('button', { name: /send test/i }));
await waitFor(() => expect(called).toBe('plugin:trek-gotify'));
});
it('FE-COMP-NOTIFICATIONS-PLUGIN-005: an INACTIVE plugin channel is not shown at all', async () => {
mockMatrix(pluginMatrix({ active: false }));
render();
await screen.findByText(/in-app/i);
// The admin hasn't enabled the channel — it must not appear as an option.
expect(screen.queryByText('Gotify')).not.toBeInTheDocument();
});
});