diff --git a/src/App/Action/Backend/Debug/DebugDownloadLogsAction.php b/src/App/Action/Backend/Debug/DebugDownloadLogsAction.php index 93be1ece1..53c7c1e93 100644 --- a/src/App/Action/Backend/Debug/DebugDownloadLogsAction.php +++ b/src/App/Action/Backend/Debug/DebugDownloadLogsAction.php @@ -6,44 +6,25 @@ use MyParcelNL\Pdk\App\Action\Contract\ActionInterface; use MyParcelNL\Pdk\Base\Contract\ZipServiceInterface; -use MyParcelNL\Pdk\Base\FileSystemInterface; +use MyParcelNL\Pdk\Facade\Logger; use MyParcelNL\Pdk\Facade\Pdk; -use MyParcelNL\Pdk\Logger\Contract\PdkLoggerInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use ZipArchive; class DebugDownloadLogsAction implements ActionInterface { - /** - * @var \MyParcelNL\Pdk\Base\FileSystemInterface - */ - protected $fileSystem; - - /** - * @var \MyParcelNL\Pdk\Logger\Contract\PdkLoggerInterface - */ - protected $logger; - /** * @var \MyParcelNL\Pdk\Base\Contract\ZipServiceInterface */ private $zipService; /** - * @param \MyParcelNL\Pdk\Logger\Contract\PdkLoggerInterface $logger - * @param \MyParcelNL\Pdk\Base\FileSystemInterface $fileSystem - * @param \MyParcelNL\Pdk\Base\Contract\ZipServiceInterface $zipService + * @param \MyParcelNL\Pdk\Base\Contract\ZipServiceInterface $zipService */ - public function __construct( - PdkLoggerInterface $logger, - FileSystemInterface $fileSystem, - ZipServiceInterface $zipService - ) { - $this->logger = $logger; - $this->fileSystem = $fileSystem; + public function __construct(ZipServiceInterface $zipService) + { $this->zipService = $zipService; } @@ -61,6 +42,7 @@ public function handle(Request $request): Response $response = new BinaryFileResponse($path); $disposition = HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, basename($path)); + $response->headers->set('Content-Disposition', $disposition); $response->deleteFileAfterSend(); @@ -74,16 +56,12 @@ public function handle(Request $request): Response */ protected function createLogsZip(string $path): void { - $logs = $this->logger->getLogs(); + $logFiles = Logger::getLogFiles(); $this->zipService->create($path); - foreach ($logs as $level => $log) { - if (empty($log)) { - continue; - } - - $this->zipService->addFromString("$level.log", $log); + foreach ($logFiles as $filePath) { + $this->zipService->addFile($filePath); } $this->zipService->close(); @@ -99,6 +77,6 @@ protected function createZipPath(): string $timestamp = date('Y-m-d_H-i-s'); $filename = "{$timestamp}_{$appInfo->name}_logs.zip"; - return $appInfo->path . $filename; + return $appInfo->createPath($filename); } } diff --git a/src/Base/Contract/ZipServiceInterface.php b/src/Base/Contract/ZipServiceInterface.php index 9b5f33241..2bcabf9f0 100644 --- a/src/Base/Contract/ZipServiceInterface.php +++ b/src/Base/Contract/ZipServiceInterface.php @@ -9,7 +9,7 @@ interface ZipServiceInterface /** * Add a file to the zip archive. Optionally specify a target filename. */ - public function addFile(string $filename, ?string $targetFilename): void; + public function addFile(string $filename, ?string $targetFilename = null): void; /** * Add a file to the zip archive from a string. diff --git a/src/Base/Model/AppInfo.php b/src/Base/Model/AppInfo.php index 52284c009..78689a3e3 100644 --- a/src/Base/Model/AppInfo.php +++ b/src/Base/Model/AppInfo.php @@ -28,4 +28,16 @@ class AppInfo extends Model 'path' => 'string', 'url' => 'string', ]; + + /** + * @param string $path + * + * @return string + */ + public function createPath(string $path): string + { + $pattern = sprintf('/\%s+/', DIRECTORY_SEPARATOR); + + return preg_replace($pattern, DIRECTORY_SEPARATOR, "$this->path/$path"); + } } diff --git a/src/Base/Service/ZipService.php b/src/Base/Service/ZipService.php index 599fbfcce..c652fd296 100644 --- a/src/Base/Service/ZipService.php +++ b/src/Base/Service/ZipService.php @@ -5,12 +5,14 @@ namespace MyParcelNL\Pdk\Base\Service; use MyParcelNL\Pdk\Base\Contract\ZipServiceInterface; +use RuntimeException; use ZipArchive; +use function basename; class ZipService implements ZipServiceInterface { /** - * @var \ZipArchive + * @var null|\ZipArchive */ private $currentFile; @@ -19,10 +21,16 @@ class ZipService implements ZipServiceInterface * @param null|string $targetFilename * * @return void + * @throws \RuntimeException */ - public function addFile(string $filename, ?string $targetFilename): void + public function addFile(string $filename, ?string $targetFilename = null): void { - $this->currentFile->addFile($filename, $targetFilename ?? $filename); + $this->validateHasFile(); + $success = $this->currentFile->addFile($filename, $targetFilename ?? basename($filename)); + + if (! $success) { + throw new RuntimeException('Failed to add file to zip'); + } } /** @@ -33,6 +41,7 @@ public function addFile(string $filename, ?string $targetFilename): void */ public function addFromString(string $string, string $targetFilename): void { + $this->validateHasFile(); $this->currentFile->addFromString($targetFilename, $string); } @@ -41,6 +50,7 @@ public function addFromString(string $string, string $targetFilename): void */ public function close(): void { + $this->validateHasFile(); $this->currentFile->close(); $this->currentFile = null; } @@ -58,4 +68,16 @@ public function create(string $filename): void $this->currentFile = $zip; } + + /** + * @return void + */ + private function validateHasFile(): void + { + if (null !== $this->currentFile) { + return; + } + + throw new RuntimeException('No zip file is open'); + } } diff --git a/src/Facade/Logger.php b/src/Facade/Logger.php index 460ed741c..0dcd7bb3f 100644 --- a/src/Facade/Logger.php +++ b/src/Facade/Logger.php @@ -19,6 +19,7 @@ * @method static void notice($message, array $context = []) * @method static void warning($message, array $context = []) * @method static void deprecated(string $subject, string $replacement = null, array $context = []) + * @method static array getLogFiles() * @see \MyParcelNL\Pdk\Logger\Contract\PdkLoggerInterface */ final class Logger extends Facade diff --git a/src/Logger/AbstractLogger.php b/src/Logger/AbstractLogger.php index 5de410644..d8af4450b 100644 --- a/src/Logger/AbstractLogger.php +++ b/src/Logger/AbstractLogger.php @@ -96,10 +96,11 @@ public function error($message, array $context = []): void } /** - * @TODO: remove this default in v3.0.0, for now it's here to prevent breaking changes - * @return array + * @TODO : remove this default in v3.0.0, for now it's here to prevent breaking changes + * @return array{notice?: string, warning?: string, error?: string, critical?: string, alert?: string, emergency?: string} + * @codeCoverageIgnore */ - public function getLogs(): array + public function getLogFiles(): array { return []; } diff --git a/src/Logger/Contract/PdkLoggerInterface.php b/src/Logger/Contract/PdkLoggerInterface.php index bc0b79892..bb58a8287 100644 --- a/src/Logger/Contract/PdkLoggerInterface.php +++ b/src/Logger/Contract/PdkLoggerInterface.php @@ -18,7 +18,9 @@ interface PdkLoggerInterface extends LoggerInterface public function deprecated(string $subject, ?string $replacement = null, array $context = []): void; /** - * @return array + * Get all logs as an associative array with log levels as keys and log file paths as values. + * + * @return array{notice?: string, warning?: string, error?: string, critical?: string, alert?: string, emergency?: string} */ - public function getLogs(): array; + public function getLogFiles(): array; } diff --git a/tests/Bootstrap/MockLogger.php b/tests/Bootstrap/MockLogger.php index 83c82032d..79f7611eb 100644 --- a/tests/Bootstrap/MockLogger.php +++ b/tests/Bootstrap/MockLogger.php @@ -4,15 +4,59 @@ namespace MyParcelNL\Pdk\Tests\Bootstrap; +use MyParcelNL\Pdk\Base\FileSystemInterface; +use MyParcelNL\Pdk\Facade\Pdk; use MyParcelNL\Pdk\Logger\AbstractLogger; +use Psr\Log\LogLevel; +use function array_filter; +use function array_reduce; +use function json_encode; class MockLogger extends AbstractLogger { + private const ALL_LOG_LEVELS = [ + LogLevel::ALERT, + LogLevel::CRITICAL, + LogLevel::DEBUG, + LogLevel::EMERGENCY, + LogLevel::ERROR, + LogLevel::INFO, + LogLevel::NOTICE, + LogLevel::WARNING, + ]; + + /** + * @var \MyParcelNL\Pdk\Base\FileSystemInterface + */ + private $fileSystem; + /** * @var array */ private $logs = []; + /** + * @var array + */ + private $streams = []; + + /** + * @param \MyParcelNL\Pdk\Base\FileSystemInterface $fileSystem + */ + public function __construct(FileSystemInterface $fileSystem) + { + $this->fileSystem = $fileSystem; + + $appInfo = Pdk::getAppInfo(); + $this->fileSystem->mkdir($appInfo->createPath('logs'), true); + + foreach (self::ALL_LOG_LEVELS as $level) { + $filename = $appInfo->createPath("logs/test_$level.log"); + + $this->streams[$level] = $this->fileSystem->openStream($filename, 'w'); + } + } + /** * @return void */ @@ -21,6 +65,20 @@ public function clear(): void $this->logs = []; } + /** + * @inheritDoc + */ + public function getLogFiles(): array + { + $appInfo = Pdk::getAppInfo(); + + return array_reduce(self::ALL_LOG_LEVELS, static function (array $acc, string $level) use ($appInfo) { + $acc[$level] = $appInfo->createPath("logs/test_$level.log"); + + return $acc; + }, []); + } + /** * @return array */ @@ -43,5 +101,13 @@ public function log($level, $message, array $context = []): void 'message' => $message, 'context' => $context, ]; + + $formattedString = implode(' ', array_filter([ + "!$level!", + $message, + empty($context) ? null : json_encode($context), + ])); + + $this->fileSystem->writeToStream($this->streams[$level], $formattedString . PHP_EOL); } } diff --git a/tests/Bootstrap/MockPdkConfig.php b/tests/Bootstrap/MockPdkConfig.php index 1cd4c0aed..64246b205 100644 --- a/tests/Bootstrap/MockPdkConfig.php +++ b/tests/Bootstrap/MockPdkConfig.php @@ -69,7 +69,7 @@ private static function getDefaultConfig(): array 'name' => 'pest', 'title' => 'Pest', 'version' => '1.0.0', - 'path' => 'APP_PATH', + 'path' => '/app/.tmp/', 'url' => 'APP_URL', ]); }), diff --git a/tests/Unit/App/Action/Backend/Debug/DebugDownloadLogsActionTest.php b/tests/Unit/App/Action/Backend/Debug/DebugDownloadLogsActionTest.php new file mode 100644 index 000000000..4275aaeae --- /dev/null +++ b/tests/Unit/App/Action/Backend/Debug/DebugDownloadLogsActionTest.php @@ -0,0 +1,75 @@ + get(FileSystem::class), +])); + +test('it downloads logs', function () { + // Warning and notice are not called to check if they're omitted from the created zip for being empty. + Logger::emergency('emergency message'); + Logger::alert('hi'); + Logger::critical('some string'); + Logger::error('error message'); + Logger::info('info message with context', ['some' => 'context']); + Logger::debug('debug message'); + Logger::debug('debug message 2'); + Logger::debug('debug message 3'); + + $request = new Request(['action' => PdkBackendActions::DEBUG_DOWNLOAD_LOGS]); + + /** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $response */ + $response = Actions::execute($request); + + $file = $response->getFile(); + + expect($response) + ->toBeInstanceOf(BinaryFileResponse::class) + ->and($response->getStatusCode()) + ->toBe(200) + ->and($file->isFile()) + ->toBeTrue() + ->and($file->getFilename()) + ->toMatch('/\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_pest_logs.zip$/'); + + // Check if the returned zip file contains the logs + $logs = readZip($file->getPathname()); + + expect($logs)->toEqual([ + 'test_emergency.log' => '!emergency! [PDK]: emergency message' . PHP_EOL, + 'test_alert.log' => '!alert! [PDK]: hi' . PHP_EOL, + 'test_critical.log' => '!critical! [PDK]: some string' . PHP_EOL, + 'test_error.log' => '!error! [PDK]: error message' . PHP_EOL, + 'test_info.log' => '!info! [PDK]: info message with context {"some":"context"}' . PHP_EOL, + 'test_debug.log' => implode(PHP_EOL, [ + '!debug! [PDK]: debug message', + '!debug! [PDK]: debug message 2', + '!debug! [PDK]: debug message 3', + ]) . PHP_EOL, + 'test_notice.log' => '', + 'test_warning.log' => '', + ]); + + // Send the response to check if the created file is deleted after sending + $response->send(); + + expect($file->isFile())->toBeFalse(); +}); diff --git a/tests/Unit/Base/Model/AppInfoTest.php b/tests/Unit/Base/Model/AppInfoTest.php new file mode 100644 index 000000000..31bdd949d --- /dev/null +++ b/tests/Unit/Base/Model/AppInfoTest.php @@ -0,0 +1,28 @@ + "/some/path$trailing", + ]); + + expect($appInfo->createPath('my_file.txt')) + ->toBe('/some/path/my_file.txt') + ->and($appInfo->createPath('/with/slashes/vroom.txt')) + ->toBe('/some/path/with/slashes/vroom.txt') + ->and($appInfo->createPath('/with//some//more////slashes//yikes.txt')) + ->toBe('/some/path/with/some/more/slashes/yikes.txt'); +})->with([ + 'path without trailing slash' => [''], + 'path with trailing slash' => ['/'], + 'path with a lot of trailing slashes' => ['////'], +]); diff --git a/tests/Unit/Base/Service/ZipServiceTest.php b/tests/Unit/Base/Service/ZipServiceTest.php new file mode 100644 index 000000000..1f5610159 --- /dev/null +++ b/tests/Unit/Base/Service/ZipServiceTest.php @@ -0,0 +1,102 @@ +createPath('test.zip'); + + $zipService->create($filename); + $zipService->addFromString('test', 'test.txt'); + $zipService->close(); + + expect($realFileSystem->fileExists($filename))->toBeTrue(); + + $contents = readZip($filename); + + expect($contents)->toEqual([ + 'test.txt' => 'test', + ]); + + // Clean up created files + $realFileSystem->unlink($filename); +}); + +it('adds files to a zip', function () { + $appInfo = Pdk::getAppInfo(); + /** @var \MyParcelNL\Pdk\Base\Contract\ZipServiceInterface $zipService */ + $zipService = Pdk::get(ZipServiceInterface::class); + /** @var FileSystem $realFileSystem */ + $realFileSystem = Pdk::get(FileSystem::class); + + $filename = $appInfo->createPath('test-from-files.zip'); + + $realFileSystem->put($appInfo->createPath('some-file.txt'), 'test some file'); + $realFileSystem->put($appInfo->createPath('some-other-file.txt'), 'test some other file'); + + $zipService->create($filename); + + $zipService->addFile($appInfo->createPath('some-file.txt')); + $zipService->addFile($appInfo->createPath('some-other-file.txt'), 'nested/some-renamed-file.txt'); + + $zipService->close(); + + expect($realFileSystem->fileExists($filename))->toBeTrue(); + + $contents = readZip($filename); + + expect($contents)->toEqual([ + 'some-file.txt' => 'test some file', + 'nested/some-renamed-file.txt' => 'test some other file', + ]); + + // Clean up created files + $realFileSystem->unlink($filename); + $realFileSystem->unlink($appInfo->createPath('some-file.txt')); + $realFileSystem->unlink($appInfo->createPath('some-other-file.txt')); +}); + +it('throws error when calling method while no zip is open', function (callable $callback) { + /** @var \MyParcelNL\Pdk\Base\Contract\ZipServiceInterface $zipService */ + $zipService = Pdk::get(ZipServiceInterface::class); + + $callback($zipService); +}) + ->throws(RuntimeException::class) + ->with([ + 'addFromString' => function () { + return function (ZipServiceInterface $zipService) { + $zipService->addFromString('test', 'test.txt'); + }; + }, + + 'addFile' => function () { + return function (ZipServiceInterface $zipService) { + $zipService->addFile('some-file.txt'); + }; + }, + + 'close' => function () { + return function (ZipServiceInterface $zipService) { + $zipService->close(); + }; + }, + ]); diff --git a/tests/__snapshots__/ContextServiceTest__it_gets_context_data_with_data_set_global__1.json b/tests/__snapshots__/ContextServiceTest__it_gets_context_data_with_data_set_global__1.json index 8b93299f2..76ead7a29 100644 --- a/tests/__snapshots__/ContextServiceTest__it_gets_context_data_with_data_set_global__1.json +++ b/tests/__snapshots__/ContextServiceTest__it_gets_context_data_with_data_set_global__1.json @@ -4,7 +4,7 @@ "name": "pest", "title": "Pest", "version": "1.0.0", - "path": "APP_PATH", + "path": "/app/.tmp/", "url": "APP_URL" }, "baseUrl": "BACKEND_URL", @@ -182,6 +182,14 @@ "path": "", "property": "webhooks" }, + "debugDownloadLogs": { + "headers": [], + "method": "GET", + "parameters": { + "action": "debugDownloadLogs" + }, + "path": "" + }, "fetchContext": { "headers": [], "method": "GET", diff --git a/tests/__snapshots__/FrontendRenderServiceTest__it_renders_component_with_data_set_init_script__1.json b/tests/__snapshots__/FrontendRenderServiceTest__it_renders_component_with_data_set_init_script__1.json index 5260bcb89..6487288e9 100644 --- a/tests/__snapshots__/FrontendRenderServiceTest__it_renders_component_with_data_set_init_script__1.json +++ b/tests/__snapshots__/FrontendRenderServiceTest__it_renders_component_with_data_set_init_script__1.json @@ -4,7 +4,7 @@ "name": "pest", "title": "Pest", "version": "1.0.0", - "path": "APP_PATH", + "path": "/app/.tmp/", "url": "APP_URL" }, "baseUrl": "BACKEND_URL", @@ -182,6 +182,14 @@ "path": "", "property": "webhooks" }, + "debugDownloadLogs": { + "headers": [], + "method": "GET", + "parameters": { + "action": "debugDownloadLogs" + }, + "path": "" + }, "fetchContext": { "headers": [], "method": "GET", diff --git a/tests/functions.php b/tests/functions.php index 146103a96..608b7f88a 100644 --- a/tests/functions.php +++ b/tests/functions.php @@ -10,6 +10,7 @@ use MyParcelNL\Pdk\Base\Concern\PdkInterface; use MyParcelNL\Pdk\Facade\Pdk; use MyParcelNL\Pdk\Tests\Factory\FactoryFactory; +use ZipArchive; function mockPdkProperties(array $properties): callable { @@ -49,3 +50,30 @@ function factory(string $class, ...$args) { return FactoryFactory::create($class, ...$args); } + +/** + * Read the contents of a zip file into an array. + * + * @param string $filename + * + * @return array + */ +function readZip(string $filename): array +{ + $zip = new ZipArchive(); + $zip->open($filename); + + $files = []; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $contents = $zip->getFromIndex($i); + + $files[$stat['name']] = $contents; + } + + $zip->close(); + + return $files; +} +