Migrate Email, Extension and Support modules to Doctrine (#3863)

* Migrate Email, Extension, Support modules to Doctrine ORM

* Add 'initialises' to cspell.json custom words

* Add patch76 and fix extension deactivation event

* Bump database patch version to 76

* Refactor Support APIs to mapped Doctrine flows

* Handle legacy email queue and ticket filters

* Migrate Support module to Doctrine ORM

* Refactor services to use typed repositories

* Route order ticket count and ticket messages through repositories

Drops the last two thin Support\Service wrappers, calling the
SupportTicketRepository and SupportTicketMessageRepository directly
from the cross-module callers.

* Rename email queue table and add patch

* Preserve unlimited email queue processing

* Preserve unlimited email queue processing
This commit is contained in:
Adam Daley authored and GitHub committed 2026-07-03 10:07:01 +03:00
1 parent e7ca860214
commit 5ee72a234a
58 files changed
+4156 -2131

No files matched your search

+2 -1
View File
@@ -55,7 +55,8 @@
"mysite",
"myhost",
"inversed",
"Autoresponder"
"Autoresponder",
"initialises"
],
"ignoreWords": [
+1 -1
View File
@@ -303,7 +303,7 @@ LOCK TABLES `setting` WRITE;
INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
VALUES
(1,'last_patch','75',0,NULL,NULL,'2024-12-30 12:00:00','2024-12-30 12:00:00'),
(1,'last_patch','77',0,NULL,NULL,'2024-12-30 12:00:00','2024-12-30 12:00:00'),
(2,'company_name','Company Name',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
(3,'company_email','support@yourcompany.com',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
(4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,'2022-12-01 12:00:00','2022-12-01 12:00:00'),
+3 -2
View File
@@ -46,6 +46,7 @@ CREATE TABLE `activity_client_email` (
`content_html` text,
`content_text` text,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `client_id_idx` (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -635,12 +636,12 @@ CREATE TABLE `support_kb_article_category` (
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `mod_email_queue`
-- Table structure for table `email_queue`
--
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `mod_email_queue` (
CREATE TABLE `email_queue` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`recipient` varchar(255) NOT NULL,
`sender` varchar(255) NOT NULL,
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace FOSSBilling\Doctrine;
use Doctrine\ORM\Mapping as ORM;
/**
* Standard `createdAt` / `updatedAt` lifecycle and accessors for Doctrine entities.
*
* Classes that use this trait should:
* - Add the class-level `#[ORM\HasLifecycleCallbacks]` attribute
* - Implement {@see \FOSSBilling\Interfaces\TimestampInterface}
*
* The trait provides:
* - `createdAt` and `updatedAt` properties mapped to the `created_at` / `updated_at`
* `DATETIME_MUTABLE` columns
* - `onPrePersist()` lifecycle callback that initialises both timestamps
* - `onPreUpdate()` lifecycle callback that bumps `updatedAt`
* - Getter and setter methods satisfying {@see \FOSSBilling\Interfaces\TimestampInterface}
*
* Special-cases:
* - Entities that must not auto-bump `updatedAt` on every Doctrine update (e.g. when
* a non-state-changing field such as a view counter is updated) can override
* `onPreUpdate()` to be a no-op and manage `updatedAt` from explicit setters.
* - Entities that need a different column name (extremely rare) can redeclare the
* property with a new `#[ORM\Column(name: '...')]` attribute — the redeclaration
* takes precedence over the trait property.
*/
trait TimestampTrait
{
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $createdAt = null;
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $updatedAt = null;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTime();
$this->createdAt ??= $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTime();
}
public function getCreatedAt(): ?\DateTime
{
return $this->createdAt;
}
public function setCreatedAt(?\DateTime $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?\DateTime
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTime $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace FOSSBilling\Enums;
/**
* Canonical client order lifecycle status.
*
* The underlying string values must match the existing RedBean `client_order.status`
* column values and the `Model_ClientOrder::STATUS_*` constants so the enum is
* interchangeable with the legacy code paths.
*/
enum ClientOrderStatusEnum: string
{
case PENDING_SETUP = 'pending_setup';
case FAILED_SETUP = 'failed_setup';
case FAILED_RENEW = 'failed_renew';
case ACTIVE = 'active';
case CANCELED = 'canceled';
case SUSPENDED = 'suspended';
/**
* @return list<string>
*/
public static function values(): array
{
return array_map(static fn (self $c): string => $c->value, self::cases());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace FOSSBilling\Enums;
/**
* Canonical client lifecycle status.
*
* The underlying string values must match the existing RedBean `client.status`
* column values and the `Model_Client::ACTIVE/SUSPENDED/CANCELED` constants
* so the enum is interchangeable with the legacy code paths.
*/
enum ClientStatusEnum: string
{
case ACTIVE = 'active';
case SUSPENDED = 'suspended';
case CANCELED = 'canceled';
}
+35
View File
@@ -121,4 +121,39 @@ class Pagination implements InjectionAwareInterface
return $this->buildPaginatedResponse($pagination->page, $pagination->perPage, $total, $result);
}
/**
* Paginate results from a Doctrine QueryBuilder and apply a custom mapper to each entity.
*
* Use this when the entity's `toApiArray()` requires context (identity, deep flag,
* cross-module lookups) that the default `paginateDoctrineQuery()` cannot provide.
* The caller supplies a closure that performs the mapping.
*
* @param QueryBuilder $qb the Doctrine QueryBuilder instance to paginate
* @param PaginationOptions $pagination pagination options
* @param callable $mapper fn(object $entity): array applied to each row
*
* @return array{
* pages: int, // Total number of pages
* page: int, // Current page number
* per_page: int, // Items per page
* total: int, // Total number of items
* list: array // List of paginated items as arrays
* }
*/
public function paginateMappedQuery(QueryBuilder $qb, PaginationOptions $pagination, callable $mapper): array
{
$offset = ($pagination->page - 1) * $pagination->perPage;
$qb->setFirstResult($offset)
->setMaxResults($pagination->perPage);
$paginator = new DoctrinePaginator($qb, true);
$total = count($paginator);
$list = [];
foreach ($paginator as $entity) {
$list[] = $mapper($entity);
}
return $this->buildPaginatedResponse($pagination->page, $pagination->perPage, $total, $list);
}
}
+28 -6
View File
@@ -481,6 +481,8 @@ class UpdatePatcher implements InjectionAwareInterface
73 => 'patch73',
74 => 'patch74',
75 => 'patch75',
76 => 'patch76',
77 => 'patch77',
];
ksort($patches, SORT_NATURAL);
@@ -641,8 +643,8 @@ class UpdatePatcher implements InjectionAwareInterface
}
// If the Kb extension exists, uninstall it.
$kb_ext = $ext_service->findExtension('mod', 'kb');
if ($kb_ext instanceof \Model_Extension) {
$kb_ext = $ext_service->getExtensionRepository()->findOneByTypeAndName('mod', 'kb');
if ($kb_ext instanceof \Box\Mod\Extension\Entity\Extension) {
$ext_service->deactivate($kb_ext);
$ext_service->uninstall('mod', 'kb');
}
@@ -664,8 +666,8 @@ class UpdatePatcher implements InjectionAwareInterface
try {
$ext_service = $this->di['mod_service']('extension');
// If the queue extension exists, uninstall it.
$queue_ext = $ext_service->findExtension('mod', 'queue');
if ($queue_ext instanceof \Model_Extension) {
$queue_ext = $ext_service->getExtensionRepository()->findOneByTypeAndName('mod', 'queue');
if ($queue_ext instanceof \Box\Mod\Extension\Entity\Extension) {
$ext_service->deactivate($queue_ext);
$ext_service->uninstall('mod', 'queue');
}
@@ -1097,8 +1099,8 @@ class UpdatePatcher implements InjectionAwareInterface
$this->executeSql("DELETE FROM extension_meta WHERE extension = 'mod_spamchecker' AND meta_key = 'config'");
$spamcheckerExt = $extService->findExtension('mod', 'spamchecker');
if ($spamcheckerExt instanceof \Model_Extension) {
$spamcheckerExt = $extService->getExtensionRepository()->findOneByTypeAndName('mod', 'spamchecker');
if ($spamcheckerExt instanceof \Box\Mod\Extension\Entity\Extension) {
$extService->deactivate($spamcheckerExt);
$extService->uninstall('mod', 'spamchecker');
}
@@ -1934,6 +1936,26 @@ class UpdatePatcher implements InjectionAwareInterface
$this->executeSql('ALTER TABLE `client` DROP COLUMN `document_type`, DROP COLUMN `document_nr`;');
}
private function patch76(): void
{
// The `activity_client_email` table was missing an `updated_at` column,
// but the Doctrine entity for it now expects one. Add the column for
// installations that were created before the entity was migrated.
if (!$this->tableHasColumn('activity_client_email', 'updated_at')) {
$this->executeSql('ALTER TABLE `activity_client_email` ADD COLUMN `updated_at` datetime DEFAULT NULL AFTER `created_at`');
}
}
private function patch77(): void
{
// The email queue table was renamed from `mod_email_queue` to
// `email_queue` when the `QueuedEmail` Doctrine entity was introduced.
// Rename it for installations that still use the legacy table name.
if ($this->tableExists('mod_email_queue') && !$this->tableExists('email_queue')) {
$this->executeSql('RENAME TABLE `mod_email_queue` TO `email_queue`');
}
}
private function generateDownloadableStoredFilename(): string
{
do {
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* Copyright 2011-2021 BoxBilling, Inc.
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
class Model_SupportTicket extends RedBeanPHP\SimpleModel
{
final public const string OPENED = 'open';
final public const string ONHOLD = 'on_hold';
final public const string CLOSED = 'closed';
final public const string REL_TYPE_ORDER = 'order';
final public const array ALLOWED_REL_TYPES = [
self::REL_TYPE_ORDER,
];
final public const string REL_STATUS_PENDING = 'pending';
final public const string REL_STATUS_COMPLETE = 'complete';
final public const array ALLOWED_REL_STATUSES = [
self::REL_STATUS_PENDING,
self::REL_STATUS_COMPLETE,
];
final public const string REL_TASK_CANCEL = 'cancel';
final public const string REL_TASK_UPGRADE = 'upgrade';
final public const array ALLOWED_REL_TASKS = [
self::REL_TASK_CANCEL,
self::REL_TASK_UPGRADE,
];
}
@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* Copyright 2011-2021 BoxBilling, Inc.
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
class Model_SupportTicketMessage extends RedBeanPHP\SimpleModel
{
}
-14
View File
@@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2025 FOSSBilling
* Copyright 2011-2021 BoxBilling, Inc.
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
class Model_SupportTicketNote extends RedBeanPHP\SimpleModel
{
}
+3 -30
View File
@@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Box\Mod\Currency\Entity;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
use Symfony\Component\Intl\Currencies;
@@ -22,6 +23,8 @@ use Symfony\Component\Intl\Currencies;
#[ORM\HasLifecycleCallbacks]
class Currency implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
@@ -36,12 +39,6 @@ class Currency implements ApiArrayInterface, TimestampInterface
private ?float $conversionRateFloat = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $createdAt = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $updatedAt = null;
public function __construct(
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 3, unique: true)]
private string $code,
@@ -59,20 +56,6 @@ class Currency implements ApiArrayInterface, TimestampInterface
];
}
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTime();
$this->createdAt = $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function updateTimestamp(): void
{
$this->updatedAt = new \DateTime();
}
public function getId(): int
{
return $this->id;
@@ -141,14 +124,4 @@ class Currency implements ApiArrayInterface, TimestampInterface
return $this;
}
public function setCreatedAt(?\DateTime $createdAt): void
{
$this->createdAt = $createdAt;
}
public function setUpdatedAt(?\DateTime $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
}
+16 -50
View File
@@ -17,7 +17,6 @@ declare(strict_types=1);
namespace Box\Mod\Email\Api;
use FOSSBilling\PaginationOptions;
use FOSSBilling\Tools;
use FOSSBilling\Validation\Api\RequiredParams;
class Admin extends \FOSSBilling\Api\AbstractApi
@@ -31,27 +30,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('email', 'view_email_history');
[$sql, $params] = $this->getService()->getSearchQuery($data);
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $params, PaginationOptions::fromArray($data));
$repo = $this->getService()->getActivityClientEmailRepository();
foreach ($pager['list'] as $key => $item) {
if (!is_array($item)) {
continue;
}
$pager['list'][$key] = [
'id' => $item['id'],
'client_id' => $item['client_id'],
'sender' => $item['sender'] ?? '',
'recipients' => $item['recipients'] ?? '',
'subject' => $item['subject'] ?? '',
'content_html' => Tools::sanitizeContent($item['content_html'] ?? ''),
'content_text' => $item['content_text'] ?? '',
'created_at' => $item['created_at'] ?? '',
'updated_at' => $item['updated_at'] ?? '',
];
}
return $pager;
return $this->getDi()['pager']->paginateDoctrineQuery(
$repo->getSearchQueryBuilder($data),
PaginationOptions::fromArray($data),
);
}
/**
@@ -113,11 +97,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('email', 'send_emails');
$model = $this->getDi()['db']->findOne('ActivityClientEmail', 'id = ?', [$data['id']]);
if (!$model instanceof \Model_ActivityClientEmail) {
throw new \FOSSBilling\Exception('Email not found');
}
$model = $this->getService()->getActivityClientEmailRepository()->findOneByIdOrFail((int) $data['id']);
return $this->getService()->resend($model);
}
@@ -132,14 +112,12 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('email', 'delete_email_history');
$model = $this->getDi()['db']->findOne('ActivityClientEmail', 'id = ?', [$data['id']]);
$em = $this->getDi()['em'];
$model = $this->getService()->getActivityClientEmailRepository()->findOneByIdOrFail((int) $data['id']);
if (!$model instanceof \Model_ActivityClientEmail) {
throw new \FOSSBilling\Exception('Email not found');
}
$id = $model->id;
$this->getDi()['db']->trash($model);
$id = $model->getId();
$em->remove($model);
$em->flush();
$this->getDi()['logger']->info('Deleted email #%s', $id);
@@ -447,23 +425,11 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('email', 'view_email_history');
[$sql, $params] = $this->getService()->queueGetSearchQuery($data);
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $params, PaginationOptions::fromArray($data));
$repo = $this->getService()->getQueuedEmailRepository();
foreach ($pager['list'] as $key => $item) {
$pager['list'][$key] = [
'id' => $item['id'] ?? '',
'recipient' => $item['recipient'] ?? '',
'subject' => $item['subject'] ?? '',
'content' => $item['content'] ?? '',
'to_name' => $item['to_name'] ?? '',
'status' => $item['status'] ?? '',
'tries' => $item['tries'] ?? '',
'created_at' => $item['created_at'] ?? '',
'updated_at' => $item['updated_at'] ?? '',
];
}
return $pager;
return $this->getDi()['pager']->paginateDoctrineQuery(
$repo->getSearchQueryBuilder($data),
PaginationOptions::fromArray($data),
);
}
}
+22 -34
View File
@@ -17,7 +17,6 @@ declare(strict_types=1);
namespace Box\Mod\Email\Api;
use FOSSBilling\PaginationOptions;
use FOSSBilling\Tools;
use FOSSBilling\Validation\Api\RequiredParams;
class Client extends \FOSSBilling\Api\AbstractApi
@@ -31,24 +30,13 @@ class Client extends \FOSSBilling\Api\AbstractApi
{
$client = $this->getIdentity();
$data['client_id'] = $client->id;
[$sql, $params] = $this->getService()->getSearchQuery($data);
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $params, PaginationOptions::fromArray($data));
foreach ($pager['list'] as $key => $item) {
$pager['list'][$key] = [
'id' => $item['id'] ?? '',
'client_id' => $item['client_id'] ?? '',
'sender' => $item['sender'] ?? '',
'recipients' => $item['recipients'] ?? '',
'subject' => $item['subject'] ?? '',
'content_html' => Tools::sanitizeContent($item['content_html'] ?? ''),
'content_text' => $item['content_text'] ?? '',
'created_at' => $item['created_at'] ?? '',
'updated_at' => $item['updated_at'] ?? '',
];
}
$repo = $this->getService()->getActivityClientEmailRepository();
return $pager;
return $this->getDi()['pager']->paginateDoctrineQuery(
$repo->getSearchQueryBuilder($data),
PaginationOptions::fromArray($data),
);
}
/**
@@ -61,11 +49,10 @@ class Client extends \FOSSBilling\Api\AbstractApi
#[RequiredParams(['id' => 'Email ID was not passed'])]
public function get($data)
{
$model = $this->getService()->findOneForClientById($this->getIdentity(), $data['id']);
if (!$model instanceof \Model_ActivityClientEmail) {
throw new \FOSSBilling\Exception('Email not found');
}
$model = $this->getService()->getActivityClientEmailRepository()->findOneForClientByIdOrFail(
(int) $this->getIdentity()->id,
(int) $data['id'],
);
return $this->getService()->toApiArray($model);
}
@@ -85,10 +72,10 @@ class Client extends \FOSSBilling\Api\AbstractApi
$this->getDi()['rate_limiter']->consumeOrThrow('client_email_resend_ip', (string) $this->getIp());
$this->getDi()['rate_limiter']->consumeOrThrow('client_email_resend_account', 'client:' . $client->id);
$model = $this->getService()->findOneForClientById($client, $data['id']);
if (!$model instanceof \Model_ActivityClientEmail) {
throw new \FOSSBilling\Exception('Email not found');
}
$model = $this->getService()->getActivityClientEmailRepository()->findOneForClientByIdOrFail(
(int) $client->id,
(int) $data['id'],
);
return $this->getService()->resend($model);
}
@@ -96,18 +83,19 @@ class Client extends \FOSSBilling\Api\AbstractApi
/**
* Remove email from system.
*
* @return bool
*
* @throws \FOSSBilling\Exception
*/
#[RequiredParams(['id' => 'Email ID was not passed'])]
public function delete($data)
public function delete($data): bool
{
$model = $this->getService()->findOneForClientById($this->getIdentity(), $data['id']);
if (!$model instanceof \Model_ActivityClientEmail) {
throw new \FOSSBilling\Exception('Email not found');
}
$em = $this->getDi()['em'];
$model = $this->getService()->getActivityClientEmailRepository()->findOneForClientByIdOrFail(
(int) $this->getIdentity()->id,
(int) $data['id'],
);
$em->remove($model);
$em->flush();
return $this->getService()->rm($model);
return true;
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Email\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
/**
* One row per email that FOSSBilling sent to a client.
*
* Used as a delivery log and to power the "resend" action on the
* client-facing email history view.
*/
#[ORM\Entity(repositoryClass: \Box\Mod\Email\Repository\ActivityClientEmailRepository::class)]
#[ORM\Table(name: 'activity_client_email')]
#[ORM\HasLifecycleCallbacks]
class ActivityClientEmail implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
private ?int $id = null;
#[ORM\Column(name: 'client_id', type: Types::INTEGER, nullable: true)]
private ?int $clientId = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $sender = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $recipients = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $subject = null;
#[ORM\Column(name: 'content_html', type: Types::TEXT, nullable: true)]
private ?string $contentHtml = null;
#[ORM\Column(name: 'content_text', type: Types::TEXT, nullable: true)]
private ?string $contentText = null;
public function toApiArray(): array
{
return [
'id' => $this->id,
'client_id' => $this->clientId,
'sender' => $this->sender,
'recipients' => $this->recipients,
'subject' => $this->subject,
'content_html' => $this->contentHtml,
'content_text' => $this->contentText,
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
public function getId(): ?int
{
return $this->id;
}
public function getClientId(): ?int
{
return $this->clientId;
}
public function setClientId(?int $clientId): self
{
$this->clientId = $clientId;
return $this;
}
public function getSender(): ?string
{
return $this->sender;
}
public function setSender(?string $sender): self
{
$this->sender = $sender;
return $this;
}
public function getRecipients(): ?string
{
return $this->recipients;
}
public function setRecipients(?string $recipients): self
{
$this->recipients = $recipients;
return $this;
}
public function getSubject(): ?string
{
return $this->subject;
}
public function setSubject(?string $subject): self
{
$this->subject = $subject;
return $this;
}
public function getContentHtml(): ?string
{
return $this->contentHtml;
}
public function setContentHtml(?string $contentHtml): self
{
$this->contentHtml = $contentHtml;
return $this;
}
public function getContentText(): ?string
{
return $this->contentText;
}
public function setContentText(?string $contentText): self
{
$this->contentText = $contentText;
return $this;
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Email\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
/**
* Pending email in the queue processed by the email-batch cron task.
*/
#[ORM\Entity(repositoryClass: \Box\Mod\Email\Repository\QueuedEmailRepository::class)]
#[ORM\Table(name: 'email_queue')]
#[ORM\HasLifecycleCallbacks]
class QueuedEmail implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
public const STATUS_PENDING = 'pending';
public const STATUS_SENT = 'sent';
public const STATUS_FAILED = 'failed';
public const STATUS_UNSENT = 'unsent';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
private ?int $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
private string $recipient;
#[ORM\Column(type: Types::STRING, length: 255)]
private string $sender;
#[ORM\Column(type: Types::STRING, length: 255)]
private string $subject;
#[ORM\Column(type: Types::TEXT)]
private string $content;
#[ORM\Column(name: 'to_name', type: Types::STRING, length: 255, nullable: true)]
private ?string $toName = null;
#[ORM\Column(name: 'from_name', type: Types::STRING, length: 255, nullable: true)]
private ?string $fromName = null;
#[ORM\Column(name: 'client_id', type: Types::INTEGER, nullable: true)]
private ?int $clientId = null;
#[ORM\Column(name: 'admin_id', type: Types::INTEGER, nullable: true)]
private ?int $adminId = null;
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $priority = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
private int $tries = 0;
#[ORM\Column(type: Types::STRING, length: 20, options: ['default' => self::STATUS_PENDING])]
private string $status = self::STATUS_PENDING;
public function toApiArray(): array
{
return [
'id' => $this->id,
'recipient' => $this->recipient,
'subject' => $this->subject,
'content' => $this->content,
'to_name' => $this->toName,
'status' => $this->status,
'tries' => $this->tries,
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
public function getId(): ?int
{
return $this->id;
}
public function getRecipient(): string
{
return $this->recipient;
}
public function setRecipient(string $recipient): self
{
$this->recipient = $recipient;
return $this;
}
public function getSender(): string
{
return $this->sender;
}
public function setSender(string $sender): self
{
$this->sender = $sender;
return $this;
}
public function getSubject(): string
{
return $this->subject;
}
public function setSubject(string $subject): self
{
$this->subject = $subject;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function getToName(): ?string
{
return $this->toName;
}
public function setToName(?string $toName): self
{
$this->toName = $toName;
return $this;
}
public function getFromName(): ?string
{
return $this->fromName;
}
public function setFromName(?string $fromName): self
{
$this->fromName = $fromName;
return $this;
}
public function getClientId(): ?int
{
return $this->clientId;
}
public function setClientId(?int $clientId): self
{
$this->clientId = $clientId;
return $this;
}
public function getAdminId(): ?int
{
return $this->adminId;
}
public function setAdminId(?int $adminId): self
{
$this->adminId = $adminId;
return $this;
}
public function getPriority(): ?int
{
return $this->priority;
}
public function setPriority(?int $priority): self
{
$this->priority = $priority;
return $this;
}
public function getTries(): int
{
return $this->tries;
}
public function setTries(int $tries): self
{
$this->tries = $tries;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): self
{
$this->status = $status;
return $this;
}
}
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Email\Repository;
use Box\Mod\Email\Entity\ActivityClientEmail;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
class ActivityClientEmailRepository extends EntityRepository
{
/**
* Build a QueryBuilder for the per-client email history listing.
*
* Accepted keys in `$data`:
* - `id` (int) exact id match
* - `client_id` (int) filter to one client
* - `sender` (string) LIKE on sender
* - `recipient` (string) LIKE on recipients
* - `subject` (string) LIKE on subject
* - `search` (string) LIKE on subject, sender, recipients, or content
* - `date_from` (string) created_at lower bound
* - `date_to` (string) created_at upper bound
*/
public function getSearchQueryBuilder(array $data = []): QueryBuilder
{
$qb = $this->createQueryBuilder('e')
->orderBy('e.id', 'DESC');
if (!empty($data['id'])) {
$qb->andWhere('e.id = :id')
->setParameter('id', (int) $data['id']);
}
if (!empty($data['client_id'])) {
$qb->andWhere('e.clientId = :client_id')
->setParameter('client_id', (int) $data['client_id']);
}
if (!empty($data['sender'])) {
$qb->andWhere('e.sender LIKE :sender')
->setParameter('sender', '%' . $data['sender'] . '%');
}
if (!empty($data['recipient'])) {
$qb->andWhere('e.recipients LIKE :recipient')
->setParameter('recipient', '%' . $data['recipient'] . '%');
}
if (!empty($data['subject'])) {
$qb->andWhere('e.subject LIKE :subject')
->setParameter('subject', '%' . $data['subject'] . '%');
}
if (!empty($data['search'])) {
$qb->andWhere('(e.subject LIKE :search OR e.sender LIKE :search OR e.recipients LIKE :search OR e.contentText LIKE :search OR e.contentHtml LIKE :search)')
->setParameter('search', '%' . $data['search'] . '%');
}
if (!empty($data['date_from'])) {
$qb->andWhere('e.createdAt >= :date_from')
->setParameter('date_from', new \DateTime((string) $data['date_from'] . ' 00:00:00'));
}
if (!empty($data['date_to'])) {
$qb->andWhere('e.createdAt <= :date_to')
->setParameter('date_to', new \DateTime((string) $data['date_to'] . ' 23:59:59'));
}
return $qb;
}
/**
* Find a single email owned by the given client.
*/
public function findOneForClientById(int $clientId, int $id): ?ActivityClientEmail
{
return $this->findOneBy(['id' => $id, 'clientId' => $clientId]);
}
/**
* Find a single email by id, throwing if it does not exist.
*/
public function findOneByIdOrFail(int $id): ActivityClientEmail
{
$email = $this->find($id);
if (!$email instanceof ActivityClientEmail) {
throw new \FOSSBilling\Exception('Email not found');
}
return $email;
}
/**
* Find a single email owned by the given client, throwing if it does not exist.
*/
public function findOneForClientByIdOrFail(int $clientId, int $id): ActivityClientEmail
{
$email = $this->findOneForClientById($clientId, $id);
if (!$email instanceof ActivityClientEmail) {
throw new \FOSSBilling\Exception('Email not found');
}
return $email;
}
/**
* @return ActivityClientEmail[]
*/
public function findByClientId(int $clientId): array
{
return $this->findBy(['clientId' => $clientId]);
}
/**
* Delete every email log row belonging to a client in a single query.
*
* Returns the number of deleted rows.
*/
public function deleteByClientId(int $clientId): int
{
return (int) $this->getEntityManager()->createQueryBuilder()
->delete(ActivityClientEmail::class, 'e')
->where('e.clientId = :client_id')
->setParameter('client_id', $clientId)
->getQuery()
->execute();
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Email\Repository;
use Box\Mod\Email\Entity\QueuedEmail;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
class QueuedEmailRepository extends EntityRepository
{
/**
* Build a QueryBuilder for the email queue listing (admin UI).
*/
public function getSearchQueryBuilder(array $data = []): QueryBuilder
{
$qb = $this->createQueryBuilder('q')
->orderBy('q.priority', 'DESC')
->addOrderBy('q.id', 'ASC');
if (!empty($data['status'])) {
$qb->andWhere('q.status = :status')
->setParameter('status', $data['status']);
}
if (!empty($data['client_id'])) {
$qb->andWhere('q.clientId = :client_id')
->setParameter('client_id', (int) $data['client_id']);
}
return $qb;
}
/**
* Return the batch of pending queue items the cron should attempt to send.
*
* Items are ordered by priority (highest first) and by ID (FIFO within a
* priority bucket). Includes legacy `unsent`, `pending`, and `failed`
* status so that transient failures and pre-migration rows get attempted.
*
* @return QueuedEmail[]
*/
public function findDueBatch(int $limit = 50): array
{
$qb = $this->createQueryBuilder('q')
->andWhere('q.status IN (:statuses)')
->setParameter('statuses', [QueuedEmail::STATUS_UNSENT, QueuedEmail::STATUS_PENDING, QueuedEmail::STATUS_FAILED])
->orderBy('q.priority', 'DESC')
->addOrderBy('q.id', 'ASC');
if ($limit > 0) {
$qb->setMaxResults($limit);
}
return $qb->getQuery()->getResult();
}
}
+86 -109
View File
@@ -12,8 +12,12 @@ declare(strict_types=1);
namespace Box\Mod\Email;
use Box\Mod\Email\Entity\ActivityClientEmail;
use Box\Mod\Email\Entity\EmailTemplate;
use Box\Mod\Email\Entity\QueuedEmail;
use Box\Mod\Email\Repository\ActivityClientEmailRepository;
use Box\Mod\Email\Repository\EmailTemplateRepository;
use Box\Mod\Email\Repository\QueuedEmailRepository;
use FOSSBilling\Config;
use FOSSBilling\Environment;
use FOSSBilling\PaginationOptions;
@@ -25,7 +29,9 @@ use Symfony\Component\Finder\Finder;
class Service implements \FOSSBilling\InjectionAwareInterface
{
protected ?\Pimple\Container $di = null;
protected ?EmailTemplateRepository $templateRepository = null;
protected EmailTemplateRepository $templateRepository;
protected ActivityClientEmailRepository $activityClientEmailRepository;
protected QueuedEmailRepository $queuedEmailRepository;
private readonly Filesystem $filesystem;
public function __construct()
@@ -36,6 +42,9 @@ class Service implements \FOSSBilling\InjectionAwareInterface
public function setDi(\Pimple\Container $di): void
{
$this->di = $di;
$this->templateRepository = $di['em']->getRepository(EmailTemplate::class);
$this->activityClientEmailRepository = $di['em']->getRepository(ActivityClientEmail::class);
$this->queuedEmailRepository = $di['em']->getRepository(QueuedEmail::class);
}
public function getDi(): ?\Pimple\Container
@@ -45,16 +54,19 @@ class Service implements \FOSSBilling\InjectionAwareInterface
public function getTemplateRepository(): EmailTemplateRepository
{
if ($this->templateRepository === null) {
if ($this->di === null) {
throw new \FOSSBilling\Exception('The dependency injection container has not been set.');
}
$this->templateRepository = $this->di['em']->getRepository(EmailTemplate::class);
}
return $this->templateRepository;
}
public function getActivityClientEmailRepository(): ActivityClientEmailRepository
{
return $this->activityClientEmailRepository;
}
public function getQueuedEmailRepository(): QueuedEmailRepository
{
return $this->queuedEmailRepository;
}
public function getModulePermissions(): array
{
return [
@@ -156,48 +168,25 @@ class Service implements \FOSSBilling\InjectionAwareInterface
return [$query, $bindings];
}
public function findOneForClientById(\Model_Client $client, $id)
{
$bindings = [
':id' => $id,
':client_id' => $client->id,
];
$db = $this->di['db'];
return $db->findOne('ActivityClientEmail', 'id = :id AND client_id = :client_id ORDER BY id DESC', $bindings);
}
public function rmByClient(\Model_Client $client): bool
{
$models = $this->di['db']->find('ActivityClientEmail', 'client_id = ?', [$client->id]);
foreach ($models as $model) {
$this->di['db']->trash($model);
}
$this->getActivityClientEmailRepository()->deleteByClientId((int) $client->id);
return true;
}
public function rm(\Model_ActivityClientEmail $email): bool
{
$db = $this->di['db'];
$db->trash($email);
return true;
}
public function toApiArray(\Model_ActivityClientEmail $model, $deep = true): array
public function toApiArray(ActivityClientEmail $model, $deep = true): array
{
return [
'id' => $model->id,
'client_id' => $model->client_id,
'sender' => $model->sender,
'recipients' => $model->recipients,
'subject' => $model->subject,
'content_html' => Tools::sanitizeContent($model->content_html ?? ''),
'content_text' => $model->content_text,
'created_at' => $model->created_at,
'updated_at' => $model->updated_at,
'id' => $model->getId(),
'client_id' => $model->getClientId(),
'sender' => $model->getSender(),
'recipients' => $model->getRecipients(),
'subject' => $model->getSubject(),
'content_html' => Tools::sanitizeContent($model->getContentHtml() ?? ''),
'content_text' => $model->getContentText(),
'created_at' => $model->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $model->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
@@ -260,13 +249,14 @@ class Service implements \FOSSBilling\InjectionAwareInterface
// send email to admins
if (isset($data['to_admin']) && $data['to_admin'] > 0) {
$oneStaff = $this->di['db']->findOne('Admin', 'id=?', [$data['to_admin']]);
/** @todo Doctrine: use Admin entity once Staff is migrated */
$oneStaff = $this->di['dbal']->fetchAssociative('SELECT id, email, name, signature FROM admin WHERE id = :id', ['id' => $data['to_admin']]);
// Convert to array with only safe fields
$vars['c'] = [
'id' => $oneStaff->id,
'email' => $oneStaff->email,
'name' => $oneStaff->name,
'signature' => $oneStaff->signature,
'id' => $oneStaff['id'],
'email' => $oneStaff['email'],
'name' => $oneStaff['name'],
'signature' => $oneStaff['signature'],
];
}
@@ -307,9 +297,9 @@ class Service implements \FOSSBilling\InjectionAwareInterface
$sent = $this->sendMail($to, $from, $subject, $content, $to_name, $from_name, null, $staff['id'], $send_now, $throw_exceptions);
}
} elseif (isset($oneStaff)) {
$to = $oneStaff->email;
$to_name = $oneStaff->name;
$sent = $this->sendMail($to, $from, $subject, $content, $to_name, $from_name, $oneStaff->id, null, $send_now, $throw_exceptions);
$to = $oneStaff['email'];
$to_name = $oneStaff['name'];
$sent = $this->sendMail($to, $from, $subject, $content, $to_name, $from_name, $oneStaff['id'], null, $send_now, $throw_exceptions);
} elseif (isset($customer)) {
$to = $customer['email'];
$to_name = $customer['first_name'] . ' ' . $customer['last_name'];
@@ -523,27 +513,26 @@ class Service implements \FOSSBilling\InjectionAwareInterface
return [$template->getSubject() ?? '', $template->getContent() ?? ''];
}
private function _queue($to, $from, $subject, $content, $to_name = null, $from_name = null, $client_id = null, $admin_id = null)
private function _queue($to, $from, $subject, $content, $to_name = null, $from_name = null, $client_id = null, $admin_id = null): QueuedEmail
{
$db = $this->di['db'];
$em = $this->di['em'];
$queue = $db->dispense('ModEmailQueue');
$queue->recipient = $to;
$queue->sender = $from;
$queue->subject = $subject;
$queue->content = $content;
$queue->to_name = $to_name;
$queue->from_name = $from_name;
$queue->client_id = $client_id;
$queue->admin_id = $admin_id;
$queue->status = 'unsent';
$queue->created_at = date('Y-m-d H:i:s');
$queue->updated_at = date('Y-m-d H:i:s');
$queue->priority = 1;
$queue->tries = 0;
$queue = new QueuedEmail();
$queue->setRecipient((string) $to);
$queue->setSender((string) $from);
$queue->setSubject((string) $subject);
$queue->setContent((string) $content);
$queue->setToName($to_name !== null ? (string) $to_name : null);
$queue->setFromName($from_name !== null ? (string) $from_name : null);
$queue->setClientId($client_id !== null ? (int) $client_id : null);
$queue->setAdminId($admin_id !== null ? (int) $admin_id : null);
$queue->setStatus(QueuedEmail::STATUS_PENDING);
$queue->setPriority(1);
$queue->setTries(0);
try {
$db->store($queue);
$em->persist($queue);
$em->flush();
} catch (\Exception $e) {
error_log($e->getMessage());
}
@@ -591,7 +580,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface
}
}
public function resend(\Model_ActivityClientEmail $email): bool
public function resend(ActivityClientEmail $email): bool
{
$extensionService = $this->di['mod_service']('extension');
if ($extensionService->isExtensionActive('mod', 'demo')) {
@@ -608,22 +597,22 @@ class Service implements \FOSSBilling\InjectionAwareInterface
}
$clientService = $this->di['mod_service']('client');
$customer = $clientService->get(['id' => $email->client_id]);
$customer = $clientService->get(['id' => $email->getClientId()]);
$customer = $clientService->toApiArray($customer);
$systemService = $this->di['mod_service']('system');
$from_name = $systemService->getParamValue('company_name');
$this->sendMail($email->recipients, $email->sender, $email->subject, $email->content_html, $customer['first_name'] . ' ' . $customer['last_name'], $from_name, $email->client_id);
$this->sendMail($email->getRecipients(), $email->getSender(), $email->getSubject(), $email->getContentHtml(), $customer['first_name'] . ' ' . $customer['last_name'], $from_name, $email->getClientId());
$this->di['logger']->info('Resent email #%s', $email->id);
$this->di['logger']->info('Resent email #%s', $email->getId());
return true;
}
public function queueGetSearchQuery($data): array
{
$query = 'SELECT * FROM mod_email_queue';
$query = 'SELECT * FROM email_queue';
$id = $data['id'] ?? null;
$search = $data['search'] ?? null;
@@ -823,14 +812,9 @@ class Service implements \FOSSBilling\InjectionAwareInterface
return true;
}
public function getEmailById($id)
public function getEmailById($id): ActivityClientEmail
{
$model = $this->di['db']->findOne('ActivityClientEmail', 'id = ?', [$id]);
if (!$model instanceof \Model_ActivityClientEmail) {
throw new \FOSSBilling\Exception('Email not found');
}
return $model;
return $this->getActivityClientEmailRepository()->findOneByIdOrFail((int) $id);
}
public function templateCreate($actionCode, $subject, $content, $enabled = 0, $category = null): EmailTemplate
@@ -1016,34 +1000,26 @@ class Service implements \FOSSBilling\InjectionAwareInterface
$start = time();
$query = 'ORDER BY created_at ASC';
if ($sendPerCron) {
$query .= ' LIMIT ' . intval($sendPerCron);
$mailQueue = $this->di['db']->findAll('mod_email_queue', $query);
} else {
$mailQueue = $this->di['db']->findAll('mod_email_queue', $query);
}
$mailQueue = $this->getQueuedEmailRepository()->findDueBatch((int) $sendPerCron);
foreach ($mailQueue as $email) {
$mailModel = new \Model_ModEmailQueue();
$mailModel->loadBean($email);
$this->_sendFromQueue($mailModel);
$this->_sendFromQueue($email);
if ($time_limit && time() - $start > $time_limit) {
break;
}
}
}
private function _sendFromQueue(\Model_ModEmailQueue $queue, bool $throw_exceptions = false): bool
private function _sendFromQueue(QueuedEmail $queue, bool $throw_exceptions = false): bool
{
$extensionService = $this->di['mod_service']('extension');
if ($extensionService->isExtensionActive('mod', 'demo')) {
return false;
}
$queue->status = 'sending';
$this->di['db']->store($queue);
$queue->setStatus('sending');
$this->di['em']->flush();
$queue->content .= PHP_EOL;
$queue->setContent($queue->getContent() . PHP_EOL);
$mod = $this->di['mod']('email');
$settings = $mod->getConfig();
@@ -1051,16 +1027,16 @@ class Service implements \FOSSBilling\InjectionAwareInterface
$transport = $settings['mailer'] ?? 'sendmail';
$sender = [
'email' => $queue->sender,
'name' => $queue->from_name,
'email' => $queue->getSender(),
'name' => $queue->getFromName(),
];
$recipient = [
'email' => $queue->recipient,
'name' => $queue->to_name,
'email' => $queue->getRecipient(),
'name' => $queue->getToName(),
];
try {
$mail = new \FOSSBilling\Mail($sender, $recipient, $queue->subject, $queue->content, $transport, $settings['custom_dsn'] ?? null);
$mail = new \FOSSBilling\Mail($sender, $recipient, $queue->getSubject(), $queue->getContent(), $transport, $settings['custom_dsn'] ?? null);
if (!empty($settings['reply_to'])) {
if (filter_var($settings['reply_to'], FILTER_VALIDATE_EMAIL)) {
$mail->addReplyTo($settings['reply_to']);
@@ -1080,11 +1056,12 @@ class Service implements \FOSSBilling\InjectionAwareInterface
// It sent without causing an exception (error), so we are safe to log it now
if ($log) {
$activityService = $this->di['mod_service']('activity');
$activityService->logEmail($queue->subject, $queue->client_id, $queue->sender, $queue->recipient, $queue->content);
$activityService->logEmail($queue->getSubject(), $queue->getClientId(), $queue->getSender(), $queue->getRecipient(), $queue->getContent());
}
try {
$this->di['db']->trash($queue);
$this->di['em']->remove($queue);
$this->di['em']->flush();
} catch (\Exception $e) {
$this->di['logger']->setChannel('email')->error($e->getMessage());
}
@@ -1092,23 +1069,23 @@ class Service implements \FOSSBilling\InjectionAwareInterface
$message = $e->getMessage();
$this->di['logger']->setChannel('email')->error($e->getMessage());
if ($queue->priority) {
--$queue->priority;
if ($queue->getPriority()) {
$queue->setPriority($queue->getPriority() - 1);
}
$queue->status = 'unsent';
++$queue->tries;
$queue->updated_at = date('Y-m-d H:i:s');
$this->di['db']->store($queue);
$queue->setStatus(QueuedEmail::STATUS_FAILED);
$queue->setTries($queue->getTries() + 1);
$this->di['em']->flush();
$maxTries = $settings['cancel_after'] ?? 5;
if ($queue->tries > $maxTries) {
if ($queue->getTries() > $maxTries) {
// The email failed to send after the max number of tries. This might be because of a server error, so let's be sure to log it which gives the client the ability to resend it.
if ($log) {
$activityService = $this->di['mod_service']('activity');
$activityService->logEmail($queue->subject, $queue->client_id, $queue->sender, $queue->recipient, $queue->content);
$activityService->logEmail($queue->getSubject(), $queue->getClientId(), $queue->getSender(), $queue->getRecipient(), $queue->getContent());
}
$this->di['db']->trash($queue);
$this->di['em']->remove($queue);
$this->di['em']->flush();
}
if ($throw_exceptions) {
+62 -72
View File
@@ -17,7 +17,6 @@ use function Tests\Helpers\moduleService;
test('email get list', function (): void {
$adminApi = new Box\Mod\Email\Api\Admin();
$emailService = new Box\Mod\Email\Service();
$willReturn = [
'list' => [
@@ -27,18 +26,22 @@ test('email get list', function (): void {
$pager = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
$pager
->shouldReceive('getPaginatedResultSet')
->atLeast()->once()
->andReturn($willReturn);
->shouldReceive('paginateDoctrineQuery')
->atLeast()->once()
->andReturn($willReturn);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
$repo->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$di = container();
$di['pager'] = $pager;
$adminApi->setDi($di);
$emailService->setDi($di);
$service = $emailService;
$adminApi->setService($service);
$adminApi->setService($emailService);
$result = $adminApi->email_get_list([]);
expect($result)->toBeArray();
@@ -63,29 +66,18 @@ test('email get', function (): void {
$created = date('Y-m-d H:i:s', time() - 86400);
$updated = date('Y-m-d H:i:s');
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->id = $id;
$model->client_id = $client_id;
$model->sender = $sender;
$model->recipients = $recipients;
$model->subject = $subject;
$model->content_html = $content_html;
$model->content_text = $content_text;
$model->created_at = $created;
$model->updated_at = $updated;
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, $id);
$model->setClientId($client_id);
$model->setSender($sender);
$model->setRecipients($recipients);
$model->setSubject($subject);
$model->setContentHtml($content_html);
$model->setContentText($content_text);
$model->setCreatedAt(new DateTime('-1 day'));
$model->setUpdatedAt(new DateTime());
$expected = [
'id' => $id,
'client_id' => $client_id,
'sender' => $sender,
'recipients' => $recipients,
'subject' => $subject,
'content_html' => $content_html,
'content_text' => $content_text,
'created_at' => $created,
'updated_at' => $updated,
];
$expected = $model->toApiArray();
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$service
@@ -119,10 +111,6 @@ test('send', function (): void {
'content' => 'Content',
];
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->id = 1;
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$emailService
->shouldReceive('sendMail')
@@ -146,25 +134,24 @@ test('resend', function (): void {
'id' => 1,
];
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->id = 1;
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, 1);
$db = Mockery::mock('Box_Database');
$db
->shouldReceive('findOne')
->atLeast()->once()
->andReturn($model);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneByIdOrFail')
->atLeast()->once()
->with(1)
->andReturn($model);
$di = container();
$di['db'] = $db;
$adminApi->setDi($di);
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$emailService
->shouldReceive('resend')
->atLeast()->once()
->andReturn(true);
->shouldReceive('resend')
->atLeast()->once()
->andReturn(true);
$adminApi->setService($emailService);
@@ -180,16 +167,17 @@ test('resend exception email not found', function (): void {
'id' => 1,
];
$db = Mockery::mock('Box_Database');
$db
->shouldReceive('findOne')
->atLeast()->once()
->andReturn(null);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneByIdOrFail')
->andThrow(new FOSSBilling\Exception('Email not found'));
$di = container();
$di['db'] = $db;
$adminApi->setDi($di);
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$adminApi->setService($emailService);
$this->expectException(FOSSBilling\Exception::class);
$this->expectExceptionMessage('Email not found');
$adminApi->email_resend($data);
@@ -202,16 +190,17 @@ test('delete exception email not found', function (): void {
'id' => 1,
];
$db = Mockery::mock('Box_Database');
$db
->shouldReceive('findOne')
->atLeast()->once()
->andReturn(null);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneByIdOrFail')
->andThrow(new FOSSBilling\Exception('Email not found'));
$di = container();
$di['db'] = $db;
$adminApi->setDi($di);
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$adminApi->setService($emailService);
$this->expectException(FOSSBilling\Exception::class);
$this->expectExceptionMessage('Email not found');
$adminApi->email_delete($data);
@@ -219,33 +208,34 @@ test('delete exception email not found', function (): void {
test('email delete', function (): void {
$adminApi = new Box\Mod\Email\Api\Admin();
$emailService = new Box\Mod\Email\Service();
$data = [
'id' => 1,
];
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->id = 1;
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, 1);
$db = Mockery::mock('Box_Database');
$db
->shouldReceive('findOne')
->atLeast()->once()
->andReturn($model);
$db
->shouldReceive('trash')
->atLeast()->once()
->andReturn(true);
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$em->shouldReceive('remove')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$loggerStub = $this->createStub('\Box_Log');
$di = container();
$di['db'] = $db;
$di['em'] = $em;
$di['logger'] = $loggerStub;
$adminApi->setDi($di);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneByIdOrFail')
->atLeast()->once()
->with(1)
->andReturn($model);
$emailService = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$emailService->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$adminApi->setService($emailService);
$result = $adminApi->email_delete($data);
+57 -49
View File
@@ -14,7 +14,6 @@ use function Tests\Helpers\container;
test('get list', function (): void {
$clientApi = new Box\Mod\Email\Api\Client();
$emailService = new Box\Mod\Email\Service();
$willReturn = [
'list' => [
@@ -23,17 +22,21 @@ test('get list', function (): void {
];
$pager = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
$pager
->shouldReceive('getPaginatedResultSet')
->atLeast()->once()
->andReturn($willReturn);
->shouldReceive('paginateDoctrineQuery')
->atLeast()->once()
->andReturn($willReturn);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
$repo->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$di = container();
$di['pager'] = $pager;
$clientApi->setDi($di);
$emailService->setDi($di);
$service = $emailService;
$clientApi->setService($service);
$client = new Model_Client();
@@ -51,17 +54,19 @@ test('get list', function (): void {
test('get', function (): void {
$clientApi = new Box\Mod\Email\Api\Client();
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, 1);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneForClientByIdOrFail')
->atLeast()->once()
->andReturn($model);
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$service
->shouldReceive('findOneForClientById')
->atLeast()->once()
->andReturn($model);
$service
->shouldReceive('toApiArray')
->atLeast()->once()
->andReturn([]);
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$service->shouldReceive('toApiArray')
->atLeast()->once()
->andReturn([]);
$clientApi->setService($service);
$di = container();
@@ -79,11 +84,12 @@ test('get', function (): void {
test('get not found exception', function (): void {
$clientApi = new Box\Mod\Email\Api\Client();
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneForClientByIdOrFail')
->andThrow(new FOSSBilling\Exception('Email not found'));
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$service
->shouldReceive('findOneForClientById')
->atLeast()->once()
->andReturn(false);
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$client = new Model_Client();
$client->loadBean(new Tests\Helpers\DummyBean());
@@ -103,18 +109,19 @@ test('get not found exception', function (): void {
test('resend', function (): void {
$clientApi = new Box\Mod\Email\Api\Client();
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, 1);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneForClientByIdOrFail')
->atLeast()->once()
->andReturn($model);
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$service
->shouldReceive('findOneForClientById')
->atLeast()->once()
->andReturn($model);
$service
->shouldReceive('resend')
->atLeast()->once()
->andReturn(true);
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$service->shouldReceive('resend')
->atLeast()->once()
->andReturn(true);
$client = new Model_Client();
$client->loadBean(new Tests\Helpers\DummyBean());
@@ -133,11 +140,12 @@ test('resend', function (): void {
test('resend not found exception', function (): void {
$clientApi = new Box\Mod\Email\Api\Client();
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneForClientByIdOrFail')
->andThrow(new FOSSBilling\Exception('Email not found'));
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$service
->shouldReceive('findOneForClientById')
->atLeast()->once()
->andReturn(false);
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$client = new Model_Client();
$client->loadBean(new Tests\Helpers\DummyBean());
@@ -160,17 +168,16 @@ test('delete', function (): void {
$di = container();
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, 1);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneForClientByIdOrFail')
->atLeast()->once()
->andReturn($model);
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$service
->shouldReceive('findOneForClientById')
->atLeast()->once()
->andReturn($model);
$service
->shouldReceive('rm')
->atLeast()->once()
->andReturn(true);
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$client = new Model_Client();
$client->loadBean(new Tests\Helpers\DummyBean());
@@ -188,11 +195,12 @@ test('delete', function (): void {
test('delete not found exception', function (): void {
$clientApi = new Box\Mod\Email\Api\Client();
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneForClientByIdOrFail')
->andThrow(new FOSSBilling\Exception('Email not found'));
$service = Mockery::mock(Box\Mod\Email\Service::class)->makePartial();
$service
->shouldReceive('findOneForClientById')
->atLeast()->once()
->andReturn(false);
$service->shouldReceive('getActivityClientEmailRepository')->andReturn($repo);
$client = new Model_Client();
$client->loadBean(new Tests\Helpers\DummyBean());
@@ -18,9 +18,6 @@ test('dependency injection container is properly set and retrieved', function ()
$controller = new Admin();
$di = container();
$dbMock = Mockery::mock('Box_Database');
$di['db'] = $dbMock;
$controller->setDi($di);
$result = $controller->getDi();
expect($result)->toEqual($di);
+116 -176
View File
@@ -62,6 +62,37 @@ function emailTemplate(string $actionCode = '', ?int $id = null, array $data = [
return $template;
}
/**
* Build a partial Mockery EntityManager that returns the correct repository mock
* for each Email entity class requested. Used because the service eagerly fetches
* all repositories inside setDi().
*
* @return Doctrine\ORM\EntityManagerInterface&Mockery\MockInterface
*/
function emailBuildEm(
?Box\Mod\Email\Repository\ActivityClientEmailRepository $activityRepo = null,
?Box\Mod\Email\Repository\EmailTemplateRepository $templateRepo = null,
?Box\Mod\Email\Repository\QueuedEmailRepository $queueRepo = null,
bool $ignoreMissing = true,
) {
$activityRepo ??= Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class)->shouldIgnoreMissing();
$templateRepo ??= Mockery::mock(Box\Mod\Email\Repository\EmailTemplateRepository::class)->shouldIgnoreMissing();
$queueRepo ??= Mockery::mock(Box\Mod\Email\Repository\QueuedEmailRepository::class)->shouldIgnoreMissing();
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
if ($ignoreMissing) {
$em->shouldIgnoreMissing();
}
$em->shouldReceive('getRepository')->andReturnUsing(static fn (string $class): object => match ($class) {
Box\Mod\Email\Entity\ActivityClientEmail::class => $activityRepo,
EmailTemplate::class => $templateRepo,
Box\Mod\Email\Entity\QueuedEmail::class => $queueRepo,
default => $activityRepo,
});
return $em;
}
test('di returns dependency injection container', function (): void {
$service = new Box\Mod\Email\Service();
@@ -132,54 +163,17 @@ test('getSearchQuery returns query and bindings', function (array $data, string
expect($result[1])->toBe($bindings);
})->with('getSearchQueryProvider');
test('findOneForClientById returns email for client', function (): void {
$service = new Box\Mod\Email\Service();
$di = container();
$id = 5;
$client_id = 1;
$activityEmail = new Model_ActivityClientEmail();
$activityEmail->loadBean(new Tests\Helpers\DummyBean());
$activityEmail->client_id = $client_id;
$activityEmail->id = $id;
$db = Mockery::mock('Box_Database');
$db->shouldReceive('findOne')
->atLeast()->once()
->andReturn($activityEmail);
$di['db'] = $db;
$service->setDi($di);
$client = new Model_Client();
$client->loadBean(new Tests\Helpers\DummyBean());
$client->id = $client_id;
$result = $service->findOneForClientById($client, $id);
expect($result)->toBeInstanceOf('Model_ActivityClientEmail');
expect($result->id)->not->toBeNull();
expect($result->id)->toBe($activityEmail->id);
expect($result->client_id)->toBe($activityEmail->client_id);
});
test('rmByClient removes emails for client', function (): void {
$service = new Box\Mod\Email\Service();
$di = container();
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('deleteByClientId')
->once()
->with(1)
->andReturn(3);
$db = Mockery::mock('Box_Database');
$db->shouldReceive('find')
->atLeast()->once()
->andReturn([$model]);
$db->shouldReceive('trash')
->atLeast()->once()
->andReturn(null);
$di['db'] = $db;
$di['em'] = emailBuildEm($repo);
$service->setDi($di);
$client = new Model_Client();
@@ -190,26 +184,6 @@ test('rmByClient removes emails for client', function (): void {
expect($result)->toBeTrue();
});
test('rm removes email', function (): void {
$service = new Box\Mod\Email\Service();
$di = container();
$db = Mockery::mock('Box_Database');
$db->shouldReceive('trash')
->atLeast()->once()
->andReturn(null);
$di['db'] = $db;
$service->setDi($di);
$email = new Model_ActivityClientEmail();
$email->loadBean(new Tests\Helpers\DummyBean());
$email->id = 1;
$result = $service->rm($email);
expect($result)->toBeTrue();
});
test('toApiArray returns API array for email', function (): void {
$service = new Box\Mod\Email\Service();
@@ -220,20 +194,19 @@ test('toApiArray returns API array for email', function (): void {
$subject = 'Subject';
$content_html = 'HTML';
$content_text = 'TEXT';
$created = date('Y-m-d H:i:s', time() - 86400);
$updated = date('Y-m-d H:i:s');
$created = new DateTime('-1 day');
$updated = new DateTime();
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->id = $id;
$model->client_id = $client_id;
$model->sender = $sender;
$model->recipients = $recipients;
$model->subject = $subject;
$model->content_html = $content_html;
$model->content_text = $content_text;
$model->created_at = $created;
$model->updated_at = $updated;
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, $id);
$model->setClientId($client_id);
$model->setSender($sender);
$model->setRecipients($recipients);
$model->setSubject($subject);
$model->setContentHtml($content_html);
$model->setContentText($content_text);
$model->setCreatedAt($created);
$model->setUpdatedAt($updated);
$expected = [
'id' => $id,
@@ -243,8 +216,8 @@ test('toApiArray returns API array for email', function (): void {
'subject' => $subject,
'content_html' => $content_html,
'content_text' => $content_text,
'created_at' => $created,
'updated_at' => $updated,
'created_at' => $created->format('Y-m-d H:i:s'),
'updated_at' => $updated->format('Y-m-d H:i:s'),
];
$result = $service->toApiArray($model);
@@ -261,7 +234,7 @@ test('setVars encrypts and sets variables', function (): void {
->atLeast()->once()
->andReturn('encrypted-vars');
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$em = emailBuildEm();
$em->shouldReceive('flush')->atLeast()->once();
$di['em'] = $em;
@@ -311,22 +284,18 @@ test('sendTemplate returns false when template does not exist', function (): voi
$emailTemplate = emailTemplate();
$db = Mockery::mock('Box_Database');
$db->shouldReceive('findOne')
->byDefault()
->andReturn(null);
$db->shouldReceive('dispense')
->byDefault()
->andReturn($emailTemplate);
$db->shouldReceive('store')
->byDefault()
->andReturn(1);
$templateRepo = Mockery::mock(Box\Mod\Email\Repository\EmailTemplateRepository::class);
$templateRepo->shouldReceive('findOneByActionCode')->andReturn(null);
$em = emailBuildEm(null, $templateRepo);
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$cryptMock = Mockery::mock('\Box_Crypt');
$cryptMock->shouldReceive('encrypt')
->atLeast()->once();
$di['db'] = $db;
$di['em'] = $em;
$di['crypt'] = $cryptMock;
$di['api_admin'] = function () use ($di) {
$api = new FOSSBilling\Api\Proxy(new Model_Admin());
@@ -359,19 +328,12 @@ test('sendTemplate sends email when template exists', function (): void {
$emailTemplate = emailTemplate(data: ['enabled' => true]);
$queueModel = new Model_ModEmailQueue();
$queueModel->loadBean(new Tests\Helpers\DummyBean());
$templateRepo = Mockery::mock(Box\Mod\Email\Repository\EmailTemplateRepository::class);
$templateRepo->shouldReceive('findOneByActionCode')->andReturn($emailTemplate);
$db = Mockery::mock('Box_Database');
$db->shouldReceive('findOne')
->byDefault()
->andReturn($emailTemplate);
$db->shouldReceive('store')
->atLeast()->once()
->andReturn(1);
$db->shouldReceive('dispense')
->atLeast()->once()
->andReturn($queueModel);
$em = emailBuildEm(null, $templateRepo);
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$systemService = Mockery::mock(Box\Mod\System\Service::class);
$systemService->shouldReceive('getParamValue')
@@ -405,7 +367,7 @@ test('sendTemplate sends email when template exists', function (): void {
'from_email' => 'test@test.com',
]);
$di['db'] = $db;
$di['em'] = $em;
$di['crypt'] = $cryptMock;
$di['twig'] = $twigStub;
$di['mod'] = $di->protect(fn () => $modMock);
@@ -453,19 +415,10 @@ test('sendTemplate handles to_staff and to_client options', function (array $dat
$emailTemplate = emailTemplate(data: ['enabled' => true]);
$queueModel = new Model_ModEmailQueue();
$queueModel->loadBean(new Tests\Helpers\DummyBean());
$templateRepo = Mockery::mock(Box\Mod\Email\Repository\EmailTemplateRepository::class);
$templateRepo->shouldReceive('findOneByActionCode')->andReturn($emailTemplate);
$db = Mockery::mock('Box_Database');
$db->shouldReceive('findOne')
->byDefault()
->andReturn(null);
$db->shouldReceive('dispense')
->atLeast()->once()
->andReturnUsing(fn ($type): EmailTemplate|\Model_ModEmailQueue => $type === 'EmailTemplate' ? $emailTemplate : $queueModel);
$db->shouldReceive('store')
->atLeast()->once()
->andReturn(1);
$em = emailBuildEm(null, $templateRepo);
$system = Mockery::mock(Box\Mod\System\Service::class);
$system->shouldReceive('getParamValue')
@@ -542,7 +495,6 @@ test('sendTemplate handles to_staff and to_client options', function (array $dat
]);
$di['mod'] = $di->protect(fn () => $modMock);
$di['db'] = $db;
$di['twig'] = $twigStub;
$di['crypt'] = $cryptMock;
$di['mod_service'] = $di->protect(moduleService([
@@ -587,14 +539,14 @@ test('resend resends email', function (): void {
$service->setDi($di);
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->client_id = 1;
$model->sender = 'sender@exemple.com';
$model->recipients = 'recipient@example.com';
$model->subject = 'Email Title';
$model->content_html = '<b>Content</b>';
$model->content_text = 'Content';
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, 1);
$model->setClientId(1);
$model->setSender('sender@exemple.com');
$model->setRecipients('recipient@example.com');
$model->setSubject('Email Title');
$model->setContentHtml('<b>Content</b>');
$model->setContentText('Content');
$result = $service->resend($model);
@@ -717,10 +669,6 @@ test('updateTemplate updates template', function (array $data, string $templateR
$emailService = new Box\Mod\Email\Service();
$db = Mockery::mock('Box_Database');
$db->shouldReceive('store')
->byDefault();
$loggerStub = new Tests\Helpers\TestLogger();
$cryptMock = Mockery::mock('\Box_Crypt');
@@ -731,7 +679,6 @@ test('updateTemplate updates template', function (array $data, string $templateR
$twigStub = Mockery::mock(Twig\Environment::class);
$di = container();
$di['db'] = $db;
$di['logger'] = $loggerStub;
$di['crypt'] = $cryptMock;
$di['config'] = $configMock;
@@ -759,44 +706,43 @@ test('getEmailById returns email by ID', function (): void {
$service = new Box\Mod\Email\Service();
$id = 1;
$model = new Model_ActivityClientEmail();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->id = $id;
$model = new Box\Mod\Email\Entity\ActivityClientEmail();
\Tests\Helpers\setEntityId($model, $id);
$db = Mockery::mock('Box_Database');
$db->shouldReceive('findOne')
->atLeast()->once()
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneByIdOrFail')
->once()
->with($id)
->andReturn($model);
$di = container();
$di['db'] = $db;
$di['em'] = emailBuildEm($repo);
$service->setDi($di);
$result = $service->getEmailById($id);
expect($result->id)->toBe($id);
expect($result->getId())->toBe($id);
});
test('getEmailById throws exception when email not found', function (): void {
$service = new Box\Mod\Email\Service();
$db = Mockery::mock('Box_Database');
$db->shouldReceive('findOne')
->atLeast()->once()
->andReturn(false);
$repo = Mockery::mock(Box\Mod\Email\Repository\ActivityClientEmailRepository::class);
$repo->shouldReceive('findOneByIdOrFail')
->andThrow(new FOSSBilling\Exception('Email not found'));
$di = container();
$di['db'] = $db;
$di['em'] = emailBuildEm($repo);
$service->setDi($di);
expect(fn () => $service->getEmailById(5))
expect(fn (): Box\Mod\Email\Entity\ActivityClientEmail => $service->getEmailById(5))
->toThrow(FOSSBilling\Exception::class);
});
test('templateCreate creates new template', function (): void {
$service = new Box\Mod\Email\Service();
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$em = emailBuildEm();
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
@@ -845,24 +791,26 @@ test('templateBatchEnable enables all templates', function (): void {
test('batchSend processes email queue', function (): void {
$service = new Box\Mod\Email\Service();
$queueModel = new Tests\Helpers\DummyBean();
$queueModel->priority = 10;
$queueModel->tries = 10;
$queueModel->subject = 'subject';
$queueModel->client_id = 1;
$queueModel->sender = 'sender@example.com';
$queueModel->recipient = 'receiver@example.com';
$queueModel->content = 'content';
$queueModel->from_name = 'From Name';
$queueModel->to_name = 'To Name';
$queueModel = new Box\Mod\Email\Entity\QueuedEmail();
\Tests\Helpers\setEntityId($queueModel, 1);
$queueModel->setPriority(10);
$queueModel->setTries(10);
$queueModel->setSubject('subject');
$queueModel->setClientId(1);
$queueModel->setSender('sender@example.com');
$queueModel->setRecipient('receiver@example.com');
$queueModel->setContent('content');
$queueModel->setFromName('From Name');
$queueModel->setToName('To Name');
$db = Mockery::mock('Box_Database');
$db->shouldReceive('findAll')
$queueRepo = Mockery::mock(Box\Mod\Email\Repository\QueuedEmailRepository::class);
$queueRepo->shouldReceive('findDueBatch')
->once()
->with(0)
->andReturn([$queueModel]);
$db->shouldReceive('store')
->atLeast()->once()
->andReturn(true);
$em = emailBuildEm(null, null, $queueRepo);
$em->shouldReceive('flush')->atLeast()->once();
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getConfig')
@@ -879,7 +827,7 @@ test('batchSend processes email queue', function (): void {
->andReturn($isExtensionActiveReturn);
$di = container();
$di['db'] = $db;
$di['em'] = $em;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['mod_service'] = $di->protect(function ($name) use ($extension) {
if ($name == 'extension') {
@@ -896,20 +844,12 @@ test('batchSend processes email queue', function (): void {
});
test('sendMail queues email for sending', function (): void {
$dbMock = Mockery::mock('\Box_Database');
$queueEmail = new Model_ModEmailQueue();
$queueEmail->loadBean(new Tests\Helpers\DummyBean());
$dbMock->shouldReceive('dispense')
->atLeast()->once()
->with('ModEmailQueue')
->andReturn($queueEmail);
$dbMock->shouldReceive('store')
->atLeast()->once();
$em = emailBuildEm();
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['logger'] = new Tests\Helpers\TestLogger();
$modMock = Mockery::mock('\stdClass');
@@ -984,7 +924,7 @@ test('validateAllTemplates reports invalid templates', function (): void {
return $template;
});
$emMock = Mockery::mock();
$emMock = emailBuildEm();
$emMock->shouldReceive('flush')->once();
$di['mod_service'] = $di->protect(function ($name) use ($systemMock) {
@@ -1028,7 +968,7 @@ test('validateAllTemplates clears previous errors on valid templates', function
->twice()
->andReturnArg(0);
$emMock = Mockery::mock();
$emMock = emailBuildEm();
$emMock->shouldReceive('flush')->once();
$di['mod_service'] = $di->protect(function ($name) use ($systemMock) {
@@ -1081,7 +1021,7 @@ test('validateAllTemplates renders templates with stored vars to enforce sandbox
->with('Hello {{ name }}', Mockery::any())
->never();
$emMock = Mockery::mock();
$emMock = emailBuildEm();
$emMock->shouldReceive('flush')->once();
$di['crypt'] = $cryptMock;
@@ -1119,7 +1059,7 @@ test('templateCreate validates subject and content', function (): void {
->withArgs(fn ($tpl): bool => str_contains((string) $tpl, 'valid content'))
->andReturn('rendered');
$emMock = Mockery::mock();
$emMock = emailBuildEm();
$emMock->shouldReceive('persist')->once();
$emMock->shouldReceive('flush')->once();
+4 -3
View File
@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Box\Mod\Extension\Api;
use Box\Mod\Extension\Entity\Extension;
use FOSSBilling\Tools;
use FOSSBilling\Validation\Api\RequiredParams;
@@ -182,7 +183,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$ext = $this->_getExtension($data);
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminDeactivateExtension', 'params' => ['id' => $ext->id]]);
$this->getDi()['events_manager']->fire(['event' => 'onBeforeAdminDeactivateExtension', 'params' => ['id' => $ext->getId()]]);
$service = $this->getService();
$service->deactivate($ext);
@@ -283,8 +284,8 @@ class Admin extends \FOSSBilling\Api\AbstractApi
private function _getExtension($data)
{
$service = $this->getService();
$ext = $service->findExtension($data['type'], $data['id']);
if (!$ext instanceof \Model_Extension) {
$ext = $service->getExtensionRepository()->findOneByTypeAndName($data['type'], $data['id']);
if (!$ext instanceof Extension) {
throw new \FOSSBilling\Exception('Extension not found');
}
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Extension\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Interfaces\ApiArrayInterface;
/**
* Installed extension record.
*
* The `extension` table tracks which extensions (modules, themes, payment
* gateways, hooks, ...) are installed in this FOSSBilling instance, along
* with their current status and version. The actual extension code lives
* on disk; this row is only the metadata used by the extension manager.
*/
#[ORM\Entity(repositoryClass: \Box\Mod\Extension\Repository\ExtensionRepository::class)]
#[ORM\Table(name: 'extension')]
#[ORM\Index(name: 'idx_extension_type', columns: ['type'])]
class Extension implements ApiArrayInterface
{
final public const string TYPE_MOD = 'mod';
final public const string TYPE_THEME = 'theme';
final public const string TYPE_PG = 'pg';
final public const string TYPE_TRANSLATION = 'translation';
final public const string TYPE_HOOK = 'hook';
final public const string STATUS_INSTALLED = 'installed';
final public const string STATUS_DEACTIVATED = 'deactivated';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
private ?int $id = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $type = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
private ?string $status = null;
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
private ?string $version = null;
public function toApiArray(): array
{
return [
'id' => $this->id,
'type' => $this->type,
'name' => $this->name,
'status' => $this->status,
'version' => $this->version,
];
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(?string $type): self
{
$this->type = $type;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): self
{
$this->name = $name;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): self
{
$this->status = $status;
return $this;
}
public function getVersion(): ?string
{
return $this->version;
}
public function setVersion(?string $version): self
{
$this->version = $version;
return $this;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Extension\Repository;
use Box\Mod\Extension\Entity\Extension;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
class ExtensionRepository extends EntityRepository
{
/**
* Build a QueryBuilder for filtering installed extensions.
*
* Accepted keys in `$data`:
* - `type` (string) exact match on `type`
* - `status` (string) exact match on `status`
* - `search` (string) LIKE on `name`
*/
public function getSearchQueryBuilder(array $data = []): QueryBuilder
{
$qb = $this->createQueryBuilder('e');
if (!empty($data['type'])) {
$qb->andWhere('e.type = :type')
->setParameter('type', $data['type']);
}
if (!empty($data['status'])) {
$qb->andWhere('e.status = :status')
->setParameter('status', $data['status']);
}
if (!empty($data['search'])) {
$qb->andWhere('e.name LIKE :search')
->setParameter('search', '%' . $data['search'] . '%');
}
$qb->orderBy('e.type', 'ASC')
->addOrderBy('e.status', 'DESC')
->addOrderBy('e.id', 'ASC');
return $qb;
}
/**
* Find a single extension by `(type, name)`.
*/
public function findOneByTypeAndName(string $type, string $name): ?Extension
{
return $this->findOneBy(['type' => $type, 'name' => $name]);
}
/**
* Return all extensions of a given type that are currently installed.
*
* @return Extension[]
*/
public function findInstalledByType(string $type): array
{
return $this->findBy(['type' => $type, 'status' => Extension::STATUS_INSTALLED]);
}
/**
* Check whether an extension with the given type and name exists and is
* currently marked as installed.
*/
public function existsActiveByTypeAndName(string $type, string $name): bool
{
return $this->findOneBy([
'type' => $type,
'name' => $name,
'status' => Extension::STATUS_INSTALLED,
]) !== null;
}
}
+98 -109
View File
@@ -12,6 +12,10 @@ declare(strict_types=1);
namespace Box\Mod\Extension;
use Box\Mod\Extension\Entity\Extension;
use Box\Mod\Extension\Entity\ExtensionMeta;
use Box\Mod\Extension\Repository\ExtensionMetaRepository;
use Box\Mod\Extension\Repository\ExtensionRepository;
use FOSSBilling\Config;
use FOSSBilling\InjectionAwareInterface;
use Symfony\Component\Filesystem\Exception\IOException;
@@ -23,6 +27,8 @@ use Symfony\Contracts\Cache\ItemInterface;
class Service implements InjectionAwareInterface
{
protected ?\Pimple\Container $di = null;
protected ExtensionRepository $extensionRepository;
protected ExtensionMetaRepository $extensionMetaRepository;
public function __construct(private ?Filesystem $filesystem = null)
{
@@ -32,6 +38,8 @@ class Service implements InjectionAwareInterface
public function setDi(\Pimple\Container $di): void
{
$this->di = $di;
$this->extensionRepository = $di['em']->getRepository(Extension::class);
$this->extensionMetaRepository = $di['em']->getRepository(ExtensionMeta::class);
}
public function getDi(): ?\Pimple\Container
@@ -39,6 +47,16 @@ class Service implements InjectionAwareInterface
return $this->di;
}
public function getExtensionRepository(): ExtensionRepository
{
return $this->extensionRepository;
}
public function getExtensionMetaRepository(): ExtensionMetaRepository
{
return $this->extensionMetaRepository;
}
public function getModulePermissions(): array
{
return [
@@ -73,17 +91,7 @@ class Service implements InjectionAwareInterface
return true;
}
$query = "SELECT id
FROM extension
WHERE type = :type
AND status = 'installed'
AND name = :id
LIMIT 1
";
$id_or_null = $this->di['db']->getCell($query, ['type' => $type, 'id' => $id]);
return (bool) $id_or_null;
return $this->getExtensionRepository()->existsActiveByTypeAndName($type, $id);
}
public static function onBeforeAdminCronRun(\Box_Event $event): bool
@@ -102,45 +110,25 @@ class Service implements InjectionAwareInterface
public function removeNotExistingModules(): int
{
$list = $this->di['db']->find('Extension', "type = 'mod'");
$list = $this->getExtensionRepository()->findBy(['type' => Extension::TYPE_MOD]);
$removedItems = 0;
foreach ($list as $ext) {
try {
$mod = $this->di['mod']($ext->name);
$mod = $this->di['mod']($ext->getName());
$mod->getManifest();
} catch (\Exception) {
$this->di['db']->trash($ext);
$this->di['em']->remove($ext);
++$removedItems;
}
}
if ($removedItems > 0) {
$this->di['em']->flush();
}
return $removedItems;
}
public function getSearchQuery($filter): array
{
$search = $filter['search'] ?? null;
$type = $filter['type'] ?? null;
$params = [];
$sql = "SELECT * FROM extension
WHERE status = 'installed' ";
if ($type !== null) {
$sql .= ' AND type = :type';
$params[':type'] = $type;
}
if ($search !== null) {
$sql .= ' AND name LIKE :search';
$params[':search'] = '%' . $search . '%';
}
$sql .= ' ORDER BY type ASC, status DESC, id ASC';
return [$sql, $params];
}
/**
* @return mixed[]
*/
@@ -148,8 +136,11 @@ class Service implements InjectionAwareInterface
{
$this->removeNotExistingModules();
[$sql, $params] = $this->getSearchQuery($filter);
$installed = $this->di['db']->getAll($sql, $params);
$installedEntities = $this->getExtensionRepository()->getSearchQueryBuilder($filter)
->andWhere('e.status = :status')
->setParameter('status', Extension::STATUS_INSTALLED)
->getQuery()
->getResult();
$has_settings = $filter['has_settings'] ?? null;
$only_installed = $filter['installed'] ?? null;
@@ -168,20 +159,20 @@ class Service implements InjectionAwareInterface
}
}
foreach ($installed as $im) {
$m = $this->di['mod']($im['name']);
foreach ($installedEntities as $ext) {
$m = $this->di['mod']($ext->getName());
try {
$manifest = $m->getManifest();
} catch (\Exception $e) {
error_log("Error while decoding the manifest file for {$im['name']} : {$e->getMessage()}.");
error_log("Error while decoding the manifest file for {$ext->getName()} : {$e->getMessage()}.");
continue;
}
$manifest['version'] = $im['version'];
$manifest['status'] = $im['status'];
if ($im['type'] == 'mod' && $im['status'] == 'installed') {
$manifest['version'] = $ext->getVersion();
$manifest['status'] = $ext->getStatus();
if ($ext->getType() === Extension::TYPE_MOD && $ext->getStatus() === Extension::STATUS_INSTALLED) {
$manifest['has_settings'] = $m->hasSettingsPage();
}
@@ -193,9 +184,9 @@ class Service implements InjectionAwareInterface
$a = $this->_getAvailable();
// unset installed modules
foreach ($installed as $ins) {
if (in_array($ins['name'], $a)) {
$key = array_search($ins['name'], $a);
foreach ($installedEntities as $ins) {
if (in_array($ins->getName(), $a, true)) {
$key = array_search($ins->getName(), $a, true);
unset($a[$key]);
}
}
@@ -383,15 +374,7 @@ class Service implements InjectionAwareInterface
return $this->di['url']->adminLink(ltrim($uri, '/'));
}
/**
* @return \Model_Extension|null
*/
public function findExtension($type, $id)
{
return $this->di['db']->findOne('Extension', 'type = ? and name = ? ', [$type, $id]);
}
public function update(\Model_Extension $model): never
public function update(Extension $model): never
{
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
@@ -403,23 +386,23 @@ class Service implements InjectionAwareInterface
*
* @return array|array{has_settings: bool, id: string, redirect: bool, type: string}
*/
public function activate(\Model_Extension $ext): array
public function activate(Extension $ext): array
{
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
$result = [
'id' => $ext->name,
'type' => $ext->type,
'id' => $ext->getName(),
'type' => $ext->getType(),
'redirect' => false,
'has_settings' => false,
];
switch ($ext->type) {
switch ($ext->getType()) {
case \FOSSBilling\ExtensionManager::TYPE_MOD:
$mod = $this->di['mod']($ext->name);
$mod = $this->di['mod']($ext->getName());
$manifest = $mod->getManifest();
$this->installModule($ext);
$ext->version = $manifest['version'];
$ext->setVersion($manifest['version'] !== null ? (string) $manifest['version'] : null);
$result['redirect'] = $mod->hasAdminController();
$result['has_settings'] = $mod->hasSettingsPage();
@@ -429,8 +412,8 @@ class Service implements InjectionAwareInterface
break;
}
$ext->status = 'installed';
$this->di['db']->store($ext);
$ext->setStatus(Extension::STATUS_INSTALLED);
$this->di['em']->flush();
return $result;
}
@@ -440,13 +423,13 @@ class Service implements InjectionAwareInterface
*
* @throws \FOSSBilling\InformationException
*/
public function deactivate(\Model_Extension $ext): bool
public function deactivate(Extension $ext): bool
{
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
switch ($ext->type) {
switch ($ext->getType()) {
case \FOSSBilling\ExtensionManager::TYPE_HOOK:
$file = Path::changeExtension(ucfirst($ext->name), '.php');
$file = Path::changeExtension(ucfirst((string) $ext->getName()), '.php');
$destination = Path::join(PATH_LIBRARY, 'Hook', $file);
if ($this->filesystem->exists($destination)) {
$this->filesystem->remove($destination);
@@ -455,7 +438,7 @@ class Service implements InjectionAwareInterface
break;
case \FOSSBilling\ExtensionManager::TYPE_MOD:
$mod = $ext->name;
$mod = $ext->getName();
if ($this->isCoreModule($mod)) {
throw new \FOSSBilling\InformationException('Core modules are an integral part of the FOSSBilling system and cannot be deactivated.');
}
@@ -466,7 +449,8 @@ class Service implements InjectionAwareInterface
break;
}
$this->di['db']->trash($ext);
$this->di['em']->remove($ext);
$this->di['em']->flush();
return true;
}
@@ -606,11 +590,11 @@ class Service implements InjectionAwareInterface
return $query->executeQuery()->fetchFirstColumn();
}
private function installModule(\Model_Extension $ext): bool
private function installModule(Extension $ext): bool
{
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('extension', 'manage_extensions');
$mod = $this->di['mod']($ext->name);
$mod = $this->di['mod']($ext->getName());
if ($mod->isCore()) {
throw new \FOSSBilling\InformationException('FOSSBilling core modules cannot be installed or removed');
@@ -632,30 +616,36 @@ class Service implements InjectionAwareInterface
}
}
$ext->version = $info['version'];
$this->di['db']->store($ext);
$ext->setVersion($info['version'] !== null ? (string) $info['version'] : null);
$this->di['em']->flush();
return true;
}
public function activateExistingExtension(array $data): array
{
$ext = $this->findExtension($data['type'], $data['id']);
if (!$ext instanceof \Model_Extension) {
$ext = $this->di['db']->dispense('Extension');
$ext->name = $data['id'];
$ext->type = $data['type'];
$ext->version = null;
$ext->status = 'deactivated';
$ext_id = $this->di['db']->store($ext);
$ext = $this->getExtensionRepository()->findOneByTypeAndName($data['type'], $data['id']);
$persistedNewly = false;
if (!$ext instanceof Extension) {
$ext = new Extension();
$ext->setName($data['id']);
$ext->setType($data['type']);
$ext->setVersion(null);
$ext->setStatus(Extension::STATUS_DEACTIVATED);
$this->di['em']->persist($ext);
$this->di['em']->flush();
$persistedNewly = true;
}
$ext_id ??= $ext->id;
$ext_id = $ext->getId();
$this->di['events_manager']->fire(['event' => 'onBeforeAdminActivateExtension', 'params' => ['id' => $ext_id]]);
try {
$result = $this->activate($ext);
} catch (\Exception $e) {
$this->di['db']->trash($ext);
if ($persistedNewly) {
$this->di['em']->remove($ext);
$this->di['em']->flush();
}
throw $e;
}
@@ -670,19 +660,18 @@ class Service implements InjectionAwareInterface
return $this->di['cache']->get("config_{$ext}", function (ItemInterface $item) use ($ext) {
$item->expiresAfter(60 * 60);
$c = $this->di['db']->findOne('ExtensionMeta', 'extension = :ext AND meta_key = :key', [':ext' => $ext, ':key' => 'config']);
if (is_null($c)) {
$c = $this->di['db']->dispense('ExtensionMeta');
$c->extension = $ext;
$c->meta_key = 'config';
$c->meta_value = null;
$c->created_at = date('Y-m-d H:i:s');
$c->updated_at = date('Y-m-d H:i:s');
$this->di['db']->store($c);
$meta = $this->getExtensionMetaRepository()->findOneByExtensionAndScope($ext, 'config');
if ($meta === null) {
$meta = new ExtensionMeta();
$meta->setExtension($ext);
$meta->setMetaKey('config');
$meta->setMetaValue(null);
$this->di['em']->persist($meta);
$this->di['em']->flush();
$config = [];
} else {
$config = $this->di['crypt']->decrypt($c->meta_value, $this->_getSalt());
$config = is_string($config) ? json_decode($config, true) : [];
$decrypted = $this->di['crypt']->decrypt($meta->getMetaValue(), $this->_getSalt());
$config = is_string($decrypted) ? json_decode($decrypted, true) : [];
}
$config['ext'] = $ext;
@@ -698,22 +687,22 @@ class Service implements InjectionAwareInterface
$this->getConfig($ext); // Creates new config if it does not exist in DB
$this->di['events_manager']->fire(['event' => 'onBeforeAdminExtensionConfigSave', 'params' => $data]);
$sql = "
UPDATE extension_meta
SET meta_value = :config
WHERE extension = :ext
AND meta_key = 'config'
LIMIT 1;
";
$meta = $this->getExtensionMetaRepository()->findOneByExtensionAndScope($ext, 'config');
$config = json_encode($data);
$config = $this->di['crypt']->encrypt($config, $this->_getSalt());
$encrypted = $this->di['crypt']->encrypt($config, $this->_getSalt());
if ($meta === null) {
$meta = new ExtensionMeta();
$meta->setExtension($ext);
$meta->setMetaKey('config');
$meta->setMetaValue($encrypted);
$this->di['em']->persist($meta);
} else {
$meta->setMetaValue($encrypted);
}
$this->di['em']->flush();
$params = [
'ext' => $ext,
'config' => $config,
];
$this->di['db']->exec($sql, $params);
$this->di['events_manager']->fire(['event' => 'onAfterAdminExtensionConfigSave', 'params' => $data]);
$this->di['logger']->info("Updated extension {$ext} configuration.");
$this->di['cache']->delete("config_{$ext}");
@@ -0,0 +1,68 @@
<?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\Extension\Entity\ExtensionMeta;
use Box\Mod\Extension\Repository\ExtensionMetaRepository;
use Doctrine\ORM\QueryBuilder;
function extensionMetaRepoCreateRepository(): ExtensionMetaRepository
{
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$classMeta = Mockery::mock(Doctrine\ORM\Mapping\ClassMetadata::class);
$classMeta->name = ExtensionMeta::class;
$classMeta->shouldReceive('getName')->andReturn(ExtensionMeta::class);
$em->shouldReceive('getClassMetadata')->with(ExtensionMeta::class)->andReturn($classMeta);
$qb = new QueryBuilder($em);
$em->shouldReceive('createQueryBuilder')->andReturn($qb);
return new ExtensionMetaRepository($em, $classMeta);
}
test('createQueryBuilderForExtension sets the extension parameter', function (): void {
$repo = extensionMetaRepoCreateRepository();
$qb = $repo->createQueryBuilderForExtension('mod_email');
expect($qb)->toBeInstanceOf(QueryBuilder::class);
expect($qb->getParameter('extension')->getValue())->toBe('mod_email');
});
test('findOneByExtensionAndId delegates to findOneBy', function (): void {
$repo = Mockery::mock(ExtensionMetaRepository::class)->makePartial();
$repo->shouldReceive('findOneBy')
->once()
->with(['extension' => 'mod_email', 'id' => 5])
->andReturn(null);
expect($repo->findOneByExtensionAndId('mod_email', 5))->toBeNull();
});
test('findOneByExtensionAndScope returns the first match', function (): void {
$meta = new ExtensionMeta();
$repo = Mockery::mock(ExtensionMetaRepository::class)->makePartial();
$repo->shouldReceive('findByExtensionAndScope')
->once()
->with('mod_email', 'config', null, null, ['id' => 'ASC'], 1)
->andReturn([$meta]);
expect($repo->findOneByExtensionAndScope('mod_email', 'config'))->toBe($meta);
});
test('findOneByExtensionAndScope returns null on empty result', function (): void {
$repo = Mockery::mock(ExtensionMetaRepository::class)->makePartial();
$repo->shouldReceive('findByExtensionAndScope')
->once()
->andReturn([]);
expect($repo->findOneByExtensionAndScope('mod_email', 'config'))->toBeNull();
});
@@ -0,0 +1,91 @@
<?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\Extension\Entity\Extension;
use Box\Mod\Extension\Repository\ExtensionRepository;
use Doctrine\ORM\QueryBuilder;
function extensionRepoCreateRepository(): ExtensionRepository
{
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$classMeta = Mockery::mock(Doctrine\ORM\Mapping\ClassMetadata::class);
$classMeta->name = Extension::class;
$classMeta->shouldReceive('getName')->andReturn(Extension::class);
$em->shouldReceive('getClassMetadata')->with(Extension::class)->andReturn($classMeta);
$qb = new QueryBuilder($em);
$em->shouldReceive('createQueryBuilder')->andReturn($qb);
return new ExtensionRepository($em, $classMeta);
}
test('getSearchQueryBuilder applies status, type and search filters', function (): void {
$repo = extensionRepoCreateRepository();
$qb = $repo->getSearchQueryBuilder([
'type' => 'mod',
'status' => 'installed',
'search' => 'sample',
]);
expect($qb)->toBeInstanceOf(QueryBuilder::class);
$dqlParts = $qb->getDQLParts();
expect($dqlParts['where']->count())->toBe(3);
});
test('getSearchQueryBuilder orders by type, status, id', function (): void {
$repo = extensionRepoCreateRepository();
$qb = $repo->getSearchQueryBuilder([]);
$orderBy = $qb->getDQLPart('orderBy');
expect(count($orderBy))->toBe(3);
});
test('findOneByTypeAndName delegates to findOneBy', function (): void {
$repo = Mockery::mock(ExtensionRepository::class)->makePartial();
$repo->shouldReceive('findOneBy')
->once()
->with(['type' => 'mod', 'name' => 'sample'])
->andReturn(null);
expect($repo->findOneByTypeAndName('mod', 'sample'))->toBeNull();
});
test('findInstalledByType returns installed extensions', function (): void {
$repo = Mockery::mock(ExtensionRepository::class)->makePartial();
$repo->shouldReceive('findBy')
->once()
->with(['type' => 'mod', 'status' => Extension::STATUS_INSTALLED])
->andReturn([]);
expect($repo->findInstalledByType('mod'))->toBe([]);
});
test('existsActiveByTypeAndName returns true when found', function (): void {
$repo = Mockery::mock(ExtensionRepository::class)->makePartial();
$entity = new Extension();
$repo->shouldReceive('findOneBy')
->once()
->with(['type' => 'mod', 'name' => 'sample', 'status' => Extension::STATUS_INSTALLED])
->andReturn($entity);
expect($repo->existsActiveByTypeAndName('mod', 'sample'))->toBeTrue();
});
test('existsActiveByTypeAndName returns false when not found', function (): void {
$repo = Mockery::mock(ExtensionRepository::class)->makePartial();
$repo->shouldReceive('findOneBy')
->once()
->andReturn(null);
expect($repo->existsActiveByTypeAndName('mod', 'sample'))->toBeFalse();
});
+249 -230
View File
@@ -5,12 +5,19 @@
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
* @license http://www.apache.org/licenses/LICENSE-2.0
*/
declare(strict_types=1);
use Box\Mod\Extension\Entity\Extension;
use Box\Mod\Extension\Entity\ExtensionMeta;
use Box\Mod\Extension\Repository\ExtensionMetaRepository;
use Box\Mod\Extension\Repository\ExtensionRepository;
use Box\Mod\Extension\Service;
use function Tests\Helpers\container;
use function Tests\Helpers\setEntityId;
class ExtensionPdoMock extends PDO
{
@@ -26,8 +33,53 @@ class ExtensionPdoStatementsMock extends PDOStatement
}
}
function extensionCreateEntity(int $id, string $type = 'mod', string $name = 'extensionName', string $status = 'installed', ?string $version = '1'): Extension
{
$entity = new Extension();
setEntityId($entity, $id);
$entity->setType($type);
$entity->setName($name);
$entity->setStatus($status);
$entity->setVersion($version);
return $entity;
}
function extensionCreateExtensionMetaEntity(int $id, string $extensionName = 'extensionName', string $metaKey = 'config', ?string $metaValue = null): ExtensionMeta
{
$entity = new ExtensionMeta();
setEntityId($entity, $id);
$entity->setExtension($extensionName);
$entity->setMetaKey($metaKey);
$entity->setMetaValue($metaValue);
return $entity;
}
/**
* Build a partial Mockery EntityManager that returns the right repository mock
* for each entity class requested.
*/
function extensionBuildEm(?ExtensionRepository $extensionRepository = null, ?ExtensionMetaRepository $metaRepository = null, bool $ignoreMissing = true): Doctrine\ORM\EntityManagerInterface&Mockery\MockInterface
{
$extensionRepository ??= Mockery::mock(ExtensionRepository::class)->shouldIgnoreMissing();
$metaRepository ??= Mockery::mock(ExtensionMetaRepository::class)->shouldIgnoreMissing();
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
if ($ignoreMissing) {
$em->shouldIgnoreMissing();
}
$em->shouldReceive('getRepository')->andReturnUsing(static fn (string $class): object => match ($class) {
Extension::class => $extensionRepository,
ExtensionMeta::class => $metaRepository,
default => $metaRepository,
});
return $em;
}
test('getDi returns the dependency injection container', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$di = container();
$service->setDi($di);
$getDi = $service->getDi();
@@ -35,7 +87,7 @@ test('getDi returns the dependency injection container', function (): void {
});
test('isCoreModule checks if module is core module', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$coreModules = ['extension', 'cron', 'staff'];
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getCoreModules')
@@ -58,7 +110,7 @@ test('isCoreModule checks if module is core module', function (): void {
});
test('isExtensionActive returns false when module not found', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$coreModules = ['extension', 'cron', 'staff'];
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getCoreModules')
@@ -66,14 +118,16 @@ test('isExtensionActive returns false when module not found', function (): void
->once()
->andReturn($coreModules);
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('getCell')
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('existsActiveByTypeAndName')
->atLeast()
->once()
->andReturn(null);
->andReturn(false);
$em = extensionBuildEm($extensionRepository);
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$service->setDi($di);
@@ -84,25 +138,25 @@ test('isExtensionActive returns false when module not found', function (): void
});
test('removeNotExistingModules removes non-existing modules', function (): void {
$service = new Box\Mod\Extension\Service();
$model = new Model_Extension();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->name = 'extensionName';
$service = new Service();
$ext = extensionCreateEntity(1, 'mod', 'extensionName');
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getManifest')->andThrow(new Exception());
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('find')
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('findBy')
->with(['type' => Extension::TYPE_MOD])
->atLeast()
->once()
->andReturn([$model]);
$dbMock->shouldReceive('trash')
->atLeast()
->once();
->andReturn([$ext]);
$em = extensionBuildEm($extensionRepository);
$em->shouldReceive('remove')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$service->setDi($di);
@@ -112,54 +166,56 @@ test('removeNotExistingModules removes non-existing modules', function (): void
expect($result > 0)->toBeTrue();
});
// Data provider for search query tests
$searchQueryData = [
[[], 'SELECT * FROM extension', []],
[['type' => 'mod'], 'AND type = :type', [':type' => 'mod']],
[['search' => 'FindUp'], 'AND name LIKE :search', [':search' => 'FindUp']],
];
test('getSearchQueryBuilder returns a QueryBuilder', function (): void {
$service = new Service();
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
$qb->shouldReceive('andWhere')->andReturnSelf();
$qb->shouldReceive('setParameter')->andReturnSelf();
$qb->shouldReceive('orderBy')->andReturnSelf();
$qb->shouldReceive('addOrderBy')->andReturnSelf();
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('getSearchQueryBuilder')
->atLeast()
->once()
->andReturn($qb);
$em = extensionBuildEm($extensionRepository);
test('getSearchQuery returns correct SQL and params', function (array $data, string $expectedStr, array $expectedParams): void {
$service = new Box\Mod\Extension\Service();
$di = container();
$di['em'] = $em;
$service->setDi($di);
[$sql, $params] = $service->getSearchQuery($data);
expect($sql)->toBeString();
expect($params)->toBeArray();
expect(str_contains((string) $sql, $expectedStr))->toBeTrue();
expect([])->toBe(array_diff_key($params, $expectedParams));
})->with($searchQueryData);
$result = $service->getExtensionRepository()->getSearchQueryBuilder([]);
expect($result)->toBeInstanceOf(Doctrine\ORM\QueryBuilder::class);
});
test('getExtensionsList returns filtered extensions list', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$data = [
'has_settings' => true,
'active' => true,
];
$model['manifest'] = '{"J":5,"0":"N"}';
$model['type'] = 'mod';
$model['status'] = 'installed';
$model['name'] = 'extensionName';
$model['version'] = '1';
$ext = extensionCreateEntity(1, 'mod', 'extensionName', 'installed', '1');
$modelFind = new Model_Extension();
$modelFind->loadBean(new Tests\Helpers\DummyBean());
$modelFind->name = 'extensionName';
$query = Mockery::mock(Doctrine\ORM\Query::class);
$query->shouldReceive('getResult')->andReturn([$ext]);
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('getAll')
->atLeast()
->once()
->andReturn([$model]);
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
$qb->shouldReceive('andWhere')->andReturnSelf();
$qb->shouldReceive('setParameter')->andReturnSelf();
$qb->shouldReceive('getQuery')->andReturn($query);
$dbMock->shouldReceive('find')
->atLeast()
->once()
->andReturn([$modelFind]);
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
$extensionRepository->shouldReceive('findBy')->andReturn([]);
$em = extensionBuildEm($extensionRepository);
$em->shouldReceive('remove')->andReturnNull();
$em->shouldReceive('flush')->andReturnNull();
$coreModules = ['extension', 'cron', 'staff'];
$modMock = Mockery::mock(FOSSBilling\Module::class);
@@ -177,7 +233,7 @@ test('getExtensionsList returns filtered extensions list', function (): void {
->andReturn(true);
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$service->setDi($di);
@@ -187,31 +243,28 @@ test('getExtensionsList returns filtered extensions list', function (): void {
});
test('getExtensionsList returns only installed extensions', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$data = [
'installed' => true,
];
$model['manifest'] = '{"J":5,"0":"N"}';
$model['type'] = 'mod';
$model['status'] = 'installed';
$model['name'] = 'extensionName';
$model['version'] = '1';
$ext = extensionCreateEntity(1, 'mod', 'extensionName', 'installed', '1');
$modelFind = new Model_Extension();
$modelFind->loadBean(new Tests\Helpers\DummyBean());
$modelFind->name = 'extensionName';
$query = Mockery::mock(Doctrine\ORM\Query::class);
$query->shouldReceive('getResult')->andReturn([$ext]);
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('getAll')
->atLeast()
->once()
->andReturn([$model]);
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
$qb->shouldReceive('andWhere')->andReturnSelf();
$qb->shouldReceive('setParameter')->andReturnSelf();
$qb->shouldReceive('getQuery')->andReturn($query);
$dbMock->shouldReceive('find')
->atLeast()
->once()
->andReturn([$modelFind]);
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
$extensionRepository->shouldReceive('findBy')->andReturn([]);
$em = extensionBuildEm($extensionRepository);
$em->shouldReceive('remove')->andReturnNull();
$em->shouldReceive('flush')->andReturnNull();
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getManifest')
@@ -224,7 +277,7 @@ test('getExtensionsList returns only installed extensions', function (): void {
->andReturn(true);
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$service->setDi($di);
@@ -234,8 +287,8 @@ test('getExtensionsList returns only installed extensions', function (): void {
});
test('getAdminNavigation returns admin navigation', function (): void {
$service = new Box\Mod\Extension\Service();
$extensionServiceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$service = new Service();
$extensionServiceMock = Mockery::mock(Service::class)->makePartial();
$extensionServiceMock->shouldAllowMockingProtectedMethods();
$extensionServiceMock->shouldReceive('getConfig')
->atLeast()
@@ -322,28 +375,28 @@ test('getAdminNavigation returns admin navigation', function (): void {
});
test('findExtension finds an extension', function (): void {
$service = new Box\Mod\Extension\Service();
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('findOne')
$service = new Service();
$ext = extensionCreateEntity(1);
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('findOneByTypeAndName')
->atLeast()
->once()
->andReturn(new Model_Extension());
->andReturn($ext);
$em = extensionBuildEm($extensionRepository);
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$service->setDi($di);
$result = $service->findExtension('mod', 'id');
expect($result)->toBeInstanceOf('\Model_Extension');
$result = $service->getExtensionRepository()->findOneByTypeAndName('mod', 'id');
expect($result)->toBeInstanceOf(Extension::class);
});
test('update throws exception for extensions that need manual update', function (): void {
$service = new Box\Mod\Extension\Service();
$model = new Model_Extension();
$model->loadBean(new Tests\Helpers\DummyBean());
$model->type = 'mod';
$model->name = 'testExtension';
$model->version = '2';
$service = new Service();
$ext = extensionCreateEntity(1, 'mod', 'testExtension', 'installed', '2');
$extensionStub = Mockery::mock(FOSSBilling\ExtensionManager::class);
@@ -356,23 +409,13 @@ test('update throws exception for extensions that need manual update', function
$service->setDi($di);
expect(fn () => $service->update($model))
expect(fn () => $service->update($ext))
->toThrow(FOSSBilling\Exception::class, 'Visit the extension directory for more information on updating this extension.');
});
test('activate activates an extension', function (): void {
$service = new Box\Mod\Extension\Service();
$ext = new Model_Extension();
$ext->loadBean(new Tests\Helpers\DummyBean());
$ext->type = 'mod';
$ext->name = 'testExtension';
$expectedResult = [
'id' => $ext->name,
'type' => $ext->type,
'redirect' => true,
'has_settings' => true,
];
$service = new Service();
$ext = extensionCreateEntity(1, 'mod', 'testExtension', 'deactivated');
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
@@ -382,52 +425,42 @@ test('activate activates an extension', function (): void {
->atLeast()
->once()
->andReturn(['version' => 1]);
$modMock->shouldReceive('hasAdminController')
->atLeast()
->once()
->andReturn(true);
$modMock->shouldReceive('hasSettingsPage')
->atLeast()
->once()
->andReturn(true);
$modMock->shouldReceive('isCore')
->atLeast()
->once()
->andReturn(false);
$modMock->shouldReceive('install')
->atLeast()
->once();
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('store')
->atLeast()
->once()
->andReturn(1);
$em = extensionBuildEm();
$em->shouldReceive('flush')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
$service->setDi($di);
$result = $service->activate($ext);
expect($result)->toBeArray();
expect($result)->toBe($expectedResult);
expect($result['id'])->toBe('testExtension');
expect($result['type'])->toBe('mod');
expect($result['redirect'])->toBeTrue();
expect($result['has_settings'])->toBeTrue();
});
test('deactivate deactivates an extension', function (): void {
$service = new Box\Mod\Extension\Service();
$ext = new Model_Extension();
$ext->loadBean(new Tests\Helpers\DummyBean());
$ext->type = 'mod';
$ext->name = 'extensionTest';
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('trash')->atLeast()->once();
$service = new Service();
$ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed');
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getCoreModules')
@@ -438,10 +471,13 @@ test('deactivate deactivates an extension', function (): void {
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$em = extensionBuildEm();
$em->shouldReceive('remove')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$di = container();
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
$service->setDi($di);
@@ -451,24 +487,23 @@ test('deactivate deactivates an extension', function (): void {
});
test('deactivate throws exception for core modules', function (): void {
$service = new Box\Mod\Extension\Service();
$ext = new Model_Extension();
$ext->loadBean(new Tests\Helpers\DummyBean());
$ext->type = 'mod';
$ext->name = 'extensionTest';
$service = new Service();
$ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed');
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getCoreModules')
->atLeast()
->once()
->andReturn([$ext->name]);
->andReturn([$ext->getName()]);
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
$di = container();
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$em = extensionBuildEm();
$di = container();
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
$service->setDi($di);
@@ -478,14 +513,7 @@ test('deactivate throws exception for core modules', function (): void {
});
test('deactivate deactivates hook extension', function (): void {
$service = new Box\Mod\Extension\Service();
$ext = new Model_Extension();
$ext->loadBean(new Tests\Helpers\DummyBean());
$ext->type = 'hook';
$ext->name = 'extensionTest';
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('trash')->atLeast()->once();
$ext = extensionCreateEntity(1, 'hook', 'extensionTest', 'installed');
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
@@ -494,10 +522,14 @@ test('deactivate deactivates hook extension', function (): void {
$filesystemMock->shouldReceive('exists')->atLeast()->once()->andReturn(true);
$filesystemMock->shouldReceive('remove')->atLeast()->once();
$service = new Box\Mod\Extension\Service($filesystemMock);
$service = new Service($filesystemMock);
$em = extensionBuildEm();
$em->shouldReceive('remove')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
$service->setDi($di);
@@ -506,11 +538,7 @@ test('deactivate deactivates hook extension', function (): void {
});
test('deactivate deactivates module', function (): void {
$service = new Box\Mod\Extension\Service();
$ext = new Model_Extension();
$ext->loadBean(new Tests\Helpers\DummyBean());
$ext->type = 'mod';
$ext->name = 'extensionTest';
$ext = extensionCreateEntity(1, 'mod', 'extensionTest', 'installed');
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getCoreModules')
@@ -521,15 +549,16 @@ test('deactivate deactivates module', function (): void {
$staffService = Mockery::mock(Box\Mod\Staff\Service::class);
$staffService->shouldReceive('checkPermissionsAndThrowException')->atLeast()->once();
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('trash')->atLeast()->once();
$em = extensionBuildEm();
$em->shouldReceive('remove')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffService);
$service = new Service();
$service->setDi($di);
$result = $service->deactivate($ext);
@@ -537,10 +566,6 @@ test('deactivate deactivates module', function (): void {
});
test('uninstall uninstalls an extension', function (): void {
$service = new Box\Mod\Extension\Service();
$dbMock = Mockery::mock('\Box_Database');
$dbMock->shouldReceive('getCell')->andReturn(null);
$modMock = Mockery::mock(FOSSBilling\Module::class);
$modMock->shouldReceive('getCoreModules')
->atLeast()
@@ -569,14 +594,18 @@ test('uninstall uninstalls an extension', function (): void {
$tmpDir = sys_get_temp_dir() . '/fb_test_ext_' . uniqid();
mkdir($tmpDir, 0o755, true);
// Create the service with filesystem in constructor, then make it a partial mock
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class . '[getExtensionPath]', [$filesystemMock]);
$serviceMock = Mockery::mock(Service::class . '[getExtensionPath]', [$filesystemMock]);
$serviceMock->shouldReceive('getExtensionPath')
->atLeast()
->once()
->andReturn($tmpDir);
$di['db'] = $dbMock;
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('existsActiveByTypeAndName')->andReturn(false);
$em = extensionBuildEm($extensionRepository);
$di['em'] = $em;
$di['logger'] = new Tests\Helpers\TestLogger();
$di['mod'] = $di->protect(fn ($name): Mockery\MockInterface => $modMock);
@@ -602,7 +631,7 @@ test('uninstall uninstalls an extension', function (): void {
});
test('getExtensionPath rejects unsafe extension IDs', function (string $type, string $id): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
expect(fn (): string => $service->getExtensionPath($type, $id))
->toThrow(FOSSBilling\InformationException::class, 'Extension ID contains invalid characters.');
@@ -614,7 +643,7 @@ test('getExtensionPath rejects unsafe extension IDs', function (string $type, st
]);
test('downloadAndExtract throws exception when download URL is missing', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$extensionMock = Mockery::mock(FOSSBilling\ExtensionManager::class);
$extensionMock->shouldReceive('getLatestExtensionRelease')
@@ -636,7 +665,7 @@ test('downloadAndExtract throws exception when download URL is missing', functio
});
test('getInstalledMods returns installed modules', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$dbalMock = new class {
public function createQueryBuilder(): object
{
@@ -681,6 +710,7 @@ test('getInstalledMods returns installed modules', function (): void {
$di = new Pimple\Container();
$di['dbal'] = $dbalMock;
$di['em'] = extensionBuildEm();
$service->setDi($di);
$result = $service->getInstalledMods();
@@ -688,46 +718,33 @@ test('getInstalledMods returns installed modules', function (): void {
});
test('activateExistingExtension activates existing extension', function (): void {
$service = new Box\Mod\Extension\Service();
$data = [
'id' => 'extensionId',
'type' => 'extensionType',
];
$model = new Model_Extension();
$model->loadBean(new Tests\Helpers\DummyBean());
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('findExtension')
->atLeast()
->once()
->andReturnUsing(function () use ($model) {
static $callCount = 0;
++$callCount;
return $callCount === 1 ? null : $model;
});
$serviceMock->shouldReceive('activate')
->atLeast()
->once()
->andReturn([]);
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('dispense')
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('findOneByTypeAndName')
->atLeast()
->once()
->andReturn($model);
$dbMock->shouldReceive('store')
->atLeast()
->once()
->andReturn(1);
->andReturn(null);
$em = extensionBuildEm($extensionRepository);
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['events_manager'] = $eventMock;
$di['logger'] = new Tests\Helpers\TestLogger();
@@ -738,30 +755,33 @@ test('activateExistingExtension activates existing extension', function (): void
});
test('activateExistingExtension throws exception on activation failure', function (): void {
$service = new Box\Mod\Extension\Service();
$data = [
'id' => 'extensionId',
'type' => 'extensionType',
];
$model = new Model_Extension();
$model->loadBean(new Tests\Helpers\DummyBean());
$model = extensionCreateEntity(1);
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('findExtension')
->atLeast()
->once()
->andReturn($model);
$serviceMock->shouldReceive('activate')
->atLeast()
->once()
->andThrow(new Exception());
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('findOneByTypeAndName')
->atLeast()
->once()
->andReturn($model);
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$em = extensionBuildEm($extensionRepository);
$di = container();
$di['em'] = $em;
$di['events_manager'] = $eventMock;
$serviceMock->setDi($di);
@@ -771,25 +791,26 @@ test('activateExistingExtension throws exception on activation failure', functio
});
test('getConfig returns extension config', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$data = [
'ext' => 'extensionName',
];
$model = new Model_ExtensionMeta();
$model->loadBean(new Tests\Helpers\DummyBean());
$meta = extensionCreateExtensionMetaEntity(1, 'extensionName', 'config', 'encryptedConfig');
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('findOne')
$metaRepo = Mockery::mock(ExtensionMetaRepository::class);
$metaRepo->shouldReceive('findOneByExtensionAndScope')
->atLeast()
->once()
->andReturn($model);
->andReturn($meta);
$cryptMock = Mockery::mock(Box_Crypt::class);
$cryptMock->shouldReceive('decrypt')->atLeast()->once();
$em = extensionBuildEm(null, $metaRepo);
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['crypt'] = $cryptMock;
$di['cache'] = new Symfony\Component\Cache\Adapter\ArrayAdapter();
@@ -800,30 +821,23 @@ test('getConfig returns extension config', function (): void {
});
test('getConfig creates new ExtensionMeta when not found', function (): void {
$service = new Box\Mod\Extension\Service();
$service = new Service();
$data = [
'ext' => 'extensionName',
];
$model = new Model_ExtensionMeta();
$model->loadBean(new Tests\Helpers\DummyBean());
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('findOne')
$metaRepo = Mockery::mock(ExtensionMetaRepository::class);
$metaRepo->shouldReceive('findOneByExtensionAndScope')
->atLeast()
->once()
->andReturn(null);
$dbMock->shouldReceive('dispense')
->atLeast()
->once()
->andReturn($model);
$dbMock->shouldReceive('store')
->atLeast()
->once()
->andReturn(1);
$em = extensionBuildEm(null, $metaRepo);
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['cache'] = new Symfony\Component\Cache\Adapter\ArrayAdapter();
$service->setDi($di);
@@ -834,12 +848,11 @@ test('getConfig creates new ExtensionMeta when not found', function (): void {
});
test('setConfig sets extension config', function (): void {
$service = new Box\Mod\Extension\Service();
$data = [
'ext' => 'extensionName',
];
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$serviceMock->shouldReceive('hasManagePermission')
->with('extensionName')
@@ -859,19 +872,21 @@ test('setConfig sets extension config', function (): void {
->once()
->andReturn('encryptedConfig');
$model = new Model_ExtensionMeta();
$model->loadBean(new Tests\Helpers\DummyBean());
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('exec')
$metaRepo = Mockery::mock(ExtensionMetaRepository::class);
$metaRepo->shouldReceive('findOneByExtensionAndScope')
->atLeast()
->once()
->andReturn([]);
->andReturn(null);
$em = extensionBuildEm(null, $metaRepo);
$em->shouldReceive('persist')->atLeast()->once();
$em->shouldReceive('flush')->atLeast()->once();
$eventMock = Mockery::mock(Box_EventManager::class);
$eventMock->shouldReceive('fire')->atLeast()->once();
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['tools'] = $toolsMock;
$di['crypt'] = $cryptMock;
$di['events_manager'] = $eventMock;
@@ -885,7 +900,7 @@ test('setConfig sets extension config', function (): void {
});
test('hasManagePermission denies access when module does not declare manage_settings', function (): void {
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$staffMock = Mockery::mock(Box\Mod\Staff\Service::class);
@@ -901,14 +916,16 @@ test('hasManagePermission denies access when module does not declare manage_sett
->once()
->andReturn([]);
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getCell')
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('existsActiveByTypeAndName')
->atLeast()
->once()
->andReturn(1);
->andReturn(true);
$em = extensionBuildEm($extensionRepository);
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffMock);
@@ -921,11 +938,11 @@ test('hasManagePermission denies access when module does not declare manage_sett
]);
expect(fn () => $serviceMock->hasManagePermission('mod_support'))
->toThrow(new FOSSBilling\InformationException('You do not have permission to perform this action', [], 403));
->toThrow(new FOSSBilling\InformationException('You do not have permission to perform this action', [], 403));
});
test('hasManagePermission allows access when module declares manage_settings and user has it', function (): void {
$serviceMock = Mockery::mock(Box\Mod\Extension\Service::class)->makePartial();
$serviceMock = Mockery::mock(Service::class)->makePartial();
$serviceMock->shouldAllowMockingProtectedMethods();
$staffMock = Mockery::mock(Box\Mod\Staff\Service::class);
@@ -946,14 +963,16 @@ test('hasManagePermission allows access when module declares manage_settings and
->once()
->andReturn([]);
$dbMock = Mockery::mock(Box_Database::class);
$dbMock->shouldReceive('getCell')
$extensionRepository = Mockery::mock(ExtensionRepository::class);
$extensionRepository->shouldReceive('existsActiveByTypeAndName')
->atLeast()
->once()
->andReturn(1);
->andReturn(true);
$em = extensionBuildEm($extensionRepository);
$di = container();
$di['db'] = $dbMock;
$di['em'] = $em;
$di['mod'] = $di->protect(fn (): Mockery\MockInterface => $modMock);
$di['mod_service'] = $di->protect(fn (): Mockery\MockInterface => $staffMock);
+12 -10
View File
@@ -15,6 +15,8 @@ namespace Box\Mod\Massmailer;
use Box\Mod\Massmailer\Entity\MassmailerMessage;
use Box\Mod\Massmailer\Repository\MassmailerMessageRepository;
use Doctrine\DBAL\ArrayParameterType;
use FOSSBilling\Enums\ClientOrderStatusEnum;
use FOSSBilling\Enums\ClientStatusEnum;
use FOSSBilling\Environment;
use FOSSBilling\InformationException;
@@ -31,17 +33,17 @@ class Service implements \FOSSBilling\InjectionAwareInterface
self::FILTER_HAS_ORDER_WITH_STATUS,
];
private const array CLIENT_STATUSES = [
\Model_Client::ACTIVE,
\Model_Client::SUSPENDED,
\Model_Client::CANCELED,
ClientStatusEnum::ACTIVE->value,
ClientStatusEnum::SUSPENDED->value,
ClientStatusEnum::CANCELED->value,
];
private const array ORDER_STATUSES = [
\Model_ClientOrder::STATUS_PENDING_SETUP,
\Model_ClientOrder::STATUS_FAILED_SETUP,
\Model_ClientOrder::STATUS_FAILED_RENEW,
\Model_ClientOrder::STATUS_ACTIVE,
\Model_ClientOrder::STATUS_CANCELED,
\Model_ClientOrder::STATUS_SUSPENDED,
ClientOrderStatusEnum::PENDING_SETUP->value,
ClientOrderStatusEnum::FAILED_SETUP->value,
ClientOrderStatusEnum::FAILED_RENEW->value,
ClientOrderStatusEnum::ACTIVE->value,
ClientOrderStatusEnum::CANCELED->value,
ClientOrderStatusEnum::SUSPENDED->value,
];
protected ?\Pimple\Container $di = null;
@@ -117,7 +119,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1;
';
$this->di['db']->exec($sql);
$this->di['dbal']->executeStatement($sql);
// default config values
$extensionService->setConfig(['ext' => 'mod_massmailer', 'limit' => '2', 'interval' => '10', 'test_client_id' => 1]);
+5 -42
View File
@@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Box\Mod\News\Entity;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
@@ -21,6 +22,8 @@ use FOSSBilling\Interfaces\TimestampInterface;
#[ORM\HasLifecycleCallbacks]
class Post implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
public const STATUS_ACTIVE = 'active';
public const STATUS_DRAFT = 'draft';
@@ -57,12 +60,6 @@ class Post implements ApiArrayInterface, TimestampInterface
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $expiresAt = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE)]
private \DateTime $createdAt;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE)]
private \DateTime $updatedAt;
/** @todo Temporary until Admin entity is migrated */
private array $adminData = [];
@@ -103,27 +100,13 @@ class Post implements ApiArrayInterface, TimestampInterface
'publish_at' => $this->getPublishAt()?->format('Y-m-d H:i:s'),
'published_at' => $this->getPublishedAt()?->format('Y-m-d H:i:s'),
'expires_at' => $this->getExpiresAt()?->format('Y-m-d H:i:s'),
'created_at' => $this->getCreatedAt()->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()->format('Y-m-d H:i:s'),
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
'author' => $this->adminData, /* @todo Doctrine: Replace with actual Admin entity and remove $adminData once it's migrated to Doctrine. */
'excerpt' => $excerpt,
];
}
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTime();
$this->createdAt = $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function updateTimestamp(): void
{
$this->updatedAt = new \DateTime();
}
public function getId(): int
{
return $this->id;
@@ -169,16 +152,6 @@ class Post implements ApiArrayInterface, TimestampInterface
return $this->status;
}
public function getCreatedAt(): \DateTime
{
return $this->createdAt;
}
public function getUpdatedAt(): \DateTime
{
return $this->updatedAt;
}
public function getPublishAt(): ?\DateTime
{
return $this->publishAt;
@@ -256,16 +229,6 @@ class Post implements ApiArrayInterface, TimestampInterface
return $this;
}
public function setCreatedAt(\DateTime $createdAt): void
{
$this->createdAt = $createdAt;
}
public function setUpdatedAt(\DateTime $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
public function setPublishAt(?\DateTime $publishAt): self
{
$this->publishAt = $publishAt;
+1 -1
View File
@@ -365,7 +365,7 @@ class Service implements InjectionAwareInterface
$data['discount'] ??= 0;
$data['title'] = $model->title;
$data['meta'] = $this->di['db']->getAssoc('SELECT name, value FROM client_order_meta WHERE client_order_id = :id', [':id' => $model->id]);
$data['active_tickets'] = $supportService->getActiveTicketsCountForOrder($model);
$data['active_tickets'] = $supportService->getSupportTicketRepository()->countActiveTicketsForOrder((int) $model->id);
$client = $this->di['db']->getExistingModelById('Client', $model->client_id, 'Client not found');
$data['client'] = $clientService->toApiArray($client, false);
+3 -1
View File
@@ -941,7 +941,9 @@ test('toApiArray returns expected keys', function (): void {
$clientService->shouldReceive('toApiArray')->atLeast()->once()->andReturn([]);
$supportService = Mockery::mock(Box\Mod\Support\Service::class);
$supportService->shouldReceive('getActiveTicketsCountForOrder')->atLeast()->once()->andReturn(1);
$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->shouldReceive('toArray')->atLeast()->once()->andReturn([]);
+6 -31
View File
@@ -13,11 +13,16 @@ declare(strict_types=1);
namespace Box\Mod\Product\Entity;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\TimestampInterface;
#[ORM\Entity(repositoryClass: \Box\Mod\Product\Repository\ProductRepository::class)]
#[ORM\Table(name: 'product')]
class Product
#[ORM\HasLifecycleCallbacks]
class Product implements TimestampInterface
{
use TimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
@@ -90,12 +95,6 @@ class Product
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)]
private ?string $config = null;
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $createdAt = null;
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $updatedAt = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)]
private ?string $type = null;
@@ -368,30 +367,6 @@ class Product
return $this;
}
public function getCreatedAt(): ?\DateTime
{
return $this->createdAt;
}
public function setCreatedAt(?\DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTime
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTime $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getType(): ?string
{
return $this->type;
+6 -31
View File
@@ -13,11 +13,16 @@ declare(strict_types=1);
namespace Box\Mod\Product\Entity;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\TimestampInterface;
#[ORM\Entity(repositoryClass: \Box\Mod\Product\Repository\ProductCategoryRepository::class)]
#[ORM\Table(name: 'product_category')]
class ProductCategory
#[ORM\HasLifecycleCallbacks]
class ProductCategory implements TimestampInterface
{
use TimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
@@ -33,12 +38,6 @@ class ProductCategory
#[ORM\Column(name: 'icon_url', type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)]
private ?string $iconUrl = null;
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $createdAt = null;
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $updatedAt = null;
public function getId(): ?int
{
return $this->id;
@@ -79,28 +78,4 @@ class ProductCategory
return $this;
}
public function getCreatedAt(): ?\DateTime
{
return $this->createdAt;
}
public function setCreatedAt(?\DateTime $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTime
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTime $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
}
+5 -42
View File
@@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Box\Mod\Product\Entity;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
@@ -21,6 +22,8 @@ use FOSSBilling\Interfaces\TimestampInterface;
#[ORM\HasLifecycleCallbacks]
class Promo implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
final public const string ABSOLUTE = 'absolute';
final public const string PERCENTAGE = 'percentage';
@@ -75,12 +78,6 @@ class Promo implements ApiArrayInterface, TimestampInterface
#[ORM\Column(name: 'end_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $endAt = null;
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $createdAt = null;
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $updatedAt = null;
public function toApiArray(): array
{
return [
@@ -100,25 +97,11 @@ class Promo implements ApiArrayInterface, TimestampInterface
'client_groups' => $this->clientGroups,
'start_at' => $this->startAt?->format('Y-m-d H:i:s'),
'end_at' => $this->endAt?->format('Y-m-d H:i:s'),
'created_at' => $this->createdAt?->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt?->format('Y-m-d H:i:s'),
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTime();
$this->createdAt ??= $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function updateTimestamp(): void
{
$this->updatedAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
@@ -303,24 +286,4 @@ class Promo implements ApiArrayInterface, TimestampInterface
return $this;
}
public function getCreatedAt(): ?\DateTime
{
return $this->createdAt;
}
public function setCreatedAt(?\DateTime $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?\DateTime
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTime $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
}
+5 -42
View File
@@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Box\Mod\Product\Entity;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
@@ -21,6 +22,8 @@ use FOSSBilling\Interfaces\TimestampInterface;
#[ORM\HasLifecycleCallbacks]
class PromoRedemption implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
final public const string PHASE_CHECKOUT = 'checkout';
final public const string PHASE_RENEWAL = 'renewal';
final public const string STATUS_RESERVED = 'reserved';
@@ -66,12 +69,6 @@ class PromoRedemption implements ApiArrayInterface, TimestampInterface
#[ORM\Column(name: 'release_reason', type: \Doctrine\DBAL\Types\Types::STRING, length: 100, nullable: true)]
private ?string $releaseReason = null;
#[ORM\Column(name: 'created_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $createdAt = null;
#[ORM\Column(name: 'updated_at', type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $updatedAt = null;
public function toApiArray(): array
{
return [
@@ -87,25 +84,11 @@ class PromoRedemption implements ApiArrayInterface, TimestampInterface
'committed_at' => $this->committedAt?->format('Y-m-d H:i:s'),
'released_at' => $this->releasedAt?->format('Y-m-d H:i:s'),
'release_reason' => $this->releaseReason,
'created_at' => $this->createdAt?->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt?->format('Y-m-d H:i:s'),
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTime();
$this->createdAt ??= $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function updateTimestamp(): void
{
$this->updatedAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
@@ -242,24 +225,4 @@ class PromoRedemption implements ApiArrayInterface, TimestampInterface
return $this;
}
public function getCreatedAt(): ?\DateTime
{
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?\DateTime
{
return $this->updatedAt;
}
public function setUpdatedAt(\DateTime $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
}
+5 -30
View File
@@ -26,7 +26,6 @@ use Box\Mod\Product\Repository\PromoRedemptionRepository;
use Box\Mod\Product\Repository\PromoRepository;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use FOSSBilling\InjectionAwareInterface;
use FOSSBilling\PaginationOptions;
@@ -425,9 +424,7 @@ class Service implements InjectionAwareInterface
->setSlug($slug)
->setType($type)
->setSetup(self::SETUP_AFTER_PAYMENT)
->setPriority((int) $priority + 10)
->setUpdatedAt(new \DateTime())
->setCreatedAt(new \DateTime());
->setPriority((int) $priority + 10);
$this->di['em']->persist($model);
$this->di['em']->flush();
@@ -587,9 +584,7 @@ class Service implements InjectionAwareInterface
->setSetup($setup ?? self::SETUP_AFTER_PAYMENT)
->setIsAddon(true)
->setIconUrl($iconUrl)
->setDescription($description)
->setUpdatedAt(new \DateTime())
->setCreatedAt(new \DateTime());
->setDescription($description);
$this->di['em']->persist($model);
$this->di['em']->flush();
@@ -636,8 +631,7 @@ class Service implements InjectionAwareInterface
$productCategory
->setTitle($title)
->setIconUrl($icon_url)
->setDescription($description)
->setUpdatedAt(new \DateTime());
->setDescription($description);
$this->di['em']->flush();
$this->di['logger']->info('Updated product category #%s', $productCategory->getId());
@@ -650,9 +644,7 @@ class Service implements InjectionAwareInterface
$model = (new ProductCategory())
->setTitle($title)
->setDescription($description)
->setIconUrl($icon_url)
->setUpdatedAt(new \DateTime())
->setCreatedAt(new \DateTime());
->setIconUrl($icon_url);
$this->di['em']->persist($model);
$this->di['em']->flush();
$id = $model->getId();
@@ -2087,24 +2079,7 @@ class Service implements InjectionAwareInterface
*/
private function paginateMappedQuery(QueryBuilder $qb, PaginationOptions $pagination, callable $mapper): array
{
$offset = ($pagination->page - 1) * $pagination->perPage;
$qb->setFirstResult($offset)
->setMaxResults($pagination->perPage);
$paginator = new DoctrinePaginator($qb, true);
$total = count($paginator);
$list = [];
foreach ($paginator as $entity) {
$list[] = $mapper($entity);
}
return [
'pages' => $total > 0 ? (int) ceil($total / $pagination->perPage) : 0,
'page' => $pagination->page,
'per_page' => $pagination->perPage,
'total' => $total,
'list' => $list,
];
return $this->di['pager']->paginateMappedQuery($qb, $pagination, $mapper);
}
public function getProductDiscount(Product $product, Promo $promo, ?array $config = null)
+2 -1
View File
@@ -370,7 +370,8 @@ class Service implements InjectionAwareInterface
$ticketModel = $supportTicketService->getTicketById((int) $params['id']);
$ticket = $supportTicketService->toApiArray($ticketModel, true);
$helpdeskModel = isset($ticketModel->support_helpdesk_id) && $ticketModel->support_helpdesk_id ? $di['em']->getRepository(Helpdesk::class)->find((int) $ticketModel->support_helpdesk_id) : null;
$helpdeskId = $ticketModel->getSupportHelpdeskId();
$helpdeskModel = $helpdeskId !== null ? $di['em']->getRepository(Helpdesk::class)->find($helpdeskId) : null;
$emailService = $di['mod_service']('email');
if ($helpdeskModel instanceof Helpdesk && !empty($helpdeskModel->getEmail())) {
$email = [];
+19 -15
View File
@@ -309,7 +309,7 @@ test('onAfterClientReplyTicket sends email notification', function (): void {
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -343,7 +343,7 @@ test('onAfterClientReplyTicket handles email exception', function (): void {
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -377,7 +377,7 @@ test('onAfterClientCloseTicket sends email notification', function (): void {
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -411,7 +411,7 @@ test('onAfterClientCloseTicket handles email exception', function (): void {
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -445,7 +445,7 @@ test('onAfterClientOpenTicket sends guest email notification', function (): void
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -479,7 +479,7 @@ test('onAfterClientOpenTicket handles guest email exception', function (): void
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -513,7 +513,7 @@ test('onAfterClientReplyTicket sends guest email notification', function (): voi
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -547,7 +547,7 @@ test('onAfterClientReplyTicket handles guest email exception', function (): void
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -644,7 +644,7 @@ test('onAfterClientCloseTicket handles guest email exception', function (): void
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$supportServiceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -675,9 +675,11 @@ test('onAfterClientCloseTicket handles guest email exception', function (): void
test('onAfterClientOpenTicket sends mod_staff_ticket_open email', function (): void {
$di = container();
$ticketModel = new Model_SupportTicket();
$ticketModel->loadBean(new Tests\Helpers\DummyBean());
$ticketModel->support_helpdesk_id = 1;
$ticketModel = new Box\Mod\Support\Entity\SupportTicket();
\Tests\Helpers\setEntityId($ticketModel, 1);
$helpdesk = new Helpdesk();
\Tests\Helpers\setEntityId($helpdesk, 1);
$ticketModel->setSupportHelpdesk($helpdesk);
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
@@ -735,9 +737,11 @@ test('onAfterClientOpenTicket sends mod_staff_ticket_open email', function (): v
test('onAfterClientOpenTicket sends mod_support_helpdesk_ticket_open email', function (): void {
$di = container();
$ticketModel = new Model_SupportTicket();
$ticketModel->loadBean(new Tests\Helpers\DummyBean());
$ticketModel->support_helpdesk_id = 1;
$ticketModel = new Box\Mod\Support\Entity\SupportTicket();
\Tests\Helpers\setEntityId($ticketModel, 1);
$helpdesk = new Helpdesk();
\Tests\Helpers\setEntityId($helpdesk, 1);
$ticketModel->setSupportHelpdesk($helpdesk);
$supportServiceMock = Mockery::mock(Box\Mod\Support\Service::class);
$supportServiceMock->shouldReceive('getTicketById')->atLeast()->once()
+20 -20
View File
@@ -21,6 +21,7 @@ use Box\Mod\Support\Entity\CannedResponseCategory;
use Box\Mod\Support\Entity\Helpdesk;
use Box\Mod\Support\Entity\KbArticle;
use Box\Mod\Support\Entity\KbArticleCategory;
use Box\Mod\Support\Entity\SupportTicket;
use FOSSBilling\PaginationOptions;
use FOSSBilling\Validation\Api\RequiredParams;
@@ -37,15 +38,13 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'view');
[$sql, $bindings] = $this->getService()->getSearchQuery($data);
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $bindings, PaginationOptions::fromArray($data));
$repo = $this->getService()->getSupportTicketRepository();
foreach ($pager['list'] as $key => $ticketArr) {
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $ticketArr['id'], 'Ticket not found');
$pager['list'][$key] = $this->getService()->toApiArray($ticket, true, $this->getIdentity());
}
return $pager;
return $this->getDi()['pager']->paginateMappedQuery(
$repo->getSearchQueryBuilder($data),
PaginationOptions::fromArray($data),
fn (SupportTicket $ticket): array => $this->getService()->toApiArray($ticket, false, $this->getIdentity()),
);
}
/**
@@ -56,7 +55,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'view');
$model = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
$model = $this->getService()->getTicketById((int) $data['id']);
return $this->getService()->toApiArray($model, true, $this->getIdentity());
}
@@ -74,7 +73,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'manage_tickets');
$model = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
$model = $this->getService()->getTicketById((int) $data['id']);
// Sanitize subject if provided
if (isset($data['subject'])) {
@@ -92,7 +91,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'manage_tickets');
$model = $this->getDi()['db']->getExistingModelById('SupportTicketMessage', $data['id'], 'Ticket message not found');
$model = $this->getService()->getTicketMessageById((int) $data['id']);
return $this->getService()->ticketMessageUpdate($model, $data['content']);
}
@@ -105,7 +104,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'manage_tickets');
$model = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
$model = $this->getService()->getTicketById((int) $data['id']);
return $this->getService()->rm($model);
}
@@ -122,7 +121,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$data['content'] = \FOSSBilling\Tools::sanitizeMarkdownContent($data['content']);
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
$ticket = $this->getService()->getTicketById((int) $data['id']);
return $this->getService()->ticketReply($ticket, $this->getIdentity(), $data['content']);
}
@@ -135,9 +134,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'manage_tickets');
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
$ticket = $this->getService()->getTicketById((int) $data['id']);
if ($ticket->status == \Model_SupportTicket::CLOSED) {
if ($ticket->getStatus() === SupportTicket::STATUS_CLOSED) {
return true;
}
@@ -159,6 +158,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$data['content'] = \FOSSBilling\Tools::sanitizeMarkdownContent($data['content']);
/** @todo Doctrine: use Client entity once Client is migrated */
$client = $this->getDi()['db']->getExistingModelById('Client', $data['client_id'], 'Client not found');
/** @var \Box\Mod\Support\Repository\HelpdeskRepository $repo */
@@ -186,9 +186,9 @@ class Admin extends \FOSSBilling\Api\AbstractApi
$expiredArr = $this->getService()->getExpired();
foreach ($expiredArr as $ticketArr) {
$ticketModel = $this->getDi()['db']->getExistingModelById('SupportTicket', $ticketArr['id'], 'Ticket not found');
$ticketModel = $this->getService()->getTicketById((int) $ticketArr['id']);
if (!$this->getService()->autoClose($ticketModel)) {
$this->getDi()['logger']->info('Ticket %s was not closed', $ticketModel->id);
$this->getDi()['logger']->info('Ticket %s was not closed', $ticketModel->getId());
}
}
@@ -542,7 +542,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'manage_tickets');
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['ticket_id'], 'Ticket not found');
$ticket = $this->getService()->getTicketById((int) $data['ticket_id']);
return $this->getService()->noteCreate($ticket, $this->getIdentity(), $data['note']);
}
@@ -557,7 +557,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'manage_tickets');
$model = $this->getDi()['db']->getExistingModelById('SupportTicketNote', $data['id'], 'Note not found');
$model = $this->getService()->getTicketNoteById((int) $data['id']);
return $this->getService()->noteRm($model);
}
@@ -572,7 +572,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
{
$this->checkPermissions('support', 'manage_tickets');
$model = $this->getDi()['db']->getExistingModelById('SupportTicket', $data['id'], 'Ticket not found');
$model = $this->getService()->getTicketById((int) $data['id']);
return $this->getService()->ticketTaskComplete($model);
}
+9 -9
View File
@@ -17,6 +17,7 @@ declare(strict_types=1);
namespace Box\Mod\Support\Api;
use Box\Mod\Support\Entity\Helpdesk;
use Box\Mod\Support\Entity\SupportTicket;
use FOSSBilling\PaginationOptions;
use FOSSBilling\Validation\Api\RequiredParams;
@@ -34,13 +35,12 @@ class Client extends \FOSSBilling\Api\AbstractApi
$identity = $this->getIdentity();
$data['client_id'] = $identity->id;
[$sql, $bindings] = $this->getService()->getSearchQuery($data);
$pager = $this->getDi()['pager']->getPaginatedResultSet($sql, $bindings, PaginationOptions::fromArray($data));
foreach ($pager['list'] as $key => $ticketArr) {
$ticket = $this->getDi()['db']->getExistingModelById('SupportTicket', $ticketArr['id'], 'Ticket not found');
$pager['list'][$key] = $this->getService()->toApiArray($ticket, true, $this->getIdentity());
}
$repo = $this->getService()->getSupportTicketRepository();
$pager = $this->getDi()['pager']->paginateMappedQuery(
$repo->getSearchQueryBuilder($data),
PaginationOptions::fromArray($data),
fn (SupportTicket $ticket): array => $this->getService()->toApiArray($ticket, true, $this->getIdentity()),
);
return $pager;
}
@@ -112,9 +112,9 @@ class Client extends \FOSSBilling\Api\AbstractApi
':id' => $data['id'],
':client_id' => $client->id,
];
$ticket = $this->getDi()['db']->findOne('SupportTicket', 'id = :id AND client_id = :client_id', $bindings);
$ticket = $this->getService()->getSupportTicketRepository()->findOneByClient((int) $client->id, (int) $data['id']);
if (!$ticket instanceof \Model_SupportTicket) {
if (!$ticket instanceof SupportTicket) {
throw new \FOSSBilling\InformationException('Ticket not found');
}
+21
View File
@@ -11,6 +11,8 @@ declare(strict_types=1);
namespace Box\Mod\Support\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Interfaces\ApiArrayInterface;
@@ -49,6 +51,17 @@ class Helpdesk implements ApiArrayInterface, TimestampInterface
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $updatedAt = null;
/**
* @var Collection<int, SupportTicket>
*/
#[ORM\OneToMany(mappedBy: 'helpdesk', targetEntity: SupportTicket::class)]
private Collection $tickets;
public function __construct()
{
$this->tickets = new ArrayCollection();
}
public function toApiArray(\Model_Admin|\Model_Client|\Model_Guest|null $identity = null): array
{
if ($identity instanceof \Model_Admin) {
@@ -169,4 +182,12 @@ class Helpdesk implements ApiArrayInterface, TimestampInterface
{
$this->updatedAt = $updatedAt;
}
/**
* @return Collection<int, SupportTicket>
*/
public function getTickets(): Collection
{
return $this->tickets;
}
}
@@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Support\Entity;
use Box\Mod\Support\Repository\SupportTicketRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
/**
* A support ticket. Cross-module fields (Client, Helpdesk FK) are stored as
* plain nullable IDs to keep this module decoupled from the Client migration
* and from the (already-migrated) Helpdesk entity.
*/
#[ORM\Entity(repositoryClass: SupportTicketRepository::class)]
#[ORM\Table(name: 'support_ticket')]
#[ORM\Index(name: 'idx_ticket_status', columns: ['status'])]
#[ORM\Index(name: 'idx_ticket_client', columns: ['client_id'])]
#[ORM\HasLifecycleCallbacks]
class SupportTicket implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
public const STATUS_OPEN = 'open';
public const STATUS_ONHOLD = 'on_hold';
public const STATUS_CLOSED = 'closed';
public const REL_TYPE_ORDER = 'order';
public const REL_STATUS_PENDING = 'pending';
public const REL_STATUS_COMPLETE = 'complete';
public const REL_TASK_CANCEL = 'cancel';
public const REL_TASK_UPGRADE = 'upgrade';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Helpdesk::class, inversedBy: 'tickets')]
#[ORM\JoinColumn(name: 'support_helpdesk_id', referencedColumnName: 'id', nullable: true)]
private ?Helpdesk $helpdesk = null;
#[ORM\Column(name: 'client_id', type: Types::INTEGER, nullable: true)]
private ?int $clientId = null;
#[ORM\Column(name: 'access_hash', type: Types::STRING, length: 255, nullable: true)]
private ?string $accessHash = null;
#[ORM\Column(name: 'author_name', type: Types::STRING, length: 255, nullable: true)]
private ?string $authorName = null;
#[ORM\Column(name: 'author_email', type: Types::STRING, length: 255, nullable: true)]
private ?string $authorEmail = null;
#[ORM\Column(type: Types::INTEGER, nullable: true, options: ['default' => 100])]
private ?int $priority = 100;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $subject = null;
#[ORM\Column(type: Types::STRING, length: 30, nullable: true, options: ['default' => self::STATUS_OPEN])]
private ?string $status = self::STATUS_OPEN;
#[ORM\Column(name: 'rel_type', type: Types::STRING, length: 100, nullable: true)]
private ?string $relType = null;
#[ORM\Column(name: 'rel_id', type: Types::INTEGER, nullable: true)]
private ?int $relId = null;
#[ORM\Column(name: 'rel_task', type: Types::STRING, length: 100, nullable: true)]
private ?string $relTask = null;
#[ORM\Column(name: 'rel_new_value', type: Types::TEXT, nullable: true)]
private ?string $relNewValue = null;
#[ORM\Column(name: 'rel_status', type: Types::STRING, length: 100, nullable: true)]
private ?string $relStatus = null;
/**
* @var Collection<int, SupportTicketMessage>
*/
#[ORM\OneToMany(mappedBy: 'ticket', targetEntity: SupportTicketMessage::class)]
#[ORM\OrderBy(['id' => 'ASC'])]
private Collection $messages;
/**
* @var Collection<int, SupportTicketNote>
*/
#[ORM\OneToMany(mappedBy: 'ticket', targetEntity: SupportTicketNote::class)]
private Collection $notes;
public function __construct()
{
$this->messages = new ArrayCollection();
$this->notes = new ArrayCollection();
}
public function toApiArray(\Model_Admin|\Model_Client|\Model_Guest|null $identity = null): array
{
$data = [
'id' => $this->id,
'support_helpdesk_id' => $this->helpdesk?->getId(),
'client_id' => $this->clientId,
'access_hash' => $this->accessHash,
'author_name' => $this->authorName,
'author_email' => $this->authorEmail,
'priority' => $this->priority,
'subject' => $this->subject,
'status' => $this->status,
'rel_type' => $this->relType,
'rel_id' => $this->relId,
'rel_task' => $this->relTask,
'rel_new_value' => $this->relNewValue,
'rel_status' => $this->relStatus,
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
if (!empty($data['access_hash'])) {
$data['hash'] = $data['access_hash'];
}
if ($identity instanceof \Model_Admin) {
return $data;
}
// @deprecated 0.9.0 Use author.id/name/email instead of client_id/author_name/author_email.
unset(
$data['support_helpdesk_id'],
$data['client_id'],
$data['priority'],
$data['access_hash'],
$data['rel_type'],
$data['rel_id'],
$data['rel_task'],
$data['rel_new_value'],
$data['rel_status'],
);
return $data;
}
/**
* A ticket belongs to a guest when it has no client but does carry a
* public access hash (used by the guest/public ticket flow).
*/
public function isGuestTicket(): bool
{
return $this->clientId === null && $this->accessHash !== null;
}
/**
* Whether a closed ticket may be re-opened.
*
* Non-closed tickets can always be re-opened. Closed tickets delegate to
* their helpdesk's `can_reopen` flag. Throws if the helpdesk cannot be
* resolved, matching the legacy behaviour.
*/
public function canBeReopen(): bool
{
if ($this->status !== self::STATUS_CLOSED) {
return true;
}
$helpdesk = $this->helpdesk;
if (!$helpdesk instanceof Helpdesk) {
throw new \FOSSBilling\Exception('Helpdesk invalid');
}
return $helpdesk->canReopen();
}
public function getId(): ?int
{
return $this->id;
}
public function getHelpdesk(): ?Helpdesk
{
return $this->helpdesk;
}
public function setHelpdesk(?Helpdesk $helpdesk): self
{
$this->helpdesk = $helpdesk;
return $this;
}
public function getSupportHelpdeskId(): ?int
{
return $this->helpdesk?->getId();
}
public function setSupportHelpdesk(?Helpdesk $helpdesk): self
{
$this->helpdesk = $helpdesk;
return $this;
}
public function getClientId(): ?int
{
return $this->clientId;
}
public function setClientId(?int $clientId): self
{
$this->clientId = $clientId;
return $this;
}
public function getAccessHash(): ?string
{
return $this->accessHash;
}
public function setAccessHash(?string $accessHash): self
{
$this->accessHash = $accessHash;
return $this;
}
public function getAuthorName(): ?string
{
return $this->authorName;
}
public function setAuthorName(?string $authorName): self
{
$this->authorName = $authorName;
return $this;
}
public function getAuthorEmail(): ?string
{
return $this->authorEmail;
}
public function setAuthorEmail(?string $authorEmail): self
{
$this->authorEmail = $authorEmail;
return $this;
}
public function getPriority(): ?int
{
return $this->priority;
}
public function setPriority(?int $priority): self
{
$this->priority = $priority;
return $this;
}
public function getSubject(): ?string
{
return $this->subject;
}
public function setSubject(?string $subject): self
{
$this->subject = $subject;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): self
{
$this->status = $status;
return $this;
}
public function getRelType(): ?string
{
return $this->relType;
}
public function setRelType(?string $relType): self
{
$this->relType = $relType;
return $this;
}
public function getRelId(): ?int
{
return $this->relId;
}
public function setRelId(?int $relId): self
{
$this->relId = $relId;
return $this;
}
public function getRelTask(): ?string
{
return $this->relTask;
}
public function setRelTask(?string $relTask): self
{
$this->relTask = $relTask;
return $this;
}
public function getRelNewValue(): ?string
{
return $this->relNewValue;
}
public function setRelNewValue(?string $relNewValue): self
{
$this->relNewValue = $relNewValue;
return $this;
}
public function getRelStatus(): ?string
{
return $this->relStatus;
}
public function setRelStatus(?string $relStatus): self
{
$this->relStatus = $relStatus;
return $this;
}
/**
* @return Collection<int, SupportTicketMessage>
*/
public function getMessages(): Collection
{
return $this->messages;
}
/**
* @return Collection<int, SupportTicketNote>
*/
public function getNotes(): Collection
{
return $this->notes;
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Support\Entity;
use Box\Mod\Support\Repository\SupportTicketMessageRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
/**
* A message in a support ticket thread. One ticket has many messages, ordered
* by `id` ASC (FIFO). The owning ticket is a `ManyToOne` association since
* both entities live in the Support module.
*/
#[ORM\Entity(repositoryClass: SupportTicketMessageRepository::class)]
#[ORM\Table(name: 'support_ticket_message')]
#[ORM\Index(name: 'idx_msg_ticket', columns: ['support_ticket_id'])]
#[ORM\HasLifecycleCallbacks]
class SupportTicketMessage implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: SupportTicket::class, inversedBy: 'messages')]
#[ORM\JoinColumn(name: 'support_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?SupportTicket $ticket = null;
#[ORM\Column(name: 'client_id', type: Types::INTEGER, nullable: true)]
private ?int $clientId = null;
#[ORM\Column(name: 'admin_id', type: Types::INTEGER, nullable: true)]
private ?int $adminId = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $content = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $attachment = null;
#[ORM\Column(type: Types::STRING, length: 45, nullable: true)]
private ?string $ip = null;
public function toApiArray(\Model_Admin|\Model_Client|\Model_Guest|null $identity = null): array
{
if ($identity instanceof \Model_Admin) {
return [
'id' => $this->id,
'support_ticket_id' => $this->ticket?->getId(),
'client_id' => $this->clientId,
'admin_id' => $this->adminId,
'content' => $this->content,
'attachment' => $this->attachment,
'ip' => $this->ip,
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
return [
'id' => $this->id,
'content' => $this->content,
'attachment' => $this->attachment,
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
public function getId(): ?int
{
return $this->id;
}
public function getTicket(): ?SupportTicket
{
return $this->ticket;
}
public function setTicket(?SupportTicket $ticket): self
{
$this->ticket = $ticket;
return $this;
}
public function getSupportTicket(): ?SupportTicket
{
return $this->ticket;
}
public function setSupportTicket(?SupportTicket $ticket): self
{
$this->ticket = $ticket;
return $this;
}
public function getSupportTicketId(): ?int
{
return $this->ticket?->getId();
}
public function getClientId(): ?int
{
return $this->clientId;
}
public function setClientId(?int $clientId): self
{
$this->clientId = $clientId;
return $this;
}
public function getAdminId(): ?int
{
return $this->adminId;
}
public function setAdminId(?int $adminId): self
{
$this->adminId = $adminId;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(?string $content): self
{
$this->content = $content;
return $this;
}
public function getAttachment(): ?string
{
return $this->attachment;
}
public function setAttachment(?string $attachment): self
{
$this->attachment = $attachment;
return $this;
}
public function getIp(): ?string
{
return $this->ip;
}
public function setIp(?string $ip): self
{
$this->ip = $ip;
return $this;
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Support\Entity;
use Box\Mod\Support\Repository\SupportTicketNoteRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\ApiArrayInterface;
use FOSSBilling\Interfaces\TimestampInterface;
/**
* An internal note on a support ticket — visible to staff but not to the
* client. One ticket has many notes.
*/
#[ORM\Entity(repositoryClass: SupportTicketNoteRepository::class)]
#[ORM\Table(name: 'support_ticket_note')]
#[ORM\Index(name: 'idx_note_ticket', columns: ['support_ticket_id'])]
#[ORM\HasLifecycleCallbacks]
class SupportTicketNote implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: Types::INTEGER)]
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: SupportTicket::class, inversedBy: 'notes')]
#[ORM\JoinColumn(name: 'support_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?SupportTicket $ticket = null;
#[ORM\Column(name: 'admin_id', type: Types::INTEGER, nullable: true)]
private ?int $adminId = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $note = null;
public function toApiArray(\Model_Admin|\Model_Client|\Model_Guest|null $identity = null): array
{
return [
'id' => $this->id,
'support_ticket_id' => $this->ticket?->getId(),
'admin_id' => $this->adminId,
'note' => $this->note,
'created_at' => $this->getCreatedAt()?->format('Y-m-d H:i:s'),
'updated_at' => $this->getUpdatedAt()?->format('Y-m-d H:i:s'),
];
}
public function getId(): ?int
{
return $this->id;
}
public function getTicket(): ?SupportTicket
{
return $this->ticket;
}
public function setTicket(?SupportTicket $ticket): self
{
$this->ticket = $ticket;
return $this;
}
public function getSupportTicket(): ?SupportTicket
{
return $this->ticket;
}
public function setSupportTicket(?SupportTicket $ticket): self
{
$this->ticket = $ticket;
return $this;
}
public function getSupportTicketId(): ?int
{
return $this->ticket?->getId();
}
public function getAdminId(): ?int
{
return $this->adminId;
}
public function setAdminId(?int $adminId): self
{
$this->adminId = $adminId;
return $this;
}
public function getNote(): ?string
{
return $this->note;
}
public function setNote(?string $note): self
{
$this->note = $note;
return $this;
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Support\Repository;
use Box\Mod\Support\Entity\SupportTicketMessage;
use Doctrine\ORM\EntityRepository;
class SupportTicketMessageRepository extends EntityRepository
{
/**
* Find a single ticket message by id, throwing if it does not exist.
*/
public function findOneByIdOrFail(int $id): SupportTicketMessage
{
$message = $this->find($id);
if (!$message instanceof SupportTicketMessage) {
throw new \FOSSBilling\Exception('Ticket message not found');
}
return $message;
}
/**
* Return all messages belonging to a ticket, oldest first.
*
* @return SupportTicketMessage[]
*/
public function findByTicketId(int $ticketId): array
{
return $this->createQueryBuilder('m')
->andWhere('m.ticket = :tid')
->setParameter('tid', $ticketId)
->orderBy('m.id', 'ASC')
->getQuery()
->getResult();
}
/**
* Return the first (oldest) message on a ticket — used to show a preview
* snippet in ticket listings. Returns null if the ticket has no messages.
*/
public function findFirstByTicketId(int $ticketId): ?SupportTicketMessage
{
return $this->createQueryBuilder('m')
->andWhere('m.ticket = :tid')
->setParameter('tid', $ticketId)
->orderBy('m.id', 'ASC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/**
* Count messages on a ticket — used to gate "first reply" notifications
* and similar rules.
*/
public function countByTicketId(int $ticketId): int
{
return (int) $this->createQueryBuilder('m')
->select('COUNT(m.id)')
->andWhere('m.ticket = :tid')
->setParameter('tid', $ticketId)
->getQuery()
->getSingleScalarResult();
}
/**
* Return a `[ticket_id => reply_count]` map for the supplied ticket ids.
*
* Implemented via DBAL because the calling code operates on raw rows (not
* entities) in a performance-sensitive batch path, and a GROUP BY over the
* entity manager would hydrate every message.
*
* @param list<int> $ticketIds
*
* @return array<int, int>
*/
public function countRepliesByTicketIds(array $ticketIds): array
{
if (empty($ticketIds)) {
return [];
}
$rows = $this->getEntityManager()->getConnection()
->fetchAllAssociative(
'SELECT support_ticket_id, COUNT(id) AS counter
FROM support_ticket_message
WHERE support_ticket_id IN (:ids)
GROUP BY support_ticket_id',
['ids' => $ticketIds],
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER]
);
$counts = [];
foreach ($rows as $row) {
$counts[(int) $row['support_ticket_id']] = (int) $row['counter'];
}
return $counts;
}
/**
* Return the lowest message id for each supplied ticket id.
*
* Used by the batch ticket fetcher to pull a single "first message" per
* ticket without hydrating the entire thread.
*
* @param list<int> $ticketIds
*
* @return array<int, int> `[ticket_id => first_message_id]`
*/
public function findFirstIdsByTicketIds(array $ticketIds): array
{
if (empty($ticketIds)) {
return [];
}
$rows = $this->getEntityManager()->getConnection()
->fetchAllAssociative(
'SELECT support_ticket_id, MIN(id) AS message_id
FROM support_ticket_message
WHERE support_ticket_id IN (:ids)
GROUP BY support_ticket_id',
['ids' => $ticketIds],
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER]
);
$first = [];
foreach ($rows as $row) {
$first[(int) $row['support_ticket_id']] = (int) $row['message_id'];
}
return $first;
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Support\Repository;
use Box\Mod\Support\Entity\SupportTicketNote;
use Doctrine\ORM\EntityRepository;
class SupportTicketNoteRepository extends EntityRepository
{
/**
* Find a single ticket note by id, throwing if it does not exist.
*/
public function findOneByIdOrFail(int $id): SupportTicketNote
{
$note = $this->find($id);
if (!$note instanceof SupportTicketNote) {
throw new \FOSSBilling\Exception('Note not found');
}
return $note;
}
/**
* @return SupportTicketNote[]
*/
public function findByTicketId(int $ticketId): array
{
return $this->createQueryBuilder('n')
->andWhere('n.ticket = :tid')
->setParameter('tid', $ticketId)
->orderBy('n.id', 'ASC')
->getQuery()
->getResult();
}
}
@@ -0,0 +1,328 @@
<?php
declare(strict_types=1);
/**
* 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
*/
namespace Box\Mod\Support\Repository;
use Box\Mod\Support\Entity\SupportTicket;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
class SupportTicketRepository extends EntityRepository
{
/**
* Build a QueryBuilder for the admin ticket listing.
*
* Accepted keys in `$data`:
* - `id` (int) exact id match
* - `status` (string) exact status match
* - `priority` (int) exact priority match
* - `client_id` (int) filter by client
* - `auth` (string) client or guest author filter
* - `order_id` (int) filter by related order
* - `subject` (string) LIKE on subject
* - `content` (string) LIKE on ticket messages
* - `name` (string) LIKE on author name
* - `email` (string) LIKE on author email
* - `support_helpdesk_id` (int) filter by helpdesk
* - `helpdesk_id` (int) filter by helpdesk (legacy alias)
* - `search` (string) LIKE on subject / author_email / author_name
* - `date_from` (string) created_at lower bound (Y-m-d)
* - `date_to` (string) created_at upper bound (Y-m-d)
*/
public function getSearchQueryBuilder(array $data = []): QueryBuilder
{
$qb = $this->createQueryBuilder('t')
->orderBy('t.id', 'DESC');
if (!empty($data['id'])) {
$qb->andWhere('t.id = :id')
->setParameter('id', (int) $data['id']);
}
if (!empty($data['status'])) {
$qb->andWhere('t.status = :status')
->setParameter('status', $data['status']);
}
if (isset($data['priority']) && $data['priority'] !== '') {
$qb->andWhere('t.priority = :priority')
->setParameter('priority', (int) $data['priority']);
}
if (!empty($data['client_id'])) {
$qb->andWhere('t.clientId = :client_id')
->setParameter('client_id', (int) $data['client_id']);
}
if (($data['auth'] ?? null) === 'guest') {
$qb->andWhere('t.clientId IS NULL')
->andWhere('t.accessHash IS NOT NULL');
} elseif (($data['auth'] ?? null) === 'client') {
$qb->andWhere('t.clientId IS NOT NULL');
}
if (!empty($data['name'])) {
$qb->andWhere('t.authorName LIKE :author_name')
->setParameter('author_name', '%' . $data['name'] . '%');
}
if (!empty($data['email'])) {
$qb->andWhere('t.authorEmail LIKE :author_email')
->setParameter('author_email', '%' . $data['email'] . '%');
}
if (!empty($data['subject'])) {
$qb->andWhere('t.subject LIKE :filter_subject')
->setParameter('filter_subject', '%' . $data['subject'] . '%');
}
if (!empty($data['order_id'])) {
$qb->andWhere('t.relType = :rel_type')
->andWhere('t.relId = :rel_id')
->setParameter('rel_type', SupportTicket::REL_TYPE_ORDER)
->setParameter('rel_id', (int) $data['order_id']);
}
if (!empty($data['content'])) {
$qb->leftJoin('t.messages', 'filter_m')
->andWhere('filter_m.content LIKE :filter_content')
->setParameter('filter_content', '%' . $data['content'] . '%')
->distinct();
}
$helpdeskId = $data['support_helpdesk_id'] ?? $data['helpdesk_id'] ?? null;
if ($helpdeskId !== null && $helpdeskId !== '') {
$qb->andWhere('IDENTITY(t.helpdesk) = :helpdesk_id')
->setParameter('helpdesk_id', (int) $helpdeskId);
}
if (!empty($data['search'])) {
if (is_numeric($data['search'])) {
$qb->andWhere('t.id = :ticket_id')
->setParameter('ticket_id', (int) $data['search']);
} else {
$search = '%' . $data['search'] . '%';
$qb->leftJoin('t.messages', 'search_m')
->andWhere('(search_m.content LIKE :search OR t.subject LIKE :search OR t.authorEmail LIKE :search OR t.authorName LIKE :search)')
->setParameter('search', $search)
->distinct();
}
}
if (!empty($data['date_from'])) {
$qb->andWhere('t.createdAt >= :date_from')
->setParameter('date_from', new \DateTime((string) $data['date_from'] . ' 00:00:00'));
}
if (!empty($data['date_to'])) {
$qb->andWhere('t.createdAt <= :date_to')
->setParameter('date_to', new \DateTime((string) $data['date_to'] . ' 23:59:59'));
}
return $qb;
}
/**
* Find a single ticket by id, throwing if it does not exist.
*/
public function findOneByIdOrFail(int $id): SupportTicket
{
$ticket = $this->find($id);
if (!$ticket instanceof SupportTicket) {
throw new \FOSSBilling\Exception('Ticket not found');
}
return $ticket;
}
/**
* Find a single ticket owned by the given client.
*/
public function findOneByClient(int $clientId, int $id): ?SupportTicket
{
return $this->findOneBy(['id' => $id, 'clientId' => $clientId]);
}
/**
* Find a single ticket owned by the given client, throwing if it does not exist.
*/
public function findOneByClientOrFail(int $clientId, int $id): SupportTicket
{
$ticket = $this->findOneByClient($clientId, $id);
if (!$ticket instanceof SupportTicket) {
throw new \FOSSBilling\Exception('Ticket not found');
}
return $ticket;
}
/**
* Find a single ticket by its public access hash, optionally restricted
* to a null client (i.e. guest tickets).
*/
public function findOneByAccessHash(string $hash, ?int $clientId = null): ?SupportTicket
{
$qb = $this->createQueryBuilder('t')
->andWhere('t.accessHash = :hash')
->setParameter('hash', $hash);
if ($clientId === null) {
$qb->andWhere('t.clientId IS NULL');
} else {
$qb->andWhere('t.clientId = :cid')
->setParameter('cid', $clientId);
}
return $qb->getQuery()->getOneOrNullResult();
}
/**
* Return the N most recent tickets, used by the dashboard widget.
*
* @return SupportTicket[]
*/
public function findLatest(int $limit = 10): array
{
return $this->createQueryBuilder('t')
->orderBy('t.id', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Find the first unassigned ticket matching a `rel_type / rel_id / rel_task`
* triple. Used by SupportTicket to coalesce duplicate automated tickets
* (e.g. multiple "order cancel" requests for the same order).
*/
public function findOneUnassignedByRel(string $relType, int $relId, string $relTask): ?SupportTicket
{
return $this->createQueryBuilder('t')
->andWhere('t.relType = :rel_type')
->andWhere('t.relId = :rel_id')
->andWhere('t.relTask = :rel_task')
->andWhere('t.clientId IS NULL')
->setParameter('rel_type', $relType)
->setParameter('rel_id', $relId)
->setParameter('rel_task', $relTask)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
/**
* Count tickets whose status is in the supplied set.
*
* @param string ...$statuses One or more status values
*/
public function countByStatus(string ...$statuses): int
{
$qb = $this->createQueryBuilder('t')
->select('COUNT(t.id)');
if ($statuses !== []) {
$qb->andWhere('t.status IN (:statuses)')
->setParameter('statuses', $statuses);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* Return a `[status => count]` map covering all tickets, grouped by status.
*
* @return array<string, int>
*/
public function countGroupedByStatus(): array
{
$result = $this->createQueryBuilder('t')
->select('t.status, COUNT(t.id) AS cnt')
->groupBy('t.status')
->getQuery()
->getArrayResult();
$counts = [];
foreach ($result as $row) {
$counts[(string) $row['status']] = (int) $row['cnt'];
}
return $counts;
}
/**
* Return on_hold tickets whose helpdesk close_after window has elapsed.
*
* Implemented via DBAL because the predicate mixes a column argument into
* `DATE_ADD(... INTERVAL ... HOUR)`, which is awkward to express in DQL.
* The result is returned as associative rows to stay consistent with the
* previous RedBean-based return shape.
*
* @return list<array<string, mixed>>
*/
public function findExpiredOnHold(\DateTimeInterface $now): array
{
$sql = 'SELECT st.*
FROM support_ticket AS st
LEFT JOIN support_helpdesk sh ON sh.id = st.support_helpdesk_id
WHERE st.status = :status
AND DATE_ADD(st.updated_at, INTERVAL sh.close_after HOUR) < :now
ORDER BY st.id ASC';
return $this->getEntityManager()->getConnection()
->fetchAllAssociative($sql, [
'status' => SupportTicket::STATUS_ONHOLD,
'now' => $now->format('Y-m-d H:i:s'),
]);
}
/**
* Return raw ticket rows (associative arrays) for the supplied id list.
*
* Used by the performance-sensitive batch fetcher in
* {@see \Box\Mod\Support\Service::getBatchForApi()}, which renders many
* tickets at once for the client-area listing and intentionally avoids
* hydrating entities for that path.
*
* @param list<int> $ids
*
* @return list<array<string, mixed>>
*/
public function findBatchRowsByIds(array $ids): array
{
if (empty($ids)) {
return [];
}
return $this->getEntityManager()->getConnection()
->fetchAllAssociative(
'SELECT * FROM support_ticket WHERE id IN (:ids)',
['ids' => $ids],
['ids' => \Doctrine\DBAL\ArrayParameterType::INTEGER]
);
}
/**
* Count how many active (open / on_hold) tickets reference a given order id.
*/
public function countActiveTicketsForOrder(int $orderId): int
{
return (int) $this->createQueryBuilder('t')
->select('COUNT(t.id)')
->andWhere('t.relType = :type')
->andWhere('t.relId = :rid')
->andWhere('t.status IN (:statuses)')
->setParameter('type', SupportTicket::REL_TYPE_ORDER)
->setParameter('rid', $orderId)
->setParameter('statuses', [SupportTicket::STATUS_OPEN, SupportTicket::STATUS_ONHOLD])
->getQuery()
->getSingleScalarResult();
}
}
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Box\Mod\Support;
use Box\Mod\Support\Entity\SupportTicket;
class SupportTicketValidator
{
public static function validateTicketCreation(array $data): void
@@ -16,28 +18,28 @@ class SupportTicketValidator
self::validateRelTask($rel_task);
self::validateRelStatus($rel_status);
if ($rel_task === \Model_SupportTicket::REL_TASK_UPGRADE) {
if ($rel_task === SupportTicket::REL_TASK_UPGRADE) {
self::validateUpgradeRequest($data, $rel_type);
}
}
protected static function validateRelStatus(?string $rel_status): void
{
if ($rel_status !== null && !in_array($rel_status, \Model_SupportTicket::ALLOWED_REL_STATUSES, true)) {
if ($rel_status !== null && !in_array($rel_status, [SupportTicket::REL_STATUS_PENDING, SupportTicket::REL_STATUS_COMPLETE], true)) {
throw new \FOSSBilling\Exception('Invalid related status.');
}
}
protected static function validateRelType(?string $rel_type): void
{
if ($rel_type !== null && !in_array($rel_type, \Model_SupportTicket::ALLOWED_REL_TYPES, true)) {
if ($rel_type !== null && !in_array($rel_type, [SupportTicket::REL_TYPE_ORDER], true)) {
throw new \FOSSBilling\Exception('Invalid related type.');
}
}
protected static function validateRelTask(?string $rel_task): void
{
if ($rel_task !== null && !in_array($rel_task, \Model_SupportTicket::ALLOWED_REL_TASKS, true)) {
if ($rel_task !== null && !in_array($rel_task, [SupportTicket::REL_TASK_CANCEL, SupportTicket::REL_TASK_UPGRADE], true)) {
throw new \FOSSBilling\Exception('Invalid related task.');
}
}
@@ -46,7 +48,7 @@ class SupportTicketValidator
{
$rel_id = $data['rel_id'] ?? null;
$rel_new_value = $data['rel_new_value'] ?? null;
if ($rel_type !== \Model_SupportTicket::REL_TYPE_ORDER || $rel_id === null || empty($rel_new_value)) {
if ($rel_type !== SupportTicket::REL_TYPE_ORDER || $rel_id === null || empty($rel_new_value)) {
throw new \FOSSBilling\Exception('You must provide both an order ID and a new product ID in order to request an upgrade.');
}
}
+54 -118
View File
@@ -15,6 +15,7 @@ use Box\Mod\Support\Entity\CannedResponseCategory;
use Box\Mod\Support\Entity\Helpdesk;
use Box\Mod\Support\Entity\KbArticle;
use Box\Mod\Support\Entity\KbArticleCategory;
use Box\Mod\Support\Entity\SupportTicket;
use Box\Mod\Support\Repository\CannedResponseCategoryRepository;
use Box\Mod\Support\Repository\CannedResponseRepository;
use Box\Mod\Support\Repository\HelpdeskRepository;
@@ -102,29 +103,23 @@ test('ticket get list', function (): void {
];
$paginatorMock = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
$paginatorMock
->shouldReceive('getPaginatedResultSet')
->shouldReceive('paginateMappedQuery')
->atLeast()->once()
->andReturn($simpleResultArr);
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getSearchQuery')->atLeast()->once()
->andReturn(['query', []]);
$serviceMock
->shouldReceive('toApiArray')
->atLeast()->once()
->andReturn([]);
$qb = Mockery::mock(QueryBuilder::class);
$repo = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
$repo->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
$model = new Model_SupportTicket();
$model->loadBean(new Tests\Helpers\DummyBean());
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn($model);
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
$em->shouldReceive('getRepository')->with(SupportTicket::class)->andReturn($repo);
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getSupportTicketRepository')->andReturn($repo);
$di = container();
$di['pager'] = $paginatorMock;
$di['db'] = $dbMock;
$di['em'] = $em;
$api->setDi($di);
@@ -138,18 +133,14 @@ test('ticket get list', function (): void {
test('ticket get', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn(new Model_SupportTicket());
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new SupportTicket());
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -164,18 +155,14 @@ test('ticket get', function (): void {
test('ticket update', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn(new Model_SupportTicket());
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new SupportTicket());
$serviceMock->shouldReceive('ticketUpdate')->atLeast()->once()
->andReturn(true);
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -190,18 +177,14 @@ test('ticket update', function (): void {
test('ticket message update', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn(new Model_SupportTicketMessage());
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getTicketMessageById')->atLeast()->once()
->andReturn(new Box\Mod\Support\Entity\SupportTicketMessage());
$serviceMock->shouldReceive('ticketMessageUpdate')->atLeast()->once()
->andReturn(true);
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -217,18 +200,14 @@ test('ticket message update', function (): void {
test('ticket delete', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn(new Model_SupportTicket());
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new SupportTicket());
$serviceMock->shouldReceive('rm')->atLeast()->once()
->andReturn(true);
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -243,18 +222,14 @@ test('ticket delete', function (): void {
test('ticket reply', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn(new Model_SupportTicket());
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new SupportTicket());
$serviceMock->shouldReceive('ticketReply')->atLeast()->once()
->andReturn(1);
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -271,21 +246,16 @@ test('ticket reply', function (): void {
test('ticket close', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$ticket = new Model_SupportTicket();
$ticket->loadBean(new Tests\Helpers\DummyBean());
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn($ticket);
$ticket = new SupportTicket();
Tests\Helpers\setEntityId($ticket, 1);
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn($ticket);
$serviceMock->shouldReceive('closeTicket')->atLeast()->once()
->andReturn(true);
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -301,22 +271,16 @@ test('ticket close', function (): void {
test('ticket close already closed', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$ticket = new Model_SupportTicket();
$ticket->loadBean(new Tests\Helpers\DummyBean());
$ticket->status = Model_SupportTicket::CLOSED;
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn($ticket);
$ticket = new SupportTicket();
Tests\Helpers\setEntityId($ticket, 1);
$ticket->setStatus(SupportTicket::STATUS_CLOSED);
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('closeTicket')
;
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn($ticket);
$serviceMock->shouldReceive('closeTicket');
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -374,28 +338,21 @@ test('ticket create', function (): void {
test('batch ticket auto close', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$ticket = new SupportTicket();
Tests\Helpers\setEntityId($ticket, 1);
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getExpired')->atLeast()->once()
->andReturn([['id' => 1], ['id' => 2]]);
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn($ticket);
$serviceMock->shouldReceive('autoClose')->atLeast()->once()
->andReturn(true);
$ticket = new Model_SupportTicket();
$ticket->loadBean(new Tests\Helpers\DummyBean());
$ticket->id = 1;
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn($ticket);
$api->setService($serviceMock);
$di = container();
$di['db'] = $dbMock;
$di['logger'] = $this->createStub('\Box_Log');
$api->setDi($di);
$api->setService($serviceMock);
$result = $api->batch_ticket_auto_close([]);
@@ -404,27 +361,21 @@ test('batch ticket auto close', function (): void {
test('batch ticket auto close not closed', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$ticket = new Model_SupportTicket();
$ticket->loadBean(new Tests\Helpers\DummyBean());
$ticket->id = 1;
$ticket = new SupportTicket();
Tests\Helpers\setEntityId($ticket, 1);
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getExpired')->atLeast()->once()
->andReturn([['id' => 1], ['id' => 2]]);
$serviceMock->shouldReceive('autoClose')->atLeast()->once()
;
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn($ticket);
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn($ticket);
$serviceMock->shouldReceive('autoClose')->atLeast()->once();
$api->setService($serviceMock);
$di = container();
$di['logger'] = $this->createStub('\Box_Log');
$di['db'] = $dbMock;
$api->setDi($di);
$result = $api->batch_ticket_auto_close([]);
expect($result)->toBeTrue();
@@ -433,9 +384,9 @@ test('batch ticket auto close not closed', function (): void {
test('ticket get statuses', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$statuses = [
Model_SupportTicket::OPENED => 'Open',
Model_SupportTicket::ONHOLD => 'On hold',
Model_SupportTicket::CLOSED => 'Closed',
SupportTicket::STATUS_OPEN => 'Open',
SupportTicket::STATUS_ONHOLD => 'On hold',
SupportTicket::STATUS_CLOSED => 'Closed',
];
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getStatuses')
@@ -453,9 +404,9 @@ test('ticket get statuses', function (): void {
test('ticket get statuses titles set', function (): void {
$api = new Box\Mod\Support\Api\Admin();
$statuses = [
Model_SupportTicket::OPENED => 'Open',
Model_SupportTicket::ONHOLD => 'On hold',
Model_SupportTicket::CLOSED => 'Closed',
SupportTicket::STATUS_OPEN => 'Open',
SupportTicket::STATUS_ONHOLD => 'On hold',
SupportTicket::STATUS_CLOSED => 'Closed',
];
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getStatuses')->atLeast()->once()
@@ -910,15 +861,10 @@ test('note create', function (): void {
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('noteCreate')->atLeast()->once()
->andReturn(1);
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn(new Model_SupportTicket());
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new SupportTicket());
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -938,15 +884,10 @@ test('note delete', function (): void {
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('noteRm')->atLeast()->once()
->andReturn(true);
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn(new Model_SupportTicketNote());
$serviceMock->shouldReceive('getTicketNoteById')->atLeast()->once()
->andReturn(new Box\Mod\Support\Entity\SupportTicketNote());
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -965,15 +906,10 @@ test('task complete', function (): void {
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('ticketTaskComplete')->atLeast()->once()
->andReturn(true);
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn(new Model_SupportTicket());
$serviceMock->shouldReceive('getTicketById')->atLeast()->once()
->andReturn(new SupportTicket());
$di = container();
$di['db'] = $dbMock;
$api->setDi($di);
$api->setService($serviceMock);
@@ -25,29 +25,21 @@ test('ticket get list', function (): void {
];
$paginatorMock = Mockery::mock(FOSSBilling\Pagination::class)->makePartial();
$paginatorMock
->shouldReceive('getPaginatedResultSet')
->shouldReceive('paginateMappedQuery')
->atLeast()->once()
->andReturn($simpleResultArr);
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('getSearchQuery')->atLeast()->once()
->andReturn(['query', []]);
$serviceMock
->shouldReceive('toApiArray')
->atLeast()->once()
->andReturn([]);
$model = new Model_SupportTicket();
$model->loadBean(new Tests\Helpers\DummyBean());
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('getExistingModelById')
->atLeast()->once()
->andReturn($model);
$repo = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
$qb = Mockery::mock(Doctrine\ORM\QueryBuilder::class);
$repo->shouldReceive('getSearchQueryBuilder')->andReturn($qb);
$serviceMock->shouldReceive('getSupportTicketRepository')->andReturn($repo);
$di = container();
$di['pager'] = $paginatorMock;
$di['db'] = $dbMock;
$di['em'] = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
$clientApi->setDi($di);
@@ -66,9 +58,12 @@ test('ticket get list', function (): void {
test('ticket get', function (): void {
$clientApi = new Box\Mod\Support\Api\Client();
$ticket = new Box\Mod\Support\Entity\SupportTicket();
\Tests\Helpers\setEntityId($ticket, 1);
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('findOneByClient')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn($ticket);
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -147,14 +142,15 @@ test('ticket reply', function (): void {
$serviceMock->shouldReceive('ticketReply')->atLeast()->once()
->andReturn(1);
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('findOne')
->atLeast()->once()
->andReturn(new Model_SupportTicket());
$ticket = new Box\Mod\Support\Entity\SupportTicket();
Tests\Helpers\setEntityId($ticket, 1);
$ticketRepoMock = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
$ticketRepoMock->shouldReceive('findOneByClient')->atLeast()->once()
->andReturn($ticket);
$serviceMock->shouldReceive('getSupportTicketRepository')->atLeast()->once()
->andReturn($ticketRepoMock);
$di = container();
$di['db'] = $dbMock;
$clientApi->setDi($di);
$client = new Model_Client();
@@ -182,15 +178,15 @@ test('ticket reply can not be reopened exception', function (): void {
$serviceMock->shouldReceive('ticketReply')
->andReturn(1);
$dbMock = Mockery::mock('\Box_Database');
$dbMock
->shouldReceive('findOne')
->atLeast()->once()
->andReturn(new Model_SupportTicket());
$ticket = new Box\Mod\Support\Entity\SupportTicket();
Tests\Helpers\setEntityId($ticket, 1);
$ticketRepoMock = Mockery::mock(Box\Mod\Support\Repository\SupportTicketRepository::class);
$ticketRepoMock->shouldReceive('findOneByClient')->atLeast()->once()
->andReturn($ticket);
$serviceMock->shouldReceive('getSupportTicketRepository')->atLeast()->once()
->andReturn($ticketRepoMock);
$di = container();
$di['db'] = $dbMock;
$clientApi->setDi($di);
$client = new Model_Client();
@@ -211,7 +207,7 @@ test('ticket close', function (): void {
$clientApi = new Box\Mod\Support\Api\Client();
$serviceMock = Mockery::mock(Box\Mod\Support\Service::class)->makePartial();
$serviceMock->shouldReceive('findOneByClient')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$serviceMock->shouldReceive('closeTicket')->atLeast()->once()
->andReturn(true);
@@ -92,7 +92,7 @@ test('ticket get', function (): void {
$guestApi = new Box\Mod\Support\Api\Guest();
$serviceMock = guestSupportServiceMock();
$serviceMock->shouldReceive('findOneByHash')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$serviceMock->shouldReceive('toApiArray')->atLeast()->once()
->andReturn([]);
@@ -113,7 +113,7 @@ test('ticket close', function (): void {
$guestApi = new Box\Mod\Support\Api\Guest();
$serviceMock = guestSupportServiceMock();
$serviceMock->shouldReceive('findOneByHash')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$serviceMock->shouldReceive('closeTicket')->atLeast()->once()
->andReturn(true);
@@ -135,7 +135,7 @@ test('ticket reply', function (): void {
$guestApi = new Box\Mod\Support\Api\Guest();
$serviceMock = guestSupportServiceMock();
$serviceMock->shouldReceive('findOneByHash')->atLeast()->once()
->andReturn(new Model_SupportTicket());
->andReturn(new Box\Mod\Support\Entity\SupportTicket());
$serviceMock->shouldReceive('ticketReply')->atLeast()->once()
->andReturn(1);
File diff suppressed because it is too large. Load diff
+12
View File
@@ -168,20 +168,32 @@ function container(): Container
$emailTemplateRepository->shouldReceive('findOneByActionCode')->byDefault()->andReturn(null);
$emailTemplateRepository->shouldReceive('getSearchQueryBuilder')->byDefault()->andReturn($templateQueryBuilder);
$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();
$em = \Mockery::mock(\Doctrine\ORM\EntityManagerInterface::class)->shouldIgnoreMissing();
$em->shouldReceive('getRepository')->byDefault()->andReturnUsing(static fn (string $class): object => match ($class) {
\Box\Mod\Email\Entity\EmailTemplate::class => $emailTemplateRepository,
\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\Extension\Entity\Extension::class => \Mockery::mock(\Box\Mod\Extension\Repository\ExtensionRepository::class)->shouldIgnoreMissing(),
default => $extensionMetaRepository,
});
+25
View File
@@ -0,0 +1,25 @@
<?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;
/**
* Force the value of an entity's `id` property via reflection.
*
* Doctrine entities have private `id` properties; tests need to set them
* directly to simulate a persisted row without going through the database.
*/
function setEntityId(object $entity, int $id): void
{
$reflection = new \ReflectionProperty($entity, 'id');
$reflection->setValue($entity, $id);
}