input mapping

This commit is contained in:
icefox 2026-02-25 16:03:00 -03:00
parent bba10b455f
commit fc46fe20ee
No known key found for this signature in database
29 changed files with 193 additions and 887 deletions

View file

@ -13,6 +13,8 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
use Psr\Log\LoggerInterface;
use ReflectionNamedType;
use phpDocumentor\Reflection\Types\AbstractList;
class DataObjectFactory
{
@ -21,7 +23,8 @@ class DataObjectFactory
*/
public static function fromRequest(string $class, Request $request): ?object
{
$routeParameters = $request->route() instanceof Route ? $request->route()->parameters() : [];
$route = $request->route();
$routeParameters = $route instanceof Route ? $route->parameters() : [];
return static::fromArray($class, $request->input(), $routeParameters);
}
@ -52,7 +55,6 @@ class DataObjectFactory
return ValueFactory::make($class, $validator->validated());
}
/**
* @param class-string $class
* @param array<string,mixed> $rawInput
@ -67,6 +69,7 @@ class DataObjectFactory
): array {
$input = [];
$parameters = ReflectionHelper::getParametersMeta($class);
foreach ($parameters as $parameter) {
$parameterName = $parameter->reflection->getName();
@ -77,17 +80,51 @@ class DataObjectFactory
}
}
$reflectionType = $parameter->reflection->getType();
$namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null;
$annotatedType = $parameter->tag?->getType();
$isListType
= $parameter->reflection->isArray()
|| in_array($namedType, config('dto.listTypes', []))
|| in_array($annotatedType?->__toString(), config('dto.listTypes', []))
|| $annotatedType instanceof AbstractList;
foreach ($parameter->reflection->getAttributes(FromInput::class) as $attr) {
if ($value = $rawInput[$attr->newInstance()->name] ?? null) {
$input[$parameterName] = $value;
if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) {
$input[$parameterName] = $isListType
? array_map(
fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger),
$value,
)
: self::mapInput($valueType, $value, $routeParameters, $logger);
} else {
$input[$parameterName] = $value;
}
continue 2;
}
}
if ($value = $rawInput[$parameterName] ?? null) {
$input[$parameterName] = $value;
if ($valueType = ReflectionHelper::getListParameterValueType($parameter->tag)) {
$input[$parameterName] = $isListType
? array_map(
fn($element) => self::mapInput($valueType, $element, $routeParameters, $logger),
$rawInput[$parameterName],
)
: self::mapInput($valueType, $rawInput[$parameterName], $routeParameters, $logger);
continue;
}
if ($reflectionType instanceof ReflectionNamedType) {
$input[$parameterName] = $reflectionType->isBuiltin()
? $rawInput[$parameterName]
: self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger);
continue;
}
$input[$parameterName] = $rawInput[$parameterName];
}
$logger->debug('input', $input);
return $input;

View file

@ -5,6 +5,8 @@ namespace Icefox\DTO;
use ReflectionParameter;
use phpDocumentor\Reflection\DocBlock\Tag;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\PseudoTypes\Generic;
use phpDocumentor\Reflection\Types\AbstractList;
use phpDocumentor\Reflection\Types\ContextFactory;
use phpDocumentor\Reflection\DocBlockFactory;
use ReflectionClass;
@ -44,4 +46,21 @@ class ReflectionHelper
);
return self::$cache[$class];
}
public static function getListParameterValueType(?Param $param): ?string
{
$type = $param?->getType();
if ($type instanceof AbstractList) {
return $type->getValueType()->__toString();
}
if (!$type instanceof Generic) {
return null;
}
$subtypes = $type->getTypes();
return count($subtypes) > 1 ? $subtypes[1]->__toString() : $subtypes[0]->__toString();
}
}

View file

@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests;
use Illuminate\Validation\ValidationException;
use Tests\Casters\SimpleValue;
use Tests\Casters\SimpleValueCaster;
use Tests\Casters\WithGlobalCaster;
use Tests\Casters\WithSpecificCaster;
use Tests\Casters\WithoutCaster;
describe('caster priority', function () {
beforeEach(function () {
config(['dto.cast' => []]);
});
it('uses CastWith attribute over global config caster', function () {
$globalCaster = function (mixed $data): SimpleValue {
return new SimpleValue($data * 3);
};
config(['dto.cast.' . SimpleValue::class => $globalCaster]);
$object = WithSpecificCaster::fromArray([
'value' => ['value' => 5],
]);
expect($object->value->value)->toBe(10); // 5 * 2
});
it('falls back to global config caster when no CastWith attribute', function () {
$globalCaster = function (mixed $data): SimpleValue {
return new SimpleValue($data['value'] * 3);
};
config(['dto.cast.' . SimpleValue::class => $globalCaster]);
$object = WithGlobalCaster::fromArray([
'simple' => ['value' => 5],
]);
expect($object->simple->value)->toBe(15); // 5 * 3
});
it('falls back to default construction when no caster exists', function () {
$object = WithoutCaster::fromArray([
'value' => ['value' => 5],
]);
expect($object)->toBeInstanceOf(WithoutCaster::class);
});
});
describe('caster with rules', function () {
beforeEach(function () {
config(['dto.cast' => []]);
});
it('validates input using caster rules before casting', function () {
expect(fn() => WithSpecificCaster::fromArray([
'value' => [],
]))->toThrow(ValidationException::class);
});
it('accepts valid input and casts correctly', function () {
$object = WithSpecificCaster::fromArray([
'value' => ['value' => 10],
]);
expect($object->value->value)->toBe(20); // 10 * 2
});
});

View file

@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
class SimpleValue
{
public function __construct(public readonly int $value) {}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
class SimpleValueCaster
{
public function cast(mixed $data): SimpleValue
{
return new SimpleValue($data['value'] * 2);
}
public static function rules(): array
{
return [
'value' => ['required', 'numeric'],
];
}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
use Icefox\DTO\DataObject;
readonly class WithGlobalCaster
{
use DataObject;
public function __construct(
public SimpleValue $simple,
) {}
}

View file

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\DataObject;
readonly class WithSpecificCaster
{
use DataObject;
public function __construct(
#[CastWith(SimpleValueCaster::class)]
public SimpleValue $value,
) {}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Casters;
use Icefox\DTO\DataObject;
readonly class WithoutCaster
{
use DataObject;
public function __construct(
public SimpleValue $value,
) {}
}

View file

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class ArrayDataObject
{
use DataObject;
/**
* @param array<int,int> $values
*/
public function __construct(public array $values) {}
}

View file

@ -1,22 +0,0 @@
<?php
namespace Tests\Classes;
use Carbon\CarbonPeriodImmutable;
use Illuminate\Support\Carbon;
class CarbonPeriodMapper
{
public function cast(mixed $data): CarbonPeriodImmutable
{
return new CarbonPeriodImmutable(Carbon::parse($data['start']), Carbon::parse($data['end']));
}
public static function rules(): array
{
return [
'start' => ['required', 'date'],
'end' => ['required', 'date'],
];
}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
use Illuminate\Support\Collection;
readonly class CollectionDataObject
{
use DataObject;
/**
* @param Collection<OptionalNullableData> $values
*/
public function __construct(public Collection $values) {}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
use Illuminate\Validation\Validator;
readonly class FailsReturnsDefault
{
use DataObject;
public function __construct(
public string $string,
public int $int = 42,
) {}
public static function fails(Validator $validator): ?static
{
return new self(string: 'default_value');
}
}

View file

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
use Illuminate\Validation\Validator;
readonly class FailsReturnsNull
{
use DataObject;
public function __construct(
public string $string,
public int $int,
) {}
public static function fails(Validator $validator): ?static
{
return null;
}
}

View file

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Validation\Validator;
readonly class FailsWithHttpResponse
{
use DataObject;
public function __construct(
public string $string,
public int $int,
) {}
public static function fails(Validator $validator): ?static
{
throw new HttpResponseException(
response()->json(['errors' => $validator->errors()], 422)
);
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\DataObject;
readonly class FromInputObject
{
use DataObject;
public function __construct(
#[FromInput('other_name')]
public string $text,
public int $standard,
) {}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Carbon\Carbon;
use Icefox\DTO\DataObject;
readonly class ObjectWithoutMapper
{
use DataObject;
public function __construct(
public Carbon $date,
) {}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class OptionalData
{
use DataObject;
public function __construct(
public string $string = 'xyz',
public float $float = 0.777,
public int $int = 3,
public bool $bool = false,
) {}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class OptionalNullableData
{
use DataObject;
public function __construct(
public string $string,
public ?int $int,
public float $float = 0.999,
public bool $bool = false,
) {}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class PrimitiveData
{
use DataObject;
public function __construct(
public string $string,
public int $int,
public float $float,
public bool $bool,
) {}
}

View file

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Icefox\DTO\DataObject;
readonly class RecursiveDataObject
{
use DataObject;
public function __construct(
public string $string,
public PrimitiveData $extra,
) {}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Classes;
use Carbon\CarbonPeriodImmutable;
use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\DataObject;
readonly class WithMapperObject
{
use DataObject;
public function __construct(
#[CastWith(CarbonPeriodMapper::class)]
public CarbonPeriodImmutable $period,
) {}
}

View file

@ -0,0 +1,127 @@
<?php
namespace Tests\DataObject;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\DataObjectFactory;
use Illuminate\Support\Collection;
use Psr\Log\NullLogger;
readonly class Element
{
public function __construct(public int $value) {}
}
readonly class Node
{
public function __construct(public Element $element) {}
}
test('basic nested object', function () {
$input = DataObjectFactory::mapInput(Node::class, ['element' => ['value' => 1 ] ], [], new NullLogger());
expect($input)->toBe(['element' => ['value' => 1]]);
});
readonly class MappedElement
{
public function __construct(#[FromInput('name')] public int $value) {}
}
readonly class MappedNode
{
public function __construct(public MappedElement $element) {}
}
test('basic nested input map', function () {
$input = DataObjectFactory::mapInput(MappedNode::class, ['element' => ['name' => 1 ] ], [], new NullLogger());
expect($input)->toBe(['element' => ['value' => 1]]);
});
readonly class MappedCollectionItem
{
public function __construct(#[FromInput('id_item')] public int $idItem) {}
}
readonly class MappedCollectionRoot
{
/**
* @param Collection<int, MappedCollectionItem> $items
*/
public function __construct(public string $text, #[FromInput('data')] public Collection $items) {}
}
test('using from input nested', function () {
$mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [
'text' => 'abc',
'data' => [
[ 'id_item' => 1 ],
[ 'id_item' => 2 ],
[ 'id_item' => 4 ],
[ 'id_item' => 8 ],
],
], [], new NullLogger());
expect($mapped)->toBe([
'text' => 'abc',
'items' => [
[ 'idItem' => 1 ],
[ 'idItem' => 2 ],
[ 'idItem' => 4 ],
[ 'idItem' => 8 ],
],
]);
});
readonly class CollectionRoot
{
/**
* @param Collection<int, MappedCollectionItem> $items
*/
public function __construct(public string $text, public Collection $items) {}
}
test('using from input', function () {
$mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [
'text' => 'abc',
'items' => [
[ 'id_item' => 1 ],
[ 'id_item' => 2 ],
[ 'id_item' => 4 ],
[ 'id_item' => 8 ],
],
], [], new NullLogger());
expect($mapped)->toBe([
'text' => 'abc',
'items' => [
[ 'idItem' => 1 ],
[ 'idItem' => 2 ],
[ 'idItem' => 4 ],
[ 'idItem' => 8 ],
],
]);
});
readonly class AnnotatedArrayItem
{
public function __construct(#[FromInput('name')] public float $value) {}
}
readonly class AnnotatedArray
{
/**
* @param array<int,AnnotatedArrayItem> $items
*/
public function __construct(public array $items) {}
}
test('annotated array', function () {
$mapped = DataObjectFactory::mapInput(
AnnotatedArray::class,
['items' => [['name' => 1], ['name' => 2]]],
[],
new NullLogger(),
);
expect($mapped)->toBe(['items' => [['value' => 1], ['value' => 2]]]);
});

View file

@ -1,44 +0,0 @@
<?php
namespace Tests;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\DataObjectFactory;
use Illuminate\Support\Collection;
use Psr\Log\NullLogger;
readonly class MappedCollectionItem
{
public function __construct(#[FromInput('id_item')] public int $idItem) {}
}
readonly class MappedCollectionRoot
{
/**
* @param Collection<int, MappedCollectionItem> $items
*/
public function __construct(public string $text, #[FromInput('data')] public Collection $items) {}
}
test('using from input', function () {
$mapped = DataObjectFactory::mapInput(MappedCollectionRoot::class, [
'text' => 'abc',
'data' => [
[ 'id_item' => 1 ],
[ 'id_item' => 2 ],
[ 'id_item' => 4 ],
[ 'id_item' => 8 ],
],
], [], new NullLogger());
var_dump($mapped);
expect($mapped)->toBe([
'text' => 'abc',
'items' => [
[ 'idItem' => 1 ],
[ 'idItem' => 2 ],
[ 'idItem' => 4 ],
[ 'idItem' => 8 ],
],
]);
});

View file

@ -1,108 +0,0 @@
<?php
namespace Tests\FailedValidation;
use Illuminate\Validation\ValidationException;
use Tests\Classes\FailsReturnsDefault;
use Tests\Classes\FailsReturnsNull;
use Tests\Classes\FailsWithHttpResponse;
use Tests\Classes\PrimitiveData;
describe('fails method behavior', function () {
it('throws ValidationException when class does not implement fails()', function () {
expect(function () {
PrimitiveData::fromArray([
'int' => 0,
'float' => 3.14,
'bool' => true,
]);
})->toThrow(ValidationException::class);
});
it('returns null when fails() returns null', function () {
$result = FailsReturnsNull::fromArray([
'int' => 0,
]);
expect($result)->toBeNull();
});
it('returns static instance when fails() returns an object', function () {
$result = FailsReturnsDefault::fromArray([
'int' => 0,
]);
expect($result)->toBeInstanceOf(FailsReturnsDefault::class);
expect($result->string)->toBe('default_value');
expect($result->int)->toBe(42);
});
});
describe('HTTP request handling', function () {
beforeEach(function () {
\Illuminate\Support\Facades\Route::post('/test-validation-exception', function () {
PrimitiveData::fromArray([
'int' => 0,
'float' => 3.14,
'bool' => true,
]);
return response()->json(['success' => true]);
});
\Illuminate\Support\Facades\Route::post('/test-http-response-exception', function () {
FailsWithHttpResponse::fromArray([
'int' => 0,
]);
return response()->json(['success' => true]);
});
\Illuminate\Support\Facades\Route::post('/test-validation-exception-html', function () {
PrimitiveData::fromArray([
'int' => 0,
'float' => 3.14,
'bool' => true,
]);
return response('success');
});
});
it('returns 422 with errors when ValidationException is thrown in JSON request', function () {
$response = $this->postJson('/test-validation-exception', [
'int' => 0,
'float' => 3.14,
'bool' => true,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['string']);
});
it('returns custom JSON response when HttpResponseException is thrown', function () {
$response = $this->postJson('/test-http-response-exception', [
'int' => 0,
]);
$response->assertStatus(422);
$response->assertJsonStructure(['errors']);
$response->assertJsonFragment([
'errors' => [
'string' => ['The string field is required.'],
],
]);
});
it('redirects back with session errors when ValidationException is thrown in text/html request', function () {
$response = $this->post('/test-validation-exception-html', [
'int' => 0,
'float' => 3.14,
'bool' => true,
], [
'Accept' => 'text/html',
]);
$response->assertRedirect();
$response->assertSessionHasErrors(['string']);
});
});

View file

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Logging;
use Psr\Log\AbstractLogger;
class CustomLogger extends AbstractLogger
{
public array $logs = [];
public function log($level, string|\Stringable $message, array $context = []): void
{
$this->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 = [];
}
}

View file

@ -1,281 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Logging;
use Icefox\DTO\Log;
use Psr\Log\LogLevel;
use Psr\Log\NullLogger;
use Tests\Classes\PrimitiveData;
use Tests\TestCase;
describe('logger resolution', function () {
afterEach(function () {
config()->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');
});
});

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Tests;
namespace Tests\Rules;
use Icefox\DTO\Attributes\Flat;
use Icefox\DTO\Attributes\Overwrite;

View file

@ -2,14 +2,10 @@
declare(strict_types=1);
namespace Tests;
namespace Tests\Values;
use Carbon\CarbonPeriod;
use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\Attributes\Flat;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\Overwrite;
use Icefox\DTO\RuleFactory;
use Icefox\DTO\ValueFactory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;

View file

@ -17,4 +17,7 @@ return [
'internals' => LogLevel::DEBUG,
],
],
'listTypes' => [
Collection::class,
],
];