Files
TREK/client/src/pages/VacayPage.test.tsx
T
jubnl d4bb8be86b 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.
2026-04-08 21:14:49 +02:00

367 lines
15 KiB
TypeScript

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();
});
});
});