Files
TREK/client/src/components/Planner/reservationFormModel.test.ts
T

385 lines
15 KiB
TypeScript

// 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<Reservation>);
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<ReservationFields>) => ({ ...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({});
});
});