Files
FOSSBilling/src/modules/Order/tests/Unit/ServiceTest.php
T
Adam Daley adcd85f52b Improve overdue order suspension handling (#4072)
* Improve account suspension workflow

* Address suspension workflow review feedback

* Refine update patcher tests
2026-07-28 19:19:44 +01:00

3161 lines
118 KiB
PHP

<?php
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
declare(strict_types=1);
use Box\Mod\Order\Entity\Order;
use Box\Mod\Order\Service;
use Box\Mod\Product\Entity\Product;
use function Tests\Helpers\container;
use function Tests\Helpers\createEntity;
function orderServiceCreateProductEntity(?int $id = null, ?string $type = null): Product
{
$product = new Product();
if ($id !== null) {
$reflection = new ReflectionProperty($product, 'id');
$reflection->setValue($product, $id);
}
if ($type !== null) {
$product->setType($type);
}
return $product;
}
function orderServiceCreateInvoiceModel(int $id): Model_Invoice
{
$invoice = new Model_Invoice();
$invoice->loadBean(new Tests\Helpers\DummyBean());
$invoice->id = $id;
return $invoice;
}
function orderServiceCreateLegacyOrderModel(int $id): Model_ClientOrder
{
$order = new Model_ClientOrder();
$order->loadBean(new Tests\Helpers\DummyBean());
$order->id = $id;
return $order;
}
test('counter returns status counts', function (): void {
$service = new Service();
$counter = [Order::STATUS_ACTIVE => 1];
$connectionMock = Mockery::mock(Doctrine\DBAL\Connection::class);
$connectionMock->shouldReceive('fetchAllKeyValue')->atLeast()->once()->andReturn($counter);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getConnection')->atLeast()->once()->andReturn($connectionMock);
$di = container();
$di['em'] = $emMock;
$service->setDi($di);
$result = $service->counter();
expect($result)->toBeArray();
expect($result)->toHaveKey('total');
expect($result['total'])->toEqual(array_sum($counter));
expect($result)->toHaveKey(Order::STATUS_PENDING_SETUP);
expect($result)->toHaveKey(Order::STATUS_FAILED_SETUP);
expect($result)->toHaveKey(Order::STATUS_ACTIVE);
expect($result)->toHaveKey(Order::STATUS_SUSPENDED);
expect($result)->toHaveKey(Order::STATUS_CANCELED);
});
test('onAfterAdminOrderActivate fires template', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true);
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->setDi($di);
$serviceMock->onAfterAdminOrderActivate($eventMock);
});
test('onAfterAdminOrderActivate logs exceptions', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')
->atLeast()->once()
->andThrow(new Exception('PHPUnit controlled exception'));
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->setDi($di);
$serviceMock->onAfterAdminOrderActivate($eventMock);
});
test('onAfterAdminOrderRenew fires template', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true);
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderRenew($eventMock);
});
test('onAfterAdminOrderRenew logs exceptions', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')
->atLeast()->once()
->andThrow(new Exception('PHPUnit controlled exception'));
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderRenew($eventMock);
});
test('onAfterAdminOrderSuspend fires template', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true);
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderSuspend($eventMock);
});
test('onAfterAdminOrderSuspend logs exceptions', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')
->atLeast()->once()
->andThrow(new Exception('PHPUnit controlled exception'));
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderSuspend($eventMock);
});
test('onAfterAdminOrderUnsuspend fires template', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true);
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderUnsuspend($eventMock);
});
test('onAfterAdminOrderUnsuspend logs exceptions', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')
->atLeast()->once()
->andThrow(new Exception('PHPUnit controlled exception'));
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderUnsuspend($eventMock);
});
test('onAfterAdminOrderCancel fires template', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true);
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderCancel($eventMock);
});
test('onAfterAdminOrderCancel logs exceptions', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')
->atLeast()->once()
->andThrow(new Exception('PHPUnit controlled exception'));
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderCancel($eventMock);
});
test('onAfterAdminOrderUncancel fires template', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')->atLeast()->once()->andReturn(true);
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderUncancel($eventMock);
});
test('onAfterAdminOrderUncancel logs exceptions', function (): void {
$params = ['id' => 1];
$eventMock = Mockery::mock(Box_Event::class);
$eventMock->shouldReceive('getParameters')->atLeast()->once()->andReturn($params);
$emailServiceMock = Mockery::mock(Box\Mod\Email\Service::class);
$emailServiceMock->shouldReceive('sendTemplate')
->atLeast()->once()
->andThrow(new Exception('PHPUnit controlled exception'));
$orderArr = [
'id' => 1,
'client' => ['id' => 1],
'service_type' => 'domain',
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getOrderServiceData')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()->andReturn($orderArr);
$admin = createEntity(Box\Mod\Staff\Entity\Admin::class);
$di = container();
$di['em']->getRepository(Order::class)->shouldReceive('find')->byDefault()->andReturn(createEntity(Order::class, ['id' => 1]));
$di['loggedin_admin'] = $admin;
$di['mod_service'] = $di->protect(function ($serviceName) use ($emailServiceMock, $serviceMock) {
if ($serviceName == 'email') {
return $emailServiceMock;
}
if ($serviceName == 'order') {
return $serviceMock;
}
});
$serviceMock->setDi($di);
$eventMock->shouldReceive('getDi')->atLeast()->once()->andReturn($di);
$serviceMock->onAfterAdminOrderUncancel($eventMock);
});
test('getOrderService returns core service', function (): void {
$serviceEntity = new Box\Mod\Servicecustom\Entity\ServiceCustom();
$serviceRepo = Mockery::mock(Doctrine\ORM\EntityRepository::class);
$serviceRepo->shouldReceive('find')->once()->with(1)->andReturn($serviceEntity);
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$em->shouldReceive('getRepository')
->once()
->with(Box\Mod\Servicecustom\Entity\ServiceCustom::class)
->andReturn($serviceRepo);
$di = container();
$di['em'] = $em;
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class, [
'service_id' => 1,
'service_type' => Box\Mod\Product\Service::CUSTOM,
]);
$result = $svc->getOrderService($order);
expect($result)->toBeInstanceOf(Box\Mod\Servicecustom\Entity\ServiceCustom::class);
});
test('getOrderService returns non-core service', function (): void {
$serviceData = ['id' => 1, 'product_id' => 5];
$di = container();
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('findOne')->once()->with('service_external', 'id = :id', [':id' => 1])->andReturn($serviceData);
$di['db'] = $dbMock;
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class, [
'service_id' => 1,
'service_type' => 'external',
]);
$result = $svc->getOrderService($order);
expect($result)->toBeArray();
});
test('getOrderService returns null when service id is not set', function (): void {
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getExistingModelById')->never();
$dbMock->shouldReceive('findOne')->never();
$di = container();
$di['db'] = $dbMock;
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$result = $svc->getOrderService($order);
expect($result)->toBeNull();
});
test('getServiceOrder returns order', function (): void {
$toolsMock = Mockery::mock(FOSSBilling\Tools::class);
$toolsMock->shouldReceive('from_camel_case')->atLeast()->once()->andReturn('custom');
$dbMock = Mockery::mock(Box_Database::class);
$di = container();
$di['db'] = $dbMock;
$di['tools'] = $toolsMock;
$svc = new Service();
$svc->setDi($di);
$service = new Model_ServiceCustom();
$service->loadBean(new Tests\Helpers\DummyBean());
$service->id = 1;
$order = new Model_ClientOrder();
$order->loadBean(new Tests\Helpers\DummyBean());
$order->id = 1;
$dbMock->shouldReceive('findOne')->once()->with('ClientOrder', 'service_type = :service_type AND service_id = :service_id', [
':service_type' => 'custom',
':service_id' => 1,
])->andReturn($order);
$result = $svc->getServiceOrder($service);
expect($result)->toBeInstanceOf(Model_ClientOrder::class);
});
test('keeps legacy order lookups available alongside entity lookups', function (): void {
$client = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 5]);
$entityOrder = createEntity(Order::class, ['id' => 10, 'client_id' => 5]);
$legacyOrder = orderServiceCreateLegacyOrderModel(10);
$orderRepository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class);
$orderRepository->shouldReceive('findForClientById')->twice()->with(5, 10)->andReturn($entityOrder);
$entityManager = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$entityManager->shouldReceive('getRepository')->once()->with(Order::class)->andReturn($orderRepository);
$database = Mockery::mock(Box_Database::class);
$database->shouldReceive('getExistingModelById')->once()->with('ClientOrder', 10)->andReturn($legacyOrder);
$di = container();
$di['em'] = $entityManager;
$di['db'] = $database;
$service = new Service();
$service->setDi($di);
expect($service->findEntityForClientById($client, 10))->toBe($entityOrder)
->and($service->findForClientById($client, 10))->toBe($legacyOrder);
});
test('getConfig returns config', function (): void {
$svc = new Service();
$di = container();
$svc->setDi($di);
$order = createEntity(Order::class);
$result = $svc->getConfig($order);
expect($result)->toBeArray();
});
dataset('productHasOrdersProvider', function (): array {
$orderEntity = new Order();
$idProp = new ReflectionProperty($orderEntity, 'id');
$idProp->setValue($orderEntity, 1);
return [
'order present' => [$orderEntity, true],
'order absent' => [null, false],
];
});
test('productHasOrders returns expected result', function (?Order $order, bool $expectedResult): void {
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('findOneByProductId')->atLeast()->once()->andReturn($order);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$product = orderServiceCreateProductEntity(1);
$result = $svc->productHasOrders($product);
expect($result)->toEqual($expectedResult);
})->with('productHasOrdersProvider');
test('saveStatusChange records history', function (): void {
$persistedEntities = [];
$nextOrderId = 1;
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('persist')->atLeast()->once()->andReturnUsing(function ($entity) use (&$persistedEntities): void {
$persistedEntities[] = $entity;
});
$emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$persistedEntities, &$nextOrderId): void {
foreach ($persistedEntities as $entity) {
$refl = new ReflectionClass($entity);
if ($refl->hasProperty('id')) {
$prop = $refl->getProperty('id');
if ($prop->getValue($entity) === null) {
$prop->setValue($entity, $nextOrderId++);
}
}
}
$persistedEntities = [];
});
$emMock->shouldReceive('remove')->andReturnNull();
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->andReturnUsing(function (?int $id) use (&$nextOrderId): ?object {
if ($id === null) {
return null;
}
$order = new Order();
$prop = new ReflectionProperty($order, 'id');
$prop->setValue($order, $id);
return $order;
});
$orderRepoMock->shouldReceive('findOneByOrderIdAndName')->byDefault()->andReturn(null);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn(Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing());
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$result = $svc->saveStatusChange($order);
expect($result)->toBeNull();
});
test('getSoonExpiringActiveOrders executes query', function (): void {
$order = createEntity(Order::class);
$connectionMock = Mockery::mock(Doctrine\DBAL\Connection::class);
$connectionMock->shouldReceive('fetchAllAssociative')->atLeast()->once()->andReturn([[], []]);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getConnection')->atLeast()->once()->andReturn($connectionMock);
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getSoonExpiringActiveOrdersQuery')->atLeast()->once()->andReturn(['query', []]);
$di = container();
$di['em'] = $emMock;
$serviceMock->setDi($di);
$serviceMock->getSoonExpiringActiveOrders();
});
test('getSoonExpiringActiveOrdersQuery builds expected SQL and bindings', function (): void {
$randId = 1;
$orderStatus = createEntity(Box\Mod\Order\Entity\OrderStatus::class);
$systemService = Mockery::mock(Box\Mod\System\Service::class);
$systemService->shouldReceive('getParamValue')->atLeast()->once()->andReturn($randId);
$di = container();
$di['mod_service'] = $di->protect(fn (string $name): Mockery\MockInterface => match (strtolower($name)) {
'system' => $systemService,
default => Mockery::mock()->shouldIgnoreMissing(),
});
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$data = ['client_id' => $randId];
$result = $svc->getSoonExpiringActiveOrdersQuery($data);
$expectedQuery = 'SELECT co.*
FROM client_order co
LEFT JOIN invoice i ON i.id = co.unpaid_invoice_id AND i.status = :unpaid_invoice_status
WHERE co.status = :status
AND co.invoice_option = :invoice_option
AND co.period IS NOT NULL
AND co.expires_at IS NOT NULL
AND i.id IS NULL
/* Pair non-executed renewal items with paid invoices to skip renewals already queued for activation. */
AND NOT EXISTS (
SELECT 1
FROM invoice_item pending_item
INNER JOIN invoice pending_invoice ON pending_invoice.id = pending_item.invoice_id
WHERE pending_item.rel_id = co.id
AND pending_item.type = :pending_item_type
AND pending_item.task = :pending_item_task
AND pending_item.status != :pending_item_status
AND pending_invoice.status = :pending_invoice_status
) AND co.client_id = :client_id HAVING DATEDIFF(co.expires_at, NOW()) <= :days_until_expiration ORDER BY co.client_id DESC';
$expectedBindings = [
'client_id' => $randId,
'unpaid_invoice_status' => Model_Invoice::STATUS_UNPAID,
'pending_item_type' => Model_InvoiceItem::TYPE_ORDER,
'pending_item_task' => Model_InvoiceItem::TASK_RENEW,
'pending_item_status' => Model_InvoiceItem::STATUS_EXECUTED,
'pending_invoice_status' => Model_Invoice::STATUS_PAID,
'status' => Order::STATUS_ACTIVE,
'invoice_option' => 'issue-invoice',
'days_until_expiration' => $randId,
];
expect($result[0])->toBeString();
expect($result[1])->toBeArray();
expect($result[0])->toEqual($expectedQuery);
expect($result[1])->toEqual($expectedBindings);
});
test('getRelatedOrderIdByType returns id', function (): void {
$id = 1;
$model = createEntity(Order::class, ['id' => $id]);
$orderEntity = new Order();
$idProp = new ReflectionProperty($orderEntity, 'id');
$idProp->setValue($orderEntity, $id);
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('findOneBy')->atLeast()->once()->andReturn($orderEntity);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$result = $svc->getRelatedOrderIdByType($model, 'domain');
expect($result)->toBeInt();
expect($result)->toEqual($id);
});
test('getRelatedOrderIdByType returns null when not found', function (): void {
$id = 1;
$model = createEntity(Order::class, ['id' => $id]);
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('findOneBy')->atLeast()->once()->andReturn(null);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$result = $svc->getRelatedOrderIdByType($model, 'domain');
expect($result)->toBeNull();
});
test('getLogger returns logger with event items', function (): void {
$model = createEntity(Order::class, [
'id' => 5,
'status' => 'active',
]);
$capturedItems = [];
$logger = new class($capturedItems) extends Box_Log {
public function __construct(public array &$capturedItems)
{
}
public function addWriter($writer): static
{
return $this;
}
public function setEventItem(string $name, mixed $value): static
{
$this->capturedItems[] = [$name, $value];
return $this;
}
};
$di = container();
$di['logger'] = $logger;
$svc = new Service();
$svc->setDi($di);
$result = $svc->getLogger($model);
expect($result)->toBeInstanceOf(Box_Log::class);
expect($capturedItems)->toHaveCount(2);
expect($capturedItems[0])->toEqual(['client_order_id', 5]);
expect($capturedItems[1])->toEqual(['status', 'active']);
});
test('toApiArray returns expected keys', function (): void {
$model = createEntity(Order::class, [
'id' => 1,
'config' => '{}',
'price' => 10,
'quantity' => 1,
'client_id' => 1,
]);
$model->setProductId(1);
$clientService = Mockery::mock(Box\Mod\Client\Service::class);
$clientService->shouldReceive('toApiArray')->atLeast()->once()->andReturn([]);
$supportService = Mockery::mock(Box\Mod\Support\Service::class);
$supportTicketRepo = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
$supportTicketRepo->shouldReceive('countActiveTicketsForOrder')->atLeast()->once()->andReturn(1);
$supportService->shouldReceive('getSupportTicketRepository')->atLeast()->once()->andReturn($supportTicketRepo);
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldNotReceive('toArray');
$clientEntity = new Box\Mod\Client\Entity\Client();
$clientRepoMock = Mockery::mock(Box\Mod\Client\Repository\ClientRepository::class);
$clientRepoMock->shouldReceive('find')->with(1)->atLeast()->once()->andReturn($clientEntity);
$orderMetaRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class);
$orderMetaRepoMock->shouldReceive('getPairsForOrder')->atLeast()->once()->andReturn([]);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->atLeast()->once()->andReturn($orderMetaRepoMock);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Client\Entity\Client::class)->atLeast()->once()->andReturn($clientRepoMock);
$emMock->shouldIgnoreMissing();
$productService = Mockery::mock(Box\Mod\Product\Service::class);
$productService->shouldReceive('getProductPluginById')->once()->with(1)->andReturn(null);
$productRepository = Mockery::mock(Box\Mod\Product\Repository\ProductRepository::class);
$productRepository->shouldReceive('find')->once()->with(1)->andReturn(null);
$productService->shouldReceive('getProductRepository')->once()->andReturn($productRepository);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($clientService, $supportService, $productService) {
if ($serviceName == 'client') {
return $clientService;
}
if ($serviceName == 'support') {
return $supportService;
}
if ($serviceName == 'product') {
return $productService;
}
});
$di['db'] = $dbMock;
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$result = $svc->toApiArray($model, true, createEntity(Box\Mod\Staff\Entity\Admin::class));
expect($result)->toHaveKey('config');
expect($result)->toHaveKey('total');
expect($result)->toHaveKey('title');
expect($result)->toHaveKey('meta');
expect($result)->toHaveKey('active_tickets');
expect($result)->toHaveKey('plugin');
expect($result['product_suspension_grace_days'])->toBeNull();
expect($result)->toHaveKey('client');
});
dataset('searchQueryData', fn (): array => [
'no data' => [[], 'SELECT co.* from client_order co', []],
'client_id' => [
['client_id' => 1],
'co.client_id = :client_id',
['client_id' => '1'],
],
'invoice_option' => [
['invoice_option' => 'issue-invoice'],
'co.invoice_option = :invoice_option',
['invoice_option' => 'issue-invoice'],
],
'id' => [
['id' => 1],
'co.id = :id',
['id' => '1'],
],
'status' => [
['status' => 'pending_setup'],
'co.status = :status',
['status' => 'pending_setup'],
],
'product_id' => [
['product_id' => 1],
'co.product_id = :product_id',
['product_id' => '1'],
],
'type' => [
['type' => 'custom'],
'co.service_type = :service_type',
['service_type' => 'custom'],
],
'title' => [
['title' => 'titleField'],
'co.title LIKE :title',
['title' => '%titleField%'],
],
'period' => [
['period' => '1Y'],
'co.period = :period',
['period' => '1Y'],
],
'hide_addons' => [
['hide_addons' => true],
'co.group_master = 1',
[],
],
'created_at' => [
['created_at' => '2012-12-11'],
"DATE_FORMAT(co.created_at, '%Y-%m-%d') = :created_at",
['created_at' => '2012-12-11'],
],
'date_from' => [
['date_from' => '2012-12-11'],
'UNIX_TIMESTAMP(co.created_at) >= :date_from',
['date_from' => strtotime('2012-12-11')],
],
'date_to' => [
['date_to' => '2012-12-11'],
'UNIX_TIMESTAMP(co.created_at) <= :date_to',
['date_to' => strtotime('2012-12-11')],
],
'search numeric' => [
['search' => 120],
'co.id = :search',
['search' => 120],
],
'search string' => [
['search' => 'John'],
'(c.first_name LIKE :first_name OR c.last_name LIKE :last_name OR co.title LIKE :title)',
[
'first_name' => '%John%',
'last_name' => '%John%',
'title' => '%John%',
],
],
'ids' => [
['ids' => [1, 2, 3]],
'co.id IN (:ids)',
['ids' => '1, 2, 3'],
],
'promo_id' => [
['promo_id' => 9],
'co.promo_id = :promo_id',
['promo_id' => 9],
],
'meta' => [
['meta' => ['param' => 'value']],
'(meta.name = :meta_name1 AND meta.value LIKE :meta_value1)',
[
'meta_name1' => 'param',
'meta_value1' => 'value%',
],
],
]);
test('getSearchQuery returns expected query and bindings', function (array $data, string $expectedStr, array $expectedParams): void {
$di = container();
$svc = new Service();
$svc->setDi($di);
$result = $svc->getSearchQuery($data);
expect($result[0])->toBeString();
expect($result[1])->toBeArray();
expect($result[0])->toContain($expectedStr);
expect($result[1])->toEqual($expectedParams);
})->with('searchQueryData');
test('getSearchQuery keeps client scope when action required filter is used', function (): void {
$di = container();
$svc = new Service();
$svc->setDi($di);
[$query, $bindings] = $svc->getSearchQuery([
'client_id' => 42,
'show_action_required' => true,
]);
expect($query)->toContain('co.client_id = :client_id');
expect($query)->toContain("(co.status = 'pending_setup' OR co.status = 'failed_setup' OR co.status ='failed_renew')");
expect($bindings['client_id'])->toBe(42);
});
test('createOrder throws when no order currency is set', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class);
$modelProduct = orderServiceCreateProductEntity();
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findDefault')->atLeast()->once()->andReturn(null);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
});
$svc = new Service();
$svc->setDi($di);
expect(fn () => $svc->createOrder($modelClient, $modelProduct, []))
->toThrow(FOSSBilling\Exception::class, 'Currency could not be determined for order');
});
test('createOrder throws when out of stock', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(1);
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->atLeast()->once()->andReturn($currencyModel);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(false);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
});
$di['events_manager'] = $eventMock;
$svc = new Service();
$svc->setDi($di);
expect(fn () => $svc->createOrder($modelClient, $modelProduct, []))
->toThrow(FOSSBilling\Exception::class, 'Product 1 is out of stock.');
});
test('createOrder throws when group id missing for addon', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(1);
$modelProduct->setIsAddon(true);
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->atLeast()->once()->andReturn($currencyModel);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
});
$di['events_manager'] = $eventMock;
$svc = new Service();
$svc->setDi($di);
expect(fn () => $svc->createOrder($modelClient, $modelProduct, []))
->toThrow(FOSSBilling\Exception::class, 'Group ID parameter is missing for addon product order');
});
test('createOrder throws when parent order not found', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(1);
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->atLeast()->once()->andReturn($currencyModel);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
});
$di['events_manager'] = $eventMock;
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('getMasterOrderForClient')
->atLeast()->once()
->andReturn(null);
$serviceMock->setDi($di);
expect(fn () => $serviceMock->createOrder($modelClient, $modelProduct, ['group_id' => 1]))
->toThrow(FOSSBilling\Exception::class, 'Parent order 1 was not found');
});
test('createOrder creates order', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(1, 'custom');
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyModel->shouldReceive('getCode')->andReturn('USD');
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->atLeast()->once()->andReturn($currencyModel);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class);
$pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class);
$pricingServiceMock->shouldReceive('getProductOrderLineConfig')->never();
$persistedEntities = [];
$nextOrderId = 1;
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('persist')->atLeast()->once()->andReturnUsing(function ($entity) use (&$persistedEntities): void {
$persistedEntities[] = $entity;
});
$emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$persistedEntities, &$nextOrderId): void {
foreach ($persistedEntities as $entity) {
$refl = new ReflectionClass($entity);
if ($refl->hasProperty('id')) {
$prop = $refl->getProperty('id');
if ($prop->getValue($entity) === null) {
$prop->setValue($entity, $nextOrderId++);
}
}
}
$persistedEntities = [];
});
$emMock->shouldReceive('wrapInTransaction')->once()->andReturnUsing(fn (callable $callback) => $callback());
$emMock->shouldReceive('remove')->andReturnNull();
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->andReturnUsing(function (?int $id) use (&$nextOrderId): ?object {
if ($id === null) {
return null;
}
$order = new Order();
$prop = new ReflectionProperty($order, 'id');
$prop->setValue($order, $id);
return $order;
});
$orderRepoMock->shouldReceive('findOneByOrderIdAndName')->byDefault()->andReturn(null);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn(Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing());
$emMock->shouldIgnoreMissing();
$newId = 1;
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getCode')->atLeast()->once()->andReturn('1Y');
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock, $productServiceMock, $pricingServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
if ($serviceName == 'Product') {
return $pricingServiceMock;
}
if ($serviceName == 'servicecustom') {
return $productServiceMock;
}
});
$di['events_manager'] = $eventMock;
$di['em'] = $emMock;
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$di['logger'] = new Box_Log();
$svc = new Service();
$svc->setDi($di);
$result = $svc->createOrder($modelClient, $modelProduct, ['period' => '1Y', 'price' => '10', 'notes' => 'test']);
expect($result)->toEqual($newId);
});
test('createOrder sets form id from product', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(1, 'custom');
$modelProduct->setFormId(42);
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->atLeast()->once()->andReturn($currencyModel);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class);
$pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class);
$pricingServiceMock->shouldReceive('getProductOrderLineConfig')->never();
$persistedEntities = [];
$nextOrderId = 1;
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('persist')->atLeast()->once()->andReturnUsing(function ($entity) use (&$persistedEntities): void {
$persistedEntities[] = $entity;
});
$emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$persistedEntities, &$nextOrderId): void {
foreach ($persistedEntities as $entity) {
$refl = new ReflectionClass($entity);
if ($refl->hasProperty('id')) {
$prop = $refl->getProperty('id');
if ($prop->getValue($entity) === null) {
$prop->setValue($entity, $nextOrderId++);
}
}
}
$persistedEntities = [];
});
$emMock->shouldReceive('wrapInTransaction')->once()->andReturnUsing(fn (callable $callback) => $callback());
$emMock->shouldReceive('remove')->andReturnNull();
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->andReturnUsing(function (?int $id) use (&$nextOrderId): ?object {
if ($id === null) {
return null;
}
$order = new Order();
$prop = new ReflectionProperty($order, 'id');
$prop->setValue($order, $id);
return $order;
});
$orderRepoMock->shouldReceive('findOneByOrderIdAndName')->byDefault()->andReturn(null);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn(Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing());
$emMock->shouldIgnoreMissing();
$newId = 1;
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getCode')->atLeast()->once()->andReturn('1Y');
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock, $productServiceMock, $pricingServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
if ($serviceName == 'Product') {
return $pricingServiceMock;
}
if ($serviceName == 'servicecustom') {
return $productServiceMock;
}
});
$di['events_manager'] = $eventMock;
$di['em'] = $emMock;
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$di['logger'] = new Box_Log();
$svc = new Service();
$svc->setDi($di);
$svc->createOrder($modelClient, $modelProduct, ['period' => '1Y', 'price' => '10']);
expect(true)->toBeTrue();
});
test('createOrder returns success when invoice follow up fails', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(1, 'custom');
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->atLeast()->once()->andReturn($currencyModel);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class);
$pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class);
$pricingServiceMock->shouldReceive('getProductOrderLineConfig')->never();
$clientOrderModel = orderServiceCreateLegacyOrderModel(1);
$invoiceModel = orderServiceCreateInvoiceModel(10);
$invoiceServiceMock = Mockery::mock();
$invoiceServiceMock->shouldReceive('generateForOrder')
->once()
->with(Mockery::any())
->andReturn($invoiceModel);
$invoiceServiceMock->shouldReceive('approveInvoice')
->once()
->with($invoiceModel, ['id' => $invoiceModel->id, 'use_credits' => true])
->andReturn(true);
$invoiceServiceMock->shouldReceive('markAsPaidByAdmin')
->once()
->with($invoiceModel, Mockery::on(fn (array $data): bool => $data['invoice_option'] === 'issue-invoice'
&& $data['mark_invoice_paid'] === true
&& $data['gateway_id'] === 7))
->andThrow(new Exception('Payment follow-up failed'));
$invoiceServiceMock->shouldReceive('addNote')
->once()
->with(
$invoiceModel,
Mockery::on(fn (string $note): bool => str_contains($note, 'Order was created, but invoice follow-up failed: Payment follow-up failed'))
)
->andReturn(true);
$persistedEntities = [];
$nextOrderId = 1;
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('persist')->atLeast()->once()->andReturnUsing(function ($entity) use (&$persistedEntities): void {
$persistedEntities[] = $entity;
});
$emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$persistedEntities, &$nextOrderId): void {
foreach ($persistedEntities as $entity) {
$refl = new ReflectionClass($entity);
if ($refl->hasProperty('id')) {
$prop = $refl->getProperty('id');
if ($prop->getValue($entity) === null) {
$prop->setValue($entity, $nextOrderId++);
}
}
}
$persistedEntities = [];
});
$emMock->shouldReceive('wrapInTransaction')->once()->andReturnUsing(fn (callable $callback) => $callback());
$emMock->shouldReceive('remove')->andReturnNull();
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->andReturnUsing(function (?int $id) use (&$nextOrderId): ?object {
if ($id === null) {
return null;
}
$order = new Order();
$prop = new ReflectionProperty($order, 'id');
$prop->setValue($order, $id);
return $order;
});
$orderRepoMock->shouldReceive('findOneByOrderIdAndName')->byDefault()->andReturn(null);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn(Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing());
$emMock->shouldIgnoreMissing();
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getExistingModelById')->once()->with('ClientOrder', 1)->andReturn($clientOrderModel);
$newId = 1;
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getCode')->atLeast()->once()->andReturn('1Y');
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($cartServiceMock, $currencyServiceMock, $invoiceServiceMock, $productServiceMock, $pricingServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
if ($serviceName == 'Product') {
return $pricingServiceMock;
}
if ($serviceName == 'invoice') {
return $invoiceServiceMock;
}
if ($serviceName == 'servicecustom') {
return $productServiceMock;
}
});
$di['events_manager'] = $eventMock;
$di['db'] = $dbMock;
$di['em'] = $emMock;
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$di['logger'] = new Box_Log();
$svc = new Service();
$svc->setDi($di);
$result = $svc->createOrder($modelClient, $modelProduct, [
'period' => '1Y',
'price' => '10',
'invoice_option' => 'issue-invoice',
'mark_invoice_paid' => true,
'gateway_id' => 7,
]);
expect($result)->toBe($newId);
});
test('createOrder uses product pricing service for domain orders', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(10, Box\Mod\Product\Service::DOMAIN);
$modelProduct->setUnit('year');
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyModel->shouldReceive('getCode')->andReturn('USD');
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->once()->with('USD')->andReturn($currencyModel);
$currencyRepositoryMock->shouldReceive('getRateByCode')->once()->with('USD')->andReturn(1.0);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$domainServiceMock = Mockery::mock(Box\Mod\Servicedomain\Service::class)->shouldIgnoreMissing();
$pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class);
$pricingServiceMock->shouldReceive('getProductOrderLineConfig')
->once()
->with(
$modelProduct,
Mockery::on(static fn (array $config): bool => ($config['quantity'] ?? null) === 1)
)
->andReturn([
'price' => 22.0,
'quantity' => 2,
'setup_price' => 0.0,
]);
$persistedEntities = [];
$nextOrderId = 1;
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('persist')->atLeast()->once()->andReturnUsing(function ($entity) use (&$persistedEntities): void {
$persistedEntities[] = $entity;
});
$emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$persistedEntities, &$nextOrderId): void {
foreach ($persistedEntities as $entity) {
$refl = new ReflectionClass($entity);
if ($refl->hasProperty('id')) {
$prop = $refl->getProperty('id');
if ($prop->getValue($entity) === null) {
$prop->setValue($entity, $nextOrderId++);
}
}
}
$persistedEntities = [];
});
$emMock->shouldReceive('wrapInTransaction')->once()->andReturnUsing(fn (callable $callback) => $callback());
$emMock->shouldReceive('remove')->andReturnNull();
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->andReturnUsing(function (?int $id) use (&$nextOrderId): ?object {
if ($id === null) {
return null;
}
$order = new Order();
$prop = new ReflectionProperty($order, 'id');
$prop->setValue($order, $id);
return $order;
});
$orderRepoMock->shouldReceive('findOneByOrderIdAndName')->byDefault()->andReturn(null);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn(Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing());
$emMock->shouldIgnoreMissing();
$newId = 1;
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($currencyServiceMock, $cartServiceMock, $domainServiceMock, $pricingServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
if ($serviceName == 'Product') {
return $pricingServiceMock;
}
if ($serviceName == 'servicedomain') {
return $domainServiceMock;
}
});
$di['events_manager'] = $eventMock;
$di['em'] = $emMock;
$di['logger'] = new Box_Log();
$svc = new Service();
$svc->setDi($di);
$result = $svc->createOrder($modelClient, $modelProduct, [
'action' => 'register',
'register_tld' => '.com',
'register_sld' => 'example',
'register_years' => 2,
]);
expect($result)->toBe($newId);
});
test('getMasterOrderForClient returns master order', function (): void {
$clientModel = createEntity(Box\Mod\Client\Entity\Client::class);
$orderEntity = new Order();
$idProp = new ReflectionProperty($orderEntity, 'id');
$idProp->setValue($orderEntity, 1);
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('findOneBy')->atLeast()->once()->andReturn($orderEntity);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$result = $svc->getMasterOrderForClient($clientModel, 1);
expect($result)->toBeInstanceOf(Order::class);
});
test('activateOrder throws for non-pending order', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->status = Order::STATUS_CANCELED;
$clientOrderModel->id = 1;
$orderEntity = new Order();
$idProp = new ReflectionProperty($orderEntity, 'id');
$idProp->setValue($orderEntity, 1);
$statusProp = new ReflectionProperty($orderEntity, 'status');
$statusProp->setValue($orderEntity, Order::STATUS_CANCELED);
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->with(1)->andReturn($orderEntity);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
expect(fn (): bool => $svc->activateOrder($clientOrderModel))
->toThrow(FOSSBilling\Exception::class, 'Only pending setup or failed orders can be activated');
});
test('activateOrder activates pending order', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->status = Order::STATUS_PENDING_SETUP;
$clientOrderModel->group_master = 1;
$clientOrderModel->id = 1;
$orderEntity = new Order();
$idProp = new ReflectionProperty($orderEntity, 'id');
$idProp->setValue($orderEntity, 1);
$statusProp = new ReflectionProperty($orderEntity, 'status');
$statusProp->setValue($orderEntity, Order::STATUS_PENDING_SETUP);
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->with(1)->andReturn($orderEntity);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['logger'] = new Box_Log();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('createFromOrder')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('activateOrderAddons')->atLeast()->once();
$serviceMock->setDi($di);
$result = $serviceMock->activateOrder($clientOrderModel);
expect($result)->toBeTrue();
});
test('activateOrder is a no-op when order was already activated by a stale reference', function (): void {
$staleOrderModel = createEntity(Order::class, [
'status' => Order::STATUS_PENDING_SETUP,
'id' => 1,
]);
$activeOrderEntity = new Order();
$idProp = new ReflectionProperty($activeOrderEntity, 'id');
$idProp->setValue($activeOrderEntity, 1);
$statusProp = new ReflectionProperty($activeOrderEntity, 'status');
$statusProp->setValue($activeOrderEntity, Order::STATUS_ACTIVE);
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->with(1)->andReturn($activeOrderEntity);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('createFromOrder')->never();
$serviceMock->shouldReceive('activateOrderAddons')->never();
$serviceMock->setDi($di);
$result = $serviceMock->activateOrder($staleOrderModel);
expect($result)->toBeTrue();
});
test('activateOrder force re-activates an already active order', function (): void {
$activeOrderModel = createEntity(Order::class, [
'status' => Order::STATUS_ACTIVE,
'group_master' => 1,
'id' => 1,
]);
$orderEntity = new Order();
$idProp = new ReflectionProperty($orderEntity, 'id');
$idProp->setValue($orderEntity, 1);
$statusProp = new ReflectionProperty($orderEntity, 'status');
$statusProp->setValue($orderEntity, Order::STATUS_ACTIVE);
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->with(1)->andReturn($orderEntity);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['em'] = $emMock;
$di['events_manager'] = $eventMock;
$di['logger'] = new Box_Log();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('createFromOrder')->atLeast()->once()->andReturn([]);
$serviceMock->shouldReceive('activateOrderAddons')->atLeast()->once();
$serviceMock->setDi($di);
$result = $serviceMock->activateOrder($activeOrderModel, ['force' => true]);
expect($result)->toBeTrue();
});
test('activateOrderAddons activates addons', function (): void {
$order = createEntity(Order::class);
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('createFromOrder')->atLeast()->once()->andReturn([]);
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->status = Order::STATUS_PENDING_SETUP;
$clientOrderModel->group_master = 1;
$serviceMock->shouldReceive('getOrderAddonsList')
->atLeast()->once()
->andReturn([$clientOrderModel]);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['events_manager'] = $eventMock;
$serviceMock->setDi($di);
$result = $serviceMock->activateOrderAddons($clientOrderModel);
expect($result)->toBeTrue();
});
test('getOrderAddonsList returns addons', function (): void {
$modelClientOrder = createEntity(Order::class);
$orderEntity = new Order();
$idProp = new ReflectionProperty($orderEntity, 'id');
$idProp->setValue($orderEntity, 1);
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('findBy')->atLeast()->once()->andReturn([$orderEntity]);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$result = $svc->getOrderAddonsList($modelClientOrder);
expect($result)->toBeArray();
expect($result[0])->toBeInstanceOf(Order::class);
});
test('stockSale reduces stock', function (): void {
$productModel = orderServiceCreateProductEntity();
$productService = Mockery::mock(Box\Mod\Product\Service::class);
$productService->shouldReceive('reduceStock')->once()->with($productModel, 2)->andReturn(true);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($productService) {
if ($serviceName == 'product') {
return $productService;
}
});
$svc = new Service();
$svc->setDi($di);
$result = $svc->stockSale($productModel, 2);
expect($result)->toBeTrue();
});
test('stockSale throws when quantity would go negative', function (): void {
$productModel = orderServiceCreateProductEntity(1);
$productService = Mockery::mock(Box\Mod\Product\Service::class);
$productService->shouldReceive('reduceStock')
->once()
->with($productModel, 2)
->andThrow(new FOSSBilling\InformationException('Product :id is out of stock.', [':id' => 1], 831));
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($productService) {
if ($serviceName == 'product') {
return $productService;
}
});
$svc = new Service();
$svc->setDi($di);
expect(fn (): bool => $svc->stockSale($productModel, 2))
->toThrow(FOSSBilling\InformationException::class, 'Product 1 is out of stock.');
});
test('updateOrder updates fields', function (): void {
$clientOrderModel = createEntity(Order::class);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['events_manager'] = $eventMock;
$di['em'] = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
$di['logger'] = new Box_Log();
$data = [
'period' => '1Y',
'created_at' => '2012-12-01',
'activated_at' => '2012-12-01',
'expires_at' => '2013-12-01',
'invoice_option' => 'issue-invoice',
'title' => 'Testing',
'price' => 10,
'status' => 'active',
'notes' => 'Empty note',
'reason' => 'non',
'suspension_grace_days' => 3,
'meta' => [],
];
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('updatePeriod')->atLeast()->once()->with($clientOrderModel, $data['period']);
$serviceMock->shouldReceive('updateOrderMeta')->atLeast()->once()->with($clientOrderModel, $data['meta']);
$serviceMock->setDi($di);
$result = $serviceMock->updateOrder($clientOrderModel, $data);
expect($result)->toBeTrue()
->and($clientOrderModel->getSuspensionGraceDays())->toBe(3);
});
test('renewOrder renews order', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->group_master = 1;
$clientOrderModel->status = Order::STATUS_PENDING_SETUP;
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['events_manager'] = $eventMock;
$di['logger'] = new Box_Log();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('renewFromOrder')->atLeast()->once()->with($clientOrderModel);
$serviceMock->shouldReceive('getOrderAddonsList')->atLeast()->once()->andReturn([$clientOrderModel]);
$serviceMock->setDi($di);
$result = $serviceMock->renewOrder($clientOrderModel);
expect($result)->toBeTrue();
});
test('renewFromOrder extends expiration', function (): void {
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('_callOnService')->atLeast()->once();
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->period = '1Y';
$clientOrderModel->expires_at = '2026-01-01 00:00:00';
$expectedExpiration = strtotime('2027-01-01 00:00:00');
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getExpirationTime')
->atLeast()->once()
->with(strtotime('2026-01-01 00:00:00'))
->andReturn($expectedExpiration);
$serviceMock->shouldReceive('saveStatusChange')
->atLeast()->once()
->with($clientOrderModel, 'Order renewed');
$di = container();
$di['mod_config'] = $di->protect(fn ($name): array => []);
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$serviceMock->setDi($di);
$serviceMock->renewFromOrder($clientOrderModel);
expect($clientOrderModel->expires_at)->toEqual(new DateTime('2027-01-01 00:00:00'));
expect($clientOrderModel->status)->toEqual(Order::STATUS_ACTIVE);
});
test('renewFromOrder treats a missing Doctrine expiration as now', function (): void {
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('_callOnService')->once();
$serviceMock->shouldReceive('saveStatusChange')->once()->with(Mockery::type(Order::class), 'Order renewed');
$order = createEntity(Order::class, ['period' => '1Y']);
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getExpirationTime')
->once()
->with(Mockery::on(static fn (int $from): bool => abs(time() - $from) <= 1))
->andReturn(strtotime('2027-01-01 00:00:00'));
$di = container();
$di['mod_config'] = $di->protect(fn ($name): array => ['order_renewal_logic' => 'from_greater']);
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$serviceMock->setDi($di);
$serviceMock->renewFromOrder($order);
expect($order->getExpiresAt())->toEqual(new DateTime('2027-01-01 00:00:00'))
->and($order->getStatus())->toBe(Order::STATUS_ACTIVE);
});
test('renewFromOrder extends free first term on first paid renewal', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->period = '1Y';
$clientOrderModel->status = Order::STATUS_ACTIVE;
$clientOrderModel->activated_at = '2025-01-01 00:00:00';
$clientOrderModel->expires_at = '2026-01-01 00:00:00';
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('_callOnService')
->once()
->with(Mockery::on(fn ($order): bool => $order === $clientOrderModel), Order::ACTION_RENEW);
$expectedExpiration = strtotime('2027-01-01 00:00:00');
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getExpirationTime')
->once()
->with(strtotime('2026-01-01 00:00:00'))
->andReturn($expectedExpiration);
$serviceMock->shouldReceive('saveStatusChange')
->once()
->with($clientOrderModel, 'Order renewed');
$di = container();
$di['mod_config'] = $di->protect(fn ($name): array => []);
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$serviceMock->setDi($di);
$serviceMock->renewFromOrder($clientOrderModel);
expect($clientOrderModel->expires_at)->toEqual(new DateTime('2027-01-01 00:00:00'));
expect($clientOrderModel->status)->toEqual(Order::STATUS_ACTIVE);
});
test('suspendFromOrder throws for non-active order', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->status = Order::STATUS_SUSPENDED;
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['events_manager'] = $eventMock;
$svc = new Service();
$svc->setDi($di);
expect(fn (): bool => $svc->suspendFromOrder($clientOrderModel))
->toThrow(FOSSBilling\Exception::class, 'Only active orders can be suspended');
});
test('suspendFromOrder suspends active order', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->status = Order::STATUS_ACTIVE;
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['events_manager'] = $eventMock;
$di['logger'] = new Box_Log();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('_callOnService')->atLeast()->once();
$serviceMock->shouldReceive('saveStatusChange')->atLeast()->once();
$serviceMock->setDi($di);
$result = $serviceMock->suspendFromOrder($clientOrderModel);
expect($result)->toBeTrue();
});
test('cancelFromOrder cancels linked subscriptions', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->id = 10;
$clientOrderModel->status = Order::STATUS_ACTIVE;
$legacyOrder = orderServiceCreateLegacyOrderModel(10);
$calls = [];
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
$subscriptionService->shouldReceive('cancelForOrder')
->once()
->with($legacyOrder)
->andReturnUsing(function () use (&$calls): int {
$calls[] = 'subscriptions';
return 1;
});
$productService = Mockery::mock(Box\Mod\Product\Service::class);
$productService->shouldReceive('releaseReservedPromoRedemptionsForOrder')
->once()
->with($clientOrderModel, 'order_canceled');
$connectionMock = Mockery::mock(Doctrine\DBAL\Connection::class);
$connectionMock->shouldReceive('executeStatement')
->once()
->with(
'DELETE FROM client_order_meta WHERE client_order_id = :order_id AND name = :name',
['order_id' => $clientOrderModel->id, 'name' => Service::META_CANCEL_AT_PERIOD_END],
);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldIgnoreMissing();
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getExistingModelById')->once()->with('ClientOrder', 10)->andReturn($legacyOrder);
$di = container();
$di['db'] = $dbMock;
$di['em'] = $emMock;
$di['dbal'] = $connectionMock;
$di['logger'] = new Box_Log();
$di['mod_service'] = $di->protect(function (string $module, string $service = '') use ($productService, $subscriptionService) {
if ($module === 'Invoice' && $service === 'Subscription') {
return $subscriptionService;
}
return $productService;
});
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('_callOnService')
->once()
->andReturnUsing(function () use (&$calls): void {
$calls[] = 'service';
});
$serviceMock->shouldReceive('saveStatusChange')->once();
$serviceMock->setDi($di);
expect($serviceMock->cancelFromOrder($clientOrderModel, skipEvent: true))->toBeTrue()
->and($clientOrderModel->status)->toBe(Order::STATUS_CANCELED)
->and($calls)->toBe(['service', 'subscriptions']);
});
test('scheduleCancellationFromOrder keeps the service active', function (): void {
$order = createEntity(Order::class, [
'id' => 10,
'status' => Order::STATUS_ACTIVE,
]);
$legacyOrder = orderServiceCreateLegacyOrderModel(10);
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
$subscriptionService->shouldReceive('canCancelAtPeriodEndForOrder')->once()->with($legacyOrder)->andReturn(true);
$subscriptionService->shouldReceive('scheduleCancellationForOrder')->once()->with($legacyOrder)->andReturn(1);
$di = container();
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getExistingModelById')->once()->with('ClientOrder', 10)->andReturn($legacyOrder);
$di['db'] = $dbMock;
$di['logger'] = new Box_Log();
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldAllowMockingProtectedMethods();
$service->shouldNotReceive('_callOnService');
$service->shouldReceive('updateOrderMeta')
->once()
->with($order, [Service::META_CANCEL_AT_PERIOD_END => '1'])
->andReturn(2);
$service->shouldReceive('saveStatusChange')
->once()
->with($order, 'Cancellation scheduled at the end of the current billing period');
$service->setDi($di);
expect($service->scheduleCancellationFromOrder($order, 'Customer request'))->toBeTrue()
->and($order->status)->toBe(Order::STATUS_ACTIVE)
->and($order->reason)->toBe('Customer request');
});
test('scheduleCancellationFromOrder does not mark the order when no subscription was scheduled', function (): void {
$order = createEntity(Order::class, [
'status' => Order::STATUS_ACTIVE,
]);
$legacyOrder = orderServiceCreateLegacyOrderModel(0);
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
$subscriptionService->shouldReceive('canCancelAtPeriodEndForOrder')->once()->with($legacyOrder)->andReturn(true);
$subscriptionService->shouldReceive('scheduleCancellationForOrder')->once()->with($legacyOrder)->andReturn(0);
$di = container();
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getExistingModelById')->once()->with('ClientOrder', 0)->andReturn($legacyOrder);
$di['db'] = $dbMock;
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldNotReceive('updateOrderMeta');
$service->setDi($di);
expect(fn () => $service->scheduleCancellationFromOrder($order))
->toThrow(FOSSBilling\InformationException::class, 'No active gateway subscription is linked to this order.');
});
test('cancelFromOrder does not cancel subscriptions when service cancellation fails', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->status = Order::STATUS_ACTIVE;
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
$subscriptionService->shouldNotReceive('cancelForOrder');
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldNotReceive('store');
$di = container();
$di['db'] = $dbMock;
$di['em'] = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('_callOnService')
->once()
->andThrow(new RuntimeException('Service cancellation failed'));
$serviceMock->setDi($di);
expect(fn () => $serviceMock->cancelFromOrder($clientOrderModel, skipEvent: true))
->toThrow(RuntimeException::class, 'Service cancellation failed')
->and($clientOrderModel->status)->toBe(Order::STATUS_ACTIVE);
});
test('cancelFromOrder remains retryable when subscription cancellation fails', function (): void {
$clientOrderModel = createEntity(Order::class);
$clientOrderModel->status = Order::STATUS_ACTIVE;
$legacyOrder = orderServiceCreateLegacyOrderModel(0);
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
$subscriptionService->shouldReceive('cancelForOrder')
->once()
->with(Mockery::any())
->andThrow(new RuntimeException('Subscription cancellation failed'));
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldNotReceive('store');
$dbMock->shouldReceive('getExistingModelById')->once()->with('ClientOrder', 0)->andReturn($legacyOrder);
$di = container();
$di['db'] = $dbMock;
$di['em'] = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('_callOnService')->once();
$serviceMock->setDi($di);
expect(fn () => $serviceMock->cancelFromOrder($clientOrderModel, skipEvent: true))
->toThrow(RuntimeException::class, 'Subscription cancellation failed')
->and($clientOrderModel->status)->toBe(Order::STATUS_ACTIVE);
});
test('rmByClient removes all client orders', function (): void {
$clientModel = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 100]);
$orderModel = createEntity(Order::class);
$queryBuilderMock = new class {
private bool $deleteCalled = false;
private bool $whereCalled = false;
private bool $setParamCalled = false;
private mixed $deleteTable = null;
private mixed $whereCond = null;
private mixed $paramId = null;
public function delete($table)
{
$this->deleteCalled = true;
$this->deleteTable = $table;
return $this;
}
public function where($cond)
{
$this->whereCalled = true;
$this->whereCond = $cond;
return $this;
}
public function setParameter($key, $val)
{
$this->setParamCalled = true;
$this->paramId = $val;
return $this;
}
public function executeStatement(): int
{
return 1;
}
public function getDeleteTable()
{
return $this->deleteTable;
}
public function getWhereCond()
{
return $this->whereCond;
}
public function getParamId()
{
return $this->paramId;
}
public function wasDeleteCalled(): bool
{
return $this->deleteCalled;
}
public function wasWhereCalled(): bool
{
return $this->whereCalled;
}
public function wasSetParamCalled(): bool
{
return $this->setParamCalled;
}
};
$dbalMock = new class($queryBuilderMock) {
public function __construct(private $qb)
{
}
public function createQueryBuilder()
{
return $this->qb;
}
};
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('findByClientId')->once()->with(100)->andReturn([$orderModel]);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldIgnoreMissing();
$productServiceMock = Mockery::mock(Box\Mod\Product\Service::class);
$productServiceMock->shouldReceive('releaseReservedPromoRedemptionsForOrder')
->once()
->with($orderModel, 'client_deleted');
$di = container();
$di['em'] = $emMock;
$di['dbal'] = $dbalMock;
$di['mod_service'] = $di->protect(fn (string $name): Mockery\MockInterface => match (strtolower($name)) {
'product' => $productServiceMock,
default => Mockery::mock()->shouldIgnoreMissing(),
});
$svc = new Service();
$svc->setDi($di);
$svc->rmByClient($clientModel);
expect($queryBuilderMock->getDeleteTable())->toBe('client_order');
expect($queryBuilderMock->getWhereCond())->toBe('client_id = :id');
expect($queryBuilderMock->getParamId())->toBe($clientModel->id);
});
test('updatePeriod sets period when given', function (): void {
$period = '1Y';
$di = container();
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getCode')->atLeast()->once();
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$svc = new Service();
$svc->setDi($di);
$clientOrder = createEntity(Order::class);
$result = $svc->updatePeriod($clientOrder, $period);
expect($result)->toEqual(1);
});
test('updatePeriod clears period when empty string', function (): void {
$period = '';
$di = container();
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getCode')->never();
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$svc = new Service();
$svc->setDi($di);
$clientOrder = createEntity(Order::class);
$result = $svc->updatePeriod($clientOrder, $period);
expect($result)->toEqual(2);
});
test('updatePeriod does nothing when null', function (): void {
$period = null;
$di = container();
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getCode')->never();
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$svc = new Service();
$svc->setDi($di);
$clientOrder = createEntity(Order::class);
$result = $svc->updatePeriod($clientOrder, $period);
expect($result)->toEqual(0);
});
test('updateOrderMeta returns 0 when meta is not an array', function (): void {
$meta = null;
$clientOrder = createEntity(Order::class);
$svc = new Service();
$result = $svc->updateOrderMeta($clientOrder, $meta);
expect($result)->toEqual(0);
});
test('updateOrderMeta clears existing meta when empty', function (): void {
$meta = [];
$metaRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing();
$metaRepoMock->shouldReceive('deleteByOrderId')->once()->with(1)->andReturn(1);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn($metaRepoMock);
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$clientOrder = createEntity(Order::class);
$clientOrder->id = 1;
$result = $svc->updateOrderMeta($clientOrder, $meta);
expect($result)->toEqual(1);
});
test('updateOrderMeta stores new meta entries', function (): void {
$meta = ['key' => 'value'];
$metaRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing();
$metaRepoMock->shouldReceive('findOneByOrderIdAndName')->with(1, 'key')->once()->andReturn(null);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn($metaRepoMock);
$emMock->shouldReceive('persist')->once();
$emMock->shouldReceive('flush')->once();
$emMock->shouldIgnoreMissing();
$di = container();
$di['em'] = $emMock;
$svc = new Service();
$svc->setDi($di);
$clientOrder = createEntity(Order::class);
$clientOrder->id = 1;
$result = $svc->updateOrderMeta($clientOrder, $meta);
expect($result)->toEqual(2);
});
test('updateOrderConfig succeeds when no form id is set', function (): void {
$di = container();
$di['logger'] = new Box_Log();
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$order->form_id = null;
$result = $svc->updateOrderConfig($order, ['key' => 'value']);
expect($result)->toBeTrue();
});
test('updateOrderConfig throws when required field is missing', function (): void {
$form = [
'fields' => [
['name' => 'hostname', 'label' => 'Hostname', 'type' => 'text', 'required' => true, 'options' => []],
],
];
$formbuilderServiceMock = Mockery::mock(Box\Mod\Formbuilder\Service::class);
$formbuilderServiceMock->shouldReceive('getForm')->once()->with(7)->andReturn($form);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($formbuilderServiceMock) {
if ($serviceName === 'formbuilder') {
return $formbuilderServiceMock;
}
});
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$order->form_id = 7;
expect(fn (): bool => $svc->updateOrderConfig($order, []))
->toThrow(FOSSBilling\Exception::class, '', 4892);
});
test('updateOrderConfig throws for invalid select option', function (): void {
$form = [
'fields' => [
['name' => 'plan', 'label' => 'Plan', 'type' => 'select', 'required' => false, 'options' => ['basic' => 'Basic', 'pro' => 'Pro']],
],
];
$formbuilderServiceMock = Mockery::mock(Box\Mod\Formbuilder\Service::class);
$formbuilderServiceMock->shouldReceive('getForm')->once()->andReturn($form);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($formbuilderServiceMock) {
if ($serviceName === 'formbuilder') {
return $formbuilderServiceMock;
}
});
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$order->form_id = 8;
expect(fn (): bool => $svc->updateOrderConfig($order, ['plan' => 'enterprise']))
->toThrow(FOSSBilling\Exception::class, '', 4893);
});
test('updateOrderConfig select rejects array value', function (): void {
$form = [
'fields' => [
['name' => 'plan', 'label' => 'Plan', 'type' => 'select', 'required' => false, 'options' => ['basic' => 'Basic', 'pro' => 'Pro']],
],
];
$formbuilderServiceMock = Mockery::mock(Box\Mod\Formbuilder\Service::class);
$formbuilderServiceMock->shouldReceive('getForm')->once()->andReturn($form);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($formbuilderServiceMock) {
if ($serviceName === 'formbuilder') {
return $formbuilderServiceMock;
}
});
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$order->form_id = 11;
expect(fn (): bool => $svc->updateOrderConfig($order, ['plan' => ['pro']]))
->toThrow(FOSSBilling\Exception::class, '', 4893);
});
test('updateOrderConfig throws for invalid radio option', function (): void {
$form = [
'fields' => [
['name' => 'os', 'label' => 'OS', 'type' => 'radio', 'required' => false, 'options' => ['linux' => 'Linux', 'windows' => 'Windows']],
],
];
$formbuilderServiceMock = Mockery::mock(Box\Mod\Formbuilder\Service::class);
$formbuilderServiceMock->shouldReceive('getForm')->once()->andReturn($form);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($formbuilderServiceMock) {
if ($serviceName === 'formbuilder') {
return $formbuilderServiceMock;
}
});
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$order->form_id = 9;
expect(fn (): bool => $svc->updateOrderConfig($order, ['os' => 'macos']))
->toThrow(FOSSBilling\Exception::class, '', 4893);
});
test('updateOrderConfig throws for invalid checkbox option', function (): void {
$form = [
'fields' => [
['name' => 'addons', 'label' => 'Addons', 'type' => 'checkbox', 'required' => false, 'options' => ['backup', 'ssl']],
],
];
$formbuilderServiceMock = Mockery::mock(Box\Mod\Formbuilder\Service::class);
$formbuilderServiceMock->shouldReceive('getForm')->once()->andReturn($form);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($formbuilderServiceMock) {
if ($serviceName === 'formbuilder') {
return $formbuilderServiceMock;
}
});
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$order->form_id = 10;
expect(fn (): bool => $svc->updateOrderConfig($order, ['addons' => ['backup', 'ddos-protection']]))
->toThrow(FOSSBilling\Exception::class, '', 4894);
});
test('updateOrderConfig succeeds with valid form data', function (): void {
$form = [
'fields' => [
['name' => 'hostname', 'label' => 'Hostname', 'type' => 'text', 'required' => true, 'options' => []],
['name' => 'plan', 'label' => 'Plan', 'type' => 'select', 'required' => false, 'options' => ['basic' => 'Basic', 'pro' => 'Pro']],
['name' => 'addons', 'label' => 'Addons', 'type' => 'checkbox', 'required' => false, 'options' => ['backup', 'ssl']],
],
];
$formbuilderServiceMock = Mockery::mock(Box\Mod\Formbuilder\Service::class);
$formbuilderServiceMock->shouldReceive('getForm')->once()->andReturn($form);
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($formbuilderServiceMock) {
if ($serviceName === 'formbuilder') {
return $formbuilderServiceMock;
}
});
$di['em'] = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
$di['logger'] = new Box_Log();
$svc = new Service();
$svc->setDi($di);
$order = createEntity(Order::class);
$order->form_id = 11;
$result = $svc->updateOrderConfig($order, ['hostname' => 'myhost.example.com', 'plan' => 'pro', 'addons' => ['backup', 'ssl']]);
expect($result)->toBeTrue();
});
test('createOrder rejects invalid price and quantity', function (array $data, string $message): void {
$service = new Service();
$client = createEntity(Box\Mod\Client\Entity\Client::class);
$product = orderServiceCreateProductEntity(1, 'custom');
expect(fn () => $service->createOrder($client, $product, $data))
->toThrow(FOSSBilling\InformationException::class, $message);
})->with([
'negative price' => [['price' => -1], 'Price cannot be negative'],
'invalid price' => [['price' => 'invalid'], 'Price must be a valid number'],
'invalid quantity' => [['quantity' => 'invalid'], 'Quantity must be a valid number'],
]);
test('updateOrder rejects a negative price', function (): void {
$order = createEntity(Order::class);
$events = Mockery::mock(Box_EventManager::class);
$events->shouldReceive('fire')->once();
$di = container();
$di['events_manager'] = $events;
$service = Mockery::mock(Service::class)->makePartial()->shouldAllowMockingProtectedMethods();
$service->shouldReceive('updatePeriod')->once();
$service->setDi($di);
expect(fn () => $service->updateOrder($order, ['price' => -1]))
->toThrow(FOSSBilling\InformationException::class, 'Price cannot be negative');
});
test('createOrder generates an invoice for a zero-price order with issue-invoice', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(1, 'custom');
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->atLeast()->once()->andReturn($currencyModel);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class);
$pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class);
$pricingServiceMock->shouldReceive('getProductOrderLineConfig')->never();
$clientOrderModel = orderServiceCreateLegacyOrderModel(1);
$invoiceModel = orderServiceCreateInvoiceModel(10);
$invoiceServiceMock = Mockery::mock();
$invoiceServiceMock->shouldReceive('generateForOrder')
->once()
->with(Mockery::any())
->andReturn($invoiceModel);
$invoiceServiceMock->shouldReceive('approveInvoice')
->once()
->with($invoiceModel, ['id' => $invoiceModel->id, 'use_credits' => true])
->andReturn(true);
$persistedEntities = [];
$nextOrderId = 1;
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('persist')->atLeast()->once()->andReturnUsing(function ($entity) use (&$persistedEntities): void {
$persistedEntities[] = $entity;
});
$emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$persistedEntities, &$nextOrderId): void {
foreach ($persistedEntities as $entity) {
$refl = new ReflectionClass($entity);
if ($refl->hasProperty('id')) {
$prop = $refl->getProperty('id');
if ($prop->getValue($entity) === null) {
$prop->setValue($entity, $nextOrderId++);
}
}
}
$persistedEntities = [];
});
$emMock->shouldReceive('wrapInTransaction')->once()->andReturnUsing(fn (callable $callback) => $callback());
$emMock->shouldReceive('remove')->andReturnNull();
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->andReturnUsing(function (?int $id) use (&$nextOrderId): ?object {
if ($id === null) {
return null;
}
$order = new Order();
$prop = new ReflectionProperty($order, 'id');
$prop->setValue($order, $id);
return $order;
});
$orderRepoMock->shouldReceive('findOneByOrderIdAndName')->byDefault()->andReturn(null);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn(Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing());
$emMock->shouldIgnoreMissing();
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getExistingModelById')->once()->with('ClientOrder', 1)->andReturn($clientOrderModel);
$newId = 1;
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getCode')->atLeast()->once()->andReturn('1Y');
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($cartServiceMock, $currencyServiceMock, $invoiceServiceMock, $productServiceMock, $pricingServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
if ($serviceName == 'Product') {
return $pricingServiceMock;
}
if ($serviceName == 'invoice') {
return $invoiceServiceMock;
}
if ($serviceName == 'servicecustom') {
return $productServiceMock;
}
});
$di['events_manager'] = $eventMock;
$di['db'] = $dbMock;
$di['em'] = $emMock;
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$di['logger'] = new Box_Log();
$svc = new Service();
$svc->setDi($di);
$result = $svc->createOrder($modelClient, $modelProduct, [
'period' => '1Y',
'price' => 0,
'invoice_option' => 'issue-invoice',
]);
expect($result)->toBe($newId);
});
test('createOrder does not roll back when invoice generation fails for a negative resolved price', function (): void {
$modelClient = createEntity(Box\Mod\Client\Entity\Client::class, ['currency' => 'USD']);
$modelProduct = orderServiceCreateProductEntity(1, 'custom');
$currencyModel = Mockery::mock(Box\Mod\Currency\Entity\Currency::class)->shouldIgnoreMissing();
$currencyRepositoryMock = Mockery::mock(Box\Mod\Currency\Repository\CurrencyRepository::class);
$currencyRepositoryMock->shouldReceive('findOneByCode')->atLeast()->once()->andReturn($currencyModel);
$currencyRepositoryMock->shouldReceive('getRateByCode')->atLeast()->once()->andReturn(1.0);
$currencyServiceMock = Mockery::mock(Box\Mod\Currency\Service::class);
$currencyServiceMock->shouldReceive('getCurrencyRepository')->atLeast()->once()->andReturn($currencyRepositoryMock);
$cartServiceMock = Mockery::mock(Box\Mod\Cart\Service::class);
$cartServiceMock->shouldReceive('isStockAvailable')
->atLeast()->once()
->with($modelProduct, Mockery::any())
->andReturn(true);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$productServiceMock = Mockery::mock(Box\Mod\Servicecustom\Service::class);
$pricingServiceMock = Mockery::mock(Box\Mod\Product\Service::class);
$pricingServiceMock->shouldReceive('getProductOrderLineConfig')
->atLeast()->once()
->andReturn(['price' => -5.0, 'quantity' => 1]);
$clientOrderModel = orderServiceCreateLegacyOrderModel(1);
$invoiceServiceMock = Mockery::mock();
$invoiceServiceMock->shouldReceive('generateForOrder')
->once()
->with(Mockery::any())
->andThrow(new FOSSBilling\InformationException('Invoices are not generated for negative amount orders.'));
$invoiceServiceMock->shouldReceive('approveInvoice')->never();
$persistedEntities = [];
$nextOrderId = 1;
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('persist')->atLeast()->once()->andReturnUsing(function ($entity) use (&$persistedEntities): void {
$persistedEntities[] = $entity;
});
$emMock->shouldReceive('flush')->atLeast()->once()->andReturnUsing(function () use (&$persistedEntities, &$nextOrderId): void {
foreach ($persistedEntities as $entity) {
$refl = new ReflectionClass($entity);
if ($refl->hasProperty('id')) {
$prop = $refl->getProperty('id');
if ($prop->getValue($entity) === null) {
$prop->setValue($entity, $nextOrderId++);
}
}
}
$persistedEntities = [];
});
$emMock->shouldReceive('wrapInTransaction')->once()->andReturnUsing(fn (callable $callback) => $callback());
$emMock->shouldReceive('remove')->andReturnNull();
$orderRepoMock = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepoMock->shouldReceive('find')->andReturnUsing(function (?int $id) use (&$nextOrderId): ?object {
if ($id === null) {
return null;
}
$order = new Order();
$prop = new ReflectionProperty($order, 'id');
$prop->setValue($order, $id);
return $order;
});
$orderRepoMock->shouldReceive('findOneByOrderIdAndName')->byDefault()->andReturn(null);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepoMock);
$emMock->shouldReceive('getRepository')->with(Box\Mod\Order\Entity\OrderMeta::class)->andReturn(Mockery::mock(Box\Mod\Order\Repository\OrderMetaRepository::class)->shouldIgnoreMissing());
$emMock->shouldIgnoreMissing();
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getExistingModelById')->once()->with('ClientOrder', 1)->andReturn($clientOrderModel);
$newId = 1;
$periodMock = Mockery::mock(Box_Period::class);
$periodMock->shouldReceive('getCode')->atLeast()->once()->andReturn('1Y');
$di = container();
$di['mod_service'] = $di->protect(function ($serviceName) use ($cartServiceMock, $currencyServiceMock, $invoiceServiceMock, $productServiceMock, $pricingServiceMock) {
if ($serviceName == 'currency') {
return $currencyServiceMock;
}
if ($serviceName == 'cart') {
return $cartServiceMock;
}
if ($serviceName == 'Product') {
return $pricingServiceMock;
}
if ($serviceName == 'invoice') {
return $invoiceServiceMock;
}
if ($serviceName == 'servicecustom') {
return $productServiceMock;
}
});
$di['events_manager'] = $eventMock;
$di['db'] = $dbMock;
$di['em'] = $emMock;
$di['period'] = $di->protect(fn (): Mockery\MockInterface => $periodMock);
$di['logger'] = new Box_Log();
$svc = new Service();
$svc->setDi($di);
$result = $svc->createOrder($modelClient, $modelProduct, [
'period' => '1Y',
'invoice_option' => 'issue-invoice',
]);
expect($result)->toBe($newId);
});
test('getExpiredOrders delegates grace-aware selection to the repository', function (): void {
$service = new Service();
$orderRepository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing();
$orderRepository->shouldReceive('getExpired')
->once()
->andReturn([]);
$emMock = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$emMock->shouldReceive('getRepository')->with(Order::class)->andReturn($orderRepository);
$di = container();
$di['em'] = $emMock;
$service->setDi($di);
expect($service->getExpiredOrders())->toBe([]);
});
test('batchSendSuspensionWarnings claims and queues each warning once', function (): void {
$order = createEntity(Order::class, ['id' => 8, 'client_id' => 12]);
$repository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class);
$repository->shouldReceive('getDueSuspensionWarnings')->twice()->andReturn([
['id' => 8, 'suspension_at' => '2026-08-01 12:00:00'],
]);
$repository->shouldReceive('find')->twice()->with(8)->andReturn($order);
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
$connection->shouldReceive('transactional')->twice()->andReturnUsing(fn (callable $callback): mixed => $callback());
$connection->shouldReceive('fetchOne')->twice()->with(
'SELECT id FROM client_order WHERE id = :id FOR UPDATE',
['id' => 8]
)->andReturn(8);
$connection->shouldReceive('fetchAssociative')->twice()->andReturn(
false,
['id' => 14, 'value' => '2026-08-01 12:00:00']
);
$connection->shouldReceive('insert')->once()->with('client_order_meta', Mockery::on(
fn (array $data): bool => $data['client_order_id'] === 8
&& $data['name'] === 'suspension_warning_for'
&& $data['value'] === '2026-08-01 12:00:00'
));
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$em->shouldReceive('getRepository')->with(Order::class)->once()->andReturn($repository);
$em->shouldReceive('getConnection')->twice()->andReturn($connection);
$emailService = Mockery::mock(Box\Mod\Email\Service::class);
$emailService->shouldReceive('sendTemplate')->once()->with(Mockery::on(
fn (array $email): bool => $email['to_client'] === 12
&& $email['code'] === 'mod_order_suspension_warning'
&& $email['order']['suspension_at'] === '2026-08-01 12:00:00'
))->andReturn(true);
$events = Mockery::mock(Box_EventManager::class);
$events->shouldReceive('fire')->times(4);
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('toApiArray')->once()->with($order, false)->andReturn(['id' => 8]);
$di = container();
$di['em'] = $em;
$di['events_manager'] = $events;
$di['logger'] = new Box_Log();
$di['mod_service'] = $di->protect(fn (string $name): Box\Mod\Email\Service => $emailService);
$service->setDi($di);
expect($service->batchSendSuspensionWarnings())->toBeTrue()
->and($service->batchSendSuspensionWarnings())->toBeTrue();
});
test('batchSendSuspensionWarnings releases a failed claim so the warning can be retried', function (): void {
$order = createEntity(Order::class, ['id' => 8, 'client_id' => 12]);
$candidate = ['id' => 8, 'suspension_at' => '2026-08-01 12:00:00'];
$repository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class);
$repository->shouldReceive('getDueSuspensionWarnings')->twice()->andReturn([$candidate]);
$repository->shouldReceive('find')->twice()->with(8)->andReturn($order);
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
$connection->shouldReceive('transactional')->twice()->andReturnUsing(fn (callable $callback): mixed => $callback());
$connection->shouldReceive('fetchOne')->twice()->andReturn(8);
$connection->shouldReceive('fetchAssociative')->twice()->andReturn(false);
$connection->shouldReceive('insert')->twice();
$connection->shouldReceive('delete')->once()->with('client_order_meta', [
'client_order_id' => 8,
'name' => 'suspension_warning_for',
'value' => $candidate['suspension_at'],
]);
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$em->shouldReceive('getRepository')->with(Order::class)->once()->andReturn($repository);
$em->shouldReceive('getConnection')->times(3)->andReturn($connection);
$attempts = 0;
$emailService = Mockery::mock(Box\Mod\Email\Service::class);
$emailService->shouldReceive('sendTemplate')->twice()->andReturnUsing(function () use (&$attempts): bool {
if (++$attempts === 1) {
throw new RuntimeException('Queue unavailable');
}
return true;
});
$events = Mockery::mock(Box_EventManager::class);
$events->shouldReceive('fire')->times(4);
$service = Mockery::mock(Service::class)->makePartial();
$service->shouldReceive('toApiArray')->twice()->with($order, false)->andReturn(['id' => 8]);
$di = container();
$di['em'] = $em;
$di['events_manager'] = $events;
$di['logger'] = new Box_Log();
$di['mod_service'] = $di->protect(fn (string $name): Box\Mod\Email\Service => $emailService);
$service->setDi($di);
expect($service->batchSendSuspensionWarnings())->toBeTrue()
->and($service->batchSendSuspensionWarnings())->toBeTrue()
->and($attempts)->toBe(2);
});