Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/controllers/parking/bookings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand Down
87 changes: 87 additions & 0 deletions backend/tests/parking/booking-source.test.ts
Original file line number Diff line number Diff line change
@@ -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)["']/)
}
})
})
19 changes: 14 additions & 5 deletions backend/validations/parking/booking-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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(
Expand Down Expand Up @@ -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({
Expand All @@ -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(),
Expand Down
10 changes: 0 additions & 10 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,10 @@ export default function DateOnlyStep({ state, actions }: DateOnlyStepProps) {
className={selectClassName}
>
<option value="direct">{t('sources.direct')}</option>
<option value="booking">{t('sources.booking_com')}</option>
<option value="booking_com">{t('sources.booking_com')}</option>
<option value="airbnb">{t('sources.airbnb')}</option>
<option value="phone">{t('sources.phone')}</option>
<option value="email">{t('sources.email')}</option>
<option value="walkin">{t('sources.walkin')}</option>
<option value="expedia">{t('sources.expedia')}</option>
<option value="agency_other">{t('sources.agency_other')}</option>
</select>
</FormField>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/lib/parking/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
5 changes: 1 addition & 4 deletions frontend/messages/en/booking.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 1 addition & 4 deletions frontend/messages/en/parking.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 1 addition & 4 deletions frontend/messages/es/booking.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 1 addition & 4 deletions frontend/messages/es/parking.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading