diff --git a/backend/controllers/parking/bookings.controller.ts b/backend/controllers/parking/bookings.controller.ts index 57bbd82e..f315596f 100644 --- a/backend/controllers/parking/bookings.controller.ts +++ b/backend/controllers/parking/bookings.controller.ts @@ -214,8 +214,8 @@ class ParkingBookingsController { expected_checkin, expected_checkout, total_amount: total_amount ? parseFloat(total_amount) : undefined, - source: booking_source, - external_id: external_booking_id, + booking_source, + external_booking_id, notes, }) diff --git a/backend/tests/parking/booking-source.test.ts b/backend/tests/parking/booking-source.test.ts new file mode 100644 index 00000000..9cd74a80 --- /dev/null +++ b/backend/tests/parking/booking-source.test.ts @@ -0,0 +1,87 @@ +// tests/parking/booking-source.test.ts +// Regression tests: creating a booking always stored booking_source 'direct' +// and dropped the external reference (the controller handed them to Zod under +// other names), and the forms offered phone, email and walk-in, which the +// parking_bookings.booking_source ENUM does not hold. Decided on 2026-10-11: +// the forms offer the ENUM values only. + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Request, Response } from 'express' +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +vi.mock('../../repositories/parking/bookings.repository.js', () => ({ + default: { create: vi.fn(async () => ({ id: 1 })) }, +})) + +const { default: controller } = await import('../../controllers/parking/bookings.controller.js') +const { default: repository } = await import('../../repositories/parking/bookings.repository.js') +const { BOOKING_SOURCES, createBookingSchema, updateBookingBodySchema } = + await import('../../validations/parking/booking-validation.js') + +const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10) +const body = { + spot_number: 1, + level_code: '-3', + vehicle_id: 1, + expected_checkin: `${tomorrow}T10:00:00`, + expected_checkout: `${tomorrow}T18:00:00`, +} + +function create(extra: object) { + const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() } + const req = { user: { id: 'u1' }, body: { ...body, ...extra } } as unknown as Request + return controller.createBooking(req, res as unknown as Response).then(() => res) +} + +describe('booking source', () => { + beforeEach(() => vi.clearAllMocks()) + + it('create keeps the source and the external reference the form sends', async () => { + const res = await create({ booking_source: 'airbnb', external_booking_id: 'HM-123' }) + + expect(res.status).toHaveBeenCalledWith(201) + expect(vi.mocked(repository.create).mock.calls[0][0]).toMatchObject({ + booking_source: 'airbnb', + external_booking_id: 'HM-123', + }) + }) + + it('create defaults to direct and refuses a value outside the ENUM', async () => { + await create({}) + expect(vi.mocked(repository.create).mock.calls[0][0]).toMatchObject({ + booking_source: 'direct', + }) + + const res = await create({ booking_source: 'phone' }) + expect(res.status).toHaveBeenCalledWith(400) + expect(repository.create).toHaveBeenCalledOnce() + }) + + it('both schemas take exactly the ENUM values', () => { + for (const source of BOOKING_SOURCES) { + expect(createBookingSchema.safeParse({ ...body, booking_source: source }).success).toBe(true) + expect(updateBookingBodySchema.safeParse({ booking_source: source }).success).toBe(true) + } + for (const source of ['phone', 'email', 'walkin', 'booking', 'other']) { + expect(updateBookingBodySchema.safeParse({ booking_source: source }).success).toBe(false) + } + }) + + it('the forms offer the ENUM values only', () => { + const frontend = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'frontend') + for (const file of [ + ['booking', 'BookingWizard', 'steps', 'DateOnlyStep.tsx'], + ['booking', 'BookingWizard', 'steps', 'DateSpotStep.tsx'], + ['parking', 'bookings', 'EditBookingModal.tsx'], + ]) { + const src = readFileSync(join(frontend, 'app', 'components', ...file), 'utf8') + const name = file.at(-1) + for (const source of BOOKING_SOURCES) { + expect(src, `${name} offers ${source}`).toMatch(new RegExp(`value(=|: )["']${source}["']`)) + } + expect(src, name).not.toMatch(/value(=|: )["'](booking|phone|email|walkin|other)["']/) + } + }) +}) diff --git a/backend/validations/parking/booking-validation.ts b/backend/validations/parking/booking-validation.ts index d7b17b68..d6be5e1f 100644 --- a/backend/validations/parking/booking-validation.ts +++ b/backend/validations/parking/booking-validation.ts @@ -8,6 +8,16 @@ import timezone from 'dayjs/plugin/timezone.js' dayjs.extend(utc) dayjs.extend(timezone) +// The values of the parking_bookings.booking_source ENUM +export const BOOKING_SOURCES = [ + 'direct', + 'booking_com', + 'expedia', + 'airbnb', + 'agency_other', +] as const +const bookingSource = z.enum(BOOKING_SOURCES) + const dateTimeSchema = z .string() .min(1, 'La fecha es requerida') @@ -22,8 +32,8 @@ export const createBookingSchema = z expected_checkin: dateTimeSchema, expected_checkout: dateTimeSchema, total_amount: z.number().positive().optional(), - booking_source: z.string().optional().default('direct'), - external_booking_id: z.string().optional(), + booking_source: bookingSource.optional().default('direct'), + external_booking_id: z.string().max(64).optional(), notes: z.string().max(500).optional(), }) .refine( @@ -53,8 +63,7 @@ export const createBookingSchema = z // DECIMAL(10,2) and never negative, payment_method is an ENUM, references and // external ids are VARCHAR(100)/(64). Before, any value reached MySQL (a // negative payment was stored, a bad ENUM or text ended in a 500). -// booking_source is left as it is: its allowed values are still to be decided -// (docs/TODO.md) +// booking_source is one of the ENUM values const amount = z.union([z.null(), z.coerce.number().finite().min(0).max(99_999_999.99)]) export const updateBookingBodySchema = z.object({ @@ -64,7 +73,7 @@ export const updateBookingBodySchema = z.object({ level_code: z.enum(['-2', '-3']).optional(), vehicle_id: z.union([z.null(), z.coerce.number().int().positive()]).optional(), total_amount: amount.optional(), - booking_source: z.string().optional(), + booking_source: bookingSource.optional(), external_booking_id: z.string().max(64).nullable().optional(), notes: z.string().max(16000).nullable().optional(), payment_amount: amount.optional(), diff --git a/docs/TODO.md b/docs/TODO.md index 473ba85c..1768debc 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -152,16 +152,6 @@ preview. - [ ] **Parking: `GET /vehicles` vuelca todos los vehículos** (matrícula y titular) sin paginar ni filtrar. _Según la revisión `security` L3 del 2026-09-28 (fichero y línea en el informe); no repasado por mí._ -- [ ] **Parking: el origen y la referencia externa se pierden al crear** — el - frontend (`useBookingWizard.ts`) envía `source` - y `external_id`; el controlador lee `booking_source` y - `external_booking_id` y además los pasa a Zod con los nombres cambiados: - siempre queda `direct`. **Decidir antes de arreglar:** los formularios - ofrecen `phone`, `email`, `walkin` y `booking` (`DateOnlyStep.tsx`), que el - `ENUM` de `parking_bookings.booking_source` no admite (`direct`, - `booking_com`, `expedia`, `airbnb`, `agency_other`); conservar el origen - sin ampliar el `ENUM` o mapear esos valores haría fallar la reserva. - _Comprobado por mí el 2026-09-28; detalle del 2026-10-02._ - [ ] **Mensajería: crear una conversación no va en transacción** — el formato y los repetidos de `participant_ids` y `user_ids` ya se validan, pero un usuario inexistente o inactivo deja la conversación creada a medias. diff --git a/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts b/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts index 79fd03cb..04165774 100644 --- a/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts +++ b/frontend/app/components/booking/BookingWizard/hooks/useBookingWizard.ts @@ -376,9 +376,9 @@ export const useBookingWizard = ({ vehicle_id: state.vehicleId ?? undefined, expected_checkin: checkinISO, expected_checkout: checkoutISO, - source: state.reservationData.booking_source || 'direct', + booking_source: state.reservationData.booking_source || 'direct', total_amount: totalAmount, - external_id: state.reservationData.external_booking_id || undefined, + external_booking_id: state.reservationData.external_booking_id || undefined, notes: state.reservationData.notes || undefined, } diff --git a/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx b/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx index 91db08b3..e6eff228 100644 --- a/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx +++ b/frontend/app/components/booking/BookingWizard/steps/DateOnlyStep.tsx @@ -165,11 +165,10 @@ export default function DateOnlyStep({ state, actions }: DateOnlyStepProps) { className={selectClassName} > - + - - - + + diff --git a/frontend/app/components/parking/bookings/EditBookingModal.tsx b/frontend/app/components/parking/bookings/EditBookingModal.tsx index bde6115a..47382db1 100644 --- a/frontend/app/components/parking/bookings/EditBookingModal.tsx +++ b/frontend/app/components/parking/bookings/EditBookingModal.tsx @@ -306,10 +306,7 @@ export function EditBookingModal({ { value: 'booking_com', label: t('bookingSources.booking_com') }, { value: 'expedia', label: t('bookingSources.expedia') }, { value: 'airbnb', label: t('bookingSources.airbnb') }, - { value: 'phone', label: t('bookingSources.phone') }, - { value: 'email', label: t('bookingSources.email') }, - { value: 'walkin', label: t('bookingSources.walkin') }, - { value: 'other', label: t('bookingSources.other') }, + { value: 'agency_other', label: t('bookingSources.agency_other') }, ]} label={t('editModal.bookingSource')} size="md" diff --git a/frontend/app/lib/parking/types.ts b/frontend/app/lib/parking/types.ts index aeec8d37..be8542e9 100644 --- a/frontend/app/lib/parking/types.ts +++ b/frontend/app/lib/parking/types.ts @@ -318,8 +318,8 @@ export interface CreateBookingDto { expected_checkin: string expected_checkout: string total_amount?: number - source?: string - external_id?: string + booking_source?: string + external_booking_id?: string notes?: string } diff --git a/frontend/messages/en/booking.json b/frontend/messages/en/booking.json index 0ae703c6..f9e69896 100644 --- a/frontend/messages/en/booking.json +++ b/frontend/messages/en/booking.json @@ -84,10 +84,7 @@ "booking_com": "Booking.com", "airbnb": "Airbnb", "expedia": "Expedia", - "agency_other": "Other Agency", - "phone": "Phone", - "email": "Email", - "walkin": "Walk-in" + "agency_other": "Other agency" }, "confirmation": { "title": "Confirm Booking", diff --git a/frontend/messages/en/parking.json b/frontend/messages/en/parking.json index d88e5dd9..a398a634 100644 --- a/frontend/messages/en/parking.json +++ b/frontend/messages/en/parking.json @@ -407,10 +407,7 @@ "booking_com": "Booking.com", "expedia": "Expedia", "airbnb": "Airbnb", - "phone": "Phone", - "email": "Email", - "walkin": "Walk-in", - "other": "Other" + "agency_other": "Other agency" }, "statusPageNav": { "title": "Parking Control", diff --git a/frontend/messages/es/booking.json b/frontend/messages/es/booking.json index 722cc50c..833f573c 100644 --- a/frontend/messages/es/booking.json +++ b/frontend/messages/es/booking.json @@ -84,10 +84,7 @@ "booking_com": "Booking.com", "airbnb": "Airbnb", "expedia": "Expedia", - "agency_other": "Otra Agencia", - "phone": "Teléfono", - "email": "Email", - "walkin": "Walk-in" + "agency_other": "Otra agencia" }, "confirmation": { "title": "Confirmar Reserva", diff --git a/frontend/messages/es/parking.json b/frontend/messages/es/parking.json index 8afbc368..c34e27be 100644 --- a/frontend/messages/es/parking.json +++ b/frontend/messages/es/parking.json @@ -407,10 +407,7 @@ "booking_com": "Booking.com", "expedia": "Expedia", "airbnb": "Airbnb", - "phone": "Teléfono", - "email": "Email", - "walkin": "Walk-in", - "other": "Otro" + "agency_other": "Otra agencia" }, "statusPageNav": { "title": "Control de Parking",