token = User::factory()->create()->createToken('test-token')->plainTextToken; }); /** * @param array $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 $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']); }); test('created_by_channel defaults to kbz_miniapp when no Device-Type header is sent', function () { [$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.created_by_channel', BookingChannel::MiniApp->value); }); test('created_by_channel is taken from the Device-Type header', function (string $deviceType, BookingChannel $expected) { [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); $this->withHeader('Authorization', "Bearer {$this->token}") ->withHeader('Device-Type', $deviceType) ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ ['vehicle_option' => 'back_seat', 'passenger_count' => 1], ])) ->assertCreated() ->assertJsonPath('data.created_by_channel', $expected->value); })->with([ 'android' => ['android', BookingChannel::Android], 'ios' => ['ios', BookingChannel::Ios], 'web' => ['web', BookingChannel::Web], 'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp], ]); test('customer-supplied notes are stored and returned', function () { [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); $payload = bookingPayload($route, $timeSlot, [ ['vehicle_option' => 'back_seat', 'passenger_count' => 1], ]); $payload['notes'] = 'Please call before arriving.'; $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson('/api/v1/bookings', $payload) ->assertCreated() ->assertJsonPath('data.notes', 'Please call before arriving.'); expect(Booking::first()->notes)->toBe('Please call before arriving.'); }); test('notes is optional and defaults to null', function () { [$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.notes', null); }); test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) { [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); $this->withHeader('Authorization', "Bearer {$this->token}") ->withHeader('Device-Type', $deviceType) ->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [ ['vehicle_option' => 'back_seat', 'passenger_count' => 1], ])) ->assertCreated() ->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value); })->with([ 'agent' => ['agent'], 'admin' => ['admin'], 'unrecognized value' => ['smart-fridge'], ]); /** * Same company as $outbound, from/to swapped — the true reverse route. * * @param array $pricedOptions */ function reverseRouteAndSlot(EvRoute $outbound, array $pricedOptions): array { $route = EvRoute::factory()->create([ 'ev_company_id' => $outbound->ev_company_id, 'from_destination_id' => $outbound->to_destination_id, 'to_destination_id' => $outbound->from_destination_id, '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]; } test('round trip: creates two linked bookings, each priced against its own route', function () { config(['booking.back_seat_enabled' => true]); [$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); [$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]); $payload = bookingPayload($outboundRoute, $outboundSlot, [ ['vehicle_option' => 'back_seat', 'passenger_count' => 1], ]); $payload['is_round_trip'] = true; $payload['return_ev_route_id'] = $returnRoute->id; $payload['return_departure_time_slot_id'] = $returnSlot->id; $payload['return_travel_date'] = now()->addDays(3)->toDateString(); $payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]]; $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson('/api/v1/bookings', $payload) ->assertCreated() ->assertJsonPath('data.is_round_trip', true) ->assertJsonPath('data.is_return_leg', false) ->assertJsonPath('data.price', '9000.00') ->assertJsonPath('data.linked_booking.is_return_leg', true) ->assertJsonPath('data.linked_booking.route.id', $returnRoute->id) ->assertJsonPath('data.linked_booking.vehicle_options.0.vehicle_option', 'back_seat') ->assertJsonPath('data.linked_booking.vehicle_options.0.unit_price', '11000.00'); expect(Booking::count())->toBe(2); $return = Booking::where('is_return_leg', true)->firstOrFail(); expect($return->price)->toEqual('11000.00') ->and($return->ev_route_id)->toBe($returnRoute->id); }); test('round trip: a return route that is not the reverse of the outbound route surfaces as 422', function () { config(['booking.back_seat_enabled' => true]); [$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); [$unrelatedRoute, $unrelatedSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); $payload = bookingPayload($outboundRoute, $outboundSlot, [ ['vehicle_option' => 'back_seat', 'passenger_count' => 1], ]); $payload['is_round_trip'] = true; $payload['return_ev_route_id'] = $unrelatedRoute->id; $payload['return_departure_time_slot_id'] = $unrelatedSlot->id; $payload['return_travel_date'] = now()->addDays(3)->toDateString(); $payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]]; $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson('/api/v1/bookings', $payload) ->assertStatus(422); expect(Booking::count())->toBe(0); }); test('round trip: return fields are required when is_round_trip is true', function () { [$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]); $payload = bookingPayload($route, $timeSlot, [ ['vehicle_option' => 'back_seat', 'passenger_count' => 1], ]); $payload['is_round_trip'] = true; $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson('/api/v1/bookings', $payload) ->assertStatus(422) ->assertJsonValidationErrors([ 'return_ev_route_id', 'return_departure_time_slot_id', 'return_travel_date', 'return_selections', ]); }); test('round trip: return_travel_date before travel_date is rejected', function () { config(['booking.back_seat_enabled' => true]); [$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]); [$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]); $payload = bookingPayload($outboundRoute, $outboundSlot, [ ['vehicle_option' => 'back_seat', 'passenger_count' => 1], ]); $payload['is_round_trip'] = true; $payload['return_ev_route_id'] = $returnRoute->id; $payload['return_departure_time_slot_id'] = $returnSlot->id; $payload['return_travel_date'] = now()->toDateString(); // before travel_date (addDay()) $payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]]; $this->withHeader('Authorization', "Bearer {$this->token}") ->postJson('/api/v1/bookings', $payload) ->assertStatus(422) ->assertJsonValidationErrors(['return_travel_date']); });