add notes/remark and refactor round-trip
PHP Tests / php-tests (push) Has been cancelled

This commit is contained in:
Nyan Lin Paing
2026-08-22 21:43:41 +07:00
parent 894352b43f
commit fa908cdcaf
46 changed files with 1679 additions and 182 deletions
@@ -206,6 +206,33 @@ test('created_by_channel is taken from the Device-Type header', function (string
'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']]);
@@ -221,3 +248,122 @@ test('a Device-Type header cannot spoof the agent or admin channel', function (s
'admin' => ['admin'],
'unrecognized value' => ['smart-fridge'],
]);
/**
* Same company as $outbound, from/to swapped the true reverse route.
*
* @param array<int, array{0: VehicleOption, 1: string}> $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']);
});
@@ -325,6 +325,45 @@ test('restoring a deleted booking brings it back', function () {
expect(Booking::find($booking->id)->trashed())->toBeFalse();
});
test('the remark action is visible for a user with manage_bookings', function () {
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->assertTableActionVisible('setRemark', $booking);
});
test('the remark action is hidden from a user without manage_bookings', function () {
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
$this->actingAs($viewer);
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->assertTableActionHidden('setRemark', $booking);
});
test('calling the remark action sets the staff remark on a booking', function () {
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->callTableAction('setRemark', $booking, data: [
'remark' => 'Passenger requested a child seat.',
])
->assertNotified();
expect($booking->refresh()->remark)->toBe('Passenger requested a child seat.');
});
test('the remark form is pre-filled with the booking\'s existing remark', function () {
$booking = Booking::factory()->create(['remark' => 'Existing remark.']);
Livewire::test(ListBookings::class)
->mountTableAction('setRemark', $booking)
->assertTableActionDataSet([
'remark' => 'Existing remark.',
]);
});
test('the restore action is hidden from a user without manage_bookings', function () {
$stranger = User::factory()->create();
$booking = Booking::factory()->create();
@@ -7,9 +7,11 @@ use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\BookingCreated;
use Modules\Booking\Exceptions\InvalidReturnRouteException;
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Exceptions\RoutePricingNotFoundException;
use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption;
@@ -33,7 +35,7 @@ function makeBookableRoute(array $pricedOptions): array
return [$route, $timeSlot];
}
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections, array $roundTrip = []): CreateBookingData
{
return new CreateBookingData(
evRouteId: $route->id,
@@ -46,9 +48,38 @@ function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selecti
dropoffAddress: '456 Dropoff Ave',
createdByChannel: BookingChannel::MiniApp,
openid: 'mini-app-openid-123',
returnEvRouteId: $roundTrip['route']->id ?? null,
returnDepartureTimeSlotId: $roundTrip['timeSlot']->id ?? null,
returnTravelDate: $roundTrip['travelDate'] ?? (isset($roundTrip['route']) ? now()->addDays(3)->toDateString() : null),
returnSelections: $roundTrip['selections'] ?? null,
);
}
/**
* Same company as $outbound, from/to swapped the true reverse route.
*
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
*/
function makeReverseRoute(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,
]);
$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];
}
test('it persists a pending_payment booking with the price snapshotted from PricingService', function () {
config(['booking.back_seat_enabled' => true]);
@@ -150,3 +181,144 @@ test('each booking created gets a unique, sequential booking_ref', function () {
expect($first->booking_ref)->toBe('EVB-AAAAA1')
->and($second->booking_ref)->toBe('EVB-AAAAA2');
});
test('a plain one-way booking has no linked leg', function () {
config(['booking.back_seat_enabled' => true]);
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
$booking = app(CreateBookingAction::class)->handle(
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
);
expect($booking->linked_booking_id)->toBeNull()
->and($booking->is_round_trip)->toBeFalse()
->and($booking->is_return_leg)->toBeFalse()
->and(Booking::count())->toBe(1);
});
test('a round trip creates two bookings linked bidirectionally, each priced independently', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
$outbound = app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
));
expect(Booking::count())->toBe(2)
->and($outbound->is_return_leg)->toBeFalse()
->and($outbound->is_round_trip)->toBeTrue()
->and($outbound->price)->toEqual('9000.00');
$return = $outbound->linkedBooking;
expect($return)->not->toBeNull()
->and($return->is_return_leg)->toBeTrue()
->and($return->is_round_trip)->toBeTrue()
->and($return->linked_booking_id)->toBe($outbound->id)
->and($return->ev_route_id)->toBe($returnRoute->id)
->and($return->departure_time_slot_id)->toBe($returnSlot->id)
->and($return->price)->toEqual('11000.00');
});
test('a round trip dispatches BookingCreated for both legs', function () {
Event::fake([BookingCreated::class]);
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
$outbound = app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
));
Event::assertDispatched(BookingCreated::class, 2);
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound));
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound->linkedBooking));
});
test('it rejects a return route that is not the reverse of the outbound route', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
// Unrelated route — not from/to swapped.
[$unrelatedRoute, $unrelatedSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $unrelatedRoute,
'timeSlot' => $unrelatedSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
)))->toThrow(InvalidReturnRouteException::class);
// The whole transaction rolls back — no orphan outbound-only booking.
expect(Booking::count())->toBe(0);
});
test('return leg selections are validated independently of the outbound leg', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::FrontSeat, '12000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::FrontSeat, '12000.00']]);
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::FrontSeat, 1)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
// Front seat max per booking is 1 — this should fail validation
// for the return leg even though the outbound leg is valid.
'selections' => [new VehicleSelectionData(VehicleOption::FrontSeat, 2)],
],
)))->toThrow(InvalidVehicleSelectionException::class);
expect(Booking::count())->toBe(0);
});
test('a failed return-leg price lookup rolls back the outbound leg too', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
// Return route exists (true reverse) but has no pricing rows at all.
$returnRoute = EvRoute::factory()->create([
'ev_company_id' => $outboundRoute->ev_company_id,
'from_destination_id' => $outboundRoute->to_destination_id,
'to_destination_id' => $outboundRoute->from_destination_id,
]);
$returnSlot = DepartureTimeSlot::factory()->create();
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
)))->toThrow(RoutePricingNotFoundException::class);
expect(Booking::count())->toBe(0);
});
@@ -74,3 +74,25 @@ test('reassigning a different driver on a still-confirmed booking overwrites the
expect($booking->refresh()->driver_name)->toBe('Daw Hla')
->and($booking->car_plate_number)->toBe('YGN-5678');
});
test('a round trip: assigning a driver to the outbound leg does not touch the linked return leg', function () {
$outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
$return = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
(new AssignDriverAction)->handle($outbound, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
));
// Each leg has its own independent driver/vehicle slot — the return leg
// can get a completely different (or no-yet-assigned) vehicle, per the
// "next available vehicle" business rule (domain.md §2b).
expect($outbound->refresh()->driver_name)->toBe('U Aung')
->and($return->refresh()->driver_name)->toBeNull();
});