diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 79b90ad..e746411 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,4 @@ parameters: paths: - src - - tests level: 5 diff --git a/src/DataObject.php b/src/DataObject.php index 163dbe4..26f35ad 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -45,6 +45,7 @@ trait DataObject */ public static function fromArray(array $input): ?static { + $logger = new Log(); $parameters = RuleFactory::getParametersMeta(static::class); foreach ($parameters as $parameter) { $parameterName = $parameter->reflection->getName(); @@ -66,12 +67,15 @@ trait DataObject continue; } } + $logger->inputRaw($input); $rules = static::getRules(); + $logger->rules($rules); $validator = static::withValidator($input, $rules); if ($validator->fails()) { + $logger->validationErrors($validator->errors()->toArray()); return static::fails($validator); } @@ -94,6 +98,7 @@ trait DataObject $parameter->reflection, ); } + $logger->input($mappedInput); return App::make(static::class, $mappedInput); } diff --git a/src/Log.php b/src/Log.php new file mode 100644 index 0000000..eb75ebf --- /dev/null +++ b/src/Log.php @@ -0,0 +1,67 @@ +logger = App::call($raw); + return; + } + if (is_object($raw)) { + $this->logger = $raw; + return; + } + if (is_string($raw) && class_exists($raw)) { + $this->logger = App::make($raw); + return; + } + $this->logger = new NullLogger(); + } + + /** + * @param array> $rules + */ + public function rules(array $rules): void + { + $level = config('dto.log.rules') ?? LogLevel::DEBUG; + $this->logger->log($level, print_r($rules, true)); + } + + /** + * @param array $input + */ + public function input(array $input): void + { + $level = config('dto.log.input') ?? LogLevel::DEBUG; + $this->logger->log($level, print_r($input, true)); + } + + /** + * @param array $input + */ + public function inputRaw(array $input): void + { + $level = config('dto.log.raw_input') ?? LogLevel::DEBUG; + $this->logger->log($level, print_r($input, true)); + } + + /** + * @param array> $errors + */ + public function validationErrors(array $errors): void + { + $level = config('dto.log.validation_errors') ?? LogLevel::INFO; + $this->logger->log($level, print_r($errors, true)); + } +} diff --git a/src/config/dto.php b/src/config/dto.php index 201d3eb..bb8ae6e 100644 --- a/src/config/dto.php +++ b/src/config/dto.php @@ -2,10 +2,20 @@ use Icefox\DTO\Factories\CollectionFactory; use Illuminate\Support\Collection; +use Psr\Log\LogLevel; +use Psr\Log\NullLogger; return [ 'cast' => [], 'rules' => [ Collection::class => CollectionFactory::rules(...), ], + 'log' => [ + 'logger' => NullLogger::class, + 'internal' => LogLevel::WARNING, + 'rules' => LogLevel::DEBUG, + 'input' => LogLevel::DEBUG, + 'raw_input' => LogLevel::DEBUG, + 'validation_errors' => LogLevel::INFO, + ], ]; diff --git a/tests/Logging/CustomLogger.php b/tests/Logging/CustomLogger.php new file mode 100644 index 0000000..c4bff3d --- /dev/null +++ b/tests/Logging/CustomLogger.php @@ -0,0 +1,36 @@ +logs[] = [ + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } + + public function hasLog(string $level, string $contains): bool + { + foreach ($this->logs as $log) { + if ($log['level'] === $level && str_contains($log['message'], $contains)) { + return true; + } + } + return false; + } + + public function clear(): void + { + $this->logs = []; + } +} diff --git a/tests/Logging/LogTest.php b/tests/Logging/LogTest.php new file mode 100644 index 0000000..6847938 --- /dev/null +++ b/tests/Logging/LogTest.php @@ -0,0 +1,281 @@ +set('dto.log.logger', NullLogger::class); + }); + + it('uses NullLogger as fallback when logger config is null', function () { + config()->set('dto.log.logger', null); + $log = new Log(); + expect($log->logger)->toBeInstanceOf(NullLogger::class); + }); + + it('uses NullLogger as fallback when logger config is invalid', function () { + config()->set('dto.log.logger', 'NonExistentLoggerClass'); + $log = new Log(); + expect($log->logger)->toBeInstanceOf(NullLogger::class); + }); + + it('instantiates logger from class name via Laravel container', function () { + config()->set('dto.log.logger', CustomLogger::class); + $log = new Log(); + expect($log->logger)->toBeInstanceOf(CustomLogger::class); + }); + + it('uses logger object directly when provided', function () { + $customLogger = new CustomLogger(); + config()->set('dto.log.logger', $customLogger); + $log = new Log(); + expect($log->logger)->toBe($customLogger); + }); + + it('invokes callable to get logger instance', function () { + config()->set('dto.log.logger', function () { + return new CustomLogger(); + }); + + $log = new Log(); + + expect($log->logger)->toBeInstanceOf(CustomLogger::class); + }); +}); + +describe('log level configuration', function () { + + beforeEach(function () { + $this->customLogger = new CustomLogger(); + config()->set('dto.log.logger', $this->customLogger); + }); + + afterEach(function () { + config()->set('dto.log.logger', NullLogger::class); + config()->set('dto.log.rules', LogLevel::DEBUG); + config()->set('dto.log.input', LogLevel::DEBUG); + config()->set('dto.log.raw_input', LogLevel::DEBUG); + config()->set('dto.log.validation_errors', LogLevel::INFO); + }); + + it('logs rules at configured level', function () { + config()->set('dto.log.rules', LogLevel::INFO); + + $log = new Log(); + $log->rules(['field' => ['required']]); + + expect($this->customLogger->hasLog(LogLevel::INFO, 'field'))->toBeTrue(); + }); + + it('logs input at configured level', function () { + config()->set('dto.log.input', LogLevel::INFO); + + $log = new Log(); + $log->input(['field' => 'value']); + + expect($this->customLogger->hasLog(LogLevel::INFO, 'value'))->toBeTrue(); + }); + + it('logs raw input at configured level', function () { + config()->set('dto.log.raw_input', LogLevel::ERROR); + + $log = new Log(); + $log->inputRaw(['field' => 'raw_value']); + + expect($this->customLogger->hasLog(LogLevel::ERROR, 'raw_value'))->toBeTrue(); + }); + + it('logs validation errors at configured level', function () { + config()->set('dto.log.validation_errors', LogLevel::ERROR); + + $log = new Log(); + $log->validationErrors(['field' => ['The field is required.']]); + + expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue(); + }); + + it('allows different log levels for each log type', function () { + config()->set('dto.log.rules', LogLevel::DEBUG); + config()->set('dto.log.input', LogLevel::INFO); + config()->set('dto.log.raw_input', LogLevel::INFO); + + $log = new Log(); + + $log->rules(['rules_field' => ['required']]); + $log->input(['input_field' => 'value']); + $log->inputRaw(['raw_field' => 'raw_value']); + + expect($this->customLogger->hasLog(LogLevel::DEBUG, 'rules_field'))->toBeTrue(); + expect($this->customLogger->hasLog(LogLevel::INFO, 'input_field'))->toBeTrue(); + expect($this->customLogger->hasLog(LogLevel::INFO, 'raw_field'))->toBeTrue(); + }); + + it('defaults to DEBUG level when not configured', function () { + config()->set('dto.log.rules', null); + config()->set('dto.log.input', null); + config()->set('dto.log.raw_input', null); + + $customLogger = new CustomLogger(); + config()->set('dto.log.logger', $customLogger); + + $log = new Log(); + + $log->rules(['field' => ['required']]); + $log->input(['field' => 'value']); + $log->inputRaw(['field' => 'raw_value']); + + expect(count($customLogger->logs))->toBe(3); + expect($customLogger->logs[0]['level'])->toBe(LogLevel::DEBUG); + expect($customLogger->logs[1]['level'])->toBe(LogLevel::DEBUG); + expect($customLogger->logs[2]['level'])->toBe(LogLevel::DEBUG); + }); +}); + +describe('integration with DataObject', function () { + + beforeEach(function () { + $this->customLogger = new CustomLogger(); + config()->set('dto.log.logger', $this->customLogger); + config()->set('dto.log.rules', LogLevel::DEBUG); + config()->set('dto.log.input', LogLevel::DEBUG); + config()->set('dto.log.raw_input', LogLevel::DEBUG); + }); + + afterEach(function () { + config()->set('dto.log.logger', NullLogger::class); + }); + + it('logs raw input during fromArray execution', function () { + PrimitiveData::fromArray([ + 'string' => 'test', + 'int' => 42, + 'float' => 3.14, + 'bool' => true, + ]); + + expect($this->customLogger->hasLog(LogLevel::DEBUG, 'raw_input'))->toBeFalse(); + expect($this->customLogger->hasLog(LogLevel::DEBUG, 'string'))->toBeTrue(); + expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue(); + }); + + it('logs rules during fromArray execution', function () { + PrimitiveData::fromArray([ + 'string' => 'test', + 'int' => 42, + 'float' => 3.14, + 'bool' => true, + ]); + + expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue(); + }); + + it('logs processed input during fromArray execution', function () { + PrimitiveData::fromArray([ + 'string' => 'test', + 'int' => 42, + 'float' => 3.14, + 'bool' => true, + ]); + + expect($this->customLogger->hasLog(LogLevel::DEBUG, 'test'))->toBeTrue(); + }); + + it('captures all three log types during successful fromArray', function () { + PrimitiveData::fromArray([ + 'string' => 'integration_test', + 'int' => 123, + 'float' => 9.99, + 'bool' => false, + ]); + + $rawInputLogged = false; + $rulesLogged = false; + $inputLogged = false; + + foreach ($this->customLogger->logs as $log) { + if (str_contains($log['message'], 'string')) { + $rawInputLogged = true; + } + if (str_contains($log['message'], 'required')) { + $rulesLogged = true; + } + if (str_contains($log['message'], 'integration_test')) { + $inputLogged = true; + } + } + + expect($rawInputLogged)->toBeTrue('Raw input should be logged'); + expect($rulesLogged)->toBeTrue('Rules should be logged'); + expect($inputLogged)->toBeTrue('Processed input should be logged'); + }); + + it('logs even when validation fails', function () { + try { + PrimitiveData::fromArray([ + 'int' => 42, + 'float' => 3.14, + 'bool' => true, + ]); + } catch (\Illuminate\Validation\ValidationException $e) { + // Expected + } + + expect($this->customLogger->hasLog(LogLevel::DEBUG, 'required'))->toBeTrue(); + expect($this->customLogger->hasLog(LogLevel::DEBUG, '42'))->toBeTrue(); + }); + + it('logs validation errors when validation fails', function () { + config()->set('dto.log.validation_errors', LogLevel::ERROR); + + try { + PrimitiveData::fromArray([ + 'int' => 42, + 'float' => 3.14, + 'bool' => true, + ]); + } catch (\Illuminate\Validation\ValidationException $e) { + // Expected + } + + expect($this->customLogger->hasLog(LogLevel::ERROR, 'string'))->toBeTrue(); + expect($this->customLogger->hasLog(LogLevel::ERROR, 'required'))->toBeTrue(); + }); +}); + +describe('logging with NullLogger', function () { + + it('does not throw when logging with NullLogger', function () { + config()->set('dto.log.logger', NullLogger::class); + + $log = new Log(); + + expect(function () use ($log) { + $log->rules(['field' => ['required']]); + $log->input(['field' => 'value']); + $log->inputRaw(['field' => 'raw_value']); + })->not->toThrow(\Throwable::class); + }); + + it('does not affect DataObject behavior when using NullLogger', function () { + config()->set('dto.log.logger', NullLogger::class); + + $object = PrimitiveData::fromArray([ + 'string' => 'test', + 'int' => 42, + 'float' => 3.14, + 'bool' => true, + ]); + + expect($object)->toBeInstanceOf(PrimitiveData::class); + expect($object->string)->toBe('test'); + }); +});