mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user