Files
TREK/client/src/pages/InAppNotificationsPage.test.tsx
T
Maurice 31859651d7 feat(help): rewrite the help center for v5 with a Tours section, new pictures in every language, and fix the tour planner bugs it surfaced
Every help screen checked against the v5 redesign. Tours is documented for
the first time, as a screen of its own with twelve screens under it, and
notifications get help of their own. All pictures are taken again on a
realistic demo trip, with short loops where a still cannot show the motion.
The English is plain and every language is written naturally rather than
word for word.

The switcher shows two levels at most, a screen's overview lists its
sub-screens as cards above its guides, each wiki page is linked once, and
the help sidebar is wider.

Fixes found along the way: Save to Collection keeps a tour's route, a track
dropped on the places column opens the Tours import, tour Share on desktop,
the unsaved tour draft lives on the account, a route click no longer adds
an end point, Trip places leaves the trip's tours out, Escape closes a
stage's day menu, confirms before destructive deletes, DWD warnings sharing
one id kept apart, a booking's date moves with it, and smaller fixes across
notifications, bookings, the day plan, stays, Atlas, Collections, Vacay and
the admin screens.
2026-10-22 22:29:35 +02:00

208 lines
7.5 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useInAppNotificationStore } from '../store/inAppNotificationStore';
import InAppNotificationsPage from './InAppNotificationsPage';
// Mock InAppNotificationItem to simplify rendering
vi.mock('../components/Notifications/InAppNotificationItem', () => ({
default: ({ notification }: { notification: { id: number; is_read: number } }) => (
<div
data-testid={`notification-${notification.id}`}
data-read={notification.is_read}
>
Notification {notification.id}
</div>
),
}));
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
});
describe('InAppNotificationsPage', () => {
describe('FE-PAGE-NOTIFPAGE-001: Notification list loads on mount', () => {
it('fetches and displays notifications on mount', async () => {
render(<InAppNotificationsPage />);
// Default handler returns 20 notifications (offset 0..19 from 25 total)
await waitFor(() => {
expect(screen.getByTestId('notification-1')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-NOTIFPAGE-002: Unread notifications shown with indicator', () => {
it('shows unread count badge when there are unread notifications', async () => {
render(<InAppNotificationsPage />);
// Default handler returns unread_count: 5
// The badge shows the count as a span inside the heading
await waitFor(() => {
// The "5" badge appears next to the Notifications heading
const badges = screen.getAllByText('5');
expect(badges.length).toBeGreaterThan(0);
});
});
});
describe('FE-PAGE-NOTIFPAGE-003: Mark all read button', () => {
it('shows "Mark all read" button when there are unread notifications', async () => {
render(<InAppNotificationsPage />);
await waitFor(() => {
// Button has "Mark all read" text (possibly hidden on mobile via CSS class)
// In jsdom, CSS "hidden" class doesn't actually hide elements
expect(screen.getByRole('button', { name: /mark all read/i })).toBeInTheDocument();
});
});
});
describe('FE-PAGE-NOTIFPAGE-004: Delete all button', () => {
it('shows "Delete all" button when there are notifications', async () => {
render(<InAppNotificationsPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /delete all/i })).toBeInTheDocument();
});
});
});
describe('FE-PAGE-NOTIFPAGE-004b: Delete all asks first', () => {
it('opens a confirmation and only deletes after confirming', async () => {
const user = userEvent.setup();
const deleteAll = vi.fn(async () => {});
render(<InAppNotificationsPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /delete all/i })).toBeInTheDocument();
});
seedStore(useInAppNotificationStore, { deleteAll });
await user.click(screen.getByRole('button', { name: /delete all/i }));
expect(deleteAll).not.toHaveBeenCalled();
expect(screen.getByText('Delete all notifications?')).toBeInTheDocument();
await user.click(screen.getAllByRole('button', { name: /delete all/i }).slice(-1)[0]);
expect(deleteAll).toHaveBeenCalled();
});
});
describe('FE-PAGE-NOTIFPAGE-005: Empty state when no notifications', () => {
it('shows empty state when API returns no notifications', async () => {
server.use(
http.get('/api/notifications/in-app', () => {
return HttpResponse.json({
notifications: [],
total: 0,
unread_count: 0,
});
}),
);
render(<InAppNotificationsPage />);
await waitFor(() => {
expect(screen.getByText(/no notifications/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-NOTIFPAGE-006: Filter toggle', () => {
it('renders "All" and "Unread" filter buttons', async () => {
render(<InAppNotificationsPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /^all$/i })).toBeInTheDocument();
});
// The unread filter button uses t('notifications.unreadOnly') = 'Unread'
expect(screen.getByRole('button', { name: /^unread$/i })).toBeInTheDocument();
});
});
describe('FE-PAGE-NOTIFPAGE-007: Unread only filter hides read notifications', () => {
it('clicking Unread filter shows only unread notifications', async () => {
const user = userEvent.setup();
// Seed store with known mix of read/unread
const unreadNotif = {
id: 100, is_read: 0, type: 'simple',
scope: 'trip', target: 1, sender_id: 2,
sender_username: 'alice', sender_avatar: null,
recipient_id: 1, title_key: 'n', title_params: '{}',
text_key: 'n', text_params: '{}',
positive_text_key: null, negative_text_key: null,
response: null, navigate_text_key: null, navigate_target: null,
created_at: '2025-01-01T00:00:00Z',
};
const readNotif = {
id: 101, is_read: 1, type: 'simple',
scope: 'trip', target: 1, sender_id: 2,
sender_username: 'alice', sender_avatar: null,
recipient_id: 1, title_key: 'n', title_params: '{}',
text_key: 'n', text_params: '{}',
positive_text_key: null, negative_text_key: null,
response: null, navigate_text_key: null, navigate_target: null,
created_at: '2025-01-01T00:00:00Z',
};
seedStore(useInAppNotificationStore, {
notifications: [unreadNotif, readNotif],
unreadCount: 1,
total: 2,
isLoading: false,
hasMore: false,
fetchNotifications: vi.fn(),
markAllRead: vi.fn(),
deleteAll: vi.fn(),
} as any);
render(<InAppNotificationsPage />);
// Both notifications start visible
await waitFor(() => {
expect(screen.getByTestId('notification-100')).toBeInTheDocument();
expect(screen.getByTestId('notification-101')).toBeInTheDocument();
});
// Click "Unread" filter
await user.click(screen.getByRole('button', { name: /^unread$/i }));
// Only unread notification should be visible
await waitFor(() => {
expect(screen.getByTestId('notification-100')).toBeInTheDocument();
expect(screen.queryByTestId('notification-101')).not.toBeInTheDocument();
});
});
});
describe('FE-PAGE-NOTIFPAGE-008: Page title', () => {
it('shows "Notifications" heading', async () => {
render(<InAppNotificationsPage />);
await waitFor(() => {
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
expect(screen.getByRole('heading', { level: 1 }).textContent).toMatch(/notifications/i);
});
});
describe('FE-PAGE-NOTIFPAGE-009: Notification total count', () => {
it('shows total notification count in the subtitle', async () => {
render(<InAppNotificationsPage />);
await waitFor(() => {
// "25 notifications" (total from default handler)
expect(screen.getByText(/25 notifications/i)).toBeInTheDocument();
});
});
});
});