http tests

This commit is contained in:
icefox 2026-02-27 11:14:42 -03:00
parent b827038df3
commit 30706c3521
No known key found for this signature in database
8 changed files with 216 additions and 214 deletions

View file

@ -9,7 +9,7 @@ use Illuminate\Http\Request;
trait DataObject trait DataObject
{ {
public static function fromRequest(Request $request): mixed public static function fromRequest(Request $request): ?static
{ {
return DataObjectFactory::fromRequest(static::class, $request); return DataObjectFactory::fromRequest(static::class, $request);
} }

View file

@ -118,14 +118,18 @@ class DataObjectFactory
} }
if ($reflectionType instanceof ReflectionNamedType) { if ($reflectionType instanceof ReflectionNamedType) {
$input[$parameterName] = $reflectionType->isBuiltin() if ($reflectionType->isBuiltin()) {
? $rawInput[$parameterName] if (array_key_exists($parameterName, $rawInput)) {
: self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger); $input[$parameterName] = $rawInput[$parameterName];
}
} else {
$input[$parameterName] = self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger);
}
continue; continue;
} }
if (array_key_exists($parameterName, $rawInput)) {
$input[$parameterName] = $rawInput[$parameterName]; $input[$parameterName] = $rawInput[$parameterName];
}
} }
$logger->debug('input', $input); $logger->debug('input', $input);
return $input; return $input;

View file

@ -137,6 +137,6 @@ class ValueFactory
$arguments[$name] = $parameterArgs; $arguments[$name] = $parameterArgs;
} }
return App::makeWith($class, $arguments); return new $class(...$arguments);
} }
} }

6
src/IDataObject.php Normal file
View file

@ -0,0 +1,6 @@
<?php
namespace Icefox\DTO;
interface IDataObject {}

View file

@ -0,0 +1,23 @@
<?php
namespace Icefox\DTO\Providers;
use Icefox\DTO\Factories\DataObjectFactory;
use Icefox\DTO\IDataObject;
use Illuminate\Support\ServiceProvider;
class DataObjectServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->beforeResolving(function ($abstract, $parameters, $app) {
if ($app->has($abstract)) {
return;
}
if (is_subclass_of($abstract, IDataObject::class)) {
$app->bind($abstract, fn($container) => DataObjectFactory::fromRequest($abstract, $container['request']));
}
});
}
}

View file

@ -3,6 +3,7 @@
namespace Tests\DataObject; namespace Tests\DataObject;
use Icefox\DTO\Attributes\FromInput; use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\FromRouteParameter;
use Icefox\DTO\Factories\DataObjectFactory; use Icefox\DTO\Factories\DataObjectFactory;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
@ -125,3 +126,70 @@ test('annotated array', function () {
expect($mapped)->toBe(['items' => [['value' => 1], ['value' => 2]]]); expect($mapped)->toBe(['items' => [['value' => 1], ['value' => 2]]]);
}); });
test('route parameter priority over from input', function () {
$dto = new class (123) {
public function __construct(
#[FromRouteParameter('user_id')]
#[FromInput('user_id')]
public int $id,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['user_id' => 456],
['user_id' => 123],
new \Psr\Log\NullLogger(),
);
expect($result['id'])->toBe(123);
});
test('multiple route parameters', function () {
$dto = new class (45, 89, 'A') {
public function __construct(
#[FromRouteParameter('course_id')]
public int $courseId,
#[FromRouteParameter('student_id')]
public int $studentId,
#[FromInput('grade')]
public string $grade,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['grade' => 'A'],
['course_id' => 45, 'student_id' => 89],
new \Psr\Log\NullLogger(),
);
expect($result['courseId'])->toBe(45)
->and($result['studentId'])->toBe(89)
->and($result['grade'])->toBe('A');
});
test('route parameter with nested object', function () {
$addressDto = new class ('Main St') {
public function __construct(public string $street) {}
};
$dto = new class (123, $addressDto) {
public function __construct(
#[FromRouteParameter('user_id')]
public int $userId,
public $address,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['user_id' => 456, 'address' => ['street' => 'Main St']],
['user_id' => 123],
new \Psr\Log\NullLogger(),
);
expect($result['userId'])->toBe(123)
->and($result['address']['street'])->toBe('Main St');
});

107
tests/Http/RequestTest.php Normal file
View file

@ -0,0 +1,107 @@
<?php
namespace Tests\Http;
use Icefox\DTO\IDataObject;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
use Illuminate\Validation\Validator;
readonly class Basic implements IDataObject
{
public string $reply;
public function __construct(string $message)
{
$this->reply = $message == 'ping' ? 'pong' : 'unknown';
}
}
test('injects data object', function () {
Route::post('/', fn(Basic $object) => ['reply' => $object->reply]);
/** @var \Tests\TestCase $this */
$this->postJson('/', ['message' => 'ping'])->assertJson(['reply' => 'pong']);
});
test('fails on validation error', function () {
Route::post('/', fn(Basic $object) => ['reply' => $object->reply]);
/** @var \Tests\TestCase $this */
$resp = $this->postJson('/', [])
->assertStatus(422)
->assertJson([
'message' => 'The message field is required.',
'errors' => ['message' => ['The message field is required.']],
]);
});
readonly class WithCustomValidator implements IDataObject
{
public string $reply;
public function __construct(string $message)
{
$this->reply = $message == 'ping' ? 'pong' : 'unknown';
}
/**
* @param array<string,mixed> $data
* @param array<string,mixed> $rules
*/
public static function withValidator(array $data, array $rules): Validator
{
return ValidatorFacade::make($data, $rules, $messages = [
'message.required' => 'the known message is pong',
]);
}
}
test('replies with custom validator', function () {
Route::post('/', fn(WithCustomValidator $object) => []);
/** @var \Tests\TestCase $this */
$this->postJson('/', [])
->assertStatus(422)
->assertJson([
'message' => 'the known message is pong',
'errors' => ['message' => ['the known message is pong']],
]);
});
readonly class WithCustomFailure implements IDataObject
{
public function __construct(public bool $flag) {}
public static function fails(Validator $validator): void
{
throw new HttpResponseException(
response(['result' => 'invalid, but that is ok' ], 202),
);
}
}
test('uses custom response', function () {
Route::post('/', fn(WithCustomFailure $object) => response('', 204));
/** @var \Tests\TestCase $this */
$this->postJson('/', [])
->assertStatus(202)
->assertJson(['result' => 'invalid, but that is ok']);
});
readonly class WithDefaultObjectOnFailure implements IDataObject
{
public function __construct(public bool $flag) {}
public static function fails(): self
{
return new self(false);
}
}
test('uses default object on failure', function () {
Route::post('/', fn(WithDefaultObjectOnFailure $object) => response(['flag' => $object->flag], 200));
/** @var \Tests\TestCase $this */
$this->postJson('/', [])
->assertStatus(200)
->assertJson(['flag' => false]);
});

View file

@ -1,206 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Http;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\FromRouteParameter;
use Icefox\DTO\Factories\DataObjectFactory;
use Icefox\DTO\Factories\ValueFactory;
use Illuminate\Routing\Route;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
readonly class SimplePostDTO
{
public function __construct(
public string $title,
public string $content,
) {}
}
test('successful validation returns 200 equivalent', function () {
$dto = DataObjectFactory::fromArray(SimplePostDTO::class, [
'title' => 'Test Title',
'content' => 'Test Content',
], []);
expect($dto)->toBeInstanceOf(SimplePostDTO::class)
->and($dto->title)->toBe('Test Title')
->and($dto->content)->toBe('Test Content');
});
test('failed validation throws validation exception', function () {
try {
DataObjectFactory::fromArray(SimplePostDTO::class, [
'title' => 'Test',
'content' => [],
], []);
} catch (\Throwable $e) {
expect($e)->toBeInstanceOf(ValidationException::class);
return;
}
throw new \Exception('Should have thrown an exception');
});
readonly class PostDTOWithNumeric
{
public function __construct(
public string $title,
public int $views,
) {}
}
test('failed validation returns proper error format', function () {
try {
DataObjectFactory::fromArray(PostDTOWithNumeric::class, [
'title' => 'Test',
'views' => 'not-a-number',
], []);
} catch (ValidationException $e) {
$errors = $e->validator->errors();
expect($errors->has('views'))->toBeTrue()
->and($errors->first('views'))->toContain('number');
}
});
test('validation error message format matches laravel', function () {
try {
DataObjectFactory::fromArray(PostDTOWithNumeric::class, [
'title' => 'Test',
'views' => 'invalid',
], []);
} catch (ValidationException $e) {
$errors = $e->validator->errors()->toArray();
expect($errors)->toHaveKey('views')
->and($errors['views'][0])->toContain('views');
}
});
readonly class RestrictedDTO
{
public function __construct(public string $content) {}
public static function fails($validator): object
{
return (object) [
'error' => 'Access denied',
'details' => $validator->errors()->toArray(),
'status' => 403,
];
}
}
test('custom fails returns custom response structure', function () {
$result = DataObjectFactory::fromArray(RestrictedDTO::class, [
'content' => [], // invalid - must be string
], []);
expect($result->error)->toBe('Access denied')
->and($result->details)->toHaveKey('content')
->and($result->status)->toBe(403);
});
readonly class CustomBagDTO
{
public string $name;
public float $price;
public function __construct(string $name, float $price)
{
$this->name = $name;
$this->price = $price;
}
public static function rules(): array
{
return [
'name' => ['required', 'min:3'],
'price' => ['required', 'numeric', 'min:0'],
];
}
}
test('named error bag test', function () {
try {
DataObjectFactory::fromArray(CustomBagDTO::class, [
'name' => 'ab',
'price' => -10,
], []);
} catch (ValidationException $e) {
$errors = $e->validator->errors()->toArray();
expect($errors)->toHaveKey('name')
->and($errors)->toHaveKey('price');
}
});
test('route parameter priority over from input', function () {
$dto = new class (123) {
public function __construct(
#[FromRouteParameter('user_id')]
#[FromInput('user_id')]
public int $id,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['user_id' => 456],
['user_id' => 123],
new \Psr\Log\NullLogger(),
);
expect($result['id'])->toBe(123);
});
test('multiple route parameters', function () {
$dto = new class (45, 89, 'A') {
public function __construct(
#[FromRouteParameter('course_id')]
public int $courseId,
#[FromRouteParameter('student_id')]
public int $studentId,
#[FromInput('grade')]
public string $grade,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['grade' => 'A'],
['course_id' => 45, 'student_id' => 89],
new \Psr\Log\NullLogger(),
);
expect($result['courseId'])->toBe(45)
->and($result['studentId'])->toBe(89)
->and($result['grade'])->toBe('A');
});
test('route parameter with nested object', function () {
$addressDto = new class ('Main St') {
public function __construct(public string $street) {}
};
$dto = new class (123, $addressDto) {
public function __construct(
#[FromRouteParameter('user_id')]
public int $userId,
public $address,
) {}
};
$result = DataObjectFactory::mapInput(
$dto::class,
['user_id' => 456, 'address' => ['street' => 'Main St']],
['user_id' => 123],
new \Psr\Log\NullLogger(),
);
expect($result['userId'])->toBe(123)
->and($result['address']['street'])->toBe('Main St');
});