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/OrderFulfillment/FulfillmentServiceTest.php
<?php

/**
 * FulfillmentServiceTest — saga-light orchestrator behavior.
 *
 * Covers idempotency (skip already-fulfilled), saga compensation on failure (refund in
 * reverse order), and refundOrder skip rules. Tests use a stub handler injected via
 * container binding override so we don't need full domain fixtures.
 *
 * See docs/payment-order-plan-subscription-saas/ORDER-SYSTEM.md §7.
 */

use App\Library\OrderFulfillment\FulfillmentService;
use App\Library\OrderFulfillment\Handlers\CreditSendingTopupHandler;
use App\Library\OrderFulfillment\Handlers\CreditVerificationTopupHandler;
use App\Library\OrderFulfillment\Handlers\CustomFeeHandler;
use App\Library\OrderFulfillment\Handlers\SubscriptionChangePlanHandler;
use App\Library\OrderFulfillment\Handlers\SubscriptionNewHandler;
use App\Library\OrderFulfillment\Handlers\SubscriptionRenewHandler;
use App\Library\OrderFulfillment\OrderItemHandler;
use App\Library\OrderFulfillment\OrderItemTypes;
use App\Model\Currency;
use App\Model\Customer;
use App\Model\Order;
use App\Model\OrderItem;
use Tests\TestCase;

uses(TestCase::class);

class StubHandler implements OrderItemHandler
{
    public static array $fulfilled = [];
    public static array $refunded = [];
    public static array $failed = [];
    public static array $cancelChecked = [];
    public static ?int $throwOnFulfillItemId = null;

    public static function reset(): void
    {
        self::$fulfilled = [];
        self::$refunded = [];
        self::$failed = [];
        self::$cancelChecked = [];
        self::$throwOnFulfillItemId = null;
    }

    public function fulfill(OrderItem $item): void
    {
        if (self::$throwOnFulfillItemId === $item->id) {
            throw new \RuntimeException('boom');
        }
        self::$fulfilled[] = $item->id;
    }

    public function refund(OrderItem $item): void
    {
        self::$refunded[] = $item->id;
    }

    public function failPayment(OrderItem $item, string $error): void
    {
        self::$failed[] = $item->id;
    }

    public function canCancel(OrderItem $item): void
    {
        self::$cancelChecked[] = $item->id;
    }

    public function currentPrice(OrderItem $item): ?int
    {
        return null;
    }

    public function describe(OrderItem $item): string
    {
        return 'stub';
    }
}

$handlerClasses = [
    SubscriptionNewHandler::class,
    SubscriptionRenewHandler::class,
    SubscriptionChangePlanHandler::class,
    CreditSendingTopupHandler::class,
    CreditVerificationTopupHandler::class,
    CustomFeeHandler::class,
];

beforeEach(function () use ($handlerClasses) {
    StubHandler::reset();
    $this->createdItemIds = [];
    $this->createdOrderIds = [];
    $this->createdCustomerIds = [];

    // Bind every concrete handler class to StubHandler so the real resolver returns it.
    foreach ($handlerClasses as $class) {
        app()->bind($class, fn () => new StubHandler());
    }
});

afterEach(function () use ($handlerClasses) {
    foreach ($handlerClasses as $class) {
        app()->offsetUnset($class);
    }

    foreach ($this->createdItemIds as $id) {
        OrderItem::where('id', $id)->delete();
    }
    foreach ($this->createdOrderIds as $id) {
        Order::where('id', $id)->delete();
    }
    foreach ($this->createdCustomerIds as $id) {
        Customer::where('id', $id)->delete();
    }
});

function makeOrderWithItems(\Tests\TestCase $test, int $count): Order
{
    $customer = Customer::forceCreate([
        'uid' => uniqid('fulfill_test_'),
        'timezone' => 'UTC',
        'status' => 'active',
    ]);
    $test->createdCustomerIds[] = $customer->id;

    $order = Order::forceCreate([
        'uid' => uniqid('fulfill_order_'),
        'customer_id' => $customer->id,
        'currency_id' => Currency::where('code', 'USD')->value('id') ?: 1,
    ]);
    $test->createdOrderIds[] = $order->id;

    for ($i = 0; $i < $count; $i++) {
        $item = OrderItem::forceCreate([
            'uid' => uniqid('fulfill_item_'),
            'order_id' => $order->id,
            'type' => OrderItemTypes::FEE_CUSTOM,
            'amount' => 10,
            'tax' => 0,
            'discount' => 0,
            'title' => "Item $i",
            'description' => 'test',
        ]);
        $test->createdItemIds[] = $item->id;
    }
    return $order->fresh(['orderItems']);
}

it('fulfillOrder skips items already fulfilled (idempotent)', function () {
    $order = makeOrderWithItems($this, 2);
    $order->orderItems[0]->update(['fulfilled_at' => now()->subMinute()]);
    $order->refresh();

    app(FulfillmentService::class)->fulfillOrder($order);

    // Only the second item should be fulfilled this run.
    expect(StubHandler::$fulfilled)->toHaveCount(1);
    expect(StubHandler::$fulfilled[0])->toBe($order->orderItems[1]->id);
});

it('fulfillOrder rolls back on failure in reverse order', function () {
    $order = makeOrderWithItems($this, 3);
    StubHandler::$throwOnFulfillItemId = $order->orderItems[2]->id;

    expect(fn () => app(FulfillmentService::class)->fulfillOrder($order))
        ->toThrow(\RuntimeException::class, 'boom');

    // Items 0 and 1 should be fulfilled, then refunded in reverse (1 first, then 0).
    expect(StubHandler::$fulfilled)->toBe([$order->orderItems[0]->id, $order->orderItems[1]->id]);
    expect(StubHandler::$refunded)->toBe([$order->orderItems[1]->id, $order->orderItems[0]->id]);
});

it('fulfillOrder rolls back state on failure (atomicity)', function () {
    $order = makeOrderWithItems($this, 1);
    StubHandler::$throwOnFulfillItemId = $order->orderItems[0]->id;

    try {
        app(FulfillmentService::class)->fulfillOrder($order);
    } catch (\Throwable $e) {
        // expected
    }

    // Whole transaction rolls back on throw — fulfilled_at and last_error both null.
    expect($order->orderItems[0]->fresh()->fulfilled_at)->toBeNull();
});

it('cancelOrder validates canCancel before refunding', function () {
    $order = makeOrderWithItems($this, 2);
    foreach ($order->orderItems as $item) {
        $item->update(['fulfilled_at' => now()]);
    }
    $order->refresh();

    app(FulfillmentService::class)->cancelOrder($order);

    expect(StubHandler::$cancelChecked)->toHaveCount(2);
    expect(StubHandler::$refunded)->toHaveCount(2);
});

it('refundOrder skips items not yet fulfilled', function () {
    $order = makeOrderWithItems($this, 2);
    $order->orderItems[0]->update(['fulfilled_at' => now()]);
    $order->refresh();

    app(FulfillmentService::class)->refundOrder($order);

    expect(StubHandler::$refunded)->toBe([$order->orderItems[0]->id]);
});

it('refundOrder skips items already refunded', function () {
    $order = makeOrderWithItems($this, 1);
    $order->orderItems[0]->update([
        'fulfilled_at' => now(),
        'refunded_at' => now(),
    ]);
    $order->refresh();

    app(FulfillmentService::class)->refundOrder($order);

    expect(StubHandler::$refunded)->toBeEmpty();
});