HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/be.naniguide.com/tests/Unit/Cashier/CheckoutHandlerTest.php
<?php

namespace Tests\Unit\Cashier;

use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Mockery;
use App\Model\PaymentIntent as PaymentIntentModel;
use App\Cashier\Contracts\PaymentMethodInfoInterface;
use App\Library\Checkout\CheckoutHandler;
use App\Services\Subscription\SubscriptionManagementService;

class CheckoutHandlerTest extends TestCase
{
    use DatabaseTransactions;

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    private function handler(): CheckoutHandler
    {
        // Stub downstream services so we only test the handler's intent row updates.
        $this->app->instance(
            SubscriptionManagementService::class,
            Mockery::mock(SubscriptionManagementService::class)->shouldIgnoreMissing()
        );

        // FulfillmentService stub — flip status only, skip real order dispatch.
        $stub = new class extends \App\Library\OrderFulfillment\FulfillmentService {
            public function __construct() {}
            public function fulfillOrder(\App\Model\Order $order): void {}
            public function failOrder(\App\Model\Order $order, string $error): void {}
            public function cancelOrder(\App\Model\Order $order): void {}
        };
        $this->app->instance(\App\Library\OrderFulfillment\FulfillmentService::class, $stub);

        return app(CheckoutHandler::class);
    }

    private function fakePmInfo(): PaymentMethodInfoInterface
    {
        $mock = Mockery::mock(PaymentMethodInfoInterface::class);
        $mock->shouldReceive('getAutoBillingData')->andReturn([]);
        $mock->shouldReceive('getPaymentGatewayUid')->andReturn('gw_uid');
        return $mock;
    }

    public function test_find_intent_returns_null_for_unknown_uid()
    {
        $this->assertNull($this->handler()->findIntent('does-not-exist'));
    }

    public function test_find_intent_returns_dto_for_known_uid()
    {
        // Capability-token model: any caller with a valid uid gets the DTO.
        // Security covered by: (a) UUIDv4 entropy, (b) status state machine,
        // (c) server-stored remote_reference_id, (d) rate limit on pay routes.
        $pi = PaymentIntentModel::factory()->create();

        $dto = $this->handler()->findIntent($pi->uid);

        $this->assertNotNull($dto);
        $this->assertSame($pi->uid, $dto->uid);
    }

    public function test_on_payment_success_transitions_intent_to_succeeded()
    {
        $pi = PaymentIntentModel::factory()->create();
        $dto = $pi->toDto();

        $this->handler()->onPaymentSuccess($dto, $this->fakePmInfo(), 'pi_remote_xyz');

        $pi->refresh();
        $this->assertSame(PaymentIntentModel::STATUS_SUCCEEDED, $pi->status);
        $this->assertSame('pi_remote_xyz', $pi->remote_reference_id);
        $this->assertNotNull($pi->succeeded_at);
    }

    public function test_on_payment_failed_transitions_intent_and_records_reason()
    {
        $pi = PaymentIntentModel::factory()->create();
        $dto = $pi->toDto();

        $this->handler()->onPaymentFailed($dto, $this->fakePmInfo(), 'Card declined');

        $pi->refresh();
        $this->assertSame(PaymentIntentModel::STATUS_FAILED, $pi->status);
        $this->assertSame('Card declined', $pi->failed_reason);
    }

    public function test_on_payment_requires_auth_locks_remote_ref_and_stores_client_secret()
    {
        $pi = PaymentIntentModel::factory()->create();
        $dto = $pi->toDto();

        $this->handler()->onPaymentRequiresAuth($dto, 'pi_secret_abc', 'pi_remote_abc');

        $pi->refresh();
        $this->assertSame(PaymentIntentModel::STATUS_REQUIRES_ACTION, $pi->status);
        $this->assertSame('pi_remote_abc', $pi->remote_reference_id);
        $this->assertSame('pi_secret_abc', $pi->metadata['client_secret']);
    }

    public function test_on_subscription_requires_auth_stores_sub_id_in_intent()
    {
        // Security-critical: remote_subscription_id must be server-stored, not trusted from client
        $pi = PaymentIntentModel::factory()->forSubscription('price_xyz')->create();
        $dto = $pi->toDto();

        $this->handler()->onSubscriptionRequiresAuth($dto, 'pi_secret_xyz', 'sub_server_stored');

        $pi->refresh();
        $this->assertSame('sub_server_stored', $pi->remote_reference_id);
        $this->assertSame('pi_secret_xyz', $pi->metadata['client_secret']);
        $this->assertSame('price_xyz', $pi->metadata['remote_plan_id'], 'existing metadata preserved');
    }

    public function test_on_subscription_created_transitions_to_succeeded_and_clears_client_secret()
    {
        $pi = PaymentIntentModel::factory()->requiresAction()->forSubscription('price_xyz')->create();
        $dto = $pi->toDto();

        $this->handler()->onSubscriptionCreated($dto, [
            'remote_subscription_id' => 'sub_abc',
            'remote_customer_id'     => 'cus_xyz',
            'status'                 => 'active',
            'current_period_end'     => 1735689600,
            'payment_method_data'    => ['card_type' => 'Visa', 'last_4' => '4242'],
        ]);

        $pi->refresh();
        $this->assertSame(PaymentIntentModel::STATUS_SUCCEEDED, $pi->status);
        $this->assertSame('sub_abc', $pi->remote_reference_id);
        $this->assertNotNull($pi->succeeded_at);
        $this->assertSame('cus_xyz', $pi->metadata['remote_customer_id']);
        $this->assertSame(1735689600, $pi->metadata['current_period_end']);
        // Security: client_secret stripped on terminal state
        $this->assertArrayNotHasKey('client_secret', $pi->metadata);
    }

    public function test_on_payment_success_strips_client_secret_from_metadata()
    {
        $pi = PaymentIntentModel::factory()->requiresAction()->create();
        // requiresAction factory seeds metadata.client_secret
        $this->assertArrayHasKey('client_secret', $pi->metadata);

        $this->handler()->onPaymentSuccess($pi->toDto(), $this->fakePmInfo(), 'pi_done');

        $pi->refresh();
        $this->assertArrayNotHasKey(
            'client_secret',
            $pi->metadata ?? [],
            'client_secret must be cleared on terminal state to reduce blast radius'
        );
    }

    public function test_on_payment_success_releases_invoice_processing_lock()
    {
        // The lock (invoice.processing_intent_id) must be cleared on terminal transitions
        // so a subsequent attempt can create a fresh intent.
        $pi = PaymentIntentModel::factory()->create();
        $pi->invoice->update(['processing_intent_id' => $pi->id]);

        $this->handler()->onPaymentSuccess($pi->toDto(), $this->fakePmInfo(), 'pi_done');

        $pi->invoice->refresh();
        $this->assertNull(
            $pi->invoice->processing_intent_id,
            'Lock pointer must be cleared after success'
        );
    }

    public function test_on_payment_failed_releases_invoice_processing_lock()
    {
        $pi = PaymentIntentModel::factory()->create();
        $pi->invoice->update(['processing_intent_id' => $pi->id]);

        $this->handler()->onPaymentFailed($pi->toDto(), $this->fakePmInfo(), 'declined');

        $pi->invoice->refresh();
        $this->assertNull($pi->invoice->processing_intent_id);
    }

    public function test_on_subscription_created_releases_invoice_processing_lock()
    {
        $pi = PaymentIntentModel::factory()->forSubscription('price_xyz')->create();
        $pi->invoice->update(['processing_intent_id' => $pi->id]);

        $this->handler()->onSubscriptionCreated($pi->toDto(), [
            'remote_subscription_id' => 'sub_abc',
            'remote_customer_id'     => 'cus_xyz',
            'status'                 => 'active',
            'current_period_end'     => 1735689600,
            'payment_method_data'    => [],
        ]);

        $pi->invoice->refresh();
        $this->assertNull($pi->invoice->processing_intent_id);
    }

    public function test_on_offline_claim_received_transitions_to_awaiting_admin_approval()
    {
        $pi = PaymentIntentModel::factory()->create();
        $pi->invoice->update(['processing_intent_id' => $pi->id]);

        $this->handler()->onOfflineClaimReceived($pi->toDto());

        $pi->refresh();
        $this->assertSame(
            PaymentIntentModel::STATUS_AWAITING_ADMIN_APPROVAL,
            $pi->status,
            'Status flips from pending → awaiting_admin_approval — admin sees this in queue'
        );
        $this->assertArrayHasKey('claimed_at', $pi->metadata, 'Metadata records when user claimed (audit timestamp)');

        $pi->invoice->refresh();
        $this->assertSame($pi->id, $pi->invoice->processing_intent_id, 'Lock stays held until terminal');
    }

    public function test_on_payment_requires_auth_does_not_release_lock()
    {
        // requires_action is in-flight, not terminal. Lock must stay set.
        $pi = PaymentIntentModel::factory()->create();
        $pi->invoice->update(['processing_intent_id' => $pi->id]);

        $this->handler()->onPaymentRequiresAuth($pi->toDto(), 'pi_secret', 'pi_remote');

        $pi->invoice->refresh();
        $this->assertSame($pi->id, $pi->invoice->processing_intent_id, 'Lock must stay set during 3DS challenge');
    }
}