From 75a76cae93eafd8b88f9ecc4e9aea035a4eec816 Mon Sep 17 00:00:00 2001 From: Michal Kaczmarek Date: Fri, 17 Oct 2025 09:53:54 +0200 Subject: [PATCH 1/2] [FEATURE] Add ablility to resend email when automatically failed generated pdf --- config/doctrine/Invoice.orm.xml | 1 + config/services.xml | 12 + config/services/cli.xml | 6 + phpstan.neon | 1 + src/Cli/RetryFailedInvoicesCommand.php | 78 +++++ src/Command/SendInvoiceEmail.php | 13 +- .../SendInvoiceEmailHandler.php | 19 +- src/Doctrine/ORM/InvoiceRepository.php | 15 + .../ORM/InvoiceRepositoryInterface.php | 5 + src/Email/InvoiceEmailRetryScheduler.php | 84 +++++ .../InvoiceEmailRetrySchedulerInterface.php | 21 ++ src/Email/InvoiceEmailSender.php | 61 +++- src/Email/InvoiceEmailSenderInterface.php | 2 +- src/Entity/Invoice.php | 11 + src/Entity/InvoiceInterface.php | 4 + .../InvoiceFileGenerationFailedException.php | 38 +++ src/Migrations/Version20251016094233.php | 31 ++ src/Migrations/Version20251016095237.php | 31 ++ src/Provider/InvoiceFileProvider.php | 21 +- .../SendInvoiceEmailHandlerTest.php | 215 +++++++++--- .../Email/InvoiceEmailRetrySchedulerTest.php | 237 +++++++++++++ tests/Unit/Email/InvoiceEmailSenderTest.php | 301 ++++++++++++++++- tests/Unit/Entity/InvoiceTest.php | 310 +++++++++++++++--- .../Unit/Provider/InvoiceFileProviderTest.php | 157 +++++++-- 24 files changed, 1523 insertions(+), 151 deletions(-) create mode 100644 src/Cli/RetryFailedInvoicesCommand.php create mode 100644 src/Email/InvoiceEmailRetryScheduler.php create mode 100644 src/Email/InvoiceEmailRetrySchedulerInterface.php create mode 100644 src/Exception/InvoiceFileGenerationFailedException.php create mode 100644 src/Migrations/Version20251016094233.php create mode 100644 src/Migrations/Version20251016095237.php create mode 100644 tests/Unit/Email/InvoiceEmailRetrySchedulerTest.php diff --git a/config/doctrine/Invoice.orm.xml b/config/doctrine/Invoice.orm.xml index c754a98f..d592380f 100644 --- a/config/doctrine/Invoice.orm.xml +++ b/config/doctrine/Invoice.orm.xml @@ -10,6 +10,7 @@ + diff --git a/config/services.xml b/config/services.xml index 47ea616a..48bbe178 100644 --- a/config/services.xml +++ b/config/services.xml @@ -23,13 +23,25 @@ @SyliusInvoicingPlugin/assets/sylius-logo.png %env(default:sylius_invoicing.default_logo_file:resolve:SYLIUS_INVOICING_LOGO_FILE)% + 3 + 60000 + + + %sylius_invoicing.email.retry_max_attempts% + %sylius_invoicing.email.retry_delay_ms% + + + + %sylius_invoicing.pdf_generator.enabled% + + diff --git a/config/services/cli.xml b/config/services/cli.xml index ed6cb0c0..409abbfd 100644 --- a/config/services/cli.xml +++ b/config/services/cli.xml @@ -24,5 +24,11 @@ + + + + + + diff --git a/phpstan.neon b/phpstan.neon index b7f1955a..a32a8a0e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -22,3 +22,4 @@ parameters: - '/Method Sylius\\InvoicingPlugin\\Security\\Voter\\InvoiceVoter::supports\(\) has parameter \$attribute with no typehint specified./' - '/Method Sylius\\InvoicingPlugin\\Security\\Voter\\InvoiceVoter::supports\(\) has parameter \$subject with no typehint specified./' - '/expects string, string\|null given\.$/' + - '/Method Sylius\\Component\\Mailer\\Sender\\SenderInterface::send\(\) invoked with 7 parameters, 2-5 required\./' diff --git a/src/Cli/RetryFailedInvoicesCommand.php b/src/Cli/RetryFailedInvoicesCommand.php new file mode 100644 index 00000000..57f79429 --- /dev/null +++ b/src/Cli/RetryFailedInvoicesCommand.php @@ -0,0 +1,78 @@ +invoiceRepository->findUnsent(); + + if ([] === $failedInvoices) { + $output->writeln('No failed invoices found to retry.'); + + return Command::SUCCESS; + } + + $dispatched = 0; + + foreach ($failedInvoices as $invoice) { + $orderNumber = $invoice->order()->getNumber(); + + if (null === $orderNumber) { + continue; + } + + try { + $this->commandBus->dispatch(new SendInvoiceEmail($orderNumber)); + } catch (ExceptionInterface $e) { + $this->logger->error( + sprintf( + 'Failed to dispatch invoice resend command for order %s: %s', + $orderNumber, + $e->getMessage(), + ), + ['exception' => $e], + ); + } + ++$dispatched; + } + + $output->writeln(sprintf('Dispatched %d invoice resend command(s).', $dispatched)); + + return Command::SUCCESS; + } +} diff --git a/src/Command/SendInvoiceEmail.php b/src/Command/SendInvoiceEmail.php index b0410658..c5eaa937 100644 --- a/src/Command/SendInvoiceEmail.php +++ b/src/Command/SendInvoiceEmail.php @@ -13,14 +13,21 @@ namespace Sylius\InvoicingPlugin\Command; -final class SendInvoiceEmail +final readonly class SendInvoiceEmail { - public function __construct(private readonly string $orderNumber) - { + public function __construct( + private string $orderNumber, + private int $attempt = 0, + ) { } public function orderNumber(): string { return $this->orderNumber; } + + public function attempt(): int + { + return $this->attempt; + } } diff --git a/src/CommandHandler/SendInvoiceEmailHandler.php b/src/CommandHandler/SendInvoiceEmailHandler.php index b88ba9c0..b0f5e44f 100644 --- a/src/CommandHandler/SendInvoiceEmailHandler.php +++ b/src/CommandHandler/SendInvoiceEmailHandler.php @@ -20,12 +20,12 @@ use Sylius\InvoicingPlugin\Email\InvoiceEmailSenderInterface; use Sylius\InvoicingPlugin\Entity\InvoiceInterface; -final class SendInvoiceEmailHandler +final readonly class SendInvoiceEmailHandler { public function __construct( - private readonly InvoiceRepositoryInterface $invoiceRepository, - private readonly OrderRepositoryInterface $orderRepository, - private readonly InvoiceEmailSenderInterface $emailSender, + private InvoiceRepositoryInterface $invoiceRepository, + private OrderRepositoryInterface $orderRepository, + private InvoiceEmailSenderInterface $emailSender, ) { } @@ -48,6 +48,15 @@ public function __invoke(SendInvoiceEmail $command): void return; } - $this->emailSender->sendInvoiceEmail($invoice, $customer->getEmail()); + $customerEmail = $customer->getEmail(); + if (null === $customerEmail) { + return; + } + + $this->emailSender->sendInvoiceEmail($invoice, $customerEmail, $command->attempt()); + + if ($invoice->isPdfSent()) { + $this->invoiceRepository->add($invoice); + } } } diff --git a/src/Doctrine/ORM/InvoiceRepository.php b/src/Doctrine/ORM/InvoiceRepository.php index 39d52eaf..639a863f 100644 --- a/src/Doctrine/ORM/InvoiceRepository.php +++ b/src/Doctrine/ORM/InvoiceRepository.php @@ -43,4 +43,19 @@ public function findByOrderNumber(string $orderNumber): array return $invoices; } + + public function findUnsent(): array + { + $invoices = $this + ->createQueryBuilder('invoice') + ->where('invoice.pdfSent = :pdfSent') + ->setParameter('pdfSent', false) + ->getQuery() + ->getResult() + ; + + Assert::isArray($invoices); + + return $invoices; + } } diff --git a/src/Doctrine/ORM/InvoiceRepositoryInterface.php b/src/Doctrine/ORM/InvoiceRepositoryInterface.php index 8d58be33..c609d30e 100644 --- a/src/Doctrine/ORM/InvoiceRepositoryInterface.php +++ b/src/Doctrine/ORM/InvoiceRepositoryInterface.php @@ -22,4 +22,9 @@ interface InvoiceRepositoryInterface extends RepositoryInterface public function findOneByOrder(OrderInterface $order): ?InvoiceInterface; public function findByOrderNumber(string $orderNumber): array; + + /** + * @return array + */ + public function findUnsent(): array; } diff --git a/src/Email/InvoiceEmailRetryScheduler.php b/src/Email/InvoiceEmailRetryScheduler.php new file mode 100644 index 00000000..b8c0e382 --- /dev/null +++ b/src/Email/InvoiceEmailRetryScheduler.php @@ -0,0 +1,84 @@ += $this->maxAttempts) { + $this->logger?->warning( + sprintf( + 'Invoice email retry aborted for invoice "%s" (%s) after %d attempts.', + $invoice->number(), + $invoice->id(), + $attempt, + ), + ['customerEmail' => $customerEmail], + ); + + return; + } + + $orderNumber = $invoice->order()->getNumber(); + if (null === $orderNumber) { + $this->logger?->warning( + sprintf( + 'Invoice email retry skipped because order number is missing for invoice "%s" (%s).', + $invoice->number(), + $invoice->id(), + ), + ['customerEmail' => $customerEmail], + ); + + return; + } + + $nextAttempt = $attempt + 1; + $message = new SendInvoiceEmail($orderNumber, $nextAttempt); + $envelope = new Envelope($message, [new DelayStamp($this->retryDelayMilliseconds)]); + + try { + $this->commandBus->dispatch($envelope); + } catch (ExceptionInterface $e) { + $this->logger?->error($e->getMessage()); + } + + $this->logger?->info( + sprintf( + 'Scheduled invoice email retry #%d for invoice "%s" (%s) to "%s".', + $nextAttempt, + $invoice->number(), + $invoice->id(), + $customerEmail, + ), + ); + } +} diff --git a/src/Email/InvoiceEmailRetrySchedulerInterface.php b/src/Email/InvoiceEmailRetrySchedulerInterface.php new file mode 100644 index 00000000..f28b0287 --- /dev/null +++ b/src/Email/InvoiceEmailRetrySchedulerInterface.php @@ -0,0 +1,21 @@ +hasEnabledPdfFileGenerator) { - $this->emailSender->send(Emails::INVOICE_GENERATED, [$customerEmail], ['invoice' => $invoice]); + $this->emailSender->send( + Emails::INVOICE_GENERATED, + [$customerEmail], + ['invoice' => $invoice], + [], + [], + [], + [], + ); return; } - $invoicePdf = $this->invoiceFileProvider->provide($invoice); - $invoicePdfPath = $invoicePdf->fullPath(); - Assert::notNull($invoicePdfPath); + try { + $invoicePdf = $this->invoiceFileProvider->provide($invoice); + $invoicePdfPath = $invoicePdf->fullPath(); + Assert::notNull($invoicePdfPath); - $this->emailSender->send(Emails::INVOICE_GENERATED, [$customerEmail], ['invoice' => $invoice], [$invoicePdfPath]); + if (!is_file($invoicePdfPath) || !is_readable($invoicePdfPath)) { + throw InvoiceFileGenerationFailedException::forInvoice( + $invoice, + new \RuntimeException(sprintf('Invoice PDF file "%s" is not readable.', $invoicePdfPath)), + ); + } + + $this->emailSender->send( + Emails::INVOICE_GENERATED, + [$customerEmail], + ['invoice' => $invoice], + [$invoicePdfPath], + [], + [], + [], + ); + + $invoice->setPdfSent(true); + } catch (InvoiceFileGenerationFailedException $exception) { + if (null !== $this->logger) { + $this->logger->error( + sprintf( + 'Invoice PDF for invoice "%s" (%s) could not be generated. Email to "%s" will not be sent.', + $invoice->number(), + $invoice->id(), + $customerEmail, + ), + ['exception' => $exception], + ); + } + + if (null !== $this->retryScheduler) { + $this->retryScheduler->scheduleRetry($invoice, $customerEmail, $attempt); + } + + $invoice->setPdfSent(false); + } } } diff --git a/src/Email/InvoiceEmailSenderInterface.php b/src/Email/InvoiceEmailSenderInterface.php index 8798270b..07e1df66 100644 --- a/src/Email/InvoiceEmailSenderInterface.php +++ b/src/Email/InvoiceEmailSenderInterface.php @@ -17,5 +17,5 @@ interface InvoiceEmailSenderInterface { - public function sendInvoiceEmail(InvoiceInterface $invoice, string $customerEmail): void; + public function sendInvoiceEmail(InvoiceInterface $invoice, string $customerEmail, int $attempt = 0): void; } diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index 969e6bd8..61ad46fd 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -36,6 +36,7 @@ public function __construct( protected ChannelInterface $channel, protected string $paymentState, protected InvoiceShopBillingDataInterface $shopBillingData, + protected bool $pdfSent = false, ) { $this->issuedAt = clone $issuedAt; @@ -143,4 +144,14 @@ public function paymentState(): string { return $this->paymentState; } + + public function isPdfSent(): bool + { + return $this->pdfSent; + } + + public function setPdfSent(bool $pdfSent): void + { + $this->pdfSent = $pdfSent; + } } diff --git a/src/Entity/InvoiceInterface.php b/src/Entity/InvoiceInterface.php index 0ddede9a..cdb5d017 100644 --- a/src/Entity/InvoiceInterface.php +++ b/src/Entity/InvoiceInterface.php @@ -53,4 +53,8 @@ public function channel(): ChannelInterface; public function shopBillingData(): InvoiceShopBillingDataInterface; public function paymentState(): string; + + public function isPdfSent(): bool; + + public function setPdfSent(bool $pdfSent): void; } diff --git a/src/Exception/InvoiceFileGenerationFailedException.php b/src/Exception/InvoiceFileGenerationFailedException.php new file mode 100644 index 00000000..39e91e9f --- /dev/null +++ b/src/Exception/InvoiceFileGenerationFailedException.php @@ -0,0 +1,38 @@ +number(), + $invoice->id(), + ), + 0, + $previous, + ); + } +} diff --git a/src/Migrations/Version20251016094233.php b/src/Migrations/Version20251016094233.php new file mode 100644 index 00000000..11c8fe7c --- /dev/null +++ b/src/Migrations/Version20251016094233.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE sylius_invoicing_plugin_invoice ADD pdf_sent BOOLEAN NOT NULL DEFAULT FALSE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE sylius_invoicing_plugin_invoice DROP pdf_sent'); + } +} diff --git a/src/Migrations/Version20251016095237.php b/src/Migrations/Version20251016095237.php new file mode 100644 index 00000000..dfa0e2f1 --- /dev/null +++ b/src/Migrations/Version20251016095237.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE sylius_invoicing_plugin_invoice ADD pdf_sent TINYINT(1) NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE sylius_invoicing_plugin_invoice DROP pdf_sent'); + } +} diff --git a/src/Provider/InvoiceFileProvider.php b/src/Provider/InvoiceFileProvider.php index 711ce694..8b1fe8e2 100644 --- a/src/Provider/InvoiceFileProvider.php +++ b/src/Provider/InvoiceFileProvider.php @@ -16,19 +16,20 @@ use Gaufrette\Exception\FileNotFound; use Gaufrette\FilesystemInterface; use Sylius\InvoicingPlugin\Entity\InvoiceInterface; +use Sylius\InvoicingPlugin\Exception\InvoiceFileGenerationFailedException; use Sylius\InvoicingPlugin\Generator\InvoiceFileNameGeneratorInterface; use Sylius\InvoicingPlugin\Generator\InvoicePdfFileGeneratorInterface; use Sylius\InvoicingPlugin\Manager\InvoiceFileManagerInterface; use Sylius\InvoicingPlugin\Model\InvoicePdf; -final class InvoiceFileProvider implements InvoiceFileProviderInterface +final readonly class InvoiceFileProvider implements InvoiceFileProviderInterface { public function __construct( - private readonly InvoiceFileNameGeneratorInterface $invoiceFileNameGenerator, - private readonly FilesystemInterface $filesystem, - private readonly InvoicePdfFileGeneratorInterface $invoicePdfFileGenerator, - private readonly InvoiceFileManagerInterface $invoiceFileManager, - private readonly string $invoicesDirectory, + private InvoiceFileNameGeneratorInterface $invoiceFileNameGenerator, + private FilesystemInterface $filesystem, + private InvoicePdfFileGeneratorInterface $invoicePdfFileGenerator, + private InvoiceFileManagerInterface $invoiceFileManager, + private string $invoicesDirectory, ) { } @@ -40,8 +41,12 @@ public function provide(InvoiceInterface $invoice): InvoicePdf $invoiceFile = $this->filesystem->get($invoiceFileName); $invoicePdf = new InvoicePdf($invoiceFileName, $invoiceFile->getContent()); } catch (FileNotFound) { - $invoicePdf = $this->invoicePdfFileGenerator->generate($invoice); - $this->invoiceFileManager->save($invoicePdf); + try { + $invoicePdf = $this->invoicePdfFileGenerator->generate($invoice); + $this->invoiceFileManager->save($invoicePdf); + } catch (\Throwable $exception) { + throw InvoiceFileGenerationFailedException::forInvoice($invoice, $exception); + } } $invoicePdf->setFullPath($this->invoicesDirectory . '/' . $invoiceFileName); diff --git a/tests/Unit/CommandHandler/SendInvoiceEmailHandlerTest.php b/tests/Unit/CommandHandler/SendInvoiceEmailHandlerTest.php index cc962ffa..36c8c5f8 100644 --- a/tests/Unit/CommandHandler/SendInvoiceEmailHandlerTest.php +++ b/tests/Unit/CommandHandler/SendInvoiceEmailHandlerTest.php @@ -13,6 +13,7 @@ namespace Tests\Sylius\InvoicingPlugin\Unit\CommandHandler; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -27,12 +28,22 @@ final class SendInvoiceEmailHandlerTest extends TestCase { + private const ORDER_NUMBER = '0000001'; + + private const CUSTOMER_EMAIL = 'customer@example.com'; + private InvoiceRepositoryInterface&MockObject $invoiceRepository; private MockObject&OrderRepositoryInterface $orderRepository; private InvoiceEmailSenderInterface&MockObject $emailSender; + private MockObject&OrderInterface $order; + + private CustomerInterface&MockObject $customer; + + private InvoiceInterface&MockObject $invoice; + private SendInvoiceEmailHandler $handler; protected function setUp(): void @@ -41,6 +52,9 @@ protected function setUp(): void $this->invoiceRepository = $this->createMock(InvoiceRepositoryInterface::class); $this->orderRepository = $this->createMock(OrderRepositoryInterface::class); $this->emailSender = $this->createMock(InvoiceEmailSenderInterface::class); + $this->order = $this->createMock(OrderInterface::class); + $this->customer = $this->createMock(CustomerInterface::class); + $this->invoice = $this->createMock(InvoiceInterface::class); $this->handler = new SendInvoiceEmailHandler( $this->invoiceRepository, @@ -50,116 +64,215 @@ protected function setUp(): void } #[Test] - public function it_requests_an_email_with_an_invoice_to_be_sent(): void + #[DataProvider('attemptProvider')] + public function it_sends_invoice_email_with_different_attempts(int $attempt): void { - $invoice = $this->createMock(InvoiceInterface::class); - $order = $this->createMock(OrderInterface::class); - $customer = $this->createMock(CustomerInterface::class); + $this->expectOrderFound(); + $this->expectCustomerFound(); + $this->expectInvoiceFound(); - $this->orderRepository + $this->invoice ->expects(self::once()) - ->method('findOneByNumber') - ->with('0000001') - ->willReturn($order); + ->method('isPdfSent') + ->willReturn(false); - $order - ->expects(self::once()) - ->method('getCustomer') - ->willReturn($customer); + $this->invoiceRepository + ->expects(self::never()) + ->method('add'); - $customer + $this->emailSender ->expects(self::once()) - ->method('getEmail') - ->willReturn('shop@example.com'); + ->method('sendInvoiceEmail') + ->with($this->invoice, self::CUSTOMER_EMAIL, $attempt); - $this->invoiceRepository + ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER, $attempt)); + } + + #[Test] + #[DataProvider('pdfSentStatusProvider')] + public function it_persists_invoice_only_when_pdf_was_sent(bool $pdfSent, bool $shouldPersist): void + { + $this->expectOrderFound(); + $this->expectCustomerFound(); + $this->expectInvoiceFound(); + + $this->invoice ->expects(self::once()) - ->method('findOneByOrder') - ->with($order) - ->willReturn($invoice); + ->method('isPdfSent') + ->willReturn($pdfSent); + + if ($shouldPersist) { + $this->invoiceRepository + ->expects(self::once()) + ->method('add') + ->with($this->invoice); + } else { + $this->invoiceRepository + ->expects(self::never()) + ->method('add'); + } $this->emailSender ->expects(self::once()) ->method('sendInvoiceEmail') - ->with($invoice, 'shop@example.com'); + ->with($this->invoice, self::CUSTOMER_EMAIL, 0); - ($this->handler)(new SendInvoiceEmail('0000001')); + ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER)); } #[Test] - public function it_does_not_request_an_email_to_be_sent_if_order_was_not_found(): void + public function it_does_not_send_email_when_order_not_found(): void { $this->orderRepository ->expects(self::once()) ->method('findOneByNumber') - ->with('0000001') + ->with(self::ORDER_NUMBER) ->willReturn(null); $this->invoiceRepository - ->expects($this->never()) + ->expects(self::never()) ->method('findOneByOrder'); $this->emailSender - ->expects($this->never()) + ->expects(self::never()) ->method('sendInvoiceEmail'); - ($this->handler)(new SendInvoiceEmail('0000001')); + $this->invoiceRepository + ->expects(self::never()) + ->method('add'); + + ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER)); } #[Test] - public function it_does_not_request_an_email_to_be_sent_if_customer_was_not_found(): void + public function it_does_not_send_email_when_customer_not_found(): void { - $order = $this->createMock(OrderInterface::class); - - $this->orderRepository - ->expects(self::once()) - ->method('findOneByNumber') - ->with('0000001') - ->willReturn($order); + $this->expectOrderFound(); - $order + $this->order ->expects(self::once()) ->method('getCustomer') ->willReturn(null); $this->invoiceRepository - ->expects($this->never()) + ->expects(self::never()) ->method('findOneByOrder'); $this->emailSender - ->expects($this->never()) + ->expects(self::never()) ->method('sendInvoiceEmail'); - ($this->handler)(new SendInvoiceEmail('0000001')); + $this->invoiceRepository + ->expects(self::never()) + ->method('add'); + + ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER)); } #[Test] - public function it_does_not_request_an_email_to_be_sent_if_invoice_was_not_found(): void + public function it_does_not_send_email_when_invoice_not_found(): void { - $order = $this->createMock(OrderInterface::class); - $customer = $this->createMock(CustomerInterface::class); + $this->expectOrderFound(); - $this->orderRepository + $this->order ->expects(self::once()) - ->method('findOneByNumber') - ->with('0000001') - ->willReturn($order); + ->method('getCustomer') + ->willReturn($this->customer); - $order + $this->invoiceRepository + ->expects(self::once()) + ->method('findOneByOrder') + ->with($this->order) + ->willReturn(null); + + $this->emailSender + ->expects(self::never()) + ->method('sendInvoiceEmail'); + + $this->invoiceRepository + ->expects(self::never()) + ->method('add'); + + ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER)); + } + + #[Test] + public function it_does_not_send_email_when_customer_email_is_null(): void + { + $this->expectOrderFound(); + + $this->order ->expects(self::once()) ->method('getCustomer') - ->willReturn($customer); + ->willReturn($this->customer); + + $this->customer + ->expects(self::once()) + ->method('getEmail') + ->willReturn(null); $this->invoiceRepository ->expects(self::once()) ->method('findOneByOrder') - ->with($order) - ->willReturn(null); + ->with($this->order) + ->willReturn($this->invoice); $this->emailSender - ->expects($this->never()) + ->expects(self::never()) ->method('sendInvoiceEmail'); - ($this->handler)(new SendInvoiceEmail('0000001')); + $this->invoiceRepository + ->expects(self::never()) + ->method('add'); + + ($this->handler)(new SendInvoiceEmail(self::ORDER_NUMBER)); + } + + public static function attemptProvider(): array + { + return [ + 'first attempt' => [0], + 'second attempt' => [1], + 'third attempt' => [2], + ]; + } + + public static function pdfSentStatusProvider(): array + { + return [ + 'pdf not sent' => [false, false], + 'pdf sent successfully' => [true, true], + ]; + } + + private function expectOrderFound(): void + { + $this->orderRepository + ->expects(self::once()) + ->method('findOneByNumber') + ->with(self::ORDER_NUMBER) + ->willReturn($this->order); + } + + private function expectCustomerFound(): void + { + $this->order + ->expects(self::once()) + ->method('getCustomer') + ->willReturn($this->customer); + + $this->customer + ->expects(self::once()) + ->method('getEmail') + ->willReturn(self::CUSTOMER_EMAIL); + } + + private function expectInvoiceFound(): void + { + $this->invoiceRepository + ->expects(self::once()) + ->method('findOneByOrder') + ->with($this->order) + ->willReturn($this->invoice); } } diff --git a/tests/Unit/Email/InvoiceEmailRetrySchedulerTest.php b/tests/Unit/Email/InvoiceEmailRetrySchedulerTest.php new file mode 100644 index 00000000..c4e2ec3d --- /dev/null +++ b/tests/Unit/Email/InvoiceEmailRetrySchedulerTest.php @@ -0,0 +1,237 @@ +commandBus = $this->createMock(MessageBusInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->invoice = $this->createMock(InvoiceInterface::class); + $this->order = $this->createMock(OrderInterface::class); + + $this->invoice + ->method('order') + ->willReturn($this->order); + + $this->invoice + ->method('number') + ->willReturn('2024/11/0001'); + + $this->invoice + ->method('id') + ->willReturn('INV-0001'); + } + + #[Test] + public function it_implements_invoice_email_retry_scheduler_interface(): void + { + $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000); + + self::assertInstanceOf(InvoiceEmailRetrySchedulerInterface::class, $scheduler); + } + + #[Test] + #[DataProvider('retryAttemptProvider')] + public function it_schedules_retry_with_correct_attempt_number_and_delay( + int $currentAttempt, + int $expectedNextAttempt, + int $retryDelay, + ): void { + $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, $retryDelay, $this->logger); + + $this->order + ->method('getNumber') + ->willReturn('0000001'); + + $this->commandBus + ->expects(self::once()) + ->method('dispatch') + ->with(self::callback(function (Envelope $envelope) use ($expectedNextAttempt, $retryDelay) { + $message = $envelope->getMessage(); + $stamps = $envelope->all(DelayStamp::class); + + return $message instanceof SendInvoiceEmail && + $message->orderNumber() === '0000001' && + $message->attempt() === $expectedNextAttempt && + count($stamps) === 1 && + $stamps[0]->getDelay() === $retryDelay; + })) + ->willReturn(new Envelope(new SendInvoiceEmail('0000001', $expectedNextAttempt))); + + $this->logger + ->expects(self::once()) + ->method('info') + ->with( + sprintf( + 'Scheduled invoice email retry #%d for invoice "2024/11/0001" (INV-0001) to "customer@example.com".', + $expectedNextAttempt, + ), + ); + + $scheduler->scheduleRetry($this->invoice, 'customer@example.com', $currentAttempt); + } + + #[Test] + #[DataProvider('abortScenarioProvider')] + public function it_aborts_retry_when_max_attempts_reached( + int $attempt, + string $orderNumber, + string $expectedWarningMessage, + ): void { + $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000, $this->logger); + + $this->order + ->method('getNumber') + ->willReturn($orderNumber); + + $this->commandBus + ->expects(self::never()) + ->method('dispatch'); + + $this->logger + ->expects(self::once()) + ->method('warning') + ->with( + $expectedWarningMessage, + ['customerEmail' => 'customer@example.com'], + ); + + $scheduler->scheduleRetry($this->invoice, 'customer@example.com', $attempt); + } + + #[Test] + public function it_skips_retry_when_order_number_is_missing(): void + { + $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000, $this->logger); + + $this->order + ->method('getNumber') + ->willReturn(null); + + $this->commandBus + ->expects(self::never()) + ->method('dispatch'); + + $this->logger + ->expects(self::once()) + ->method('warning') + ->with( + 'Invoice email retry skipped because order number is missing for invoice "2024/11/0001" (INV-0001).', + ['customerEmail' => 'customer@example.com'], + ); + + $scheduler->scheduleRetry($this->invoice, 'customer@example.com', 0); + } + + #[Test] + public function it_logs_error_when_dispatch_fails(): void + { + $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000, $this->logger); + + $this->order + ->method('getNumber') + ->willReturn('0000001'); + + $exception = new class('Transport failed') extends \Exception implements ExceptionInterface { + }; + + $this->commandBus + ->expects(self::once()) + ->method('dispatch') + ->willThrowException($exception); + + $this->logger + ->expects(self::once()) + ->method('error') + ->with('Transport failed'); + + $this->logger + ->expects(self::once()) + ->method('info') + ->with( + 'Scheduled invoice email retry #1 for invoice "2024/11/0001" (INV-0001) to "customer@example.com".', + ); + + $scheduler->scheduleRetry($this->invoice, 'customer@example.com', 0); + } + + #[Test] + public function it_works_without_logger(): void + { + $scheduler = new InvoiceEmailRetryScheduler($this->commandBus, 3, 60000); + + $this->order + ->method('getNumber') + ->willReturn('0000001'); + + $this->commandBus + ->expects(self::once()) + ->method('dispatch') + ->willReturn(new Envelope(new SendInvoiceEmail('0000001', 1))); + + $scheduler->scheduleRetry($this->invoice, 'customer@example.com', 0); + } + + public static function retryAttemptProvider(): array + { + return [ + 'first attempt' => [0, 1, 60000], + 'second attempt' => [1, 2, 60000], + 'third attempt' => [2, 3, 60000], + 'custom delay' => [0, 1, 120000], + ]; + } + + public static function abortScenarioProvider(): array + { + return [ + 'max attempts reached' => [ + 3, + '0000001', + 'Invoice email retry aborted for invoice "2024/11/0001" (INV-0001) after 3 attempts.', + ], + 'attempts exceeded' => [ + 5, + '0000001', + 'Invoice email retry aborted for invoice "2024/11/0001" (INV-0001) after 5 attempts.', + ], + ]; + } +} diff --git a/tests/Unit/Email/InvoiceEmailSenderTest.php b/tests/Unit/Email/InvoiceEmailSenderTest.php index c25f2761..1ada833c 100644 --- a/tests/Unit/Email/InvoiceEmailSenderTest.php +++ b/tests/Unit/Email/InvoiceEmailSenderTest.php @@ -13,28 +13,60 @@ namespace Tests\Sylius\InvoicingPlugin\Unit\Email; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Sylius\Component\Mailer\Sender\SenderInterface; use Sylius\InvoicingPlugin\Email\Emails; +use Sylius\InvoicingPlugin\Email\InvoiceEmailRetrySchedulerInterface; use Sylius\InvoicingPlugin\Email\InvoiceEmailSender; use Sylius\InvoicingPlugin\Email\InvoiceEmailSenderInterface; use Sylius\InvoicingPlugin\Entity\InvoiceInterface; +use Sylius\InvoicingPlugin\Exception\InvoiceFileGenerationFailedException; use Sylius\InvoicingPlugin\Model\InvoicePdf; use Sylius\InvoicingPlugin\Provider\InvoiceFileProviderInterface; final class InvoiceEmailSenderTest extends TestCase { + private const CUSTOMER_EMAIL = 'customer@example.com'; + + private const INVOICE_NUMBER = '2024/11/0001'; + + private const INVOICE_ID = 'INV-0001'; + private MockObject&SenderInterface $sender; private InvoiceFileProviderInterface&MockObject $invoiceFileProvider; + private InvoiceInterface&MockObject $invoice; + + private ?string $temporaryFilePath = null; + protected function setUp(): void { parent::setUp(); $this->sender = $this->createMock(SenderInterface::class); $this->invoiceFileProvider = $this->createMock(InvoiceFileProviderInterface::class); + $this->invoice = $this->createMock(InvoiceInterface::class); + + $this->invoice + ->method('number') + ->willReturn(self::INVOICE_NUMBER); + + $this->invoice + ->method('id') + ->willReturn(self::INVOICE_ID); + } + + protected function tearDown(): void + { + if (null !== $this->temporaryFilePath && file_exists($this->temporaryFilePath)) { + @unlink($this->temporaryFilePath); + } + + parent::tearDown(); } #[Test] @@ -46,48 +78,289 @@ public function it_implements_invoice_email_sender_interface(): void } #[Test] - public function it_sends_an_invoice_to_a_given_email_address(): void + #[DataProvider('attemptProvider')] + public function it_sends_invoice_email_with_pdf_attachment(int $attempt): void { $invoiceEmailSender = new InvoiceEmailSender($this->sender, $this->invoiceFileProvider); - $invoice = $this->createMock(InvoiceInterface::class); - $invoicePdf = new InvoicePdf('invoice.pdf', 'CONTENT'); - $invoicePdf->setFullPath('/path/to/invoices/invoice.pdf'); + $temporaryPath = $this->createTemporaryPdfFile(); + $invoicePdf = new InvoicePdf('invoice.pdf', 'PDF_CONTENT'); + $invoicePdf->setFullPath($temporaryPath); $this->invoiceFileProvider ->expects(self::once()) ->method('provide') - ->with($invoice) + ->with($this->invoice) ->willReturn($invoicePdf); + $this->invoice + ->expects(self::once()) + ->method('setPdfSent') + ->with(true); + $this->sender ->expects(self::once()) ->method('send') ->with( Emails::INVOICE_GENERATED, - ['sylius@example.com'], - ['invoice' => $invoice], - ['/path/to/invoices/invoice.pdf'], + [self::CUSTOMER_EMAIL], + ['invoice' => $this->invoice], + [$temporaryPath], + [], + [], + [], ); - $invoiceEmailSender->sendInvoiceEmail($invoice, 'sylius@example.com'); + $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL, $attempt); } #[Test] - public function it_sends_an_invoice_without_attachment_to_a_given_email_address(): void + public function it_sends_invoice_email_without_pdf_when_pdf_generation_disabled(): void { $invoiceEmailSender = new InvoiceEmailSender($this->sender, $this->invoiceFileProvider, false); - $invoice = $this->createMock(InvoiceInterface::class); $this->invoiceFileProvider - ->expects($this->never()) + ->expects(self::never()) ->method('provide'); + $this->invoice + ->expects(self::never()) + ->method('setPdfSent'); + $this->sender ->expects(self::once()) ->method('send') - ->with(Emails::INVOICE_GENERATED, ['sylius@example.com'], ['invoice' => $invoice]); + ->with( + Emails::INVOICE_GENERATED, + [self::CUSTOMER_EMAIL], + ['invoice' => $this->invoice], + [], + [], + [], + [], + ); + + $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL); + } + + #[Test] + public function it_handles_pdf_file_not_readable_exception(): void + { + $logger = $this->createMock(LoggerInterface::class); + $retryScheduler = $this->createMock(InvoiceEmailRetrySchedulerInterface::class); + + $invoiceEmailSender = new InvoiceEmailSender( + $this->sender, + $this->invoiceFileProvider, + true, + $logger, + $retryScheduler, + ); + + $invoicePdf = new InvoicePdf('invoice.pdf', 'PDF_CONTENT'); + $invoicePdf->setFullPath('/nonexistent/path/invoice.pdf'); + + $this->invoiceFileProvider + ->expects(self::once()) + ->method('provide') + ->with($this->invoice) + ->willReturn($invoicePdf); + + $this->invoice + ->expects(self::once()) + ->method('setPdfSent') + ->with(false); + + $logger + ->expects(self::once()) + ->method('error') + ->with( + sprintf( + 'Invoice PDF for invoice "%s" (%s) could not be generated. Email to "%s" will not be sent.', + self::INVOICE_NUMBER, + self::INVOICE_ID, + self::CUSTOMER_EMAIL, + ), + self::callback(fn (array $context) => isset($context['exception']) && $context['exception'] instanceof InvoiceFileGenerationFailedException), + ); + + $retryScheduler + ->expects(self::once()) + ->method('scheduleRetry') + ->with($this->invoice, self::CUSTOMER_EMAIL, 0); + + $this->sender + ->expects(self::never()) + ->method('send'); + + $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL); + } + + #[Test] + public function it_schedules_retry_when_pdf_generation_fails(): void + { + $logger = $this->createMock(LoggerInterface::class); + $retryScheduler = $this->createMock(InvoiceEmailRetrySchedulerInterface::class); + + $invoiceEmailSender = new InvoiceEmailSender( + $this->sender, + $this->invoiceFileProvider, + true, + $logger, + $retryScheduler, + ); + + $this->invoiceFileProvider + ->expects(self::once()) + ->method('provide') + ->with($this->invoice) + ->willThrowException(InvoiceFileGenerationFailedException::occur()); + + $this->invoice + ->expects(self::once()) + ->method('setPdfSent') + ->with(false); + + $logger + ->expects(self::once()) + ->method('error') + ->with( + sprintf( + 'Invoice PDF for invoice "%s" (%s) could not be generated. Email to "%s" will not be sent.', + self::INVOICE_NUMBER, + self::INVOICE_ID, + self::CUSTOMER_EMAIL, + ), + self::callback(fn (array $context) => isset($context['exception']) && $context['exception'] instanceof InvoiceFileGenerationFailedException), + ); + + $retryScheduler + ->expects(self::once()) + ->method('scheduleRetry') + ->with($this->invoice, self::CUSTOMER_EMAIL, 0); + + $this->sender + ->expects(self::never()) + ->method('send'); + + $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL); + } + + #[Test] + public function it_handles_pdf_generation_failure_without_retry_scheduler(): void + { + $logger = $this->createMock(LoggerInterface::class); + + $invoiceEmailSender = new InvoiceEmailSender( + $this->sender, + $this->invoiceFileProvider, + true, + $logger, + null, + ); + + $this->invoiceFileProvider + ->expects(self::once()) + ->method('provide') + ->with($this->invoice) + ->willThrowException(InvoiceFileGenerationFailedException::occur()); + + $this->invoice + ->expects(self::once()) + ->method('setPdfSent') + ->with(false); + + $logger + ->expects(self::once()) + ->method('error'); + + $this->sender + ->expects(self::never()) + ->method('send'); + + $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL); + } + + #[Test] + public function it_handles_pdf_generation_failure_without_logger(): void + { + $retryScheduler = $this->createMock(InvoiceEmailRetrySchedulerInterface::class); + + $invoiceEmailSender = new InvoiceEmailSender( + $this->sender, + $this->invoiceFileProvider, + true, + null, + $retryScheduler, + ); + + $this->invoiceFileProvider + ->expects(self::once()) + ->method('provide') + ->with($this->invoice) + ->willThrowException(InvoiceFileGenerationFailedException::occur()); + + $this->invoice + ->expects(self::once()) + ->method('setPdfSent') + ->with(false); + + $retryScheduler + ->expects(self::once()) + ->method('scheduleRetry') + ->with($this->invoice, self::CUSTOMER_EMAIL, 0); + + $this->sender + ->expects(self::never()) + ->method('send'); + + $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL); + } + + #[Test] + public function it_handles_pdf_generation_failure_without_logger_and_retry_scheduler(): void + { + $invoiceEmailSender = new InvoiceEmailSender( + $this->sender, + $this->invoiceFileProvider, + true, + null, + null, + ); + + $this->invoiceFileProvider + ->expects(self::once()) + ->method('provide') + ->with($this->invoice) + ->willThrowException(InvoiceFileGenerationFailedException::occur()); + + $this->invoice + ->expects(self::once()) + ->method('setPdfSent') + ->with(false); + + $this->sender + ->expects(self::never()) + ->method('send'); + + $invoiceEmailSender->sendInvoiceEmail($this->invoice, self::CUSTOMER_EMAIL); + } + + public static function attemptProvider(): array + { + return [ + 'first attempt' => [0], + 'second attempt' => [1], + 'third attempt' => [2], + ]; + } + + private function createTemporaryPdfFile(): string + { + $this->temporaryFilePath = tempnam(sys_get_temp_dir(), 'invoice_pdf_'); + self::assertNotFalse($this->temporaryFilePath); + self::assertNotFalse(file_put_contents($this->temporaryFilePath, 'PDF_CONTENT')); - $invoiceEmailSender->sendInvoiceEmail($invoice, 'sylius@example.com'); + return $this->temporaryFilePath; } } diff --git a/tests/Unit/Entity/InvoiceTest.php b/tests/Unit/Entity/InvoiceTest.php index be210558..9a52f6ae 100644 --- a/tests/Unit/Entity/InvoiceTest.php +++ b/tests/Unit/Entity/InvoiceTest.php @@ -14,7 +14,9 @@ namespace Tests\Sylius\InvoicingPlugin\Unit\Entity; use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\OrderInterface; @@ -28,19 +30,27 @@ final class InvoiceTest extends TestCase { - private BillingDataInterface $billingData; + private const ID = '7903c83a-4c5e-4bcf-81d8-9dc304c6a353'; - private LineItemInterface $lineItem; + private const INVOICE_NUMBER = '2019/01/000000001'; - private TaxItemInterface $taxItem; + private const CURRENCY_CODE = 'USD'; - private ChannelInterface $channel; + private const LOCALE_CODE = 'en_US'; - private InvoiceShopBillingDataInterface $shopBillingData; + private const TOTAL = 10300; - private OrderInterface $order; + private BillingDataInterface&MockObject $billingData; - private Invoice $invoice; + private LineItemInterface&MockObject $lineItem; + + private MockObject&TaxItemInterface $taxItem; + + private ChannelInterface&MockObject $channel; + + private InvoiceShopBillingDataInterface&MockObject $shopBillingData; + + private MockObject&OrderInterface $order; private \DateTimeImmutable $issuedAt; @@ -54,58 +64,282 @@ protected function setUp(): void $this->shopBillingData = $this->createMock(InvoiceShopBillingDataInterface::class); $this->order = $this->createMock(OrderInterface::class); - $this->issuedAt = \DateTimeImmutable::createFromFormat('Y-m', '2019-01'); + $issuedAt = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2019-01-15 10:30:00'); + self::assertNotFalse($issuedAt); + $this->issuedAt = $issuedAt; + } - $this->lineItem->expects(self::once()) - ->method('setInvoice') - ->with($this->isInstanceOf(Invoice::class)); + #[Test] + public function it_implements_invoice_interface(): void + { + $invoice = $this->createInvoice(); - $this->taxItem->expects(self::once()) - ->method('setInvoice') - ->with($this->isInstanceOf(Invoice::class)); + self::assertInstanceOf(InvoiceInterface::class, $invoice); + } + + #[Test] + public function it_implements_resource_interface(): void + { + $invoice = $this->createInvoice(); + + self::assertInstanceOf(ResourceInterface::class, $invoice); + } + + #[Test] + public function it_returns_id(): void + { + $invoice = $this->createInvoice(); + + self::assertSame(self::ID, $invoice->id()); + } + + #[Test] + public function it_returns_id_via_get_id_method(): void + { + $invoice = $this->createInvoice(); + + self::assertSame(self::ID, $invoice->getId()); + } + + #[Test] + public function it_returns_invoice_number(): void + { + $invoice = $this->createInvoice(); + + self::assertSame(self::INVOICE_NUMBER, $invoice->number()); + } + + #[Test] + public function it_returns_order(): void + { + $invoice = $this->createInvoice(); + + self::assertSame($this->order, $invoice->order()); + } + + #[Test] + public function it_returns_cloned_issued_at_date(): void + { + $invoice = $this->createInvoice(); + + $issuedAt = $invoice->issuedAt(); + + self::assertEquals($this->issuedAt, $issuedAt); + self::assertNotSame($this->issuedAt, $issuedAt); + } + + #[Test] + public function it_returns_billing_data(): void + { + $invoice = $this->createInvoice(); + + self::assertSame($this->billingData, $invoice->billingData()); + } + + #[Test] + public function it_returns_currency_code(): void + { + $invoice = $this->createInvoice(); + + self::assertSame(self::CURRENCY_CODE, $invoice->currencyCode()); + } + + #[Test] + public function it_returns_locale_code(): void + { + $invoice = $this->createInvoice(); + + self::assertSame(self::LOCALE_CODE, $invoice->localeCode()); + } + + #[Test] + public function it_returns_total(): void + { + $invoice = $this->createInvoice(); + + self::assertSame(self::TOTAL, $invoice->total()); + } + + #[Test] + public function it_returns_line_items(): void + { + $invoice = $this->createInvoice(); + + self::assertEquals(new ArrayCollection([$this->lineItem]), $invoice->lineItems()); + } - $this->invoice = new Invoice( - '7903c83a-4c5e-4bcf-81d8-9dc304c6a353', - $this->issuedAt->format('Y/m') . '/000000001', + #[Test] + public function it_returns_tax_items(): void + { + $invoice = $this->createInvoice(); + + self::assertEquals(new ArrayCollection([$this->taxItem]), $invoice->taxItems()); + } + + #[Test] + public function it_returns_channel(): void + { + $invoice = $this->createInvoice(); + + self::assertSame($this->channel, $invoice->channel()); + } + + #[Test] + public function it_returns_shop_billing_data(): void + { + $invoice = $this->createInvoice(); + + self::assertSame($this->shopBillingData, $invoice->shopBillingData()); + } + + #[Test] + public function it_returns_payment_state(): void + { + $invoice = $this->createInvoice(); + + self::assertSame(InvoiceInterface::PAYMENT_STATE_COMPLETED, $invoice->paymentState()); + } + + #[Test] + #[DataProvider('subtotalDataProvider')] + public function it_calculates_subtotal_from_line_items(array $lineItemSubtotals, int $expectedSubtotal): void + { + $lineItems = []; + foreach ($lineItemSubtotals as $subtotal) { + $lineItem = $this->createMock(LineItemInterface::class); + $lineItem->method('subtotal')->willReturn($subtotal); + $lineItem->expects(self::once())->method('setInvoice'); + $lineItems[] = $lineItem; + } + + $invoice = new Invoice( + self::ID, + self::INVOICE_NUMBER, $this->order, $this->issuedAt, $this->billingData, - 'USD', - 'en_US', - 10300, - new ArrayCollection([$this->lineItem]), - new ArrayCollection([$this->taxItem]), + self::CURRENCY_CODE, + self::LOCALE_CODE, + self::TOTAL, + new ArrayCollection($lineItems), + new ArrayCollection([]), $this->channel, InvoiceInterface::PAYMENT_STATE_COMPLETED, $this->shopBillingData, ); + + self::assertSame($expectedSubtotal, $invoice->subtotal()); } #[Test] - public function it_implements_invoice_interface(): void + #[DataProvider('taxesTotalDataProvider')] + public function it_calculates_taxes_total_from_line_items(array $lineItemTaxes, int $expectedTaxesTotal): void { - self::assertInstanceOf(InvoiceInterface::class, $this->invoice); + $lineItems = []; + foreach ($lineItemTaxes as $taxTotal) { + $lineItem = $this->createMock(LineItemInterface::class); + $lineItem->method('taxTotal')->willReturn($taxTotal); + $lineItem->expects(self::once())->method('setInvoice'); + $lineItems[] = $lineItem; + } + + $invoice = new Invoice( + self::ID, + self::INVOICE_NUMBER, + $this->order, + $this->issuedAt, + $this->billingData, + self::CURRENCY_CODE, + self::LOCALE_CODE, + self::TOTAL, + new ArrayCollection($lineItems), + new ArrayCollection([]), + $this->channel, + InvoiceInterface::PAYMENT_STATE_COMPLETED, + $this->shopBillingData, + ); + + self::assertSame($expectedTaxesTotal, $invoice->taxesTotal()); } #[Test] - public function it_implements_resource_interface(): void + #[DataProvider('pdfSentStatusProvider')] + public function it_returns_pdf_sent_status(bool $pdfSent): void { - self::assertInstanceOf(ResourceInterface::class, $this->invoice); + $invoice = $this->createInvoice($pdfSent); + + self::assertSame($pdfSent, $invoice->isPdfSent()); } #[Test] - public function it_has_data(): void + public function it_allows_setting_pdf_sent_status(): void + { + $invoice = $this->createInvoice(false); + + self::assertFalse($invoice->isPdfSent()); + + $invoice->setPdfSent(true); + + self::assertTrue($invoice->isPdfSent()); + + $invoice->setPdfSent(false); + + self::assertFalse($invoice->isPdfSent()); + } + + public static function subtotalDataProvider(): array + { + return [ + 'single line item' => [[1000], 1000], + 'multiple line items' => [[1000, 2000, 1500], 4500], + 'empty line items' => [[], 0], + ]; + } + + public static function taxesTotalDataProvider(): array { - self::assertSame('7903c83a-4c5e-4bcf-81d8-9dc304c6a353', $this->invoice->id()); - self::assertSame('2019/01/000000001', $this->invoice->number()); - self::assertSame($this->order, $this->invoice->order()); - self::assertSame($this->billingData, $this->invoice->billingData()); - self::assertSame('USD', $this->invoice->currencyCode()); - self::assertSame('en_US', $this->invoice->localeCode()); - self::assertSame(10300, $this->invoice->total()); - self::assertEquals(new ArrayCollection([$this->lineItem]), $this->invoice->lineItems()); - $this->assertEquals(new ArrayCollection([$this->taxItem]), $this->invoice->taxItems()); - $this->assertSame($this->channel, $this->invoice->channel()); - $this->assertSame($this->shopBillingData, $this->invoice->shopBillingData()); + return [ + 'single line item with tax' => [[200], 200], + 'multiple line items with taxes' => [[200, 300, 150], 650], + 'empty line items' => [[], 0], + ]; + } + + public static function pdfSentStatusProvider(): array + { + return [ + 'pdf not sent by default' => [false], + 'pdf sent' => [true], + ]; + } + + private function createInvoice(bool $pdfSent = false): Invoice + { + $this->lineItem + ->expects(self::once()) + ->method('setInvoice') + ->with($this->isInstanceOf(Invoice::class)); + + $this->taxItem + ->expects(self::once()) + ->method('setInvoice') + ->with($this->isInstanceOf(Invoice::class)); + + return new Invoice( + self::ID, + self::INVOICE_NUMBER, + $this->order, + $this->issuedAt, + $this->billingData, + self::CURRENCY_CODE, + self::LOCALE_CODE, + self::TOTAL, + new ArrayCollection([$this->lineItem]), + new ArrayCollection([$this->taxItem]), + $this->channel, + InvoiceInterface::PAYMENT_STATE_COMPLETED, + $this->shopBillingData, + $pdfSent, + ); } } diff --git a/tests/Unit/Provider/InvoiceFileProviderTest.php b/tests/Unit/Provider/InvoiceFileProviderTest.php index 541df8a7..4893c387 100644 --- a/tests/Unit/Provider/InvoiceFileProviderTest.php +++ b/tests/Unit/Provider/InvoiceFileProviderTest.php @@ -16,10 +16,12 @@ use Gaufrette\Exception\FileNotFound; use Gaufrette\File; use Gaufrette\FilesystemInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Sylius\InvoicingPlugin\Entity\InvoiceInterface; +use Sylius\InvoicingPlugin\Exception\InvoiceFileGenerationFailedException; use Sylius\InvoicingPlugin\Generator\InvoiceFileNameGeneratorInterface; use Sylius\InvoicingPlugin\Generator\InvoicePdfFileGeneratorInterface; use Sylius\InvoicingPlugin\Manager\InvoiceFileManagerInterface; @@ -29,6 +31,12 @@ final class InvoiceFileProviderTest extends TestCase { + private const INVOICE_FILENAME = 'invoice_2024_11_0001.pdf'; + + private const INVOICES_DIRECTORY = '/path/to/invoices'; + + private const PDF_CONTENT = 'PDF_CONTENT'; + private InvoiceFileNameGeneratorInterface&MockObject $invoiceFileNameGenerator; private FilesystemInterface&MockObject $filesystem; @@ -37,6 +45,8 @@ final class InvoiceFileProviderTest extends TestCase private InvoiceFileManagerInterface&MockObject $invoiceFileManager; + private InvoiceInterface&MockObject $invoice; + private InvoiceFileProvider $provider; protected function setUp(): void @@ -46,13 +56,14 @@ protected function setUp(): void $this->filesystem = $this->createMock(FilesystemInterface::class); $this->invoicePdfFileGenerator = $this->createMock(InvoicePdfFileGeneratorInterface::class); $this->invoiceFileManager = $this->createMock(InvoiceFileManagerInterface::class); + $this->invoice = $this->createMock(InvoiceInterface::class); $this->provider = new InvoiceFileProvider( $this->invoiceFileNameGenerator, $this->filesystem, $this->invoicePdfFileGenerator, $this->invoiceFileManager, - '/path/to/invoices', + self::INVOICES_DIRECTORY, ); } @@ -63,72 +74,168 @@ public function it_implements_invoice_file_provider_interface(): void } #[Test] - public function it_provides_invoice_file_for_invoice(): void + #[DataProvider('invoiceFileDataProvider')] + public function it_provides_existing_invoice_file_from_filesystem(string $fileName, string $content): void { - $invoice = $this->createMock(InvoiceInterface::class); $invoiceFile = $this->createMock(File::class); $this->invoiceFileNameGenerator ->expects(self::once()) ->method('generateForPdf') - ->with($invoice) - ->willReturn('invoice.pdf'); + ->with($this->invoice) + ->willReturn($fileName); $this->filesystem ->expects(self::once()) ->method('get') - ->with('invoice.pdf') + ->with($fileName) ->willReturn($invoiceFile); $invoiceFile ->expects(self::once()) ->method('getContent') - ->willReturn('CONTENT'); + ->willReturn($content); + + $this->invoicePdfFileGenerator + ->expects(self::never()) + ->method('generate'); + + $this->invoiceFileManager + ->expects(self::never()) + ->method('save'); - $result = $this->provider->provide($invoice); + $result = $this->provider->provide($this->invoice); - $expected = new InvoicePdf('invoice.pdf', 'CONTENT'); - $expected->setFullPath('/path/to/invoices/invoice.pdf'); + $expected = $this->createExpectedInvoicePdf($fileName, $content); self::assertEquals($expected, $result); } #[Test] - public function it_generates_invoice_if_it_does_not_exist_and_provides_it(): void + #[DataProvider('invoiceFileDataProvider')] + public function it_generates_and_saves_invoice_when_file_not_found(string $fileName, string $content): void { - $invoice = $this->createMock(InvoiceInterface::class); - $this->invoiceFileNameGenerator ->expects(self::once()) ->method('generateForPdf') - ->with($invoice) - ->willReturn('invoice.pdf'); + ->with($this->invoice) + ->willReturn($fileName); $this->filesystem ->expects(self::once()) ->method('get') - ->with('invoice.pdf') - ->willThrowException(new FileNotFound('invoice.pdf')); + ->with($fileName) + ->willThrowException(new FileNotFound($fileName)); - $invoicePdf = new InvoicePdf('invoice.pdf', 'CONTENT'); - $invoicePdf->setFullPath('/path/to/invoices/invoice.pdf'); + $generatedInvoicePdf = new InvoicePdf($fileName, $content); $this->invoicePdfFileGenerator ->expects(self::once()) ->method('generate') - ->with($invoice) - ->willReturn($invoicePdf); + ->with($this->invoice) + ->willReturn($generatedInvoicePdf); $this->invoiceFileManager ->expects(self::once()) ->method('save') - ->with($invoicePdf); + ->with($generatedInvoicePdf); - $result = $this->provider->provide($invoice); + $result = $this->provider->provide($this->invoice); - $expected = new InvoicePdf('invoice.pdf', 'CONTENT'); - $expected->setFullPath('/path/to/invoices/invoice.pdf'); + $expected = $this->createExpectedInvoicePdf($fileName, $content); self::assertEquals($expected, $result); } + + #[Test] + #[DataProvider('generationExceptionProvider')] + public function it_throws_invoice_file_generation_failed_exception_when_generation_fails(\Throwable $exception): void + { + $this->expectException(InvoiceFileGenerationFailedException::class); + + $this->expectInvoiceFileNameGeneration(); + + $this->filesystem + ->expects(self::once()) + ->method('get') + ->with(self::INVOICE_FILENAME) + ->willThrowException(new FileNotFound(self::INVOICE_FILENAME)); + + $this->invoicePdfFileGenerator + ->expects(self::once()) + ->method('generate') + ->with($this->invoice) + ->willThrowException($exception); + + $this->invoiceFileManager + ->expects(self::never()) + ->method('save'); + + $this->provider->provide($this->invoice); + } + + #[Test] + public function it_throws_invoice_file_generation_failed_exception_when_save_fails(): void + { + $this->expectException(InvoiceFileGenerationFailedException::class); + + $this->expectInvoiceFileNameGeneration(); + + $this->filesystem + ->expects(self::once()) + ->method('get') + ->with(self::INVOICE_FILENAME) + ->willThrowException(new FileNotFound(self::INVOICE_FILENAME)); + + $generatedInvoicePdf = new InvoicePdf(self::INVOICE_FILENAME, self::PDF_CONTENT); + + $this->invoicePdfFileGenerator + ->expects(self::once()) + ->method('generate') + ->with($this->invoice) + ->willReturn($generatedInvoicePdf); + + $this->invoiceFileManager + ->expects(self::once()) + ->method('save') + ->with($generatedInvoicePdf) + ->willThrowException(new \RuntimeException('Failed to save file')); + + $this->provider->provide($this->invoice); + } + + public static function invoiceFileDataProvider(): array + { + return [ + 'standard filename and content' => ['invoice_2024_11_0001.pdf', 'PDF_CONTENT'], + 'custom filename' => ['custom_invoice.pdf', 'CUSTOM_CONTENT'], + 'filename with special chars' => ['invoice-special_#123.pdf', 'SPECIAL_CONTENT'], + ]; + } + + public static function generationExceptionProvider(): array + { + return [ + 'RuntimeException' => [new \RuntimeException('Failed to generate PDF')], + 'InvalidArgumentException' => [new \InvalidArgumentException('Invalid template')], + 'LogicException' => [new \LogicException('Logic error in generation')], + ]; + } + + private function expectInvoiceFileNameGeneration(): void + { + $this->invoiceFileNameGenerator + ->expects(self::once()) + ->method('generateForPdf') + ->with($this->invoice) + ->willReturn(self::INVOICE_FILENAME); + } + + private function createExpectedInvoicePdf(string $fileName = self::INVOICE_FILENAME, string $content = self::PDF_CONTENT): InvoicePdf + { + $invoicePdf = new InvoicePdf($fileName, $content); + $invoicePdf->setFullPath(self::INVOICES_DIRECTORY . '/' . $fileName); + + return $invoicePdf; + } } From 41973227bddaa327a11bb3981bd600584ef78415 Mon Sep 17 00:00:00 2001 From: Michal Kaczmarek Date: Fri, 17 Oct 2025 12:30:15 +0200 Subject: [PATCH 2/2] Modify import in migrations --- src/Migrations/Version20251016094233.php | 5 +++-- src/Migrations/Version20251016095237.php | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Migrations/Version20251016094233.php b/src/Migrations/Version20251016094233.php index 11c8fe7c..bec0d89a 100644 --- a/src/Migrations/Version20251016094233.php +++ b/src/Migrations/Version20251016094233.php @@ -5,12 +5,13 @@ namespace Sylius\InvoicingPlugin\Migrations; use Doctrine\DBAL\Schema\Schema; -use Doctrine\Migrations\AbstractMigration; +use Sylius\Bundle\CoreBundle\Doctrine\Migrations\AbstractPostgreSQLMigration; + /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20251016094233 extends AbstractMigration +final class Version20251016094233 extends AbstractPostgreSQLMigration { public function getDescription(): string { diff --git a/src/Migrations/Version20251016095237.php b/src/Migrations/Version20251016095237.php index dfa0e2f1..63190e10 100644 --- a/src/Migrations/Version20251016095237.php +++ b/src/Migrations/Version20251016095237.php @@ -5,7 +5,8 @@ namespace Sylius\InvoicingPlugin\Migrations; use Doctrine\DBAL\Schema\Schema; -use Doctrine\Migrations\AbstractMigration; +use Sylius\Bundle\CoreBundle\Doctrine\Migrations\AbstractMigration; + /** * Auto-generated Migration: Please modify to your needs!