// FE-PLANNER-RESFORM-001 to -016: the booking form both the desktop ReservationModal // and the phone's booking sheet save through. import { describe, expect, it } from 'vitest'; import { buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'; import type { Accommodation, Reservation } from '../../types'; import type { BookingReviewDraft } from './parsedItemToDraft'; import { RESERVATION_TYPE_OPTIONS, type ReservationFields, endTimeBeforeStartTime, isEndBeforeStart, reservationFieldsFrom, reservationFieldsFromPrefill, reservationSaveData, selectedDayDate, splitEndTime, startParts, tripDateBounds, withHotelEnd, withHotelPlace, withHotelStart, withLinkedPlace, withLinkedStopDay, withStartDate, withStartTime, } from './reservationFormModel'; const days = [ buildDay({ id: 1, date: '2026-07-03' }), buildDay({ id: 2, date: '2026-07-01' }), buildDay({ id: 3, date: '2026-07-02' }), ]; const EMPTY: ReservationFields = { title: '', type: 'other', status: 'pending', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', notes: '', url: '', place_id: '', accommodation_id: '', meta_check_in_time: '', meta_check_out_time: '', hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '', }; describe('reservation form fields', () => { it('FE-PLANNER-RESFORM-001: the type picker offers the six booking types, hotel first', () => { expect(RESERVATION_TYPE_OPTIONS.map((o) => o.value)).toEqual([ 'hotel', 'restaurant', 'event', 'tour', 'parking', 'other', ]); }); it('FE-PLANNER-RESFORM-002: a stored end splits into its date and its time', () => { expect(splitEndTime('2026-07-02T18:30:00')).toEqual({ endDate: '2026-07-02', endTime: '18:30' }); expect(splitEndTime('2026-07-02')).toEqual({ endDate: '2026-07-02', endTime: '' }); expect(splitEndTime('18:30')).toEqual({ endDate: '', endTime: '18:30' }); expect(splitEndTime('')).toEqual({ endDate: '', endTime: '' }); }); it('FE-PLANNER-RESFORM-003: a saved hotel booking reads its stay; only the desktop keeps the check-in window', () => { const acc = { id: 9, place_id: 4, start_day_id: 2, end_day_id: 3 } as Accommodation; const res = buildReservation({ type: 'hotel', status: 'confirmed', reservation_time: '2026-07-01T15:00:00', reservation_end_time: '2026-07-02T11:00', accommodation_id: 9, location: 'Typed street', url: 'https://h.test', metadata: JSON.stringify({ check_in_time: '15:00', check_in_end_time: '22:00', check_out_time: '11:00' }), } as Partial); const places = [buildPlace({ id: 4, address: 'Hotel street 1' })]; const desktop = reservationFieldsFrom(res, [acc], places, true); expect(desktop).toMatchObject({ type: 'hotel', status: 'confirmed', reservation_time: '2026-07-01T15:00', reservation_end_time: '11:00', end_date: '2026-07-02', url: 'https://h.test', accommodation_id: 9, meta_check_in_time: '15:00', meta_check_in_end_time: '22:00', meta_check_out_time: '11:00', hotel_place_id: 4, hotel_start_day: 2, hotel_end_day: 3, hotel_address: 'Hotel street 1', }); const phone = reservationFieldsFrom(res, [acc], places, false); expect(phone).not.toHaveProperty('meta_check_in_end_time'); expect(reservationFieldsFrom(res, [acc], [], false).hotel_address).toBe('Typed street'); expect(reservationFieldsFrom(buildReservation({ type: undefined }), [], [], false)).toMatchObject({ type: 'other', hotel_place_id: '', place_id: '', }); }); it('FE-PLANNER-RESFORM-004: an import under review resolves its stay days and keeps its source details', () => { const pf = { title: 'Inn', type: 'hotel', reservation_time: '2026-07-01T14:00:00', reservation_end_time: '2026-07-02', metadata: { check_in_time: '14:00', check_in_end_time: '20:00' }, url: 'https://inn.test', _accommodation: { check_in: '2026-07-01', check_out: '2026-07-03' }, _venue: { name: 'Inn', address: 'Lane 2' }, } as unknown as BookingReviewDraft; expect(reservationFieldsFromPrefill(pf, days, true)).toEqual({ title: 'Inn', type: 'hotel', status: 'pending', reservation_time: '2026-07-01T14:00', reservation_end_time: '', end_date: '2026-07-02', location: '', confirmation_number: '', notes: '', url: 'https://inn.test', meta_check_in_time: '14:00', meta_check_in_end_time: '20:00', meta_check_out_time: '', hotel_start_day: 2, hotel_end_day: 1, hotel_address: 'Lane 2', }); const bare = reservationFieldsFromPrefill({ metadata: null } as BookingReviewDraft, days, false); expect(bare).toMatchObject({ type: 'other', reservation_time: '', hotel_start_day: '', hotel_address: '' }); expect(bare).not.toHaveProperty('meta_check_in_end_time'); }); }); describe('reservation dates', () => { const form = (patch: Partial) => ({ ...EMPTY, ...patch }); it('FE-PLANNER-RESFORM-005: an end before the start blocks; an all-day end on the start day does not (#2107)', () => { expect( isEndBeforeStart( form({ reservation_time: '2026-07-02T10:00', end_date: '2026-07-02', reservation_end_time: '09:00' }) ) ).toBe(true); expect( isEndBeforeStart( form({ reservation_time: '2026-07-02T10:00', end_date: '2026-07-02', reservation_end_time: '10:00' }) ) ).toBe(true); expect( isEndBeforeStart( form({ reservation_time: '2026-07-02T10:00', end_date: '2026-07-02', reservation_end_time: '11:00' }) ) ).toBe(false); expect( isEndBeforeStart(form({ reservation_time: '2026-07-02', end_date: '2026-07-02', reservation_end_time: '09:00' })) ).toBe(false); expect(isEndBeforeStart(form({ reservation_time: '2026-07-02T10:00', end_date: '2026-07-01' }))).toBe(true); expect(isEndBeforeStart(form({ type: 'hotel', reservation_time: '2026-07-02', end_date: '2026-07-01' }))).toBe( false ); expect(isEndBeforeStart(form({ end_date: '2026-07-01' }))).toBe(false); }); it('FE-PLANNER-RESFORM-016: an end time before the start time without an end date warns but does not block (#2604)', () => { const overnight = form({ reservation_time: '2026-11-24T18:00', reservation_end_time: '09:00' }); expect(endTimeBeforeStartTime(overnight)).toBe(true); // Time zones can make a booking end "before" it starts, so the save stays open. expect(isEndBeforeStart(overnight)).toBe(false); expect(endTimeBeforeStartTime(form({ reservation_time: '2026-11-24T09:00', reservation_end_time: '18:00' }))).toBe(false); expect(endTimeBeforeStartTime(form({ reservation_time: '2026-11-24T18:00', reservation_end_time: '18:00' }))).toBe(false); // With the next day as end date the times are fine, and an end date of its own is checked by isEndBeforeStart. expect( endTimeBeforeStartTime(form({ reservation_time: '2026-11-24T18:00', end_date: '2026-11-25', reservation_end_time: '09:00' })) ).toBe(false); expect(endTimeBeforeStartTime(form({ reservation_time: '2026-11-24', reservation_end_time: '09:00' }))).toBe(false); expect(endTimeBeforeStartTime(form({ reservation_end_time: '09:00' }))).toBe(false); expect( endTimeBeforeStartTime(form({ type: 'hotel', reservation_time: '2026-11-24T18:00', reservation_end_time: '09:00' })) ).toBe(false); }); it('FE-PLANNER-RESFORM-006: the start is edited as a date and a time', () => { expect(startParts('2026-07-02T10:00')).toEqual({ date: '2026-07-02', time: '10:00' }); expect(startParts('')).toEqual({ date: '', time: '' }); expect(withStartDate('2026-07-02T10:00', '2026-07-03')).toBe('2026-07-03T10:00'); expect(withStartDate('2026-07-02', '2026-07-03')).toBe('2026-07-03'); expect(withStartDate('2026-07-02T10:00', '')).toBe(''); expect(withStartDate('2026-07-02T10:00', null)).toBe(''); expect(withStartTime('2026-07-02', '09:15', '2026-01-01')).toBe('2026-07-02T09:15'); expect(withStartTime('', '09:15', '2026-01-01')).toBe('2026-01-01T09:15'); expect(withStartTime('2026-07-02T09:15', '', '2026-01-01')).toBe('2026-07-02'); expect(withStartTime('', '09:15', '')).toBe('T09:15'); }); it('FE-PLANNER-RESFORM-015: a time typed without a day takes the picked day, never today (#2604)', () => { expect(selectedDayDate(days, 3)).toBe('2026-07-02'); expect(selectedDayDate(days, null)).toBe(''); expect(selectedDayDate(days, 99)).toBe(''); expect(withStartTime('', '09:15', selectedDayDate(days, null))).toBe('T09:15'); }); it('FE-PLANNER-RESFORM-007: the pickers are bounded by the earliest and latest dated day', () => { expect(tripDateBounds(days)).toEqual({ min: '2026-07-01', max: '2026-07-03' }); expect(tripDateBounds([{ date: null }] as { date: string }[])).toEqual({ min: undefined, max: undefined }); }); it('FE-PLANNER-RESFORM-008: linking a stop dates only an undated booking', () => { const undated = form({}); expect(withLinkedStopDay(undated, '2026-07-02').reservation_time).toBe('2026-07-02'); const dated = form({ reservation_time: '2026-07-03' }); expect(withLinkedStopDay(dated, '2026-07-02')).toBe(dated); }); }); describe('reservation place and stay picks', () => { const places = [ buildPlace({ id: 1, name: 'Museum', address: 'Museum road' }), buildPlace({ id: 2, name: 'Bare', address: null }), ]; it('FE-PLANNER-RESFORM-009: a linked place fills a missing title and address only', () => { expect(withLinkedPlace(EMPTY, 1, places)).toMatchObject({ place_id: 1, title: 'Museum', location: 'Museum road' }); const typed = { ...EMPTY, title: 'Mine', location: 'Here' }; expect(withLinkedPlace(typed, 1, places)).toMatchObject({ place_id: 1, title: 'Mine', location: 'Here' }); expect(withLinkedPlace(EMPTY, 2, places)).toMatchObject({ place_id: 2, title: 'Bare', location: '' }); expect(withLinkedPlace({ ...EMPTY, place_id: 1 }, '', places)).toMatchObject({ place_id: '', title: '' }); }); it('FE-PLANNER-RESFORM-010: a picked hotel shows its address and keeps a typed one when it has none', () => { expect(withHotelPlace(EMPTY, 1, places)).toMatchObject({ hotel_place_id: 1, title: 'Museum', hotel_address: 'Museum road', }); const typed = { ...EMPTY, title: 'Stay', hotel_address: 'Typed' }; expect(withHotelPlace(typed, 1, places)).toMatchObject({ title: 'Stay', hotel_address: 'Museum road' }); expect(withHotelPlace(typed, 2, places)).toMatchObject({ hotel_place_id: 2, hotel_address: 'Typed' }); }); it('FE-PLANNER-RESFORM-011: the stay range never ends before it starts', () => { const order = [{ id: 10 }, { id: 11 }, { id: 12 }]; const stay = { ...EMPTY, hotel_start_day: 10, hotel_end_day: 11 }; expect(withHotelStart(stay, 12, order)).toMatchObject({ hotel_start_day: 12, hotel_end_day: 12 }); expect(withHotelStart(stay, 11, order)).toMatchObject({ hotel_start_day: 11, hotel_end_day: 11 }); expect(withHotelStart({ ...stay, hotel_end_day: 12 }, 11, order)).toMatchObject({ hotel_end_day: 12 }); expect(withHotelEnd({ ...stay, hotel_start_day: 12 }, 11, order)).toMatchObject({ hotel_start_day: 11, hotel_end_day: 11, }); expect(withHotelEnd(stay, 12, order)).toMatchObject({ hotel_start_day: 10, hotel_end_day: 12 }); }); }); describe('reservationSaveData', () => { it('FE-PLANNER-RESFORM-012: a booking sends its times, links and an empty metadata object; an edit leaves the endpoints alone', () => { const form = { ...EMPTY, title: 'Dinner', type: 'restaurant', reservation_time: '2026-07-02T19:00', reservation_end_time: '21:00', location: 'Old town', place_id: 4, url: 'u', }; expect(reservationSaveData(form, { assignmentId: 7, isEdit: false, withCheckInEnd: true })).toEqual({ title: 'Dinner', type: 'restaurant', status: 'pending', reservation_time: '2026-07-02T19:00', reservation_end_time: '2026-07-02T21:00', location: 'Old town', confirmation_number: '', notes: '', url: 'u', assignment_id: 7, accommodation_id: null, place_id: 4, metadata: {}, endpoints: [], needs_review: false, }); const edit = reservationSaveData( { ...form, end_date: '2026-07-03' }, { assignmentId: '', isEdit: true, withCheckInEnd: false } ); expect(edit).not.toHaveProperty('endpoints'); expect(edit).toMatchObject({ assignment_id: null, reservation_end_time: '2026-07-03T21:00' }); expect( reservationSaveData( { ...form, reservation_end_time: '', end_date: '2026-07-03' }, { assignmentId: '', isEdit: true, withCheckInEnd: false } ).reservation_end_time ).toBe('2026-07-03'); expect( reservationSaveData( { ...form, reservation_time: '', reservation_end_time: '' }, { assignmentId: '', isEdit: true, withCheckInEnd: false } ) ).toMatchObject({ reservation_time: null, reservation_end_time: null, }); }); it('FE-PLANNER-RESFORM-013: a hotel sends its address, its stay and the check-in window only from the desktop', () => { const hotel = { ...EMPTY, title: 'Inn', type: 'hotel', reservation_time: '2026-07-01T10:00', location: 'ignored', hotel_address: 'Lane 2', hotel_start_day: 2, hotel_end_day: '', meta_check_in_time: '15:00', meta_check_in_end_time: '22:00', meta_check_out_time: '11:00', confirmation_number: 'C1', }; const desktop = reservationSaveData(hotel, { assignmentId: 7, isEdit: false, withCheckInEnd: true }); expect(desktop).toMatchObject({ reservation_time: null, reservation_end_time: null, location: 'Lane 2', assignment_id: null, accommodation_id: null, place_id: null, metadata: { check_in_time: '15:00', check_in_end_time: '22:00', check_out_time: '11:00' }, }); expect(desktop.create_accommodation).toEqual({ place_id: null, venue: { name: 'Inn', address: 'Lane 2' }, address: 'Lane 2', start_day_id: 2, end_day_id: 2, check_in: '15:00', check_in_end: '22:00', check_out: '11:00', confirmation: 'C1', }); const phone = reservationSaveData(hotel, { assignmentId: 7, isEdit: false, withCheckInEnd: false }); expect(phone.metadata).toEqual({ check_in_time: '15:00', check_out_time: '11:00' }); expect(phone.create_accommodation).not.toHaveProperty('check_in_end'); }); it('FE-PLANNER-RESFORM-014: a hotel with a picked place and a linked stay keeps both; one without days creates no stay', () => { const linked = reservationSaveData( { ...EMPTY, type: 'hotel', title: 'Inn', hotel_place_id: 4, accommodation_id: 9, hotel_end_day: 3 }, { assignmentId: 7, isEdit: true, withCheckInEnd: true } ); expect(linked).toMatchObject({ assignment_id: 7, accommodation_id: 9 }); expect(linked.create_accommodation).toMatchObject({ place_id: 4, venue: null, start_day_id: 3, end_day_id: 3 }); const noDays = reservationSaveData( { ...EMPTY, type: 'hotel' }, { assignmentId: '', isEdit: true, withCheckInEnd: true } ); expect(noDays).not.toHaveProperty('create_accommodation'); expect(noDays.metadata).toEqual({}); }); });