test: expand frontend test suite to 82% coverage

Adds ~45 new and updated test files covering Admin, Collab, Dashboard, Map, Memories, PDF, Photos, Planner, Settings, Vacay, Weather components, pages, stores, and a WebSocket integration test.
This commit is contained in:
jubnl
2026-04-08 21:14:23 +02:00
parent 2b7057b922
commit d4bb8be86b
45 changed files with 13643 additions and 524 deletions
+230
View File
@@ -0,0 +1,230 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, act } from '../../tests/helpers/render';
import { Route, Routes } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildTrip } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import PhotosPage from './PhotosPage';
import type { Photo } from '../types';
vi.mock('../components/Photos/PhotoGallery', () => ({
default: ({ photos }: { photos: Photo[]; onUpload: unknown; onDelete: unknown; onUpdate: unknown; places: unknown[]; days: unknown[]; tripId: unknown }) =>
React.createElement('div', { 'data-testid': 'photo-gallery' }, `${photos.length} photos`),
}));
vi.mock('../components/Layout/Navbar', () => ({
default: ({ tripTitle }: { tripTitle?: string }) =>
React.createElement('nav', { 'data-testid': 'navbar' }, tripTitle),
}));
function buildPhoto(overrides: Partial<Photo> = {}): Photo {
return {
id: 1,
trip_id: 1,
filename: 'photo1.jpg',
original_name: 'photo1.jpg',
mime_type: 'image/jpeg',
size: 12345,
caption: null,
place_id: null,
day_id: null,
created_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
function renderPhotosPage(tripId: number | string = 1) {
return render(
<Routes>
<Route path="/trips/:id/photos" element={<PhotosPage />} />
</Routes>,
{ initialEntries: [`/trips/${tripId}/photos`] },
);
}
beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
seedStore(useTripStore, {
photos: [],
loadPhotos: vi.fn().mockResolvedValue(undefined),
addPhoto: vi.fn().mockResolvedValue(undefined),
deletePhoto: vi.fn().mockResolvedValue(undefined),
updatePhoto: vi.fn().mockResolvedValue(undefined),
} as any);
});
describe('PhotosPage', () => {
describe('FE-PAGE-PHOTOS-001: Loading spinner shown while data fetches', () => {
it('shows a spinner while data is loading', async () => {
server.use(
http.get('/api/trips/:id', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
const trip = buildTrip({ id: 1 });
return HttpResponse.json({ trip });
}),
);
renderPhotosPage(1);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
});
describe('FE-PAGE-PHOTOS-002: Trip name in Navbar after load', () => {
it('passes the trip name to Navbar after data loads', async () => {
const trip = buildTrip({ id: 1, name: 'Venice Trip' });
server.use(
http.get('/api/trips/:id', () => HttpResponse.json({ trip })),
);
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('navbar')).toHaveTextContent('Venice Trip');
});
});
});
describe('FE-PAGE-PHOTOS-003: PhotoGallery renders after load', () => {
it('renders the PhotoGallery after data loads', async () => {
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-PHOTOS-004: Photo count shown in header', () => {
it('shows the correct photo count in the header', async () => {
const photo = buildPhoto({ id: 1, trip_id: 1 });
seedStore(useTripStore, {
photos: [photo],
loadPhotos: vi.fn().mockResolvedValue(undefined),
addPhoto: vi.fn().mockResolvedValue(undefined),
deletePhoto: vi.fn().mockResolvedValue(undefined),
updatePhoto: vi.fn().mockResolvedValue(undefined),
} as any);
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
expect(screen.getByText(/1 Fotos/)).toBeInTheDocument();
});
});
describe('FE-PAGE-PHOTOS-005: Back link navigates to trip planner', () => {
it('back link points to the trip planner page', async () => {
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
const backLink = screen.getByRole('link', { name: /back to planning/i });
expect(backLink.getAttribute('href')).toContain('/trips/1');
});
});
describe('FE-PAGE-PHOTOS-006: loadPhotos called with trip ID on mount', () => {
it('calls tripStore.loadPhotos with the trip ID from the URL', async () => {
const mockLoadPhotos = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, {
photos: [],
loadPhotos: mockLoadPhotos,
addPhoto: vi.fn().mockResolvedValue(undefined),
deletePhoto: vi.fn().mockResolvedValue(undefined),
updatePhoto: vi.fn().mockResolvedValue(undefined),
} as any);
renderPhotosPage(1);
await waitFor(() => {
expect(mockLoadPhotos).toHaveBeenCalledWith('1');
});
});
});
describe('FE-PAGE-PHOTOS-007: Navigation to /dashboard on fetch error', () => {
it('navigates to /dashboard when trip fetch fails', async () => {
server.use(
http.get('/api/trips/:id', () =>
HttpResponse.json({ error: 'Not found' }, { status: 404 }),
),
);
render(
<Routes>
<Route path="/trips/:id/photos" element={<PhotosPage />} />
<Route path="/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
</Routes>,
{ initialEntries: ['/trips/1/photos'] },
);
await waitFor(() => {
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-PHOTOS-008: Photos sync from tripStore to local state', () => {
it('PhotoGallery re-renders when store photos change', async () => {
seedStore(useTripStore, {
photos: [],
loadPhotos: vi.fn().mockResolvedValue(undefined),
addPhoto: vi.fn().mockResolvedValue(undefined),
deletePhoto: vi.fn().mockResolvedValue(undefined),
updatePhoto: vi.fn().mockResolvedValue(undefined),
} as any);
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
expect(screen.getByTestId('photo-gallery')).toHaveTextContent('0 photos');
act(() => {
useTripStore.setState({ photos: [buildPhoto({ id: 99 })] } as any);
});
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toHaveTextContent('1 photos');
});
});
});
describe('FE-PAGE-PHOTOS-009: Empty photo list renders gallery with 0 photos', () => {
it('renders PhotoGallery with 0 photos when photos array is empty', async () => {
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
expect(screen.getByTestId('photo-gallery')).toHaveTextContent('0 photos');
});
});
describe('FE-PAGE-PHOTOS-010: Page heading present', () => {
it('renders the "Fotos" heading', async () => {
renderPhotosPage(1);
await waitFor(() => {
expect(screen.getByTestId('photo-gallery')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: /fotos/i })).toBeInTheDocument();
});
});
});
+186
View File
@@ -0,0 +1,186 @@
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 } from '../../tests/helpers/store';
import RegisterPage from './RegisterPage';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
const USERNAME_PLACEHOLDER = 'johndoe';
const EMAIL_PLACEHOLDER = 'your@email.com';
const PASSWORD_PLACEHOLDER = 'Min. 6 characters';
const CONFIRM_PASSWORD_PLACEHOLDER = 'Repeat password';
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
});
describe('RegisterPage', () => {
describe('FE-PAGE-REG-001: Renders registration form with all fields', () => {
it('shows username, email, password, confirm-password inputs and submit button', () => {
render(<RegisterPage />);
expect(screen.getByPlaceholderText(USERNAME_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByPlaceholderText(CONFIRM_PASSWORD_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument();
});
});
describe('FE-PAGE-REG-002: Password mismatch shows error', () => {
it('displays mismatch error without calling API', async () => {
const user = userEvent.setup();
render(<RegisterPage />);
await user.type(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'testuser');
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'test@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password1');
await user.type(screen.getByPlaceholderText(CONFIRM_PASSWORD_PLACEHOLDER), 'password2');
await user.click(screen.getByRole('button', { name: /^register$/i }));
await waitFor(() => {
expect(screen.getByText(/do not match/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-REG-003: Password too short shows error', () => {
it('displays length error when passwords are the same but too short', async () => {
const user = userEvent.setup();
render(<RegisterPage />);
await user.type(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'testuser');
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'test@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'abc');
await user.type(screen.getByPlaceholderText(CONFIRM_PASSWORD_PLACEHOLDER), 'abc');
await user.click(screen.getByRole('button', { name: /^register$/i }));
await waitFor(() => {
expect(screen.getByText(/at least 8/i)).toBeInTheDocument();
});
});
});
describe('FE-PAGE-REG-004: Successful registration navigates to /dashboard', () => {
it('calls navigate("/dashboard") after successful registration', async () => {
const user = userEvent.setup();
render(<RegisterPage />);
await user.type(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'testuser');
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'test@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.type(screen.getByPlaceholderText(CONFIRM_PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /^register$/i }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
});
});
});
describe('FE-PAGE-REG-005: Loading state during submission', () => {
it('disables submit button and shows loading text while registering', async () => {
server.use(
http.post('/api/auth/register', async () => {
await new Promise(resolve => setTimeout(resolve, 100));
return HttpResponse.json({ user: { id: 1, username: 'newuser' } });
}),
);
const user = userEvent.setup();
render(<RegisterPage />);
await user.type(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'testuser');
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'test@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.type(screen.getByPlaceholderText(CONFIRM_PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /^register$/i }));
await waitFor(() => {
const btn = screen.getByRole('button', { name: /registering/i });
expect(btn).toBeDisabled();
});
});
});
describe('FE-PAGE-REG-006: API error displayed', () => {
it('shows error message returned by the API', async () => {
server.use(
http.post('/api/auth/register', () => {
return HttpResponse.json({ error: 'Username already taken' }, { status: 409 });
}),
);
const user = userEvent.setup();
render(<RegisterPage />);
await user.type(screen.getByPlaceholderText(USERNAME_PLACEHOLDER), 'testuser');
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'test@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.type(screen.getByPlaceholderText(CONFIRM_PASSWORD_PLACEHOLDER), 'password123');
await user.click(screen.getByRole('button', { name: /^register$/i }));
await waitFor(() => {
expect(screen.getByText('Username already taken')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-REG-007: Show/hide password toggle', () => {
it('toggles password input type between password and text', async () => {
const user = userEvent.setup();
render(<RegisterPage />);
const passwordInput = screen.getByPlaceholderText(PASSWORD_PLACEHOLDER);
const confirmInput = screen.getByPlaceholderText(CONFIRM_PASSWORD_PLACEHOLDER);
expect(passwordInput).toHaveAttribute('type', 'password');
expect(confirmInput).toHaveAttribute('type', 'password');
// The toggle button is the only button of type "button" (not submit) before form submission
const toggleButton = screen.getByRole('button', { name: '' });
await user.click(toggleButton);
expect(passwordInput).toHaveAttribute('type', 'text');
expect(confirmInput).toHaveAttribute('type', 'text');
await user.click(toggleButton);
expect(passwordInput).toHaveAttribute('type', 'password');
expect(confirmInput).toHaveAttribute('type', 'password');
});
});
describe('FE-PAGE-REG-008: Link to login page is present', () => {
it('renders a Sign In link pointing to /login', () => {
render(<RegisterPage />);
const link = screen.getByRole('link', { name: /sign in/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/login');
});
});
describe('FE-PAGE-REG-009: Feature list rendered', () => {
it('renders feature list items in the DOM', () => {
render(<RegisterPage />);
// Features are always in the DOM (hidden via CSS on mobile)
expect(screen.getByText(/Unlimited trip plans/i)).toBeInTheDocument();
expect(screen.getByText(/Interactive map view/i)).toBeInTheDocument();
expect(screen.getByText(/Track reservations/i)).toBeInTheDocument();
});
});
describe('FE-PAGE-REG-010: Required attribute on username input', () => {
it('username input has required attribute', () => {
render(<RegisterPage />);
expect(screen.getByPlaceholderText(USERNAME_PLACEHOLDER)).toBeRequired();
});
});
});
+271 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '../../tests/helpers/render';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
import { Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
@@ -50,6 +50,7 @@ function renderSharedTrip(token: string) {
beforeEach(() => {
// SharedTripPage does NOT require authentication — do NOT seed auth store
resetAllStores();
vi.clearAllMocks();
});
describe('SharedTripPage', () => {
@@ -135,4 +136,273 @@ describe('SharedTripPage', () => {
expect(screen.getByTestId('map-container')).toBeInTheDocument();
});
});
describe('FE-PAGE-SHARED-008: Bookings tab is visible when share_bookings is true', () => {
it('shows bookings tab button with default test-token permissions', async () => {
renderSharedTrip('test-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
const bookingsTab = screen.getByRole('button', { name: /bookings/i });
expect(bookingsTab).toBeInTheDocument();
// Clicking should not crash
fireEvent.click(bookingsTab);
expect(bookingsTab).toBeInTheDocument();
});
});
describe('FE-PAGE-SHARED-009: Packing tab hidden when share_packing is false', () => {
it('does not show packing tab with default test-token (share_packing: false)', async () => {
renderSharedTrip('test-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: /packing/i })).toBeNull();
});
});
describe('FE-PAGE-SHARED-010: Packing tab visible when share_packing is true', () => {
it('shows packing tab and packing items when share_packing is true', async () => {
server.use(
http.get('/api/shared/:token', ({ params }) => {
if (params.token !== 'packing-token') return;
return HttpResponse.json({
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
days: [],
assignments: {},
dayNotes: {},
places: [],
reservations: [],
accommodations: [],
packing: [{ id: 1, name: 'Sunscreen', category: 'Health', checked: false }],
budget: [],
categories: [],
permissions: { share_bookings: false, share_packing: true, share_budget: false, share_collab: false },
collab: [],
});
}),
);
renderSharedTrip('packing-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
const packingTab = screen.getByRole('button', { name: /packing/i });
expect(packingTab).toBeInTheDocument();
fireEvent.click(packingTab);
await waitFor(() => {
expect(screen.getByText('Sunscreen')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-SHARED-011: Budget tab visible when share_budget is true', () => {
it('shows budget tab and budget items when share_budget is true', async () => {
server.use(
http.get('/api/shared/:token', ({ params }) => {
if (params.token !== 'budget-token') return;
return HttpResponse.json({
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05', currency: 'EUR' },
days: [],
assignments: {},
dayNotes: {},
places: [],
reservations: [],
accommodations: [],
packing: [],
budget: [{ id: 1, name: 'Hotel', total_price: '200', category: 'Accommodation' }],
categories: [],
permissions: { share_bookings: false, share_packing: false, share_budget: true, share_collab: false },
collab: [],
});
}),
);
renderSharedTrip('budget-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
const budgetTab = screen.getByRole('button', { name: /budget/i });
expect(budgetTab).toBeInTheDocument();
fireEvent.click(budgetTab);
await waitFor(() => {
expect(screen.getByText('Hotel')).toBeInTheDocument();
});
expect(screen.getAllByText(/200/).length).toBeGreaterThan(0);
});
});
describe('FE-PAGE-SHARED-012: Collab tab renders messages when share_collab is true', () => {
it('shows collab messages when share_collab is true', async () => {
server.use(
http.get('/api/shared/:token', ({ params }) => {
if (params.token !== 'collab-token') return;
return HttpResponse.json({
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
days: [],
assignments: {},
dayNotes: {},
places: [],
reservations: [],
accommodations: [],
packing: [],
budget: [],
categories: [],
permissions: { share_bookings: false, share_packing: false, share_budget: false, share_collab: true },
collab: [{ id: 1, username: 'alice', text: 'Hello team!', created_at: '2025-01-01T10:00:00Z', avatar: null }],
});
}),
);
renderSharedTrip('collab-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
const collabTab = screen.getByRole('button', { name: /chat/i });
expect(collabTab).toBeInTheDocument();
fireEvent.click(collabTab);
await waitFor(() => {
expect(screen.getByText('Hello team!')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-SHARED-013: Day card expands when clicked', () => {
it('reveals place names after clicking a collapsed day card header', async () => {
const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One', notes: null };
const place = { id: 201, trip_id: 1, name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, category_id: null, image_url: null, address: null };
server.use(
http.get('/api/shared/:token', ({ params }) => {
if (params.token !== 'expand-token') return;
return HttpResponse.json({
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
days: [day],
assignments: {
'101': [{ id: 301, day_id: 101, place_id: 201, order_index: 0, place }],
},
dayNotes: {},
places: [place],
reservations: [],
accommodations: [],
packing: [],
budget: [],
categories: [],
permissions: { share_bookings: false, share_packing: false, share_budget: false, share_collab: false },
collab: [],
});
}),
);
renderSharedTrip('expand-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
// Eiffel Tower is only in the mocked map tooltip (1 occurrence)
expect(screen.getAllByText('Eiffel Tower')).toHaveLength(1);
// Click the day card header to expand it
fireEvent.click(screen.getByText('Day One'));
// Now Eiffel Tower also appears in the expanded day content
await waitFor(() => {
expect(screen.getAllByText('Eiffel Tower')).toHaveLength(2);
});
});
});
describe('FE-PAGE-SHARED-014: Language picker toggles', () => {
it('opens language dropdown and closes after selecting a language', async () => {
renderSharedTrip('test-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
// Language picker button shows current language
const langButton = screen.getByRole('button', { name: /english/i });
expect(langButton).toBeInTheDocument();
// Open the dropdown
fireEvent.click(langButton);
// Language options should now be visible
expect(screen.getByRole('button', { name: /deutsch/i })).toBeInTheDocument();
// Select a different language
fireEvent.click(screen.getByRole('button', { name: /deutsch/i }));
// Dropdown should close — Español is no longer visible
expect(screen.queryByRole('button', { name: /español/i })).toBeNull();
});
});
describe('FE-PAGE-SHARED-015: TREK branding footer is rendered', () => {
it('renders the Shared via TREK footer', async () => {
renderSharedTrip('test-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
expect(screen.getByText(/shared via/i)).toBeInTheDocument();
});
});
describe('FE-PAGE-SHARED-016: Bookings tab shows reservation list', () => {
it('renders reservations when bookings tab is active and reservations are provided', async () => {
server.use(
http.get('/api/shared/:token', ({ params }) => {
if (params.token !== 'bookings-token') return;
return HttpResponse.json({
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
days: [],
assignments: {},
dayNotes: {},
places: [],
reservations: [
{ id: 1, title: 'Flight to Paris', type: 'flight', status: 'confirmed', reservation_time: '2026-07-01T10:00:00', metadata: '{}' },
],
accommodations: [],
packing: [],
budget: [],
categories: [],
permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false },
collab: [],
});
}),
);
renderSharedTrip('bookings-token');
await waitFor(() => {
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /bookings/i }));
await waitFor(() => {
expect(screen.getByText('Flight to Paris')).toBeInTheDocument();
});
});
});
});
File diff suppressed because it is too large Load Diff
+366
View File
@@ -0,0 +1,366 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import React from 'react';
import { render, screen, waitFor, fireEvent } from '../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useVacayStore } from '../store/vacayStore';
import VacayPage from './VacayPage';
import * as websocket from '../api/websocket';
vi.mock('../components/Vacay/VacayCalendar', () => ({
default: () => <div data-testid="vacay-calendar" />,
}));
vi.mock('../components/Vacay/VacayPersons', () => ({
default: () => <div data-testid="vacay-persons" />,
}));
vi.mock('../components/Vacay/VacayStats', () => ({
default: () => <div data-testid="vacay-stats" />,
}));
vi.mock('../components/Vacay/VacaySettings', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="vacay-settings">
<button data-testid="vacay-settings-close" onClick={onClose}>
Close settings
</button>
</div>
),
}));
vi.mock('../components/Layout/Navbar', () => ({
default: () => <nav data-testid="navbar" />,
}));
vi.mock('../api/websocket', () => ({
addListener: vi.fn(),
removeListener: vi.fn(),
}));
const makeVacayState = (overrides = {}) => ({
years: [2025],
selectedYear: 2025,
loading: false,
incomingInvites: [] as any[],
plan: null,
loadAll: vi.fn().mockResolvedValue(undefined),
loadPlan: vi.fn().mockResolvedValue(undefined),
loadEntries: vi.fn().mockResolvedValue(undefined),
loadStats: vi.fn().mockResolvedValue(undefined),
loadHolidays: vi.fn().mockResolvedValue(undefined),
setSelectedYear: vi.fn(),
addYear: vi.fn(),
removeYear: vi.fn().mockResolvedValue(undefined),
acceptInvite: vi.fn(),
declineInvite: vi.fn(),
...overrides,
});
describe('VacayPage', () => {
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
seedStore(useVacayStore, makeVacayState() as any);
});
// FE-PAGE-VACAY-001
it('shows loading spinner when loading=true', () => {
seedStore(useVacayStore, makeVacayState({ loading: true }) as any);
render(<VacayPage />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
expect(screen.queryByTestId('vacay-calendar')).not.toBeInTheDocument();
});
// FE-PAGE-VACAY-002
it('renders main layout when not loading', async () => {
render(<VacayPage />);
await waitFor(() => {
expect(screen.getByTestId('vacay-calendar')).toBeInTheDocument();
expect(screen.getByTestId('vacay-persons')).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-003
it('displays the selected year', async () => {
seedStore(useVacayStore, makeVacayState({ selectedYear: 2025 }) as any);
render(<VacayPage />);
await waitFor(() => {
// The large year display in the sidebar year selector
const instances = screen.getAllByText('2025');
expect(instances.length).toBeGreaterThan(0);
});
});
// FE-PAGE-VACAY-004
it('calls loadAll on mount', () => {
const mockLoadAll = vi.fn().mockResolvedValue(undefined);
seedStore(useVacayStore, makeVacayState({ loadAll: mockLoadAll }) as any);
render(<VacayPage />);
expect(mockLoadAll).toHaveBeenCalledTimes(1);
});
// FE-PAGE-VACAY-005
it('opens settings modal on settings button click', async () => {
render(<VacayPage />);
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
await waitFor(() => {
expect(screen.getByTestId('vacay-settings')).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-006
it('closes settings modal via close callback', async () => {
render(<VacayPage />);
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
await waitFor(() => {
expect(screen.getByTestId('vacay-settings')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('vacay-settings-close'));
await waitFor(() => {
expect(screen.queryByTestId('vacay-settings')).not.toBeInTheDocument();
});
});
// FE-PAGE-VACAY-007
it('shows all years in the year selector', async () => {
seedStore(useVacayStore, makeVacayState({ years: [2024, 2025], selectedYear: 2025 }) as any);
render(<VacayPage />);
await waitFor(() => {
expect(screen.getAllByText('2024')[0]).toBeInTheDocument();
expect(screen.getAllByText('2025')[0]).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-008
it('opens delete year modal when minus button clicked on year tile', async () => {
seedStore(useVacayStore, makeVacayState({ years: [2024, 2025], selectedYear: 2025 }) as any);
const { container } = render(<VacayPage />);
await waitFor(() => {
expect(screen.getAllByText('2024')[0]).toBeInTheDocument();
});
const deleteBtn = container.querySelector('.bg-red-500');
expect(deleteBtn).toBeInTheDocument();
fireEvent.click(deleteBtn!);
await waitFor(() => {
expect(screen.getByText(/remove year/i)).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-009
it('shows incoming invite overlay with username and action buttons', async () => {
seedStore(useVacayStore, makeVacayState({
incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }],
}) as any);
render(<VacayPage />);
await waitFor(() => {
expect(screen.getByText('bob')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /accept/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /decline/i })).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-010
it('calls acceptInvite with plan_id on accept button click', async () => {
const mockAcceptInvite = vi.fn();
seedStore(useVacayStore, makeVacayState({
incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }],
acceptInvite: mockAcceptInvite,
}) as any);
render(<VacayPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /accept/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /accept/i }));
expect(mockAcceptInvite).toHaveBeenCalledWith(99);
});
// FE-PAGE-VACAY-011
it('calls declineInvite with plan_id on decline button click', async () => {
const mockDeclineInvite = vi.fn();
seedStore(useVacayStore, makeVacayState({
incomingInvites: [{ id: 1, plan_id: 99, username: 'bob' }],
declineInvite: mockDeclineInvite,
}) as any);
render(<VacayPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /decline/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /decline/i }));
expect(mockDeclineInvite).toHaveBeenCalledWith(99);
});
// FE-PAGE-VACAY-012
it('registers WebSocket listener on mount and removes it on unmount', () => {
const addListenerMock = websocket.addListener as ReturnType<typeof vi.fn>;
const removeListenerMock = websocket.removeListener as ReturnType<typeof vi.fn>;
const { unmount } = render(<VacayPage />);
expect(addListenerMock).toHaveBeenCalledTimes(1);
unmount();
expect(removeListenerMock).toHaveBeenCalledTimes(1);
});
// FE-PAGE-VACAY-013: WebSocket vacay:update triggers loadPlan + loadEntries + loadStats
it('handles vacay:update WebSocket message', () => {
const mockLoadPlan = vi.fn().mockResolvedValue(undefined);
const mockLoadEntries = vi.fn().mockResolvedValue(undefined);
const mockLoadStats = vi.fn().mockResolvedValue(undefined);
const addListenerMock = websocket.addListener as ReturnType<typeof vi.fn>;
seedStore(useVacayStore, makeVacayState({ loadPlan: mockLoadPlan, loadEntries: mockLoadEntries, loadStats: mockLoadStats }) as any);
render(<VacayPage />);
const handler = addListenerMock.mock.calls[0][0];
handler({ type: 'vacay:update' });
expect(mockLoadPlan).toHaveBeenCalled();
expect(mockLoadEntries).toHaveBeenCalledWith(2025);
expect(mockLoadStats).toHaveBeenCalledWith(2025);
});
// FE-PAGE-VACAY-014: WebSocket vacay:settings also calls loadAll
it('handles vacay:settings WebSocket message', () => {
const mockLoadAll = vi.fn().mockResolvedValue(undefined);
const mockLoadPlan = vi.fn().mockResolvedValue(undefined);
const addListenerMock = websocket.addListener as ReturnType<typeof vi.fn>;
seedStore(useVacayStore, makeVacayState({ loadAll: mockLoadAll, loadPlan: mockLoadPlan }) as any);
render(<VacayPage />);
const handler = addListenerMock.mock.calls[0][0];
// loadAll is called once on mount, reset to track the WS-triggered call
mockLoadAll.mockClear();
handler({ type: 'vacay:settings' });
expect(mockLoadAll).toHaveBeenCalled();
expect(mockLoadPlan).toHaveBeenCalled();
});
// FE-PAGE-VACAY-015: WebSocket vacay:invite calls loadAll
it('handles vacay:invite WebSocket message', () => {
const mockLoadAll = vi.fn().mockResolvedValue(undefined);
const addListenerMock = websocket.addListener as ReturnType<typeof vi.fn>;
seedStore(useVacayStore, makeVacayState({ loadAll: mockLoadAll }) as any);
render(<VacayPage />);
const handler = addListenerMock.mock.calls[0][0];
mockLoadAll.mockClear();
handler({ type: 'vacay:invite' });
expect(mockLoadAll).toHaveBeenCalled();
});
// FE-PAGE-VACAY-016: Add next year button calls addYear with max+1
it('calls addYear with next year when + button at end is clicked', async () => {
const mockAddYear = vi.fn();
seedStore(useVacayStore, makeVacayState({ years: [2024, 2025], selectedYear: 2025, addYear: mockAddYear }) as any);
const { container } = render(<VacayPage />);
// The "add next year" button is the last Plus button in the year selector header
const plusButtons = container.querySelectorAll('button[title]');
const addNextBtn = Array.from(plusButtons).find(btn => btn.getAttribute('title') && btn.getAttribute('title')!.length > 0 && !btn.getAttribute('title')!.toLowerCase().includes('prev'));
// Use getAllByTitle or find the second Plus button
const allPlusButtons = container.querySelectorAll('.p-0\\.5.rounded');
// Click the rightmost + button (add next year)
const rightPlusBtn = container.querySelector('button[title]:last-of-type') ??
Array.from(container.querySelectorAll('button')).find(btn => btn.title && !btn.title.toLowerCase().includes('prev'));
if (rightPlusBtn) fireEvent.click(rightPlusBtn);
expect(mockAddYear).toHaveBeenCalledWith(2026);
});
// FE-PAGE-VACAY-017: Add prev year button calls addYear with min-1
it('calls addYear with previous year when + button at start is clicked', async () => {
const mockAddYear = vi.fn();
seedStore(useVacayStore, makeVacayState({ years: [2024, 2025], selectedYear: 2025, addYear: mockAddYear }) as any);
const { container } = render(<VacayPage />);
const prevBtn = container.querySelector('button[title]');
expect(prevBtn).toBeInTheDocument();
fireEvent.click(prevBtn!);
expect(mockAddYear).toHaveBeenCalledWith(2023);
});
// FE-PAGE-VACAY-018: Year tile click calls setSelectedYear
it('calls setSelectedYear when a year tile is clicked', async () => {
const mockSetSelectedYear = vi.fn();
seedStore(useVacayStore, makeVacayState({ years: [2024, 2025], selectedYear: 2025, setSelectedYear: mockSetSelectedYear }) as any);
render(<VacayPage />);
await waitFor(() => {
expect(screen.getAllByText('2024')[0]).toBeInTheDocument();
});
// Click the 2024 year tile (first one in grid)
fireEvent.click(screen.getAllByText('2024')[0]);
expect(mockSetSelectedYear).toHaveBeenCalledWith(2024);
});
// FE-PAGE-VACAY-019: Legend renders when plan has holidays enabled
it('renders legend when plan has holidays_enabled', async () => {
seedStore(useVacayStore, makeVacayState({
plan: {
id: 1,
holidays_enabled: true,
holiday_calendars: [],
company_holidays_enabled: false,
block_weekends: false,
},
}) as any);
render(<VacayPage />);
await waitFor(() => {
expect(screen.getAllByText(/legend/i)[0]).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-020: Legend renders holiday calendar items
it('renders legend calendar items from plan', async () => {
seedStore(useVacayStore, makeVacayState({
plan: {
id: 1,
holidays_enabled: true,
holiday_calendars: [{ id: 1, region: 'DE', label: 'Germany', color: '#ef4444', sort_order: 0 }],
company_holidays_enabled: false,
block_weekends: false,
},
}) as any);
render(<VacayPage />);
await waitFor(() => {
expect(screen.getByText('Germany')).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-021: Mobile sidebar toggle opens drawer
it('opens mobile sidebar drawer when toggle button is clicked', async () => {
const { container } = render(<VacayPage />);
// The mobile sidebar toggle button has the SlidersHorizontal icon and no text
const mobileToggle = Array.from(container.querySelectorAll('button')).find(
btn => btn.className.includes('lg:hidden') || btn.className.includes('SlidersHorizontal')
) ?? container.querySelector('.lg\\:hidden');
expect(mobileToggle).toBeInTheDocument();
fireEvent.click(mobileToggle as Element);
await waitFor(() => {
// The mobile sidebar backdrop renders in document.body via portal
expect(document.body.querySelector('.fixed.inset-0')).toBeInTheDocument();
});
});
// FE-PAGE-VACAY-022: Delete year modal cancel button closes modal
it('closes delete year modal when cancel is clicked', async () => {
seedStore(useVacayStore, makeVacayState({ years: [2024, 2025], selectedYear: 2025 }) as any);
const { container } = render(<VacayPage />);
await waitFor(() => expect(screen.getAllByText('2024')[0]).toBeInTheDocument());
fireEvent.click(container.querySelector('.bg-red-500')!);
await waitFor(() => expect(screen.getByText(/remove year/i)).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
});
});
// FE-PAGE-VACAY-023: Delete year modal confirm button calls removeYear
it('calls removeYear when Remove button is clicked in delete modal', async () => {
const mockRemoveYear = vi.fn().mockResolvedValue(undefined);
seedStore(useVacayStore, makeVacayState({ years: [2024, 2025], selectedYear: 2025, removeYear: mockRemoveYear }) as any);
const { container } = render(<VacayPage />);
await waitFor(() => expect(screen.getAllByText('2024')[0]).toBeInTheDocument());
fireEvent.click(container.querySelector('.bg-red-500')!);
await waitFor(() => expect(screen.getByText(/remove year/i)).toBeInTheDocument());
// The Remove button is the red one in the modal footer (not the year tile delete button)
const removeBtn = screen.getByRole('button', { name: /^remove$/i }) ??
Array.from(document.querySelectorAll('button')).find(btn => /^remove$/i.test(btn.textContent ?? ''));
if (removeBtn) fireEvent.click(removeBtn);
await waitFor(() => {
expect(mockRemoveYear).toHaveBeenCalled();
});
});
});