Add Booking module: model, create/read/cancel API, Filament resource (T4.1-T4.7)
- 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)
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
Permission::findOrCreate('manage_bookings', 'web');
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('the owner can cancel their own pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.status', BookingStatus::Cancelled->value);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('cancelling a confirmed booking surfaces as 422 and leaves it untouched', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertStatus(422);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('a non-owner without manage_bookings cannot cancel someone else\'s booking', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertForbidden();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::PendingPayment);
|
||||
});
|
||||
|
||||
test('staff with manage_bookings can cancel someone else\'s pending_payment booking', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('unauthenticated requests are rejected', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('404s for a booking that does not exist', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings/EVB-DOES-NOT-EXIST/cancel')
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
<?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']);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Policies\BookingPolicy;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
@@ -10,16 +11,37 @@ beforeEach(function () {
|
||||
}
|
||||
});
|
||||
|
||||
test('viewAny and view require the view_bookings permission', function () {
|
||||
test('viewAny is open to any authenticated user — listing is scoped to their own bookings at the query level', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$withPermission = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$withoutPermission = User::factory()->create();
|
||||
expect($policy->viewAny(User::factory()->create()))->toBeTrue();
|
||||
});
|
||||
|
||||
expect($policy->viewAny($withPermission))->toBeTrue()
|
||||
->and($policy->view($withPermission, null))->toBeTrue()
|
||||
->and($policy->viewAny($withoutPermission))->toBeFalse()
|
||||
->and($policy->view($withoutPermission, null))->toBeFalse();
|
||||
test('view allows the booking\'s owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
expect($policy->view($owner, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('view rejects a non-owner without the view_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->view($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('view allows a non-owner with the view_bookings permission (admin/support)', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$admin = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->view($admin, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('create is open to any authenticated user', function () {
|
||||
@@ -28,14 +50,31 @@ test('create is open to any authenticated user', function () {
|
||||
expect($policy->create(User::factory()->create()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel requires the manage_bookings permission', function () {
|
||||
test('cancel allows the booking\'s owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$withPermission = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$withoutPermission = User::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
expect($policy->cancel($withPermission, null))->toBeTrue()
|
||||
->and($policy->cancel($withoutPermission, null))->toBeFalse();
|
||||
expect($policy->cancel($owner, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel allows staff with the manage_bookings permission on someone else\'s booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->cancel($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel rejects a non-owner without the manage_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->cancel($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('refund requires the process_refunds permission', function () {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
Permission::findOrCreate('view_bookings', 'web');
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('index lists only the authenticated user\'s own bookings, latest first', function () {
|
||||
$mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
|
||||
$mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $mineNewer->id)
|
||||
->assertJsonPath('data.1.id', $mine->id);
|
||||
});
|
||||
|
||||
test('index rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/bookings')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('show allows the owner to view their own booking', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.id', $booking->id);
|
||||
});
|
||||
|
||||
test('show rejects a non-owner without the view_bookings permission', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('show allows an admin/support user (view_bookings permission) to view someone else\'s booking', function () {
|
||||
$admin = User::factory()->create();
|
||||
$admin->givePermissionTo('view_bookings');
|
||||
$adminToken = $admin->createToken('admin-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$adminToken}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.id', $booking->id);
|
||||
});
|
||||
|
||||
test('show rejects unauthenticated requests', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
$this->getJson("/api/v1/bookings/{$booking->id}")->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('show 404s for a booking that does not exist', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings/EVB-DOES-NOT-EXIST')
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['view_bookings', 'manage_bookings'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('can list bookings', function () {
|
||||
$bookings = Booking::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($bookings);
|
||||
});
|
||||
|
||||
test('can filter bookings by status', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('status', BookingStatus::PendingPayment->value)
|
||||
->assertCanSeeTableRecords([$pending])
|
||||
->assertCanNotSeeTableRecords([$confirmed]);
|
||||
});
|
||||
|
||||
test('the cancel action is visible and enabled for a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('cancel', $booking)
|
||||
->assertTableActionEnabled('cancel', $booking);
|
||||
});
|
||||
|
||||
test('the cancel action is visible but disabled for a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('cancel', $booking)
|
||||
->assertTableActionDisabled('cancel', $booking);
|
||||
});
|
||||
|
||||
test('the cancel action is hidden from a user without manage_bookings and not the owner', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('cancel', $booking);
|
||||
});
|
||||
|
||||
test('calling the cancel action cancels a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('cancel', $booking)
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('the view action is visible for a user with view_bookings', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('view', $booking);
|
||||
});
|
||||
|
||||
test('the view action is hidden from a non-owner without view_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('view', $booking);
|
||||
});
|
||||
|
||||
test('can view a booking\'s detail page', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'passenger_name' => 'Jane Doe',
|
||||
'passenger_phone' => '+959123456789',
|
||||
]);
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'passenger_count' => 2,
|
||||
'unit_price' => 9000,
|
||||
'line_total' => 18000,
|
||||
]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee($booking->booking_ref)
|
||||
->assertSee('Jane Doe')
|
||||
->assertSee('+959123456789')
|
||||
->assertSee($booking->route->company->name)
|
||||
->assertSee($booking->pickup_address)
|
||||
->assertSee($booking->dropoff_address);
|
||||
});
|
||||
|
||||
test('the assign driver action is visible for a confirmed booking and hidden otherwise', function () {
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('assignDriver', $confirmed)
|
||||
->assertTableActionHidden('assignDriver', $pending);
|
||||
});
|
||||
|
||||
test('the assign driver action is hidden from a user without manage_bookings', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('assignDriver', $booking);
|
||||
});
|
||||
|
||||
test('calling the assign driver action sets driver and car details on a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('assignDriver', $booking, data: [
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
$booking->refresh();
|
||||
|
||||
expect($booking->driver_name)->toBe('U Aung')
|
||||
->and($booking->driver_phone)->toBe('+959111222333')
|
||||
->and($booking->car_plate_number)->toBe('YGN-1234')
|
||||
->and($booking->car_model)->toBe('Tesla Model Y');
|
||||
});
|
||||
|
||||
test('the assign driver form requires driver_name, driver_phone, and car_plate_number', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('assignDriver', $booking, data: [
|
||||
'driver_name' => '',
|
||||
'driver_phone' => '',
|
||||
'car_plate_number' => '',
|
||||
])
|
||||
->assertHasTableActionErrors(['driver_name' => 'required', 'driver_phone' => 'required', 'car_plate_number' => 'required']);
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('the assign driver form is pre-filled with the booking\'s existing driver/car details', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->mountTableAction('assignDriver', $booking)
|
||||
->assertTableActionDataSet([
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the detail page also has assign driver and cancel actions, shared with the table', function () {
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $confirmed->getRouteKey()])
|
||||
->assertActionVisible('assignDriver')
|
||||
->assertActionVisible('cancel')
|
||||
->assertActionDisabled('cancel');
|
||||
});
|
||||
|
||||
test('calling assign driver from the detail page sets driver and car details', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->callAction('assignDriver', data: [
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('the detail page\'s assign driver action is hidden for a pending_payment booking', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $pending->getRouteKey()])
|
||||
->assertActionHidden('assignDriver')
|
||||
->assertActionEnabled('cancel');
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
test('a booking belongs to a route and a time slot', function () {
|
||||
$route = EvRoute::factory()->create();
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
$booking = Booking::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'departure_time_slot_id' => $timeSlot->id,
|
||||
]);
|
||||
|
||||
expect($booking->route)->toBeInstanceOf(EvRoute::class)
|
||||
->and($booking->route->is($route))->toBeTrue()
|
||||
->and($booking->timeSlot)->toBeInstanceOf(DepartureTimeSlot::class)
|
||||
->and($booking->timeSlot->is($timeSlot))->toBeTrue();
|
||||
});
|
||||
|
||||
test('booking_ref is unique', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'EVB-DUPLICATE']);
|
||||
|
||||
expect(fn () => Booking::factory()->create(['booking_ref' => 'EVB-DUPLICATE']))
|
||||
->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('status and created_by_channel cast to their enums', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'created_by_channel' => BookingChannel::Android,
|
||||
]);
|
||||
|
||||
expect($booking->status)->toBe(BookingStatus::Confirmed)
|
||||
->and($booking->created_by_channel)->toBe(BookingChannel::Android);
|
||||
});
|
||||
|
||||
test('a booking defaults to pending_payment', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
expect($booking->status)->toBe(BookingStatus::PendingPayment);
|
||||
});
|
||||
|
||||
test('a booking can have multiple vehicle option lines, e.g. front seat and back seat together', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::FrontSeat,
|
||||
'passenger_count' => 1,
|
||||
'unit_price' => 12000,
|
||||
'line_total' => 12000,
|
||||
]);
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'passenger_count' => 2,
|
||||
'unit_price' => 9000,
|
||||
'line_total' => 18000,
|
||||
]);
|
||||
|
||||
expect($booking->vehicleOptions)->toHaveCount(2)
|
||||
->and($booking->vehicleOptions->pluck('vehicle_option')->map(fn ($option) => $option->value)->sort()->values()->all())
|
||||
->toEqual(['back_seat', 'front_seat']);
|
||||
});
|
||||
|
||||
test('a vehicle option line cannot be duplicated on the same booking', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
]);
|
||||
|
||||
expect(fn () => BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('price is snapshotted onto the booking and does not change when RoutePricing is edited later', function () {
|
||||
$route = EvRoute::factory()->create();
|
||||
|
||||
$pricing = RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'price' => 15000,
|
||||
]);
|
||||
|
||||
$booking = Booking::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'price' => $pricing->price,
|
||||
]);
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'unit_price' => $pricing->price,
|
||||
'line_total' => $pricing->price,
|
||||
]);
|
||||
|
||||
$pricing->update(['price' => 25000]);
|
||||
|
||||
expect($booking->refresh()->price)->toEqual('15000.00')
|
||||
->and($booking->vehicleOptions()->first()->unit_price)->toEqual('15000.00')
|
||||
->and($pricing->refresh()->price)->toEqual('25000.00');
|
||||
});
|
||||
|
||||
test('a booking can have a user or be guest-checked-out via mini app openid', function () {
|
||||
$guestBooking = Booking::factory()->create([
|
||||
'user_id' => null,
|
||||
'openid' => 'mini-app-openid-123',
|
||||
]);
|
||||
|
||||
expect($guestBooking->user_id)->toBeNull()
|
||||
->and($guestBooking->openid)->toBe('mini-app-openid-123');
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Booking\Actions\CreateBookingAction;
|
||||
use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\BookingCreated;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function makeBookableRoute(array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create();
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
foreach ($pricedOptions as [$vehicleOption, $price]) {
|
||||
RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => $vehicleOption,
|
||||
'price' => $price,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$route, $timeSlot];
|
||||
}
|
||||
|
||||
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData
|
||||
{
|
||||
return new CreateBookingData(
|
||||
evRouteId: $route->id,
|
||||
departureTimeSlotId: $timeSlot->id,
|
||||
travelDate: now()->addDay()->toDateString(),
|
||||
selections: $selections,
|
||||
passengerName: 'Jane Doe',
|
||||
passengerPhone: '+959123456789',
|
||||
pickupAddress: '123 Pickup St',
|
||||
dropoffAddress: '456 Dropoff Ave',
|
||||
createdByChannel: BookingChannel::MiniApp,
|
||||
openid: 'mini-app-openid-123',
|
||||
);
|
||||
}
|
||||
|
||||
test('it persists a pending_payment booking with the price snapshotted from PricingService', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
|
||||
);
|
||||
|
||||
expect($booking->exists)->toBeTrue()
|
||||
->and($booking->booking_ref)->toBe('EVB-AAAAA1')
|
||||
->and($booking->status)->toBe(BookingStatus::PendingPayment)
|
||||
->and($booking->price)->toEqual('15000.00')
|
||||
->and($booking->ev_route_id)->toBe($route->id)
|
||||
->and($booking->departure_time_slot_id)->toBe($timeSlot->id)
|
||||
->and($booking->openid)->toBe('mini-app-openid-123')
|
||||
->and($booking->vehicleOptions)->toHaveCount(1)
|
||||
->and($booking->vehicleOptions->first()->vehicle_option)->toBe(VehicleOption::BackSeat)
|
||||
->and($booking->vehicleOptions->first()->unit_price)->toEqual('15000.00');
|
||||
});
|
||||
|
||||
test('it books front seat and back seat together and sums the price across both lines', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([
|
||||
[VehicleOption::FrontSeat, '12000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [
|
||||
new VehicleSelectionData(VehicleOption::FrontSeat, 1),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 2),
|
||||
]));
|
||||
|
||||
expect($booking->price)->toEqual('30000.00') // 12000 + (9000 * 2)
|
||||
->and($booking->vehicleOptions)->toHaveCount(2);
|
||||
|
||||
$frontSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::FrontSeat);
|
||||
$backSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::BackSeat);
|
||||
|
||||
expect($frontSeatLine->passenger_count)->toBe(1)
|
||||
->and($frontSeatLine->line_total)->toEqual('12000.00')
|
||||
->and($backSeatLine->passenger_count)->toBe(2)
|
||||
->and($backSeatLine->line_total)->toEqual('18000.00');
|
||||
});
|
||||
|
||||
test('it dispatches BookingCreated', function () {
|
||||
Event::fake([BookingCreated::class]);
|
||||
config(['booking.whole_vehicle_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)])
|
||||
);
|
||||
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($booking));
|
||||
});
|
||||
|
||||
test('it rejects a disabled vehicle option before touching the database', function () {
|
||||
config(['booking.whole_vehicle_enabled' => false]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)])
|
||||
))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('it rejects mixing whole vehicle with another option before touching the database', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([
|
||||
[VehicleOption::WholeVehicle, '30000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [
|
||||
new VehicleSelectionData(VehicleOption::WholeVehicle),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat),
|
||||
])))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('each booking created gets a unique, sequential booking_ref', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$first = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]));
|
||||
$second = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]));
|
||||
|
||||
expect($first->booking_ref)->toBe('EVB-AAAAA1')
|
||||
->and($second->booking_ref)->toBe('EVB-AAAAA2');
|
||||
});
|
||||
Reference in New Issue
Block a user