From 1dcc3d1cd26497494f0492cee03c18e3cd344f2f Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Sat, 19 Aug 2023 15:30:35 +0100 Subject: [PATCH] feat: add attach relationship command --- .../AttachRelationshipImplementation.php | 59 ++++ src/Core/Auth/ResourceAuthorizer.php | 40 +++ .../AttachRelationshipCommand.php | 141 +++++++++ .../AttachRelationshipCommandHandler.php | 101 +++++++ .../HandlesAttachRelationshipCommands.php | 33 +++ .../AuthorizeAttachRelationshipCommand.php | 58 ++++ .../TriggerAttachRelationshipHooks.php | 63 +++++ src/Core/Bus/Commands/Dispatcher.php | 3 + .../ValidateRelationshipCommand.php | 3 +- src/Core/Http/Hooks/HooksImplementation.php | 35 ++- src/Core/Store/Store.php | 12 +- .../AttachRelationshipCommandHandlerTest.php | 167 +++++++++++ ...AuthorizeAttachRelationshipCommandTest.php | 257 +++++++++++++++++ .../TriggerAttachRelationshipHooksTest.php | 196 +++++++++++++ .../ValidateRelationshipCommandTest.php | 34 ++- .../Http/Hooks/HooksImplementationTest.php | 267 ++++++++++++++++++ 16 files changed, 1457 insertions(+), 12 deletions(-) create mode 100644 src/Contracts/Http/Hooks/AttachRelationshipImplementation.php create mode 100644 src/Core/Bus/Commands/AttachRelationship/AttachRelationshipCommand.php create mode 100644 src/Core/Bus/Commands/AttachRelationship/AttachRelationshipCommandHandler.php create mode 100644 src/Core/Bus/Commands/AttachRelationship/HandlesAttachRelationshipCommands.php create mode 100644 src/Core/Bus/Commands/AttachRelationship/Middleware/AuthorizeAttachRelationshipCommand.php create mode 100644 src/Core/Bus/Commands/AttachRelationship/Middleware/TriggerAttachRelationshipHooks.php create mode 100644 tests/Unit/Bus/Commands/AttachRelationship/AttachRelationshipCommandHandlerTest.php create mode 100644 tests/Unit/Bus/Commands/AttachRelationship/Middleware/AuthorizeAttachRelationshipCommandTest.php create mode 100644 tests/Unit/Bus/Commands/AttachRelationship/Middleware/TriggerAttachRelationshipHooksTest.php diff --git a/src/Contracts/Http/Hooks/AttachRelationshipImplementation.php b/src/Contracts/Http/Hooks/AttachRelationshipImplementation.php new file mode 100644 index 0000000..75c012b --- /dev/null +++ b/src/Contracts/Http/Hooks/AttachRelationshipImplementation.php @@ -0,0 +1,59 @@ +authorizer->attachRelationship( + $request, + $model, + $fieldName, + ); + + return $passes ? null : $this->failed(); + } + + /** + * Authorize a JSON:API attach relationship command, or fail. + * + * @param Request|null $request + * @param object $model + * @param string $fieldName + * @return void + * @throws AuthorizationException + * @throws AuthenticationException + * @throws HttpExceptionInterface + */ + public function attachRelationshipOrFail(?Request $request, object $model, string $fieldName): void + { + if ($errors = $this->attachRelationship($request, $model, $fieldName)) { + throw new JsonApiException($errors); + } + } + /** * @return ErrorList * @throws AuthorizationException diff --git a/src/Core/Bus/Commands/AttachRelationship/AttachRelationshipCommand.php b/src/Core/Bus/Commands/AttachRelationship/AttachRelationshipCommand.php new file mode 100644 index 0000000..f65bf56 --- /dev/null +++ b/src/Core/Bus/Commands/AttachRelationship/AttachRelationshipCommand.php @@ -0,0 +1,141 @@ +operation->isAttachingRelationship(), + 'Expecting a to-many operation that is to attach resources to a relationship.', + ); + + parent::__construct($request); + } + + /** + * @inheritDoc + * @TODO support operation with a href. + */ + public function type(): ResourceType + { + $type = $this->operation->ref()?->type; + + assert($type !== null, 'Expecting an update relationship operation with a ref.'); + + return $type; + } + + /** + * @inheritDoc + * @TODO support operation with a href + */ + public function id(): ResourceId + { + $id = $this->operation->ref()?->id; + + assert($id !== null, 'Expecting an update relationship operation with a ref that has an id.'); + + return $id; + } + + /** + * @inheritDoc + */ + public function fieldName(): string + { + $fieldName = $this->operation->ref()?->relationship ?? $this->operation->href()?->getRelationshipName(); + + assert( + is_string($fieldName), + 'Expecting update relationship operation to have a field name.', + ); + + return $fieldName; + } + + /** + * @inheritDoc + */ + public function operation(): UpdateToMany + { + return $this->operation; + } + + /** + * Set the hooks implementation. + * + * @param AttachRelationshipImplementation|null $hooks + * @return $this + */ + public function withHooks(?AttachRelationshipImplementation $hooks): self + { + $copy = clone $this; + $copy->hooks = $hooks; + + return $copy; + } + + /** + * @return AttachRelationshipImplementation|null + */ + public function hooks(): ?AttachRelationshipImplementation + { + return $this->hooks; + } +} diff --git a/src/Core/Bus/Commands/AttachRelationship/AttachRelationshipCommandHandler.php b/src/Core/Bus/Commands/AttachRelationship/AttachRelationshipCommandHandler.php new file mode 100644 index 0000000..4f0e82a --- /dev/null +++ b/src/Core/Bus/Commands/AttachRelationship/AttachRelationshipCommandHandler.php @@ -0,0 +1,101 @@ +pipelines + ->pipe($command) + ->through($pipes) + ->via('handle') + ->then(fn (AttachRelationshipCommand $cmd): Result => $this->handle($cmd)); + + if ($result instanceof Result) { + return $result; + } + + throw new UnexpectedValueException('Expecting pipeline to return a command result.'); + } + + /** + * Handle the command. + * + * @param AttachRelationshipCommand $command + * @return Result + */ + private function handle(AttachRelationshipCommand $command): Result + { + $fieldName = $command->fieldName(); + $validated = $command->validated(); + + Contracts::assert( + array_key_exists($fieldName, $validated), + sprintf('Relation %s must have a validation rule so that it is validated.', $fieldName) + ); + + $input = $validated[$command->fieldName()]; + $model = $command->modelOrFail(); + + $result = $this->store + ->modifyToMany($command->type(), $model, $fieldName) + ->withRequest($command->request()) + ->attach($input); + + return Result::ok(new Payload($result, true)); + } +} diff --git a/src/Core/Bus/Commands/AttachRelationship/HandlesAttachRelationshipCommands.php b/src/Core/Bus/Commands/AttachRelationship/HandlesAttachRelationshipCommands.php new file mode 100644 index 0000000..e167648 --- /dev/null +++ b/src/Core/Bus/Commands/AttachRelationship/HandlesAttachRelationshipCommands.php @@ -0,0 +1,33 @@ +mustAuthorize()) { + $errors = $this->authorizerFactory + ->make($command->type()) + ->attachRelationship($command->request(), $command->modelOrFail(), $command->fieldName()); + } + + if ($errors) { + return Result::failed($errors); + } + + return $next($command); + } +} diff --git a/src/Core/Bus/Commands/AttachRelationship/Middleware/TriggerAttachRelationshipHooks.php b/src/Core/Bus/Commands/AttachRelationship/Middleware/TriggerAttachRelationshipHooks.php new file mode 100644 index 0000000..8fd8ad8 --- /dev/null +++ b/src/Core/Bus/Commands/AttachRelationship/Middleware/TriggerAttachRelationshipHooks.php @@ -0,0 +1,63 @@ +hooks(); + + if ($hooks === null) { + return $next($command); + } + + $request = $command->request() ?? throw new RuntimeException('Hooks require a request to be set.'); + $query = $command->query() ?? throw new RuntimeException('Hooks require a query to be set.'); + $model = $command->modelOrFail(); + $fieldName = $command->fieldName(); + + $hooks->attachingRelationship($model, $fieldName, $request, $query); + + /** @var Result $result */ + $result = $next($command); + + if ($result->didSucceed()) { + $hooks->attachedRelationship( + $model, + $fieldName, + $result->payload()->data, + $request, + $query, + ); + } + + return $result; + } +} diff --git a/src/Core/Bus/Commands/Dispatcher.php b/src/Core/Bus/Commands/Dispatcher.php index 65d970c..872d348 100644 --- a/src/Core/Bus/Commands/Dispatcher.php +++ b/src/Core/Bus/Commands/Dispatcher.php @@ -21,6 +21,8 @@ use Illuminate\Contracts\Container\Container; use LaravelJsonApi\Contracts\Bus\Commands\Dispatcher as DispatcherContract; +use LaravelJsonApi\Core\Bus\Commands\AttachRelationship\AttachRelationshipCommand; +use LaravelJsonApi\Core\Bus\Commands\AttachRelationship\AttachRelationshipCommandHandler; use LaravelJsonApi\Core\Bus\Commands\Command\Command; use LaravelJsonApi\Core\Bus\Commands\Destroy\DestroyCommand; use LaravelJsonApi\Core\Bus\Commands\Destroy\DestroyCommandHandler; @@ -75,6 +77,7 @@ private function handlerFor(string $commandClass): string UpdateCommand::class => UpdateCommandHandler::class, DestroyCommand::class => DestroyCommandHandler::class, UpdateRelationshipCommand::class => UpdateRelationshipCommandHandler::class, + AttachRelationshipCommand::class => AttachRelationshipCommandHandler::class, default => throw new RuntimeException('Unexpected command class: ' . $commandClass), }; } diff --git a/src/Core/Bus/Commands/Middleware/ValidateRelationshipCommand.php b/src/Core/Bus/Commands/Middleware/ValidateRelationshipCommand.php index 1f932ee..7563035 100644 --- a/src/Core/Bus/Commands/Middleware/ValidateRelationshipCommand.php +++ b/src/Core/Bus/Commands/Middleware/ValidateRelationshipCommand.php @@ -24,6 +24,7 @@ use LaravelJsonApi\Contracts\Validation\Container as ValidatorContainer; use LaravelJsonApi\Contracts\Validation\RelationshipValidator; use LaravelJsonApi\Contracts\Validation\ResourceErrorFactory; +use LaravelJsonApi\Core\Bus\Commands\AttachRelationship\AttachRelationshipCommand; use LaravelJsonApi\Core\Bus\Commands\Result; use LaravelJsonApi\Core\Bus\Commands\UpdateRelationship\HandlesUpdateRelationshipCommands; use LaravelJsonApi\Core\Bus\Commands\UpdateRelationship\UpdateRelationshipCommand; @@ -48,7 +49,7 @@ public function __construct( /** * @inheritDoc */ - public function handle(UpdateRelationshipCommand $command, Closure $next): Result + public function handle(UpdateRelationshipCommand|AttachRelationshipCommand $command, Closure $next): Result { if ($command->mustValidate()) { $validator = $this diff --git a/src/Core/Http/Hooks/HooksImplementation.php b/src/Core/Http/Hooks/HooksImplementation.php index 404a3f5..12b8aca 100644 --- a/src/Core/Http/Hooks/HooksImplementation.php +++ b/src/Core/Http/Hooks/HooksImplementation.php @@ -22,6 +22,7 @@ use Illuminate\Contracts\Support\Responsable; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; +use LaravelJsonApi\Contracts\Http\Hooks\AttachRelationshipImplementation; use LaravelJsonApi\Contracts\Http\Hooks\DestroyImplementation; use LaravelJsonApi\Contracts\Http\Hooks\IndexImplementation; use LaravelJsonApi\Contracts\Http\Hooks\ShowImplementation; @@ -43,7 +44,8 @@ class HooksImplementation implements DestroyImplementation, ShowRelatedImplementation, ShowRelationshipImplementation, - UpdateRelationshipImplementation + UpdateRelationshipImplementation, + AttachRelationshipImplementation { /** * HooksImplementation constructor @@ -280,4 +282,35 @@ public function updatedRelationship( $this($method, $model, $related, $request, $query); } + + /** + * @inheritDoc + */ + public function attachingRelationship( + object $model, + string $fieldName, + Request $request, + QueryParameters $query, + ): void + { + $method = 'attaching' . Str::classify($fieldName); + + $this($method, $model, $request, $query); + } + + /** + * @inheritDoc + */ + public function attachedRelationship( + object $model, + string $fieldName, + mixed $related, + Request $request, + QueryParameters $query, + ): void + { + $method = 'attached' . Str::classify($fieldName); + + $this($method, $model, $related, $request, $query); + } } diff --git a/src/Core/Store/Store.php b/src/Core/Store/Store.php index db41893..b7f25eb 100644 --- a/src/Core/Store/Store.php +++ b/src/Core/Store/Store.php @@ -221,7 +221,11 @@ public function delete(ResourceType|string $resourceType, $modelOrResourceId): v /** * @inheritDoc */ - public function modifyToOne(string $resourceType, $modelOrResourceId, string $fieldName): ToOneBuilder + public function modifyToOne( + ResourceType|string $resourceType, + $modelOrResourceId, + string $fieldName, + ): ToOneBuilder { $repository = $this->resources($resourceType); @@ -235,7 +239,11 @@ public function modifyToOne(string $resourceType, $modelOrResourceId, string $fi /** * @inheritDoc */ - public function modifyToMany(string $resourceType, $modelOrResourceId, string $fieldName): ToManyBuilder + public function modifyToMany( + ResourceType|string $resourceType, + $modelOrResourceId, + string $fieldName, + ): ToManyBuilder { $repository = $this->resources($resourceType); diff --git a/tests/Unit/Bus/Commands/AttachRelationship/AttachRelationshipCommandHandlerTest.php b/tests/Unit/Bus/Commands/AttachRelationship/AttachRelationshipCommandHandlerTest.php new file mode 100644 index 0000000..3a2c854 --- /dev/null +++ b/tests/Unit/Bus/Commands/AttachRelationship/AttachRelationshipCommandHandlerTest.php @@ -0,0 +1,167 @@ +handler = new AttachRelationshipCommandHandler( + $this->pipelineFactory = $this->createMock(PipelineFactory::class), + $this->store = $this->createMock(StoreContract::class), + ); + } + + /** + * @return void + */ + public function test(): void + { + $operation = new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: new ResourceType('posts'), id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ); + + $original = new AttachRelationshipCommand( + $request = $this->createMock(Request::class), + $operation, + ); + + $validated = [ + 'tags' => [ + ['type' => 'tags', 'id' => '1'], + ['type' => 'tags', 'id' => '2'], + ], + ]; + + $passed = AttachRelationshipCommand::make($request, $operation) + ->withModel($model = new stdClass()) + ->withValidated($validated); + + $sequence = []; + + $this->pipelineFactory + ->expects($this->once()) + ->method('pipe') + ->with($this->identicalTo($original)) + ->willReturn($pipeline = $this->createMock(Pipeline::class)); + + $pipeline + ->expects($this->once()) + ->method('through') + ->willReturnCallback(function (array $actual) use (&$sequence, $pipeline): Pipeline { + $sequence[] = 'through'; + $this->assertSame([ + SetModelIfMissing::class, + AuthorizeAttachRelationshipCommand::class, + ValidateRelationshipCommand::class, + TriggerAttachRelationshipHooks::class, + ], $actual); + return $pipeline; + }); + + $pipeline + ->expects($this->once()) + ->method('via') + ->with('handle') + ->willReturnCallback(function () use (&$sequence, $pipeline): Pipeline { + $sequence[] = 'via'; + return $pipeline; + }); + + $pipeline + ->expects($this->once()) + ->method('then') + ->willReturnCallback(function (\Closure $fn) use ($passed, &$sequence): Result { + $this->assertSame(['through', 'via'], $sequence); + return $fn($passed); + }); + + $this->store + ->expects($this->once()) + ->method('modifyToMany') + ->with($this->identicalTo($passed->type()), $this->identicalTo($model), 'tags') + ->willReturn($builder = $this->createMock(ToManyBuilder::class)); + + $builder + ->expects($this->once()) + ->method('withRequest') + ->with($this->identicalTo($request)) + ->willReturnSelf(); + + $builder + ->expects($this->once()) + ->method('attach') + ->with($this->identicalTo($validated['tags'])) + ->willReturn($expected = new ArrayObject()); + + $payload = $this->handler + ->execute($original) + ->payload(); + + $this->assertTrue($payload->hasData); + $this->assertSame($expected, $payload->data); + $this->assertEmpty($payload->meta); + } +} diff --git a/tests/Unit/Bus/Commands/AttachRelationship/Middleware/AuthorizeAttachRelationshipCommandTest.php b/tests/Unit/Bus/Commands/AttachRelationship/Middleware/AuthorizeAttachRelationshipCommandTest.php new file mode 100644 index 0000000..4c19491 --- /dev/null +++ b/tests/Unit/Bus/Commands/AttachRelationship/Middleware/AuthorizeAttachRelationshipCommandTest.php @@ -0,0 +1,257 @@ +type = new ResourceType('posts'); + + $this->middleware = new AuthorizeAttachRelationshipCommand( + $this->authorizerFactory = $this->createMock(ResourceAuthorizerFactory::class), + ); + } + + /** + * @return void + */ + public function testItPassesAuthorizationWithRequest(): void + { + $command = AttachRelationshipCommand::make( + $request = $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: $this->type, id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ), + )->withModel($model = new stdClass()); + + $this->willAuthorize($request, $model, 'tags', null); + + $expected = Result::ok(); + + $actual = $this->middleware->handle($command, function ($cmd) use ($command, $expected): Result { + $this->assertSame($command, $cmd); + return $expected; + }); + + $this->assertSame($expected, $actual); + } + + /** + * @return void + */ + public function testItPassesAuthorizationWithoutRequest(): void + { + $command = AttachRelationshipCommand::make( + null, + new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: $this->type, id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ), + )->withModel($model = new stdClass()); + + $this->willAuthorize(null, $model, 'tags', null); + + $expected = Result::ok(); + + $actual = $this->middleware->handle($command, function ($cmd) use ($command, $expected): Result { + $this->assertSame($command, $cmd); + return $expected; + }); + + $this->assertSame($expected, $actual); + } + + /** + * @return void + */ + public function testItFailsAuthorizationWithException(): void + { + $command = AttachRelationshipCommand::make( + $request = $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: $this->type, id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ), + )->withModel($model = new stdClass()); + + $this->willAuthorizeAndThrow( + $request, + $model, + 'tags', + $expected = new AuthorizationException('Boom!'), + ); + + try { + $this->middleware->handle( + $command, + fn() => $this->fail('Expecting next middleware to not be called.'), + ); + $this->fail('Middleware did not throw an exception.'); + } catch (AuthorizationException $actual) { + $this->assertSame($expected, $actual); + } + } + + /** + * @return void + */ + public function testItFailsAuthorizationWithErrorList(): void + { + $command = AttachRelationshipCommand::make( + $request = $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: $this->type, id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ), + )->withModel($model = new stdClass()); + + $this->willAuthorize($request, $model, 'tags', $expected = new ErrorList()); + + $result = $this->middleware->handle( + $command, + fn() => $this->fail('Expecting next middleware not to be called.'), + ); + + $this->assertTrue($result->didFail()); + $this->assertSame($expected, $result->errors()); + } + + /** + * @return void + */ + public function testItSkipsAuthorization(): void + { + $command = AttachRelationshipCommand::make( + $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: $this->type, id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ), + )->withModel(new stdClass())->skipAuthorization(); + + + $this->authorizerFactory + ->expects($this->never()) + ->method($this->anything()); + + $expected = Result::ok(); + + $actual = $this->middleware->handle($command, function ($cmd) use ($command, $expected): Result { + $this->assertSame($command, $cmd); + return $expected; + }); + + $this->assertSame($expected, $actual); + } + + /** + * @param Request|null $request + * @param stdClass $model + * @param string $fieldName + * @param ErrorList|null $expected + * @return void + */ + private function willAuthorize(?Request $request, stdClass $model, string $fieldName, ?ErrorList $expected): void + { + $this->authorizerFactory + ->expects($this->once()) + ->method('make') + ->with($this->identicalTo($this->type)) + ->willReturn($authorizer = $this->createMock(ResourceAuthorizer::class)); + + $authorizer + ->expects($this->once()) + ->method('attachRelationship') + ->with($this->identicalTo($request), $this->identicalTo($model), $this->identicalTo($fieldName)) + ->willReturn($expected); + } + + /** + * @param Request|null $request + * @param stdClass $model + * @param string $fieldName + * @param AuthorizationException $expected + * @return void + */ + private function willAuthorizeAndThrow( + ?Request $request, + stdClass $model, + string $fieldName, + AuthorizationException $expected, + ): void + { + $this->authorizerFactory + ->expects($this->once()) + ->method('make') + ->with($this->identicalTo($this->type)) + ->willReturn($authorizer = $this->createMock(ResourceAuthorizer::class)); + + $authorizer + ->expects($this->once()) + ->method('attachRelationship') + ->with($this->identicalTo($request), $this->identicalTo($model), $this->identicalTo($fieldName)) + ->willThrowException($expected); + } +} diff --git a/tests/Unit/Bus/Commands/AttachRelationship/Middleware/TriggerAttachRelationshipHooksTest.php b/tests/Unit/Bus/Commands/AttachRelationship/Middleware/TriggerAttachRelationshipHooksTest.php new file mode 100644 index 0000000..7f053cf --- /dev/null +++ b/tests/Unit/Bus/Commands/AttachRelationship/Middleware/TriggerAttachRelationshipHooksTest.php @@ -0,0 +1,196 @@ +middleware = new TriggerAttachRelationshipHooks(); + } + + /** + * @return void + */ + public function testItHasNoHooks(): void + { + $command = AttachRelationshipCommand::make( + $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: new ResourceType('posts'), id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ), + )->withModel(new stdClass()); + + $expected = Result::ok(); + + $actual = $this->middleware->handle( + $command, + function (AttachRelationshipCommand $cmd) use ($command, $expected): Result { + $this->assertSame($command, $cmd); + return $expected; + }, + ); + + $this->assertSame($expected, $actual); + } + + /** + * @return void + */ + public function testItTriggersHooks(): void + { + $request = $this->createMock(Request::class); + $hooks = $this->createMock(AttachRelationshipImplementation::class); + $query = $this->createMock(QueryParameters::class); + $model = new stdClass(); + $related = new ArrayObject(); + $sequence = []; + + $operation = new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: new ResourceType('posts'), id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ); + + $command = AttachRelationshipCommand::make($request, $operation) + ->withModel($model) + ->withHooks($hooks) + ->withQuery($query); + + $hooks + ->expects($this->once()) + ->method('attachingRelationship') + ->willReturnCallback(function ($m, $f, $req, $q) use (&$sequence, $model, $request, $query): void { + $sequence[] = 'attaching'; + $this->assertSame($model, $m); + $this->assertSame('tags', $f); + $this->assertSame($request, $req); + $this->assertSame($query, $q); + }); + + $hooks + ->expects($this->once()) + ->method('attachedRelationship') + ->willReturnCallback( + function ($m, $f, $rel, $req, $q) use (&$sequence, $model, $related, $request, $query): void { + $sequence[] = 'attached'; + $this->assertSame($model, $m); + $this->assertSame('tags', $f); + $this->assertSame($related, $rel); + $this->assertSame($request, $req); + $this->assertSame($query, $q); + }, + ); + + $expected = Result::ok(new Payload($related, true)); + + $actual = $this->middleware->handle( + $command, + function (AttachRelationshipCommand $cmd) use ($command, $expected, &$sequence): Result { + $this->assertSame($command, $cmd); + $this->assertSame(['attaching'], $sequence); + return $expected; + }, + ); + + $this->assertSame($expected, $actual); + $this->assertSame(['attaching', 'attached'], $sequence); + } + + /** + * @return void + */ + public function testItDoesNotTriggerAfterHooksIfItFails(): void + { + $request = $this->createMock(Request::class); + $hooks = $this->createMock(AttachRelationshipImplementation::class); + $query = $this->createMock(QueryParameters::class); + $model = new stdClass(); + $sequence = []; + + $operation = new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: new ResourceType('posts'), id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ); + + $command = AttachRelationshipCommand::make($request, $operation) + ->withModel($model) + ->withHooks($hooks) + ->withQuery($query); + + $hooks + ->expects($this->once()) + ->method('attachingRelationship') + ->willReturnCallback(function ($m, $f, $req, $q) use (&$sequence, $model, $request, $query): void { + $sequence[] = 'attaching'; + $this->assertSame($model, $m); + $this->assertSame('tags', $f); + $this->assertSame($request, $req); + $this->assertSame($query, $q); + }); + + $hooks + ->expects($this->never()) + ->method('attachedRelationship'); + + $expected = Result::failed(); + + $actual = $this->middleware->handle( + $command, + function (AttachRelationshipCommand $cmd) use ($command, $expected, &$sequence): Result { + $this->assertSame($command, $cmd); + $this->assertSame(['attaching'], $sequence); + return $expected; + }, + ); + + $this->assertSame($expected, $actual); + $this->assertSame(['attaching'], $sequence); + } +} diff --git a/tests/Unit/Bus/Commands/Middleware/ValidateRelationshipCommandTest.php b/tests/Unit/Bus/Commands/Middleware/ValidateRelationshipCommandTest.php index aaed993..3c8165c 100644 --- a/tests/Unit/Bus/Commands/Middleware/ValidateRelationshipCommandTest.php +++ b/tests/Unit/Bus/Commands/Middleware/ValidateRelationshipCommandTest.php @@ -28,14 +28,18 @@ use LaravelJsonApi\Contracts\Validation\Factory; use LaravelJsonApi\Contracts\Validation\RelationshipValidator; use LaravelJsonApi\Contracts\Validation\ResourceErrorFactory; +use LaravelJsonApi\Core\Bus\Commands\AttachRelationship\AttachRelationshipCommand; use LaravelJsonApi\Core\Bus\Commands\Middleware\ValidateRelationshipCommand; use LaravelJsonApi\Core\Bus\Commands\Result; use LaravelJsonApi\Core\Bus\Commands\UpdateRelationship\UpdateRelationshipCommand; use LaravelJsonApi\Core\Document\ErrorList; +use LaravelJsonApi\Core\Document\Input\Values\ListOfResourceIdentifiers; use LaravelJsonApi\Core\Document\Input\Values\ResourceId; use LaravelJsonApi\Core\Document\Input\Values\ResourceIdentifier; use LaravelJsonApi\Core\Document\Input\Values\ResourceType; +use LaravelJsonApi\Core\Extensions\Atomic\Operations\UpdateToMany; use LaravelJsonApi\Core\Extensions\Atomic\Operations\UpdateToOne; +use LaravelJsonApi\Core\Extensions\Atomic\Values\OpCodeEnum; use LaravelJsonApi\Core\Extensions\Atomic\Values\Ref; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -112,14 +116,25 @@ function (ResourceType $type, Request $request = null): UpdateRelationshipComman new ResourceIdentifier(new ResourceType('users'), new ResourceId('456')), ); - return UpdateRelationshipCommand::make($request, $operation); + return new UpdateRelationshipCommand($request, $operation); + }, + ], + 'attach' => [ + function (ResourceType $type, Request $request = null): AttachRelationshipCommand { + $operation = new UpdateToMany( + OpCodeEnum::Add, + new Ref(type: $type, id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ); + + return new AttachRelationshipCommand($request, $operation); }, ], ]; } /** - * @param Closure $factory + * @param Closure(ResourceType, ?Request=): (UpdateRelationshipCommand|AttachRelationshipCommand) $factory * @return void * @dataProvider commandProvider */ @@ -153,7 +168,8 @@ public function testItPassesValidation(Closure $factory): void $actual = $this->middleware->handle( $command, - function (UpdateRelationshipCommand $cmd) use ($command, $validated, $expected): Result { + function (UpdateRelationshipCommand|AttachRelationshipCommand $cmd) + use ($command, $validated, $expected): Result { $this->assertNotSame($command, $cmd); $this->assertSame($validated, $cmd->validated()); return $expected; @@ -164,7 +180,7 @@ function (UpdateRelationshipCommand $cmd) use ($command, $validated, $expected): } /** - * @param Closure $factory + * @param Closure(ResourceType, ?Request=): (UpdateRelationshipCommand|AttachRelationshipCommand) $factory * @return void * @dataProvider commandProvider */ @@ -205,7 +221,7 @@ public function testItFailsValidation(Closure $factory): void } /** - * @param Closure $factory + * @param Closure(ResourceType, ?Request=): (UpdateRelationshipCommand|AttachRelationshipCommand) $factory * @return void * @dataProvider commandProvider */ @@ -229,7 +245,8 @@ public function testItSetsValidatedDataIfNotValidating(Closure $factory): void $actual = $this->middleware->handle( $command, - function (UpdateRelationshipCommand $cmd) use ($command, $validated, $expected): Result { + function (UpdateRelationshipCommand|AttachRelationshipCommand $cmd) + use ($command, $validated, $expected): Result { $this->assertNotSame($command, $cmd); $this->assertSame($validated, $cmd->validated()); return $expected; @@ -240,7 +257,7 @@ function (UpdateRelationshipCommand $cmd) use ($command, $validated, $expected): } /** - * @param Closure $factory + * @param Closure(ResourceType, ?Request=): (UpdateRelationshipCommand|AttachRelationshipCommand) $factory * @return void * @dataProvider commandProvider */ @@ -259,7 +276,8 @@ public function testItDoesNotValidateIfAlreadyValidated(Closure $factory): void $actual = $this->middleware->handle( $command, - function (UpdateRelationshipCommand $cmd) use ($command, $validated, $expected): Result { + function (UpdateRelationshipCommand|AttachRelationshipCommand $cmd) + use ($command, $validated, $expected): Result { $this->assertSame($command, $cmd); $this->assertSame($validated, $cmd->validated()); return $expected; diff --git a/tests/Unit/Http/Hooks/HooksImplementationTest.php b/tests/Unit/Http/Hooks/HooksImplementationTest.php index 193ae59..64a6a29 100644 --- a/tests/Unit/Http/Hooks/HooksImplementationTest.php +++ b/tests/Unit/Http/Hooks/HooksImplementationTest.php @@ -24,6 +24,7 @@ use Illuminate\Contracts\Support\Responsable; use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; +use LaravelJsonApi\Contracts\Http\Hooks\AttachRelationshipImplementation; use LaravelJsonApi\Contracts\Http\Hooks\DestroyImplementation; use LaravelJsonApi\Contracts\Http\Hooks\IndexImplementation; use LaravelJsonApi\Contracts\Http\Hooks\ShowImplementation; @@ -2271,4 +2272,270 @@ public function updatedTags( $this->assertSame($response, $ex->getResponse()); } } + + /** + * @return void + */ + public function testItInvokesAttachingRelationshipMethod(): void + { + $target = new class { + public ?stdClass $model = null; + public ?Request $request = null; + public ?QueryParameters $query = null; + + public function attachingBlogPosts( + stdClass $model, + Request $request, + QueryParameters $query, + ): void + { + $this->model = $model; + $this->request = $request; + $this->query = $query; + } + }; + + $model = new stdClass(); + + $implementation = new HooksImplementation($target); + $implementation->attachingRelationship($model, 'blog-posts', $this->request, $this->query); + + $this->assertInstanceOf(AttachRelationshipImplementation::class, $implementation); + $this->assertSame($model, $target->model); + $this->assertSame($this->request, $target->request); + $this->assertSame($this->query, $target->query); + } + + /** + * @return void + */ + public function testItInvokesAttachingRelationshipMethodAndThrowsResponse(): void + { + $response = $this->createMock(Response::class); + + $target = new class($response) { + public ?stdClass $model = null; + public ?Request $request = null; + public ?QueryParameters $query = null; + + public function __construct(private readonly Response $response) + { + } + + public function attachingComments( + stdClass $model, + Request $request, + QueryParameters $query, + ): Response + { + $this->model = $model; + $this->request = $request; + $this->query = $query; + + return $this->response; + } + }; + + $model = new stdClass(); + $implementation = new HooksImplementation($target); + + try { + $implementation->attachingRelationship($model, 'comments', $this->request, $this->query); + $this->fail('No exception thrown.'); + } catch (HttpResponseException $ex) { + $this->assertSame($model, $target->model); + $this->assertSame($this->request, $target->request); + $this->assertSame($this->query, $target->query); + $this->assertSame($response, $ex->getResponse()); + } + } + + /** + * @return void + */ + public function testItInvokesAttachingRelationshipMethodAndThrowsResponseFromResponsable(): void + { + $result = $this->createMock(Responsable::class); + $result + ->expects($this->once()) + ->method('toResponse') + ->with($this->identicalTo($this->request)) + ->willReturn($response = $this->createMock(Response::class)); + + $target = new class($result) { + public ?stdClass $model = null; + public ?Request $request = null; + public ?QueryParameters $query = null; + + public function __construct(private readonly Responsable $result) + { + } + + public function attachingTags( + stdClass $model, + Request $request, + QueryParameters $query, + ): Responsable + { + $this->model = $model; + $this->request = $request; + $this->query = $query; + + return $this->result; + } + }; + + $model = new stdClass(); + $implementation = new HooksImplementation($target); + + try { + $implementation->attachingRelationship($model, 'tags', $this->request, $this->query); + $this->fail('No exception thrown.'); + } catch (HttpResponseException $ex) { + $this->assertSame($model, $target->model); + $this->assertSame($this->request, $target->request); + $this->assertSame($this->query, $target->query); + $this->assertSame($response, $ex->getResponse()); + } + } + + /** + * @return void + */ + public function testItInvokesAttachedRelationshipMethod(): void + { + $target = new class { + public ?stdClass $model = null; + public ?ArrayObject $related = null; + public ?Request $request = null; + public ?QueryParameters $query = null; + + public function attachedBlogPosts( + stdClass $model, + ArrayObject $related, + Request $request, + QueryParameters $query, + ): void + { + $this->model = $model; + $this->related = $related; + $this->request = $request; + $this->query = $query; + } + }; + + $model = new stdClass(); + $related = new ArrayObject(); + + $implementation = new HooksImplementation($target); + $implementation->attachedRelationship($model, 'blog-posts', $related, $this->request, $this->query); + + $this->assertSame($model, $target->model); + $this->assertSame($related, $target->related); + $this->assertSame($this->request, $target->request); + $this->assertSame($this->query, $target->query); + } + + /** + * @return void + */ + public function testItInvokesAttachedRelationshipMethodAndThrowsResponse(): void + { + $response = $this->createMock(Response::class); + + $target = new class($response) { + public ?stdClass $model = null; + public ?ArrayObject $related = null; + public ?Request $request = null; + public ?QueryParameters $query = null; + + public function __construct(private readonly Response $response) + { + } + + public function attachedComments( + stdClass $model, + ArrayObject $related, + Request $request, + QueryParameters $query, + ): Response + { + $this->model = $model; + $this->related = $related; + $this->request = $request; + $this->query = $query; + + return $this->response; + } + }; + + $model = new stdClass(); + $related = new ArrayObject(); + $implementation = new HooksImplementation($target); + + try { + $implementation->attachedRelationship($model, 'comments', $related, $this->request, $this->query); + $this->fail('No exception thrown.'); + } catch (HttpResponseException $ex) { + $this->assertSame($model, $target->model); + $this->assertSame($related, $target->related); + $this->assertSame($this->request, $target->request); + $this->assertSame($this->query, $target->query); + $this->assertSame($response, $ex->getResponse()); + } + } + + /** + * @return void + */ + public function testItInvokesAttachedRelationshipMethodAndThrowsResponseFromResponsable(): void + { + $result = $this->createMock(Responsable::class); + $result + ->expects($this->once()) + ->method('toResponse') + ->with($this->identicalTo($this->request)) + ->willReturn($response = $this->createMock(Response::class)); + + $target = new class($result) { + public ?stdClass $model = null; + public ?ArrayObject $related = null; + public ?Request $request = null; + public ?QueryParameters $query = null; + + public function __construct(private readonly Responsable $result) + { + } + + public function attachedTags( + stdClass $model, + ArrayObject $related, + Request $request, + QueryParameters $query, + ): Responsable + { + $this->model = $model; + $this->related = $related; + $this->request = $request; + $this->query = $query; + + return $this->result; + } + }; + + $model = new stdClass(); + $related = new ArrayObject(); + $implementation = new HooksImplementation($target); + + try { + $implementation->attachedRelationship($model, 'tags', $related, $this->request, $this->query); + $this->fail('No exception thrown.'); + } catch (HttpResponseException $ex) { + $this->assertSame($model, $target->model); + $this->assertSame($related, $target->related); + $this->assertSame($this->request, $target->request); + $this->assertSame($this->query, $target->query); + $this->assertSame($response, $ex->getResponse()); + } + } }