diff --git a/app/modules/web/Controllers/AccountFile/UploadController.php b/app/modules/web/Controllers/AccountFile/UploadController.php index bb987967..bc399269 100644 --- a/app/modules/web/Controllers/AccountFile/UploadController.php +++ b/app/modules/web/Controllers/AccountFile/UploadController.php @@ -32,6 +32,8 @@ use SP\Core\Events\EventMessage; use SP\Domain\Account\Models\File; use SP\Domain\Account\Ports\AccountFileService; use SP\Domain\Account\Ports\AccountService; +use SP\Domain\Auth\Services\AuthException; +use SP\Domain\Core\Exceptions\SessionTimeout; use SP\Domain\Core\Exceptions\SPException; use SP\Domain\File\Ports\FileHandlerInterface; use SP\Infrastructure\File\FileException; @@ -40,6 +42,10 @@ use SP\Modules\Web\Controllers\ControllerBase; use SP\Modules\Web\Controllers\Traits\JsonTrait; use SP\Mvc\Controller\WebControllerHelper; +use function SP\__; +use function SP\__u; +use function SP\processException; + /** * Class UploadController * @@ -49,21 +55,19 @@ final class UploadController extends ControllerBase { use JsonTrait; - private AccountFileService $accountFileService; - private AccountService $accountService; - + /** + * @throws AuthException + * @throws SessionTimeout + */ public function __construct( - Application $application, - WebControllerHelper $webControllerHelper, - AccountFileService $accountFileService, - AccountService $accountService + Application $application, + WebControllerHelper $webControllerHelper, + private readonly AccountFileService $accountFileService, + private readonly AccountService $accountService ) { parent::__construct($application, $webControllerHelper); $this->checkLoggedIn(); - - $this->accountFileService = $accountFileService; - $this->accountService = $accountService; } /** @@ -73,6 +77,7 @@ final class UploadController extends ControllerBase * * @return bool * @throws JsonException + * @throws SPException */ public function uploadAction(int $accountId): bool { @@ -90,57 +95,57 @@ final class UploadController extends ControllerBase } try { - $fileHandler = new FileHandler($file['tmp_name']); + $fileName = htmlspecialchars($file['name'] ?? '', ENT_QUOTES); - $fileData = new File(); - $fileData->setAccountId($accountId); - $fileData->setName(htmlspecialchars($file['name'], ENT_QUOTES)); - $fileData->setSize($file['size']); - $fileData->setType($file['type']); - $fileData->setExtension(mb_strtoupper(pathinfo($fileData->getName(), PATHINFO_EXTENSION))); - - if ($fileData->getName() === '') { - throw new SPException( - __u('Invalid file'), - SPException::ERROR, - sprintf(__u('File: %s'), $fileData->getName()) - ); + if (empty($fileName)) { + throw SPException::error(__u('Invalid file'), sprintf(__u('File: %s'), $fileName)); } - $fileHandler->checkFileExists(); - - $fileData->setType($this->checkAllowedMimeType($fileData, $fileHandler)); - $allowedSize = $this->configData->getFilesAllowedSize(); - if ($fileData->getSize() > ($allowedSize * 1000)) { - throw new SPException( + if ($file['size'] > ($allowedSize * 1000)) { + throw SPException::error( __u('File size exceeded'), - SPException::ERROR, - sprintf(__u('Maximum size: %d KB'), $fileData->getRoundSize()) + sprintf(__u('Maximum size: %f KB'), round($allowedSize / 1000, 2)) ); } - $fileData->setContent($fileHandler->readToString()); + $fileHandler = new FileHandler($file['tmp_name']); + + $fileData = [ + 'accountId' => $accountId, + 'name' => $fileName, + 'size' => $file['size'], + 'type' => $this->checkAllowedMimeType($file['type'], $fileHandler), + 'extension' => mb_strtoupper(pathinfo($fileName, PATHINFO_EXTENSION)), + 'content' => $fileHandler->readToString() + ]; } catch (FileException $e) { - throw new SPException(__u('Internal error while reading the file')); + $this->eventDispatcher->notify('exception', new Event($e)); + + throw SPException::error(__u('Internal error while reading the file')); } - $this->accountFileService->create($fileData); - - $account = $this->accountService->getByIdEnriched($accountId)->getAccountVData(); + $this->accountFileService->create(new File($fileData)); $this->eventDispatcher->notify( 'upload.accountFile', new Event( $this, - EventMessage::factory() - ->addDescription(__u('File saved')) - ->addDetail(__u('File'), $fileData->getName()) - ->addDetail(__u('Account'), $account->getName()) - ->addDetail(__u('Client'), $account->getClientName()) - ->addDetail(__u('Type'), $fileData->getType()) - ->addDetail(__u('Size'), $fileData->getRoundSize() . 'KB') + static function () use ($accountId, $fileData): EventMessage { + $account = $this->accountService->getByIdEnriched($accountId); + + return EventMessage::factory() + ->addDescription(__u('File saved')) + ->addDetail(__u('File'), $fileData['name']) + ->addDetail(__u('Account'), $account->getName()) + ->addDetail(__u('Client'), $account->getClientName()) + ->addDetail(__u('Type'), $fileData['type']) + ->addDetail( + __u('Size'), + sprintf('%f KB', round($fileData['size'] / 1000)) + ); + } ) ); @@ -150,7 +155,7 @@ final class UploadController extends ControllerBase $this->eventDispatcher->notify('exception', new Event($e)); - return $this->returnJsonResponse(1, $e->getMessage(), [$e->getHint()]); + return $this->returnJsonResponse(1, $e->getMessage(), $e->getHint()); } catch (Exception $e) { processException($e); @@ -161,27 +166,23 @@ final class UploadController extends ControllerBase } /** - * @param \SP\Domain\Account\Models\File $fileData + * @param string $type * @param FileHandlerInterface $fileHandler * * @return string - * @throws SPException * @throws FileException + * @throws SPException */ - private function checkAllowedMimeType(File $fileData, FileHandlerInterface $fileHandler): string + private function checkAllowedMimeType(string $type, FileHandlerInterface $fileHandler): string { - if (in_array($fileData->getType(), $this->configData->getFilesAllowedMime(), true)) { - return $fileData->getType(); + if (in_array($type, $this->configData->getFilesAllowedMime(), true)) { + return $type; } if (in_array($fileHandler->getFileType(), $this->configData->getFilesAllowedMime(), true)) { return $fileHandler->getFileType(); } - throw new SPException( - __u('File type not allowed'), - SPException::ERROR, - sprintf(__('MIME type: %s'), $fileData->getType()) - ); + throw SPException::error(__u('File type not allowed'), sprintf(__('MIME type: %s'), $type)); } } diff --git a/app/modules/web/Controllers/Traits/JsonTrait.php b/app/modules/web/Controllers/Traits/JsonTrait.php index 2a47cdeb..8796e4f0 100644 --- a/app/modules/web/Controllers/Traits/JsonTrait.php +++ b/app/modules/web/Controllers/Traits/JsonTrait.php @@ -44,19 +44,19 @@ trait JsonTrait * * @param int $status Status code * @param string $description Untranslated description string - * @param array|null $messages Untranslated massages array of strings + * @param array|string|null $messages Untranslated massages array of strings * * @return bool * @throws SPException */ - protected function returnJsonResponse(int $status, string $description, ?array $messages = null): bool + protected function returnJsonResponse(int $status, string $description, array|string|null $messages = null): bool { $jsonResponse = new JsonMessage(); $jsonResponse->setStatus($status); $jsonResponse->setDescription($description); if (null !== $messages) { - $jsonResponse->setMessages($messages); + $jsonResponse->setMessages((array)$messages); } return JsonResponse::factory($this->router->response())->send($jsonResponse); diff --git a/lib/SP/Core/Events/Event.php b/lib/SP/Core/Events/Event.php index 490ad1d3..65134421 100644 --- a/lib/SP/Core/Events/Event.php +++ b/lib/SP/Core/Events/Event.php @@ -26,6 +26,7 @@ declare(strict_types=1); namespace SP\Core\Events; +use Closure; use SP\Domain\Core\Exceptions\InvalidClassException; /** @@ -33,9 +34,13 @@ use SP\Domain\Core\Exceptions\InvalidClassException; */ readonly class Event { + /** + * @param object $source The emmiter of the event + * @param EventMessage|Closure|null $eventMessage An {@link EventMessage} or a {@link Closure} that returns an {@link EventMessage} + */ public function __construct( - private object $source, - private ?EventMessage $eventMessage = null + private object $source, + private EventMessage|Closure|null $eventMessage = null ) { } @@ -60,6 +65,10 @@ readonly class Event public function getEventMessage(): ?EventMessage { + if ($this->eventMessage instanceof Closure) { + return $this->eventMessage->call($this); + } + return $this->eventMessage; } } diff --git a/lib/SP/Domain/Account/Models/File.php b/lib/SP/Domain/Account/Models/File.php index 6513c9a4..104f4c71 100644 --- a/lib/SP/Domain/Account/Models/File.php +++ b/lib/SP/Domain/Account/Models/File.php @@ -91,13 +91,4 @@ class File extends Model implements ItemWithIdAndNameModel { return $this->size; } - - public function getRoundSize(): float - { - if (null === $this->size) { - return 0.0; - } - - return round($this->size / 1000, 2); - } } diff --git a/lib/SP/Domain/Http/Dtos/JsonMessage.php b/lib/SP/Domain/Http/Dtos/JsonMessage.php index cee36795..0b3be663 100644 --- a/lib/SP/Domain/Http/Dtos/JsonMessage.php +++ b/lib/SP/Domain/Http/Dtos/JsonMessage.php @@ -80,7 +80,7 @@ final class JsonMessage implements JsonSerializable public function setMessages(array $messages): JsonMessage { - $this->messages = array_map('__', $messages); + $this->messages = array_map('SP\__', $messages); return $this; } diff --git a/tests/SP/IntegrationTestCase.php b/tests/SP/IntegrationTestCase.php index e4de36b8..552cd83c 100644 --- a/tests/SP/IntegrationTestCase.php +++ b/tests/SP/IntegrationTestCase.php @@ -182,7 +182,6 @@ abstract class IntegrationTestCase extends TestCase $configData->method('isMaintenance')->willReturn(false); $configData->method('getDbName')->willReturn(self::$faker->colorName()); $configData->method('getPasswordSalt')->willReturn($this->passwordSalt); - $configData->method('isFilesEnabled')->willReturn(true); return $configData; } @@ -244,8 +243,13 @@ abstract class IntegrationTestCase extends TestCase $this->databaseMapperResolvers[$className] = $queryResult; } - protected function buildRequest(string $method, string $uri, array $paramsGet = [], array $paramsPost = []): Request - { + protected function buildRequest( + string $method, + string $uri, + array $paramsGet = [], + array $paramsPost = [], + array $files = [] + ): Request { $server = array_merge( $_SERVER, [ @@ -262,7 +266,7 @@ abstract class IntegrationTestCase extends TestCase array_merge($_POST, $paramsPost), $_COOKIE, $server, - $_FILES, + array_merge($_FILES, $files), null ); } diff --git a/tests/SP/Modules/Web/Controllers/AccountFile/AccountFileTest.php b/tests/SP/Modules/Web/Controllers/AccountFile/AccountFileTest.php index 77221500..1784926f 100644 --- a/tests/SP/Modules/Web/Controllers/AccountFile/AccountFileTest.php +++ b/tests/SP/Modules/Web/Controllers/AccountFile/AccountFileTest.php @@ -29,9 +29,11 @@ namespace SP\Tests\Modules\Web\Controllers\AccountFile; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\Exception; +use PHPUnit\Framework\MockObject\Stub; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use SP\Domain\Account\Models\File; +use SP\Domain\Config\Ports\ConfigDataInterface; use SP\Domain\Core\Exceptions\InvalidClassException; use SP\Infrastructure\Database\QueryData; use SP\Infrastructure\Database\QueryResult; @@ -224,4 +226,62 @@ class AccountFileTest extends IntegrationTestCase $this->runApp($container); } + + /** + * @return void + * @throws ContainerExceptionInterface + * @throws Exception + * @throws FileException + * @throws InvalidClassException + * @throws NotFoundExceptionInterface + */ + #[Test] + public function upload() + { + $definitions = $this->getModuleDefinitions(); + $definitions[OutputHandlerInterface::class] = $this->setupOutputHandler(function (string $output): void { + $crawler = new Crawler($output); + $filter = $crawler->filterXPath('//table/tbody//tr[string-length(@data-item-id) > 0]') + ->extract(['class']); + + assert(!empty($output)); + assert(count($filter) === 2); + + $this->assertTrue(true); + }); + + $file = sprintf('%s.txt', self::$faker->filePath()); + + file_put_contents($file, self::$faker->text()); + + $files = [ + 'inFile' => [ + 'name' => self::$faker->name(), + 'tmp_name' => $file, + 'size' => filesize($file), + 'type' => 'text/plain' + ] + ]; + + $container = $this->buildContainer( + $definitions, + $this->buildRequest('post', 'index.php', ['r' => 'accountFile/upload/100'], [], $files) + ); + + $this->runApp($container); + + $this->expectOutputString( + '{"status":0,"description":"File saved","data":[],"messages":[]}' + ); + } + + protected function getConfigData(): ConfigDataInterface|Stub + { + $configData = parent::getConfigData(); + $configData->method('isFilesEnabled')->willReturn(true); + $configData->method('getFilesAllowedMime')->willReturn(['text/plain']); + $configData->method('getFilesAllowedSize')->willReturn(1000); + + return $configData; + } }