diff --git a/.env b/.env index 708b98e..76c46ee 100644 --- a/.env +++ b/.env @@ -73,3 +73,5 @@ DATEV_EMAIL_SENDER=honorar@ep-reisen.de FEATURE_SANITIZE_UPLOADS=false FEATURE_STAMP_INVOICES=false + +UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf diff --git a/assets/pdf/chicken.pdf b/assets/pdf/chicken.pdf new file mode 100644 index 0000000..09ac530 Binary files /dev/null and b/assets/pdf/chicken.pdf differ diff --git a/config/services.yaml b/config/services.yaml index 6c746ed..0fb7c9a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -135,6 +135,8 @@ services: arguments: $orphanageManager: '@oneup_uploader.orphanage_manager' $projectDir: '%kernel.project_dir%' + $environment: '%kernel.environment%' + $uploadReplacementFile: '%env(default::UPLOAD_REPLACEMENT_FILE)%' App\Service\Pdf\Sanitizer: arguments: diff --git a/src/Controller/Administrative/Document/BatchDownloadController.php b/src/Controller/Administrative/Document/BatchDownloadController.php index 1f94aa8..f35437d 100644 --- a/src/Controller/Administrative/Document/BatchDownloadController.php +++ b/src/Controller/Administrative/Document/BatchDownloadController.php @@ -64,7 +64,7 @@ class BatchDownloadController extends AbstractController /** @var Upload $upload */ $teamer = $upload->getOwner()->getTeamer(); $filename = $namer->nameForTeamer($upload, $teamer); - $filepath = $this->uploadHandler->getUploadFilepath($upload); + $filepath = $this->uploadHandler->getResolvedUploadFilepath($upload); try { $zip->addFileFromPath(fileName: $filename, path: $filepath); diff --git a/src/Controller/Common/DownloadController.php b/src/Controller/Common/DownloadController.php index b835c3b..fc0e219 100644 --- a/src/Controller/Common/DownloadController.php +++ b/src/Controller/Common/DownloadController.php @@ -29,7 +29,7 @@ class DownloadController extends AbstractController #[IsGranted('DOWNLOAD', subject: 'upload')] public function index(Upload $upload, Request $request): Response { - $path = $this->uploadHandler->getUploadFilepath($upload); + $path = $this->uploadHandler->getResolvedUploadFilepath($upload); if (true === in_array($upload->getType(), [Upload::TYPE_INVOICE, Upload::TYPE_CONTRACT])) { $namer = new DownloadNamer(); diff --git a/src/EventListener/DatevEmailSubscriber.php b/src/EventListener/DatevEmailSubscriber.php index 65550b3..ae8693f 100644 --- a/src/EventListener/DatevEmailSubscriber.php +++ b/src/EventListener/DatevEmailSubscriber.php @@ -65,7 +65,7 @@ class DatevEmailSubscriber implements EventSubscriberInterface return; } - $path = $this->uploadHandler->getUploadFilepath($document); + $path = $this->uploadHandler->getResolvedUploadFilepath($document); if (false === file_exists($path)) { $this->logger->error('DATEV email skipped: attachment file not found', [ diff --git a/src/Service/Pdf/InvoiceApprover.php b/src/Service/Pdf/InvoiceApprover.php index c3fe76d..edb769d 100644 --- a/src/Service/Pdf/InvoiceApprover.php +++ b/src/Service/Pdf/InvoiceApprover.php @@ -4,10 +4,20 @@ namespace App\Service\Pdf; use App\Entity\Disposition; use App\Entity\Upload; +use App\Service\Upload\UploadHandler; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Contracts\Translation\TranslatorInterface; class InvoiceApprover extends AbstractPdfRenderer { + public function __construct( + TranslatorInterface $translator, + array $options, + private readonly UploadHandler $uploadHandler, + ) { + parent::__construct($translator, $options); + } + /** * @throws \InvalidArgumentException */ @@ -16,24 +26,23 @@ class InvoiceApprover extends AbstractPdfRenderer $teamer = $disposition->getTeamer(); $assignment = $disposition->getAssignment(); $invoice = $disposition->getDocumentByType(Upload::TYPE_INVOICE); - $originalFilename = $invoice->getFilename(); if ('application/pdf' !== $invoice->getMimeType()) { throw new \InvalidArgumentException('Expected PDF but got '.$invoice->getMimeType()); } - // Load template - $folder = substr($originalFilename, 0, 1); - $filepath = sprintf('%s/uploads/invoice/%s/%s', $this->config['project_dir'], $folder, $originalFilename); - if (false === file_exists($filepath)) { - throw new \InvalidArgumentException('Uploaded document not found at '.$filepath); + $sourcePath = $this->uploadHandler->getResolvedUploadFilepath($invoice); + $targetPath = $this->uploadHandler->getUploadFilepath($invoice); + + if (false === file_exists($sourcePath)) { + throw new \InvalidArgumentException('Uploaded document not found at '.$sourcePath); } $pdf = new Pdf(); $pdf->SetAuthor($teamer->getFullName(), true); $pdf->SetCreator($teamer->getFullName(), true); - $pdf->appendPdf($filepath); + $pdf->appendPdf($sourcePath); $pdf->setPage(1); // Cost unit job profile @@ -53,9 +62,16 @@ class InvoiceApprover extends AbstractPdfRenderer $pdf->SetTextColor(0, 128, 0); $pdf->Text(20, 30, $pdf->encodeString('geprüft: '.$invoice->getApprovedBy())); - $this->createBackup($filepath); + $filesystem = new Filesystem(); + $targetDir = \dirname($targetPath); - $pdf->Output('F', $filepath); + if (false === $filesystem->exists($targetDir)) { + $filesystem->mkdir($targetDir); + } + + $this->createBackup($targetPath); + + $pdf->Output('F', $targetPath); return $pdf; } diff --git a/src/Service/Upload/UploadHandler.php b/src/Service/Upload/UploadHandler.php index 5c95904..eca0bb2 100644 --- a/src/Service/Upload/UploadHandler.php +++ b/src/Service/Upload/UploadHandler.php @@ -22,6 +22,8 @@ class UploadHandler private readonly Sanitizer $sanitizer, private readonly LoggerInterface $logger, private readonly string $projectDir, + private readonly string $environment, + private readonly string $uploadReplacementFile, ) { } @@ -143,6 +145,27 @@ class UploadHandler return true === $absolute ? sprintf('%s/%s', $this->projectDir, $relativePath) : $relativePath; } + /** + * Returns the upload file path, substituting a replacement file in non-production environments + * when configured. Use this for read-only access (downloads, email attachments). + */ + public function getResolvedUploadFilepath(Upload $upload): string + { + $originalPath = $this->getUploadFilepath($upload); + + if ('' === $this->uploadReplacementFile || 'prod' === $this->environment) { + return $originalPath; + } + + $replacementPath = sprintf('%s/%s', $this->projectDir, $this->uploadReplacementFile); + + if (false === file_exists($replacementPath)) { + return $originalPath; + } + + return $replacementPath; + } + public function getTempUploadFilepath(UploadDto $uploadDto, string $uploadSessionId): string { $tempUploadDir = $this->getTempUploadDir($uploadSessionId); diff --git a/tests/EventListener/DatevEmailSubscriberTest.php b/tests/EventListener/DatevEmailSubscriberTest.php index 3fb6bd5..da53a72 100644 --- a/tests/EventListener/DatevEmailSubscriberTest.php +++ b/tests/EventListener/DatevEmailSubscriberTest.php @@ -12,6 +12,7 @@ use App\Event\DocumentConfirmedEvent; use App\EventListener\DatevEmailSubscriber; use App\Model\EmailPathAttachmentDto; use App\Service\Upload\UploadHandler; +use Flagception\Manager\FeatureManagerInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -21,6 +22,7 @@ class DatevEmailSubscriberTest extends TestCase private Mailer&MockObject $mailer; private UploadHandler&MockObject $uploadHandler; private LoggerInterface&MockObject $logger; + private FeatureManagerInterface&MockObject $featureManager; private DatevEmailSubscriber $subscriber; protected function setUp(): void @@ -28,6 +30,8 @@ class DatevEmailSubscriberTest extends TestCase $this->mailer = $this->createMock(Mailer::class); $this->uploadHandler = $this->createMock(UploadHandler::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->featureManager = $this->createMock(FeatureManagerInterface::class); + $this->featureManager->method('isActive')->with('datev_email')->willReturn(true); $this->subscriber = new DatevEmailSubscriber( $this->mailer, @@ -35,6 +39,7 @@ class DatevEmailSubscriberTest extends TestCase $this->logger, 'datev@example.com', 'sender@example.com', + $this->featureManager, ); } @@ -118,7 +123,7 @@ class DatevEmailSubscriberTest extends TestCase $nonExistentPath = '/tmp/non_existent_file_'.uniqid().'.pdf'; $this->uploadHandler - ->method('getUploadFilepath') + ->method('getResolvedUploadFilepath') ->with($document) ->willReturn($nonExistentPath); @@ -160,7 +165,7 @@ class DatevEmailSubscriberTest extends TestCase try { $this->uploadHandler ->expects($this->once()) - ->method('getUploadFilepath') + ->method('getResolvedUploadFilepath') ->with($document) ->willReturn($tmpFile); diff --git a/tests/Resources/.gitignore b/tests/Resources/.gitignore new file mode 100644 index 0000000..8b8a8c4 --- /dev/null +++ b/tests/Resources/.gitignore @@ -0,0 +1 @@ +upload.orig.pdf diff --git a/tests/Service/Pdf/InvoiceApproverTest.php b/tests/Service/Pdf/InvoiceApproverTest.php index f0f1d74..5d16be8 100644 --- a/tests/Service/Pdf/InvoiceApproverTest.php +++ b/tests/Service/Pdf/InvoiceApproverTest.php @@ -14,6 +14,8 @@ use App\Entity\Upload; use App\Service\Pdf\InvoiceApprover; use App\Service\Pdf\InvoiceRenderer; use App\Service\Pdf\Pdf; +use App\Service\Upload\UploadHandler; +use PHPUnit\Framework\MockObject\MockObject; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Filesystem\Filesystem; use Symfony\Contracts\Translation\TranslatorInterface; @@ -25,6 +27,7 @@ class InvoiceApproverTest extends WebTestCase private string $pdfPath; private string $origPath; private Filesystem $filesystem; + private UploadHandler&MockObject $uploadHandler; protected function setUp(): void { @@ -35,6 +38,7 @@ class InvoiceApproverTest extends WebTestCase $this->pdfPath = $this->invoiceDir.'/test_approver_backup.pdf'; $this->origPath = $this->invoiceDir.'/test_approver_backup.orig.pdf'; $this->filesystem = new Filesystem(); + $this->uploadHandler = $this->createMock(UploadHandler::class); $this->filesystem->mkdir($this->invoiceDir); } @@ -80,7 +84,7 @@ class InvoiceApproverTest extends WebTestCase $translator = $this->createMock(TranslatorInterface::class); /** @var TranslatorInterface $translator */ - return new InvoiceApprover($translator, ['project_dir' => $this->projectDir]); + return new InvoiceApprover($translator, ['project_dir' => $this->projectDir], $this->uploadHandler); } private function createDispositionWithInvoicePdf(): Disposition @@ -130,6 +134,9 @@ class InvoiceApproverTest extends WebTestCase ; $disposition->addDocument($invoice); + $this->uploadHandler->method('getResolvedUploadFilepath')->with($invoice)->willReturn($this->pdfPath); + $this->uploadHandler->method('getUploadFilepath')->with($invoice)->willReturn($this->pdfPath); + return $disposition; } } diff --git a/tests/Service/Pdf/InvoiceRendererTest.php b/tests/Service/Pdf/InvoiceRendererTest.php index 0f79c6d..4608e5f 100644 --- a/tests/Service/Pdf/InvoiceRendererTest.php +++ b/tests/Service/Pdf/InvoiceRendererTest.php @@ -16,6 +16,7 @@ use App\Entity\User; use App\Service\Pdf\InvoiceApprover; use App\Service\Pdf\InvoiceRenderer; use App\Service\Pdf\Pdf; +use App\Service\Upload\UploadHandler; use Carbon\CarbonPeriod; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Contracts\Translation\TranslatorInterface; @@ -108,8 +109,13 @@ class InvoiceRendererTest extends WebTestCase ; $disposition->addDocument($invoice); + $invoicePath = $projectDir.'/uploads/invoice/a/abcdef0123456789.pdf'; + $uploadHandler = $this->createMock(UploadHandler::class); + $uploadHandler->method('getResolvedUploadFilepath')->with($invoice)->willReturn($invoicePath); + $uploadHandler->method('getUploadFilepath')->with($invoice)->willReturn($invoicePath); + /** @var TranslatorInterface $translator */ - $approver = new InvoiceApprover($translator, ['project_dir' => $projectDir]); + $approver = new InvoiceApprover($translator, ['project_dir' => $projectDir], $uploadHandler); $pdf = $approver->render($disposition); $this->assertInstanceOf(Pdf::class, $pdf); }