mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
test(front): add test suite frontend (WIP)
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user