http tests
This commit is contained in:
parent
b827038df3
commit
30706c3521
8 changed files with 216 additions and 214 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
6
src/IDataObject.php
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Icefox\DTO;
|
||||||
|
|
||||||
|
interface IDataObject {}
|
||||||
|
|
||||||
23
src/Providers/DataObjectServiceProvider.php
Normal file
23
src/Providers/DataObjectServiceProvider.php
Normal 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']));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
107
tests/Http/RequestTest.php
Normal 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]);
|
||||||
|
});
|
||||||
|
|
@ -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');
|
|
||||||
});
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue