test(front): add test suite frontend (WIP)

This commit is contained in:
jubnl
2026-04-07 12:31:09 +02:00
parent 96080e8a03
commit 3c31902885
97 changed files with 16973 additions and 4 deletions
@@ -0,0 +1,124 @@
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-015
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPlace, buildCategory } from '../../../tests/helpers/factories';
import PlaceFormModal from './PlaceFormModal';
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSave: vi.fn(),
place: null,
prefillCoords: null,
tripId: 1,
categories: [],
onCategoryCreated: vi.fn(),
assignmentId: null,
dayAssignments: [],
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
describe('PlaceFormModal', () => {
it('FE-COMP-PLACEFORM-001: renders modal when isOpen is true', () => {
render(<PlaceFormModal {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-002: shows Add Place title for new place', () => {
render(<PlaceFormModal {...defaultProps} place={null} />);
// places.addPlace = "Add Place/Activity"
expect(screen.getAllByText(/Add Place\/Activity/i).length).toBeGreaterThan(0);
});
it('FE-COMP-PLACEFORM-003: shows Edit Place title when editing', () => {
const place = buildPlace({ name: 'Eiffel Tower' });
render(<PlaceFormModal {...defaultProps} place={place} />);
expect(screen.getByText('Edit Place')).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-004: shows Name field with placeholder', () => {
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i)).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-005: shows Description field', () => {
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByPlaceholderText(/Short description/i)).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-006: shows Address field', () => {
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByPlaceholderText(/Street, City, Country/i)).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-007: shows Add button for new place', () => {
render(<PlaceFormModal {...defaultProps} place={null} />);
expect(screen.getByRole('button', { name: /^Add$/i })).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-008: shows Update button when editing', () => {
const place = buildPlace({ name: 'Test Place' });
render(<PlaceFormModal {...defaultProps} place={place} />);
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-009: shows Cancel button', () => {
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-010: clicking Cancel calls onClose', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<PlaceFormModal {...defaultProps} onClose={onClose} />);
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('FE-COMP-PLACEFORM-011: pre-fills name field when editing existing place', () => {
const place = buildPlace({ name: 'Notre Dame' });
render(<PlaceFormModal {...defaultProps} place={place} />);
const nameInput = screen.getByDisplayValue('Notre Dame');
expect(nameInput).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-012: pre-fills address when editing existing place', () => {
const place = buildPlace({ name: 'Test', address: '123 Main St' });
render(<PlaceFormModal {...defaultProps} place={place} />);
expect(screen.getByDisplayValue('123 Main St')).toBeInTheDocument();
});
it('FE-COMP-PLACEFORM-013: submitting empty form does not call onSave (name required)', async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: /^Add$/i }));
// Form validation prevents calling onSave without a name
expect(onSave).not.toHaveBeenCalled();
});
it('FE-COMP-PLACEFORM-014: typing in name field and submitting calls onSave', async () => {
const user = userEvent.setup();
const onSave = vi.fn().mockResolvedValue(undefined);
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
await user.type(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), 'Sacre Coeur');
await user.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Sacre Coeur' }));
});
it('FE-COMP-PLACEFORM-015: categories appear in category selector', () => {
const cats = [buildCategory({ name: 'Museum' }), buildCategory({ name: 'Park' })];
render(<PlaceFormModal {...defaultProps} categories={cats} />);
// Category label is present
expect(screen.getByText('Category')).toBeInTheDocument();
});
});
@@ -0,0 +1,164 @@
// FE-COMP-PLACES-001 to FE-COMP-PLACES-015
import { render, screen } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPlace, buildCategory, buildDay } from '../../../tests/helpers/factories';
import PlacesSidebar from './PlacesSidebar';
// Mock photoService so PlaceAvatar doesn't trigger API calls
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
}));
// PlaceAvatar uses `new IntersectionObserver(...)` — needs a class-based mock
class MockIO {
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
}
beforeAll(() => { (globalThis as any).IntersectionObserver = MockIO; });
const defaultProps = {
tripId: 1,
places: [],
categories: [],
assignments: {},
selectedDayId: null,
selectedPlaceId: null,
onPlaceClick: vi.fn(),
onAddPlace: vi.fn(),
onAssignToDay: vi.fn(),
onEditPlace: vi.fn(),
onDeletePlace: vi.fn(),
days: [],
isMobile: false,
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
describe('PlacesSidebar', () => {
it('FE-COMP-PLACES-001: renders without crashing', () => {
render(<PlacesSidebar {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-PLACES-002: shows search input', () => {
render(<PlacesSidebar {...defaultProps} />);
const searchInput = screen.getByPlaceholderText(/Search places/i);
expect(searchInput).toBeInTheDocument();
});
it('FE-COMP-PLACES-003: renders places from props', () => {
const places = [
buildPlace({ name: 'Eiffel Tower' }),
buildPlace({ name: 'Louvre Museum' }),
];
render(<PlacesSidebar {...defaultProps} places={places} />);
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument();
expect(screen.getByText('Louvre Museum')).toBeInTheDocument();
});
it('FE-COMP-PLACES-004: shows Add Place button', () => {
render(<PlacesSidebar {...defaultProps} />);
// Multiple "Add Place/Activity" buttons may exist (top toolbar + empty state)
const addBtns = screen.getAllByText(/Add Place\/Activity/i);
expect(addBtns.length).toBeGreaterThan(0);
});
it('FE-COMP-PLACES-005: clicking Add Place calls onAddPlace', async () => {
const user = userEvent.setup();
const onAddPlace = vi.fn();
render(<PlacesSidebar {...defaultProps} onAddPlace={onAddPlace} />);
const addBtns = screen.getAllByText(/Add Place\/Activity/i);
await user.click(addBtns[0]);
expect(onAddPlace).toHaveBeenCalled();
});
it('FE-COMP-PLACES-006: clicking a place calls onPlaceClick with place id', async () => {
const user = userEvent.setup();
const onPlaceClick = vi.fn();
const place = buildPlace({ id: 42, name: 'Notre Dame' });
render(<PlacesSidebar {...defaultProps} places={[place]} onPlaceClick={onPlaceClick} />);
await user.click(screen.getByText('Notre Dame'));
expect(onPlaceClick).toHaveBeenCalled();
});
it('FE-COMP-PLACES-007: search filters places by name', async () => {
const user = userEvent.setup();
const places = [
buildPlace({ name: 'Arc de Triomphe' }),
buildPlace({ name: 'Sacre Coeur' }),
];
render(<PlacesSidebar {...defaultProps} places={places} />);
const searchInput = screen.getByPlaceholderText(/Search places/i);
await user.type(searchInput, 'Arc');
expect(screen.getByText('Arc de Triomphe')).toBeInTheDocument();
expect(screen.queryByText('Sacre Coeur')).not.toBeInTheDocument();
});
it('FE-COMP-PLACES-008: search is case-insensitive', async () => {
const user = userEvent.setup();
const places = [buildPlace({ name: 'Museum of Art' })];
render(<PlacesSidebar {...defaultProps} places={places} />);
const searchInput = screen.getByPlaceholderText(/Search places/i);
await user.type(searchInput, 'museum');
expect(screen.getByText('Museum of Art')).toBeInTheDocument();
});
it('FE-COMP-PLACES-009: selected place is highlighted', () => {
const place = buildPlace({ id: 10, name: 'Central Park' });
render(<PlacesSidebar {...defaultProps} places={[place]} selectedPlaceId={10} />);
expect(screen.getByText('Central Park')).toBeInTheDocument();
});
it('FE-COMP-PLACES-010: shows place count', () => {
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
render(<PlacesSidebar {...defaultProps} places={places} />);
// i18n: places.count = "{count} places"
expect(screen.getByText(/3 places/i)).toBeInTheDocument();
});
it('FE-COMP-PLACES-011: empty list shows no place names', () => {
render(<PlacesSidebar {...defaultProps} places={[]} />);
expect(screen.queryByText(/Eiffel/)).not.toBeInTheDocument();
});
it('FE-COMP-PLACES-012: categories from props render without error', () => {
const cats = [buildCategory({ name: 'Restaurant' }), buildCategory({ name: 'Hotel' })];
render(<PlacesSidebar {...defaultProps} categories={cats} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-PLACES-013: clearing search shows all places again', async () => {
const user = userEvent.setup();
const places = [buildPlace({ name: 'Place A' }), buildPlace({ name: 'Place B' })];
render(<PlacesSidebar {...defaultProps} places={places} />);
const searchInput = screen.getByPlaceholderText(/Search places/i);
await user.type(searchInput, 'Place A');
expect(screen.queryByText('Place B')).not.toBeInTheDocument();
await user.clear(searchInput);
expect(screen.getByText('Place B')).toBeInTheDocument();
});
it('FE-COMP-PLACES-014: renders with days prop for day assignment', () => {
const days = [buildDay({ id: 1, date: '2025-06-01' })];
render(<PlacesSidebar {...defaultProps} days={days} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-PLACES-015: onEditPlace passed to component correctly', () => {
const onEditPlace = vi.fn();
const place = buildPlace({ name: 'Test Place' });
render(<PlacesSidebar {...defaultProps} places={[place]} onEditPlace={onEditPlace} />);
expect(screen.getByText('Test Place')).toBeInTheDocument();
});
});
@@ -0,0 +1,140 @@
// FE-COMP-RES-001 to FE-COMP-RES-015
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildReservation } from '../../../tests/helpers/factories';
import ReservationsPanel from './ReservationsPanel';
const defaultProps = {
tripId: 1,
reservations: [],
days: [],
assignments: {},
files: [],
onAdd: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onNavigateToFiles: vi.fn(),
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
describe('ReservationsPanel', () => {
it('FE-COMP-RES-001: renders without crashing', () => {
render(<ReservationsPanel {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-RES-002: shows Bookings title', () => {
render(<ReservationsPanel {...defaultProps} />);
// reservations.title = "Bookings"
expect(screen.getByText('Bookings')).toBeInTheDocument();
});
it('FE-COMP-RES-003: shows empty state when no reservations', () => {
render(<ReservationsPanel {...defaultProps} reservations={[]} />);
// "No reservations yet" appears in both header subtitle and empty state body
const els = screen.getAllByText('No reservations yet');
expect(els.length).toBeGreaterThan(0);
});
it('FE-COMP-RES-004: shows empty hint text', () => {
render(<ReservationsPanel {...defaultProps} reservations={[]} />);
expect(screen.getByText(/Add reservations for flights/i)).toBeInTheDocument();
});
it('FE-COMP-RES-005: shows Manual Booking add button', () => {
render(<ReservationsPanel {...defaultProps} />);
// Button text is reservations.addManual = "Manual Booking"
expect(screen.getByText('Manual Booking')).toBeInTheDocument();
});
it('FE-COMP-RES-006: clicking Manual Booking button calls onAdd', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ReservationsPanel {...defaultProps} onAdd={onAdd} />);
await user.click(screen.getByText('Manual Booking'));
expect(onAdd).toHaveBeenCalled();
});
it('FE-COMP-RES-007: renders reservation title', () => {
// Component renders r.title, not r.name
const res = buildReservation({ title: 'Hotel Paris', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Hotel Paris')).toBeInTheDocument();
});
it('FE-COMP-RES-008: renders confirmed reservation badge', () => {
const res = buildReservation({ title: 'Flight NY', type: 'flight', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// "Confirmed" appears in both section header and card badge
const els = screen.getAllByText('Confirmed');
expect(els.length).toBeGreaterThan(0);
});
it('FE-COMP-RES-009: renders pending reservation badge', () => {
const res = buildReservation({ title: 'Hotel Rome', type: 'hotel', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// "Pending" appears in both section header and card badge
const els = screen.getAllByText('Pending');
expect(els.length).toBeGreaterThan(0);
});
it('FE-COMP-RES-010: shows summary text with confirmed and pending counts', () => {
const r1 = buildReservation({ title: 'Flight', type: 'flight', status: 'confirmed' });
const r2 = buildReservation({ title: 'Hotel', type: 'hotel', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[r1, r2]} />);
// reservations.summary = "{confirmed} confirmed, {pending} pending"
expect(screen.getByText(/1 confirmed, 1 pending/i)).toBeInTheDocument();
});
it('FE-COMP-RES-011: hotel reservation renders', () => {
const res = buildReservation({ title: 'Grand Hotel', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Grand Hotel')).toBeInTheDocument();
});
it('FE-COMP-RES-012: flight reservation renders', () => {
const res = buildReservation({ title: 'Air France 123', type: 'flight', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Air France 123')).toBeInTheDocument();
});
it('FE-COMP-RES-013: multiple reservations all render', () => {
const r1 = buildReservation({ title: 'Hotel A', type: 'hotel', status: 'confirmed' });
const r2 = buildReservation({ title: 'Flight B', type: 'flight', status: 'confirmed' });
const r3 = buildReservation({ title: 'Restaurant C', type: 'restaurant', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[r1, r2, r3]} />);
expect(screen.getByText('Hotel A')).toBeInTheDocument();
expect(screen.getByText('Flight B')).toBeInTheDocument();
expect(screen.getByText('Restaurant C')).toBeInTheDocument();
});
it('FE-COMP-RES-014: edit button calls onEdit with reservation', async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
const res = buildReservation({ id: 77, title: 'Editable Res', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} onEdit={onEdit} />);
const editBtn = screen.getByTitle('Edit');
await user.click(editBtn);
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 77 }));
});
it('FE-COMP-RES-015: delete button opens confirm dialog, then calls onDelete', async () => {
const user = userEvent.setup();
const onDelete = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 88, title: 'Delete Me', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} onDelete={onDelete} />);
await user.click(screen.getByTitle('Delete'));
// Confirm dialog appears — click the Confirm button
const confirmBtn = await screen.findByText('Confirm');
await user.click(confirmBtn);
await waitFor(() => expect(onDelete).toHaveBeenCalledWith(88));
});
});