mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
d4bb8be86b
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.
367 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|