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();
});