Files
famous-ly4-ev/app-modules/booking/tests/Feature/BookingCancelApiTest.php
T
Nyan Lin Paing 37f9dc1905
PHP Tests / php-tests (push) Waiting to run
Only expose completed refunds in booking API responses
BookingController's eager load now constrains the refunds relation to
status completed only (a new eagerLoads() layering that onto the
existing EAGER_LOADS list, applied uniformly across
index/show/store/cancel). A pending or failed refund attempt isn't
customer-facing — staff still track those via Filament's Refunds
resource, which loads the relation unconstrained.
2026-09-03 21:48:22 +07:00

193 lines
8.2 KiB
PHP

<?php
use App\Models\User;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Contracts\PaymentGatewayInterface;
use Modules\Payment\Data\PaymentRequestData;
use Modules\Payment\Data\PaymentResultData;
use Modules\Payment\Data\RefundResultData;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Enums\RefundStatus;
use Modules\Payment\Factories\PaymentGatewayFactory;
use Modules\Payment\Models\Payment;
use Spatie\Permission\Models\Permission;
/**
* Never calls the real KBZ refund API in tests.
*/
class FakeCancelApiRefundGateway implements PaymentGatewayInterface
{
public function initiate(PaymentRequestData $data): PaymentResultData
{
throw new RuntimeException('not needed for this test');
}
public function verify(string $gatewayTransactionId): PaymentResultData
{
throw new RuntimeException('not needed for this test');
}
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
{
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
}
public function handleWebhook(array $payload): PaymentResultData
{
throw new RuntimeException('not needed for this test');
}
}
beforeEach(function () {
foreach (['manage_bookings', 'process_refunds'] as $permission) {
Permission::findOrCreate($permission, 'web');
}
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelApiRefundGateway::class);
$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('the owner cannot cancel their own confirmed booking without process_refunds', 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")
->assertForbidden();
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
});
test('staff with process_refunds can cancel a confirmed booking, which refunds it in full', function () {
$staff = User::factory()->create()->givePermissionTo('process_refunds');
$staffToken = $staff->createToken('staff-token')->plainTextToken;
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]);
Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-CANCEL-API-1',
]);
$this->withHeader('Authorization', "Bearer {$staffToken}")
->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 returns the refund it created', function () {
$staff = User::factory()->create()->givePermissionTo('process_refunds');
$staffToken = $staff->createToken('staff-token')->plainTextToken;
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]);
Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-CANCEL-API-2',
]);
$response = $this->withHeader('Authorization', "Bearer {$staffToken}")
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
->assertSuccessful()
->assertJsonPath('data.status', BookingStatus::Cancelled->value)
->assertJsonCount(1, 'data.refunds')
->assertJsonPath('data.refunds.0.status', RefundStatus::Completed->value)
->assertJsonPath('data.refunds.0.amount', '15000.00')
->assertJsonPath('data.refunds.0.reason', 'Booking cancellation');
expect($response->json('data.refunds.0.completed_at'))->not->toBeNull();
});
test('a pending or failed refund is not returned — only completed refunds are exposed', function () {
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Cancelled, 'price' => 15000]);
$payment = Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
]);
$booking->refunds()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Pending, 'amount' => 15000, 'reason' => 'Booking cancellation']);
$booking->refunds()->create(['payment_id' => $payment->id, 'status' => RefundStatus::Failed, 'amount' => 15000, 'reason' => 'Booking cancellation']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson("/api/v1/bookings/{$booking->booking_ref}")
->assertSuccessful()
->assertJsonPath('data.refunds', []);
});
test('cancelling a pending_payment booking returns an empty refunds list — no money moved yet', 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.refunds', []);
});
test('cancelling a confirmed booking with no completed payment surfaces as 422 and leaves it untouched', function () {
$staff = User::factory()->create()->givePermissionTo('process_refunds');
$staffToken = $staff->createToken('staff-token')->plainTextToken;
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
$this->withHeader('Authorization', "Bearer {$staffToken}")
->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();
});