Files
famous-ly4-ev/app-modules/booking/tests/Feature/BookingCreateApiTest.php
T
Nyan Lin Paing 8d74ac74cd
PHP Tests / php-tests (push) Failing after 9m1s
fix dashboard and apis
2026-08-16 23:50:39 +07:00

224 lines
8.6 KiB
PHP

<?php
use App\Models\User;
use Modules\Booking\Enums\BookingChannel;
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']);
});
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('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'],
]);