Files
FOSSBilling/tests/Helpers/Container.php
Adam Daley 256751b831 Allow clients to belong to multiple client groups (#4405)
* Allow clients to belong to multiple client groups (#4387)

* Use spellcheck-clean identifiers in multi-group E2E

* Renumber multi-group migration to patch125 after main merge

* Halve guest logins in multi-group E2E for the shared api_login budget

* Only migrate group assignments that reference existing groups
2026-09-29 18:58:44 +01:00

556 lines
31 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);
namespace Tests\Helpers;
use Pimple\Container;
use Psr\Log\AbstractLogger;
use Symfony\Component\HttpFoundation\Request;
/**
* Create a minimal DI container for testing.
*/
function container(): Container
{
$di = new Container();
$di['config'] = [
'salt' => 'test_salt_' . uniqid(),
'url' => 'http://localhost/',
];
$di['validator'] = fn (): \FOSSBilling\Validate => new \FOSSBilling\Validate();
$di['tools'] = fn (): \FOSSBilling\Tools => new \FOSSBilling\Tools();
$di['filesystem'] = fn (): \Symfony\Component\Filesystem\Filesystem => new \Symfony\Component\Filesystem\Filesystem();
$di['logger'] = fn (): \Psr\Log\LoggerInterface => new class extends AbstractLogger {
public array $calls = [];
private string $channel = 'application';
private array $context = [];
public function log($level, string|\Stringable $message, array $context = []): void
{
$effectiveContext = [...$this->context, ...$context];
$call = ['method' => $level, 'params' => [$message, $effectiveContext]];
if ($this->channel !== 'application') {
$call['channel'] = $this->channel;
}
$this->calls[] = $call;
}
public function withChannel(string $channel): static
{
$this->calls[] = ['method' => 'withChannel', 'params' => [$channel]];
$logger = clone $this;
$logger->calls = &$this->calls;
$logger->channel = $channel;
return $logger;
}
public function withContext(array $context): static
{
$this->calls[] = ['method' => 'withContext', 'params' => [$context]];
$logger = clone $this;
$logger->calls = &$this->calls;
$logger->context = [...$this->context, ...$context];
return $logger;
}
};
$di['request'] = fn (): Request => Request::create('http://localhost/');
$di['session'] = static function (): object {
$session = \Mockery::mock(\FOSSBilling\Session::class)->shouldIgnoreMissing();
$session->shouldReceive('regenerateId')->byDefault()->andReturnNull();
$session->shouldReceive('set')->byDefault()->andReturnNull();
$session->shouldReceive('get')->byDefault()->andReturnNull();
$session->shouldReceive('delete')->byDefault()->andReturnNull();
return $session;
};
$di['dbal'] = static function (): object {
$dbal = \Mockery::mock(\Doctrine\DBAL\Connection::class)->shouldIgnoreMissing();
$result = \Mockery::mock(\Doctrine\DBAL\Result::class)->shouldIgnoreMissing();
$result->shouldReceive('fetchAssociative')->byDefault()->andReturn(false);
$result->shouldReceive('fetchAllAssociative')->byDefault()->andReturn([]);
$result->shouldReceive('fetchOne')->byDefault()->andReturn(false);
$queryBuilder = \Mockery::mock(\Doctrine\DBAL\Query\QueryBuilder::class)->shouldIgnoreMissing();
foreach ([
'select',
'addSelect',
'from',
'leftJoin',
'innerJoin',
'where',
'andWhere',
'orWhere',
'orderBy',
'addOrderBy',
'setFirstResult',
'setMaxResults',
'setParameter',
'setParameters',
'insert',
'update',
'delete',
'values',
'setValue',
'set',
] as $method) {
$queryBuilder->shouldReceive($method)->byDefault()->andReturn($queryBuilder);
}
$queryBuilder->shouldReceive('executeQuery')->byDefault()->andReturn($result);
$queryBuilder->shouldReceive('executeStatement')->byDefault()->andReturn(1);
$queryBuilder->shouldReceive('fetchAssociative')->byDefault()->andReturn(false);
$queryBuilder->shouldReceive('fetchAllAssociative')->byDefault()->andReturn([]);
$queryBuilder->shouldReceive('fetchOne')->byDefault()->andReturn(false);
$dbal->shouldReceive('fetchAssociative')->byDefault()->andReturn([]);
$dbal->shouldReceive('fetchAllAssociative')->byDefault()->andReturn([]);
$dbal->shouldReceive('fetchOne')->byDefault()->andReturn(null);
$dbal->shouldReceive('executeQuery')->byDefault()->andReturn($result);
$dbal->shouldReceive('executeStatement')->byDefault()->andReturn(1);
$dbal->shouldReceive('insert')->byDefault()->andReturn(1);
$dbal->shouldReceive('update')->byDefault()->andReturn(1);
$dbal->shouldReceive('lastInsertId')->byDefault()->andReturn(1);
$dbal->shouldReceive('createQueryBuilder')->byDefault()->andReturn($queryBuilder);
return $dbal;
};
$di['event_dispatcher'] = fn (): \FOSSBilling\Events\EventDispatcher => new \FOSSBilling\Events\EventDispatcher(
static fn (): array => [],
static fn (string $module): object => throw new \LogicException('No module listeners are configured in the test container.'),
);
$di['auth'] = fn (): object => \Mockery::mock()->shouldIgnoreMissing();
$di['pager'] = fn (): object => new class {
public function getDefaultPerPage(): int
{
return 20;
}
public function getAdvancedSearch(array $data, array $fields = [], string $table = ''): array
{
return ['', []];
}
public function getSimpleResultSet(string $query, array $params = [], int $perPage = 20): array
{
return ['list' => [], 'total' => 0, 'pages' => 0, 'page' => 1, 'per_page' => $perPage];
}
public function getPaginatedResultSet(string $query, array $params = [], mixed $pagination = null): array
{
return ['list' => [], 'total' => 0, 'pages' => 0, 'page' => 1, 'per_page' => 20];
}
public function paginateDoctrineQuery(\Doctrine\ORM\QueryBuilder $qb, \FOSSBilling\PaginationOptions $pagination, mixed ...$apiArrayArgs): array
{
return ['list' => [], 'total' => 0, 'pages' => 0, 'page' => $pagination->page, 'per_page' => $pagination->perPage];
}
};
$di['rate_limiter'] = fn (): object => new class {
public function consume(string $policyName, string $subject, int $tokens = 1): \FOSSBilling\Security\RateLimitResult
{
return new \FOSSBilling\Security\RateLimitResult($policyName, false, null, null);
}
public function consumeOrThrow(string $policyName, string $subject, int $tokens = 1): \FOSSBilling\Security\RateLimitResult
{
return $this->consume($policyName, $subject, $tokens);
}
};
$di['mod_config'] = $di->protect(fn (string $name): array => []);
$di['cookie_queue'] = fn (): \FOSSBilling\Http\CookieQueue => new \FOSSBilling\Http\CookieQueue();
$di['em'] = static function () use ($di): object {
$adminGroupRepository = \Mockery::mock(\Box\Mod\Staff\Repository\AdminGroupRepository::class)->shouldIgnoreMissing();
$adminGroupMemberRepository = \Mockery::mock(\Box\Mod\Staff\Repository\AdminGroupMemberRepository::class)->shouldIgnoreMissing();
$adminPasswordResetRepository = \Mockery::mock(\Box\Mod\Staff\Repository\AdminPasswordResetRepository::class)->shouldIgnoreMissing();
$clientQueryBuilder = \Mockery::mock(\Doctrine\ORM\QueryBuilder::class)->shouldIgnoreMissing();
foreach (['andWhere', 'orWhere', 'setParameter', 'orderBy', 'setFirstResult', 'setMaxResults', 'delete', 'where'] as $method) {
$clientQueryBuilder->shouldReceive($method)->byDefault()->andReturn($clientQueryBuilder);
}
$clientQuery = \Mockery::mock(\Doctrine\ORM\AbstractQuery::class)->shouldIgnoreMissing();
$clientQuery->shouldReceive('getResult')->byDefault()->andReturn([]);
$clientQuery->shouldReceive('execute')->byDefault()->andReturn(0);
$clientQueryBuilder->shouldReceive('getQuery')->byDefault()->andReturn($clientQuery);
$clientRepository = \Mockery::mock(\Box\Mod\Client\Repository\ClientRepository::class)->shouldIgnoreMissing();
$clientRepository->shouldReceive('find')->byDefault()->andReturnUsing(static fn (int $id): ?object => createEntity(\Box\Mod\Client\Entity\Client::class, ['id' => $id]));
$clientRepository->shouldReceive('findOneBy')->byDefault()->andReturn(null);
$clientRepository->shouldReceive('findOneByEmail')->byDefault()->andReturnUsing(static fn (string $email): ?object => createEntity(\Box\Mod\Client\Entity\Client::class, ['id' => 1, 'email' => $email]));
$clientRepository->shouldReceive('findOneByEmailAndActive')->byDefault()->andReturnUsing(static fn (string $email): ?object => createEntity(\Box\Mod\Client\Entity\Client::class, ['id' => 1, 'email' => $email, 'status' => 'active']));
$clientRepository->shouldReceive('findOneByApiToken')->byDefault()->andReturn(null);
$clientRepository->shouldReceive('getIdNamePairs')->byDefault()->andReturn([]);
$clientRepository->shouldReceive('getStatusCounts')->byDefault()->andReturn(['active' => 1, 'suspended' => 0, 'canceled' => 0]);
$clientRepository->shouldReceive('createQueryBuilder')->byDefault()->andReturn($clientQueryBuilder);
$clientBalanceRepository = \Mockery::mock(\Box\Mod\Client\Repository\ClientBalanceRepository::class)->shouldIgnoreMissing();
$clientBalanceRepository->shouldReceive('find')->byDefault()->andReturnUsing(static fn (int $id): ?object => createEntity(\Box\Mod\Client\Entity\ClientBalance::class, ['id' => $id]));
$clientBalanceRepository->shouldReceive('findBy')->byDefault()->andReturn([]);
$clientBalanceRepository->shouldReceive('getClientBalanceSum')->byDefault()->andReturn(0.0);
$clientGroupRepository = \Mockery::mock(\Box\Mod\Client\Repository\ClientGroupRepository::class)->shouldIgnoreMissing();
$clientGroupRepository->shouldReceive('find')->byDefault()->andReturnUsing(static fn (int $id): ?object => createEntity(\Box\Mod\Client\Entity\ClientGroup::class, ['id' => $id]));
$clientGroupRepository->shouldReceive('getIdTitlePairs')->byDefault()->andReturn([]);
$clientGroupMembershipRepository = \Mockery::mock(\Box\Mod\Client\Repository\ClientGroupMembershipRepository::class)->shouldIgnoreMissing();
$clientGroupMembershipRepository->shouldReceive('findOneBy')->byDefault()->andReturn(null);
$clientGroupMembershipRepository->shouldReceive('findBy')->byDefault()->andReturn([]);
$clientPasswordResetRepository = \Mockery::mock(\Box\Mod\Client\Repository\ClientPasswordResetRepository::class)->shouldIgnoreMissing();
$clientPasswordResetRepository->shouldReceive('find')->byDefault()->andReturn(null);
$clientPasswordResetRepository->shouldReceive('findBy')->byDefault()->andReturn([]);
$clientPasswordResetRepository->shouldReceive('findOneBy')->byDefault()->andReturn(null);
$clientPasswordResetRepository->shouldReceive('findOneByHash')->byDefault()->andReturn(null);
$clientPasswordResetRepository->shouldReceive('findExpiredBefore')->byDefault()->andReturn([]);
$clientPasswordResetRepository->shouldReceive('createQueryBuilder')->byDefault()->andReturn($clientQueryBuilder);
$extensionMetaRepository = \Mockery::mock(\Box\Mod\Extension\Repository\ExtensionMetaRepository::class)->shouldIgnoreMissing();
$extensionMetaRepository->shouldReceive('findOneByExtensionAndScope')->byDefault()->andReturn(null);
$extensionMetaRepository->shouldReceive('findByExtensionAndScope')->byDefault()->andReturn([]);
$extensionMetaRepository->shouldReceive('deleteByExtensionAndScope')->byDefault()->andReturn(0);
$emailTemplateRepository = \Mockery::mock(\Box\Mod\Email\Repository\EmailTemplateRepository::class)->shouldIgnoreMissing();
$templateQueryBuilder = \Mockery::mock(\Doctrine\ORM\QueryBuilder::class)->shouldIgnoreMissing();
$emailTemplateRepository->shouldReceive('find')->byDefault()->andReturn(null);
$emailTemplateRepository->shouldReceive('findOneByActionCode')->byDefault()->andReturn(null);
$emailTemplateRepository->shouldReceive('getSearchQueryBuilder')->byDefault()->andReturn($templateQueryBuilder);
$emailTemplateGroupRepository = \Mockery::mock(\Box\Mod\Email\Repository\EmailTemplateGroupRepository::class)->shouldIgnoreMissing();
$emailTemplateGroupRepository->shouldReceive('getGroupIdsForTemplate')->byDefault()->andReturn([]);
$emailTemplateGroupRepository->shouldReceive('countTemplatesUsingGroup')->byDefault()->andReturn(0);
$activityClientEmailRepository = \Mockery::mock(\Box\Mod\Email\Repository\ActivityClientEmailRepository::class)->shouldIgnoreMissing();
$queuedEmailRepository = \Mockery::mock(\Box\Mod\Email\Repository\QueuedEmailRepository::class)->shouldIgnoreMissing();
$kbArticleRepository = \Mockery::mock(\Box\Mod\Support\Repository\KbArticleRepository::class)->shouldIgnoreMissing();
$kbArticleCategoryRepository = \Mockery::mock(\Box\Mod\Support\Repository\KbArticleCategoryRepository::class)->shouldIgnoreMissing();
$cannedResponseRepository = \Mockery::mock(\Box\Mod\Support\Repository\CannedResponseRepository::class)->shouldIgnoreMissing();
$cannedResponseCategoryRepository = \Mockery::mock(\Box\Mod\Support\Repository\CannedResponseCategoryRepository::class)->shouldIgnoreMissing();
$helpdeskRepository = \Mockery::mock(\Box\Mod\Support\Repository\HelpdeskRepository::class)->shouldIgnoreMissing();
$supportTicketRepository = \Mockery::mock(\Box\Mod\Support\Repository\SupportTicketRepository::class)->shouldIgnoreMissing();
$supportTicketMessageRepository = \Mockery::mock(\Box\Mod\Support\Repository\SupportTicketMessageRepository::class)->shouldIgnoreMissing();
$supportTicketNoteRepository = \Mockery::mock(\Box\Mod\Support\Repository\SupportTicketNoteRepository::class)->shouldIgnoreMissing();
$supportTicketMessageHistoryRepository = \Mockery::mock(\Box\Mod\Support\Repository\SupportTicketMessageHistoryRepository::class)->shouldIgnoreMissing();
$payGatewayRepository = \Mockery::mock(\Box\Mod\Invoice\Repository\PayGatewayRepository::class)->shouldIgnoreMissing();
$payGatewayRepository->shouldReceive('find')->byDefault()->andReturn(null);
$payGatewayRepository->shouldReceive('findEnabledOrderedByIdDesc')->byDefault()->andReturn([]);
$payGatewayRepository->shouldReceive('findEnabledByGateway')->byDefault()->andReturn(null);
$payGatewayQueryBuilder = \Mockery::mock(\Doctrine\ORM\QueryBuilder::class)->shouldIgnoreMissing();
foreach (['andWhere', 'orWhere', 'setParameter', 'orderBy', 'setFirstResult', 'setMaxResults', 'where'] as $method) {
$payGatewayQueryBuilder->shouldReceive($method)->byDefault()->andReturn($payGatewayQueryBuilder);
}
$payGatewayRepository->shouldReceive('getSearchQueryBuilder')->byDefault()->andReturn($payGatewayQueryBuilder);
$transactionRepository = \Mockery::mock(\Box\Mod\Invoice\Repository\TransactionRepository::class)->shouldIgnoreMissing();
$transactionRepository->shouldReceive('find')->byDefault()->andReturn(null);
$transactionRepository->shouldReceive('findOneBy')->byDefault()->andReturn(null);
$transactionRepository->shouldReceive('findOneByTxnIdAndGatewayId')->byDefault()->andReturn(null);
$transactionRepository->shouldReceive('findOneByGatewayIdAndIpnHash')->byDefault()->andReturn(null);
$transactionRepository->shouldReceive('findActiveByTxnIdAndGatewayId')->byDefault()->andReturn(null);
$transactionRepository->shouldReceive('findProcessingOrProcessedByTxnId')->byDefault()->andReturn(null);
$transactionRepository->shouldReceive('competingTransactionQuery')->byDefault()->andReturn($payGatewayQueryBuilder);
$subscriptionRepository = \Mockery::mock(\Box\Mod\Invoice\Repository\SubscriptionRepository::class)->shouldIgnoreMissing();
$subscriptionRepository->shouldReceive('find')->byDefault()->andReturn(null);
$subscriptionRepository->shouldReceive('findOneBy')->byDefault()->andReturn(null);
$subscriptionRepository->shouldReceive('findOneBySid')->byDefault()->andReturn(null);
$invoiceRepository = \Mockery::mock(\Box\Mod\Invoice\Repository\InvoiceRepository::class)->shouldIgnoreMissing();
$invoiceRepository->shouldReceive('find')->byDefault()->andReturn(null);
$invoiceRepository->shouldReceive('findByHash')->byDefault()->andReturn(null);
$invoiceRepository->shouldReceive('findLatestWithNr')->byDefault()->andReturn(null);
$invoiceRepository->shouldReceive('findPaid')->byDefault()->andReturn([]);
$invoiceRepository->shouldReceive('findByClientId')->byDefault()->andReturn([]);
$invoiceRepository->shouldReceive('findUnpaidIssuedNotRemindedBefore')->byDefault()->andReturn([]);
$invoiceRepository->shouldReceive('findUnpaidOlderThan')->byDefault()->andReturn([]);
$invoiceRepository->shouldReceive('findPaidByRelId')->byDefault()->andReturn([]);
$invoiceItemRepository = \Mockery::mock(\Box\Mod\Invoice\Repository\InvoiceItemRepository::class)->shouldIgnoreMissing();
$invoiceItemRepository->shouldReceive('find')->byDefault()->andReturn(null);
$invoiceItemRepository->shouldReceive('findByInvoiceId')->byDefault()->andReturn([]);
$customPageRepository = \Mockery::mock(\Box\Mod\Custompages\Repository\CustomPageRepository::class)->shouldIgnoreMissing();
$customPageRepository->shouldReceive('find')->byDefault()->andReturn(null);
$customPageRepository->shouldReceive('findOneBySlug')->byDefault()->andReturn(null);
$customPageRepository->shouldReceive('findOneBySlugExcludingId')->byDefault()->andReturn(null);
$customPageRepository->shouldReceive('deleteByIds')->byDefault()->andReturn(0);
$customPageQueryBuilder = \Mockery::mock(\Doctrine\ORM\QueryBuilder::class)->shouldIgnoreMissing();
foreach (['andWhere', 'orWhere', 'setParameter', 'orderBy', 'setFirstResult', 'setMaxResults', 'where'] as $method) {
$customPageQueryBuilder->shouldReceive($method)->byDefault()->andReturn($customPageQueryBuilder);
}
$customPageRepository->shouldReceive('getSearchQueryBuilder')->byDefault()->andReturn($customPageQueryBuilder);
$settingRepository = \Mockery::mock(\Box\Mod\System\Repository\SettingRepository::class)->shouldIgnoreMissing();
$settingRepository->shouldReceive('findOneByParam')->byDefault()->andReturn(null);
$settingRepository->shouldReceive('findOnePublicByParam')->byDefault()->andReturn(null);
$settingRepository->shouldReceive('findByParams')->byDefault()->andReturn([]);
$settingRepository->shouldReceive('findAll')->byDefault()->andReturn([]);
$em = \Mockery::mock(\Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
$em->shouldReceive('wrapInTransaction')->byDefault()->andReturnUsing(static fn (callable $callback): mixed => $callback());
$em->shouldReceive('getConnection')->byDefault()->andReturn($di['dbal']);
$em->shouldReceive('getRepository')->byDefault()->andReturnUsing(static fn (string $class): object => match ($class) {
\Box\Mod\Client\Entity\Client::class => $clientRepository,
\Box\Mod\Client\Entity\ClientBalance::class => $clientBalanceRepository,
\Box\Mod\Client\Entity\ClientGroup::class => $clientGroupRepository,
\Box\Mod\Client\Entity\ClientGroupMembership::class => $clientGroupMembershipRepository,
\Box\Mod\Client\Entity\ClientPasswordReset::class => $clientPasswordResetRepository,
\Box\Mod\Staff\Entity\AdminGroup::class => $adminGroupRepository,
\Box\Mod\Staff\Entity\AdminGroupMember::class => $adminGroupMemberRepository,
\Box\Mod\Staff\Entity\AdminPasswordReset::class => $adminPasswordResetRepository,
\Box\Mod\Email\Entity\EmailTemplate::class => $emailTemplateRepository,
\Box\Mod\Email\Entity\EmailTemplateGroup::class => $emailTemplateGroupRepository,
\Box\Mod\Email\Entity\ActivityClientEmail::class => $activityClientEmailRepository,
\Box\Mod\Email\Entity\QueuedEmail::class => $queuedEmailRepository,
\Box\Mod\Support\Entity\KbArticle::class => $kbArticleRepository,
\Box\Mod\Support\Entity\KbArticleCategory::class => $kbArticleCategoryRepository,
\Box\Mod\Support\Entity\CannedResponse::class => $cannedResponseRepository,
\Box\Mod\Support\Entity\CannedResponseCategory::class => $cannedResponseCategoryRepository,
\Box\Mod\Support\Entity\Helpdesk::class => $helpdeskRepository,
\Box\Mod\Support\Entity\SupportTicket::class => $supportTicketRepository,
\Box\Mod\Support\Entity\SupportTicketMessage::class => $supportTicketMessageRepository,
\Box\Mod\Support\Entity\SupportTicketNote::class => $supportTicketNoteRepository,
\Box\Mod\Support\Entity\SupportTicketMessageHistory::class => $supportTicketMessageHistoryRepository,
\Box\Mod\Invoice\Entity\PayGateway::class => $payGatewayRepository,
\Box\Mod\Invoice\Entity\Transaction::class => $transactionRepository,
\Box\Mod\Invoice\Entity\Subscription::class => $subscriptionRepository,
\Box\Mod\Invoice\Entity\Invoice::class => $invoiceRepository,
\Box\Mod\Invoice\Entity\InvoiceItem::class => $invoiceItemRepository,
\Box\Mod\Custompages\Entity\CustomPage::class => $customPageRepository,
\Box\Mod\System\Entity\Setting::class => $settingRepository,
\Box\Mod\Extension\Entity\Extension::class => \Mockery::mock(\Box\Mod\Extension\Repository\ExtensionRepository::class)->shouldIgnoreMissing(),
default => $extensionMetaRepository,
});
return $em;
};
$staffService = \Mockery::mock(\Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
$staffService->shouldReceive('hasPermission')->byDefault()->andReturn(true);
$staffService->shouldReceive('checkPermissionsAndThrowException')->byDefault()->andReturn(true);
$emailService = \Mockery::mock(\Box\Mod\Email\Service::class)->shouldIgnoreMissing();
$emailTemplateGroupRepositoryDefault = \Mockery::mock(\Box\Mod\Email\Repository\EmailTemplateGroupRepository::class)->shouldIgnoreMissing();
$emailTemplateGroupRepositoryDefault->shouldReceive('countTemplatesUsingGroup')->byDefault()->andReturn(0);
$emailService->shouldReceive('getTemplateGroupRepository')->byDefault()->andReturn($emailTemplateGroupRepositoryDefault);
$di['mod_service'] = $di->protect(static function (string $name = '', string $sub = '') use ($staffService, $emailService): object {
if (strtolower($name) === 'staff') {
return $staffService;
}
if (strtolower($name) === 'email') {
return $emailService;
}
return \Mockery::mock()->shouldIgnoreMissing();
});
return $di;
}
/**
* Create a module service resolver with staff permissions enabled by default.
*
* @param array<string, object> $services
*/
function moduleService(array $services = []): \Closure
{
$staffService = $services['staff'] ?? \Mockery::mock(\Box\Mod\Staff\Service::class)->shouldIgnoreMissing();
$staffService->shouldReceive('hasPermission')->byDefault()->andReturn(true);
$staffService->shouldReceive('checkPermissionsAndThrowException')->byDefault()->andReturn(true);
if (!isset($services['email'])) {
$emailTemplateGroupRepositoryDefault = \Mockery::mock(\Box\Mod\Email\Repository\EmailTemplateGroupRepository::class)->shouldIgnoreMissing();
$emailTemplateGroupRepositoryDefault->shouldReceive('countTemplatesUsingGroup')->byDefault()->andReturn(0);
$services['email'] = \Mockery::mock(\Box\Mod\Email\Service::class)->shouldIgnoreMissing();
$services['email']->shouldReceive('getTemplateGroupRepository')->byDefault()->andReturn($emailTemplateGroupRepositoryDefault);
}
return static function (string $name = '', string $sub = '') use ($services, $staffService): object {
if (strtolower($name) === 'staff') {
return $staffService;
}
$module = strtolower($name);
$moduleWithSub = $sub === '' ? $module : $module . ':' . strtolower($sub);
return $services[$moduleWithSub] ?? $services[$module] ?? \Mockery::mock()->shouldIgnoreMissing();
};
}
/**
* Access a private property on an object using reflection.
*
* @return mixed The property value when getting, null when setting
*/
function accessPrivate(object $instance, string $property, mixed $value = null): mixed
{
$refl = new \ReflectionClass($instance);
$prop = null;
if ($refl->hasProperty($property)) {
$prop = $refl->getProperty($property);
} else {
$parentClass = $refl->getParentClass();
if ($parentClass && $parentClass->hasProperty($property)) {
$prop = $parentClass->getProperty($property);
}
}
if ($prop === null || $prop->isStatic()) {
return null;
}
// If value is provided, set the property
if (func_num_args() > 2) {
$prop->setValue($instance, $value);
return null;
}
// Otherwise, get the property value
return $prop->getValue($instance);
}
/**
* Create a Doctrine entity proxy that accepts snake_case and camelCase properties.
*
* The helper keeps focused unit tests concise while leaving production entities
* with explicit getters and setters.
*
* @param class-string $class
*/
function createEntity(string $class, array $properties = []): object
{
static $proxied = [];
$key = md5($class);
if (!isset($proxied[$key])) {
$namespace = 'Tests\\Helpers';
$shortName = 'EntityProxy_' . $key;
$fqcn = $namespace . '\\' . $shortName;
$code = sprintf(
<<<'PHP'
namespace %s;
class %s extends \%s
{
private array $_extra = [];
public function __construct(array $properties = [])
{
foreach ($properties as $name => $value) {
$this->$name = $value;
}
}
public function __set(string $name, mixed $value): void
{
$method = 'set' . str_replace('_', '', ucwords($name, '_'));
if (method_exists($this, $method)) {
$parameter = (new \ReflectionMethod($this, $method))->getParameters()[0] ?? null;
$type = $parameter?->getType();
if ($type instanceof \ReflectionNamedType) {
if ($type->getName() === \DateTime::class && is_string($value)) {
$value = new \DateTime($value);
} elseif ($type->getName() === 'int' && is_string($value) && ctype_digit($value)) {
$value = (int) $value;
} elseif ($type->getName() === 'float' && is_numeric($value)) {
$value = (float) $value;
} elseif ($type->getName() === 'bool' && is_int($value)) {
$value = (bool) $value;
} elseif ($type->getName() === 'string' && is_scalar($value)) {
$value = (string) $value;
}
}
$this->$method($value);
return;
}
$propertyName = lcfirst(str_replace('_', '', ucwords($name, '_')));
$reflection = new \ReflectionClass($this);
while (!$reflection->hasProperty($propertyName) && ($parent = $reflection->getParentClass())) {
$reflection = $parent;
}
if ($reflection->hasProperty($propertyName)) {
$property = $reflection->getProperty($propertyName);
$type = $property->getType();
if ($type instanceof \ReflectionNamedType) {
if ($type->getName() === \DateTime::class && is_string($value)) {
$value = new \DateTime($value);
} elseif ($type->getName() === 'int' && is_numeric($value)) {
$value = (int) $value;
} elseif ($type->getName() === 'bool' && is_int($value)) {
$value = (bool) $value;
} elseif ($type->getName() === 'string' && is_scalar($value)) {
$value = (string) $value;
}
}
$property->setValue($this, $value);
return;
}
$this->_extra[$name] = $value;
}
public function __get(string $name): mixed
{
if (array_key_exists($name, $this->_extra)) {
return $this->_extra[$name];
}
$method = 'get' . str_replace('_', '', ucwords($name, '_'));
return method_exists($this, $method) ? $this->$method() : null;
}
public function __isset(string $name): bool
{
return $this->__get($name) !== null;
}
}
PHP,
$namespace,
$shortName,
$class,
);
eval($code);
$proxied[$key] = $fqcn;
}
$proxy = $proxied[$key];
return new $proxy($properties);
}
/**
* Inject a mock filesystem into a service that has a private filesystem property.
*/
function injectMockFilesystem(object $service, \Mockery\MockInterface $filesystemMock): void
{
$refl = new \ReflectionClass($service);
while (!$refl->hasProperty('filesystem')) {
$parent = $refl->getParentClass();
if ($parent === false) {
throw new \ReflectionException(sprintf('Property %s::$filesystem does not exist', $service::class));
}
$refl = $parent;
}
$prop = $refl->getProperty('filesystem');
$prop->setValue($service, $filesystemMock);
}