5b68f4fa38
- Booking model with booking_vehicle_options line items (supports mixing vehicle options like front_seat + back_seat in one booking), price snapshot, status machine, and driver/car assignment fields - BookingService: front-seat max, disabled-option toggles, duplicate-option and whole-vehicle-exclusivity guards - CreateBookingAction, CancelBookingAction, AssignDriverAction - BookingRefGenerator: sequential EVB-AAAAA1-style refs via row lock - POST/GET/cancel booking API endpoints (Sanctum, ownership + admin policy) - BookingPlugin + Filament BookingResource: list, detail view, Cancel and Assign Driver actions (shared between table and detail page) - domain.md updated for multi-vehicle-option bookings (§2) and driver/ vehicle assignment (§5a)
179 lines
6.6 KiB
PHP
179 lines
6.6 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use Modules\Booking\Enums\BookingStatus;
|
|
use Modules\Booking\Models\Booking;
|
|
use Modules\Catalog\Models\DepartureTimeSlot;
|
|
use Modules\Routing\Models\EvRoute;
|
|
use Modules\Routing\Models\RoutePricing;
|
|
use Modules\Shared\Enums\VehicleOption;
|
|
|
|
beforeEach(function () {
|
|
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
|
|
});
|
|
|
|
/**
|
|
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
|
*/
|
|
function bookableRouteAndSlot(array $pricedOptions): array
|
|
{
|
|
$route = EvRoute::factory()->create(['is_active' => true]);
|
|
$timeSlot = DepartureTimeSlot::factory()->create();
|
|
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
|
|
|
|
foreach ($pricedOptions as [$vehicleOption, $price]) {
|
|
RoutePricing::factory()->create([
|
|
'ev_route_id' => $route->id,
|
|
'vehicle_option' => $vehicleOption,
|
|
'price' => $price,
|
|
]);
|
|
}
|
|
|
|
return [$route, $timeSlot];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{vehicle_option: string, passenger_count: int}> $selections
|
|
*/
|
|
function bookingPayload(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): array
|
|
{
|
|
return [
|
|
'ev_route_id' => $route->id,
|
|
'departure_time_slot_id' => $timeSlot->id,
|
|
'travel_date' => now()->addDay()->toDateString(),
|
|
'selections' => $selections,
|
|
'passenger_name' => 'Jane Doe',
|
|
'passenger_phone' => '+959123456789',
|
|
'pickup_address' => '123 Pickup St',
|
|
'dropoff_address' => '456 Dropoff Ave',
|
|
];
|
|
}
|
|
|
|
test('happy path: it creates a pending_payment booking with a snapshotted price', function () {
|
|
config(['booking.back_seat_enabled' => true]);
|
|
|
|
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
|
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
|
]))
|
|
->assertCreated()
|
|
->assertJsonPath('data.status', BookingStatus::PendingPayment->value)
|
|
->assertJsonPath('data.price', '15000.00')
|
|
->assertJsonPath('data.vehicle_options.0.vehicle_option', VehicleOption::BackSeat->value)
|
|
->assertJsonPath('data.route.id', $route->id)
|
|
->assertJsonPath('data.time_slot.id', $timeSlot->id);
|
|
|
|
expect(Booking::count())->toBe(1);
|
|
});
|
|
|
|
test('happy path: front seat and back seat can be booked together', function () {
|
|
config(['booking.back_seat_enabled' => true]);
|
|
|
|
[$route, $timeSlot] = bookableRouteAndSlot([
|
|
[VehicleOption::FrontSeat, '12000.00'],
|
|
[VehicleOption::BackSeat, '9000.00'],
|
|
]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
|
['vehicle_option' => 'front_seat', 'passenger_count' => 1],
|
|
['vehicle_option' => 'back_seat', 'passenger_count' => 2],
|
|
]))
|
|
->assertCreated()
|
|
->assertJsonPath('data.price', '30000.00')
|
|
->assertJsonCount(2, 'data.vehicle_options');
|
|
});
|
|
|
|
test('front-seat-limit rejection surfaces as 422', function () {
|
|
config(['booking.front_seat_max_per_booking' => 1]);
|
|
|
|
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::FrontSeat, '12000.00']]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
|
['vehicle_option' => 'front_seat', 'passenger_count' => 2],
|
|
]))
|
|
->assertStatus(422)
|
|
->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.');
|
|
|
|
expect(Booking::count())->toBe(0);
|
|
});
|
|
|
|
test('disabled-vehicle-option rejection surfaces as 422', function () {
|
|
config(['booking.whole_vehicle_enabled' => false]);
|
|
|
|
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::WholeVehicle, '30000.00']]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
|
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1],
|
|
]))
|
|
->assertStatus(422)
|
|
->assertJsonPath('message', 'Vehicle option [whole_vehicle] is not currently available for booking.');
|
|
|
|
expect(Booking::count())->toBe(0);
|
|
});
|
|
|
|
test('mixing whole vehicle with another option surfaces as 422', function () {
|
|
config([
|
|
'booking.back_seat_enabled' => true,
|
|
'booking.whole_vehicle_enabled' => true,
|
|
]);
|
|
|
|
[$route, $timeSlot] = bookableRouteAndSlot([
|
|
[VehicleOption::WholeVehicle, '30000.00'],
|
|
[VehicleOption::BackSeat, '9000.00'],
|
|
]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
|
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1],
|
|
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
|
]))
|
|
->assertStatus(422);
|
|
|
|
expect(Booking::count())->toBe(0);
|
|
});
|
|
|
|
test('unauthenticated requests are rejected', function () {
|
|
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
|
|
|
$this->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
|
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
|
]))->assertUnauthorized();
|
|
});
|
|
|
|
test('shape validation rejects a missing required field', function () {
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings', [])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors([
|
|
'ev_route_id', 'departure_time_slot_id', 'travel_date', 'selections',
|
|
'passenger_name', 'passenger_phone', 'pickup_address', 'dropoff_address',
|
|
]);
|
|
});
|
|
|
|
test('shape validation rejects an invalid vehicle_option value', function () {
|
|
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
|
|
|
$payload = bookingPayload($route, $timeSlot, [
|
|
['vehicle_option' => 'business_class', 'passenger_count' => 1],
|
|
]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings', $payload)
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['selections.0.vehicle_option']);
|
|
});
|
|
|
|
test('shape validation rejects an empty selections array', function () {
|
|
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
|
|
|
$this->withHeader('Authorization', "Bearer {$this->token}")
|
|
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, []))
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['selections']);
|
|
});
|