From 9a98f712b694c07df5592707f25b3b8a30a2a778 Mon Sep 17 00:00:00 2001 From: Christopher Gammie Date: Sat, 19 Aug 2023 16:04:33 +0100 Subject: [PATCH] feat: add detach relationship command --- .../DetachRelationshipImplementation.php | 59 ++++ src/Core/Auth/ResourceAuthorizer.php | 40 +++ src/Core/Bus/Commands/Command/IsRelatable.php | 8 + .../DetachRelationshipCommand.php | 141 +++++++++ .../DetachRelationshipCommandHandler.php | 101 ++++++ .../HandlesDetachRelationshipCommands.php | 33 ++ .../AuthorizeDetachRelationshipCommand.php | 58 ++++ .../TriggerDetachRelationshipHooks.php | 63 ++++ src/Core/Bus/Commands/Dispatcher.php | 3 + .../ValidateRelationshipCommand.php | 8 +- src/Core/Http/Hooks/HooksImplementation.php | 35 ++- .../DetachRelationshipCommandHandlerTest.php | 167 ++++++++++ ...AuthorizeDetachRelationshipCommandTest.php | 257 ++++++++++++++++ .../TriggerDetachRelationshipHooksTest.php | 196 ++++++++++++ .../ValidateRelationshipCommandTest.php | 31 +- .../Http/Hooks/HooksImplementationTest.php | 287 ++++++++++++++++++ 16 files changed, 1472 insertions(+), 15 deletions(-) create mode 100644 src/Contracts/Http/Hooks/DetachRelationshipImplementation.php create mode 100644 src/Core/Bus/Commands/DetachRelationship/DetachRelationshipCommand.php create mode 100644 src/Core/Bus/Commands/DetachRelationship/DetachRelationshipCommandHandler.php create mode 100644 src/Core/Bus/Commands/DetachRelationship/HandlesDetachRelationshipCommands.php create mode 100644 src/Core/Bus/Commands/DetachRelationship/Middleware/AuthorizeDetachRelationshipCommand.php create mode 100644 src/Core/Bus/Commands/DetachRelationship/Middleware/TriggerDetachRelationshipHooks.php create mode 100644 tests/Unit/Bus/Commands/DetachRelationship/DetachRelationshipCommandHandlerTest.php create mode 100644 tests/Unit/Bus/Commands/DetachRelationship/Middleware/AuthorizeDetachRelationshipCommandTest.php create mode 100644 tests/Unit/Bus/Commands/DetachRelationship/Middleware/TriggerDetachRelationshipHooksTest.php diff --git a/src/Contracts/Http/Hooks/DetachRelationshipImplementation.php b/src/Contracts/Http/Hooks/DetachRelationshipImplementation.php new file mode 100644 index 0000000..d5f9aaf --- /dev/null +++ b/src/Contracts/Http/Hooks/DetachRelationshipImplementation.php @@ -0,0 +1,59 @@ +authorizer->attachRelationship( + $request, + $model, + $fieldName, + ); + + return $passes ? null : $this->failed(); + } + + /** + * Authorize a JSON:API detach relationship command, or fail. + * + * @param Request|null $request + * @param object $model + * @param string $fieldName + * @return void + * @throws AuthorizationException + * @throws AuthenticationException + * @throws HttpExceptionInterface + */ + public function detachRelationshipOrFail(?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/Command/IsRelatable.php b/src/Core/Bus/Commands/Command/IsRelatable.php index 2bd33b2..c29104e 100644 --- a/src/Core/Bus/Commands/Command/IsRelatable.php +++ b/src/Core/Bus/Commands/Command/IsRelatable.php @@ -19,6 +19,9 @@ namespace LaravelJsonApi\Core\Bus\Commands\Command; +use LaravelJsonApi\Core\Extensions\Atomic\Operations\UpdateToMany; +use LaravelJsonApi\Core\Extensions\Atomic\Operations\UpdateToOne; + interface IsRelatable extends IsIdentifiable { /** @@ -27,4 +30,9 @@ interface IsRelatable extends IsIdentifiable * @return string */ public function fieldName(): string; + + /** + * @return UpdateToOne|UpdateToMany + */ + public function operation(): UpdateToOne|UpdateToMany; } diff --git a/src/Core/Bus/Commands/DetachRelationship/DetachRelationshipCommand.php b/src/Core/Bus/Commands/DetachRelationship/DetachRelationshipCommand.php new file mode 100644 index 0000000..063670e --- /dev/null +++ b/src/Core/Bus/Commands/DetachRelationship/DetachRelationshipCommand.php @@ -0,0 +1,141 @@ +operation->isDetachingRelationship(), + 'Expecting a to-many operation that is to detach resources from 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 DetachRelationshipImplementation|null $hooks + * @return $this + */ + public function withHooks(?DetachRelationshipImplementation $hooks): self + { + $copy = clone $this; + $copy->hooks = $hooks; + + return $copy; + } + + /** + * @return DetachRelationshipImplementation|null + */ + public function hooks(): ?DetachRelationshipImplementation + { + return $this->hooks; + } +} diff --git a/src/Core/Bus/Commands/DetachRelationship/DetachRelationshipCommandHandler.php b/src/Core/Bus/Commands/DetachRelationship/DetachRelationshipCommandHandler.php new file mode 100644 index 0000000..4cb6240 --- /dev/null +++ b/src/Core/Bus/Commands/DetachRelationship/DetachRelationshipCommandHandler.php @@ -0,0 +1,101 @@ +pipelines + ->pipe($command) + ->through($pipes) + ->via('handle') + ->then(fn (DetachRelationshipCommand $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 DetachRelationshipCommand $command + * @return Result + */ + private function handle(DetachRelationshipCommand $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()) + ->detach($input); + + return Result::ok(new Payload($result, true)); + } +} diff --git a/src/Core/Bus/Commands/DetachRelationship/HandlesDetachRelationshipCommands.php b/src/Core/Bus/Commands/DetachRelationship/HandlesDetachRelationshipCommands.php new file mode 100644 index 0000000..556a676 --- /dev/null +++ b/src/Core/Bus/Commands/DetachRelationship/HandlesDetachRelationshipCommands.php @@ -0,0 +1,33 @@ +mustAuthorize()) { + $errors = $this->authorizerFactory + ->make($command->type()) + ->detachRelationship($command->request(), $command->modelOrFail(), $command->fieldName()); + } + + if ($errors) { + return Result::failed($errors); + } + + return $next($command); + } +} diff --git a/src/Core/Bus/Commands/DetachRelationship/Middleware/TriggerDetachRelationshipHooks.php b/src/Core/Bus/Commands/DetachRelationship/Middleware/TriggerDetachRelationshipHooks.php new file mode 100644 index 0000000..16bba1b --- /dev/null +++ b/src/Core/Bus/Commands/DetachRelationship/Middleware/TriggerDetachRelationshipHooks.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->detachingRelationship($model, $fieldName, $request, $query); + + /** @var Result $result */ + $result = $next($command); + + if ($result->didSucceed()) { + $hooks->detachedRelationship( + $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 872d348..835701b 100644 --- a/src/Core/Bus/Commands/Dispatcher.php +++ b/src/Core/Bus/Commands/Dispatcher.php @@ -26,6 +26,8 @@ use LaravelJsonApi\Core\Bus\Commands\Command\Command; use LaravelJsonApi\Core\Bus\Commands\Destroy\DestroyCommand; use LaravelJsonApi\Core\Bus\Commands\Destroy\DestroyCommandHandler; +use LaravelJsonApi\Core\Bus\Commands\DetachRelationship\DetachRelationshipCommand; +use LaravelJsonApi\Core\Bus\Commands\DetachRelationship\DetachRelationshipCommandHandler; use LaravelJsonApi\Core\Bus\Commands\Store\StoreCommand; use LaravelJsonApi\Core\Bus\Commands\Store\StoreCommandHandler; use LaravelJsonApi\Core\Bus\Commands\Update\UpdateCommand; @@ -78,6 +80,7 @@ private function handlerFor(string $commandClass): string DestroyCommand::class => DestroyCommandHandler::class, UpdateRelationshipCommand::class => UpdateRelationshipCommandHandler::class, AttachRelationshipCommand::class => AttachRelationshipCommandHandler::class, + DetachRelationshipCommand::class => DetachRelationshipCommandHandler::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 7563035..4bf97b0 100644 --- a/src/Core/Bus/Commands/Middleware/ValidateRelationshipCommand.php +++ b/src/Core/Bus/Commands/Middleware/ValidateRelationshipCommand.php @@ -24,10 +24,10 @@ 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\Command\Command; +use LaravelJsonApi\Core\Bus\Commands\Command\IsRelatable; use LaravelJsonApi\Core\Bus\Commands\Result; use LaravelJsonApi\Core\Bus\Commands\UpdateRelationship\HandlesUpdateRelationshipCommands; -use LaravelJsonApi\Core\Bus\Commands\UpdateRelationship\UpdateRelationshipCommand; use LaravelJsonApi\Core\Document\Input\Values\ResourceType; class ValidateRelationshipCommand implements HandlesUpdateRelationshipCommands @@ -49,7 +49,7 @@ public function __construct( /** * @inheritDoc */ - public function handle(UpdateRelationshipCommand|AttachRelationshipCommand $command, Closure $next): Result + public function handle(Command&IsRelatable $command, Closure $next): Result { if ($command->mustValidate()) { $validator = $this @@ -82,7 +82,7 @@ public function handle(UpdateRelationshipCommand|AttachRelationshipCommand $comm } /** - * Make an update relationship validator. + * Make a relationship validator. * * @param ResourceType $type * @return RelationshipValidator diff --git a/src/Core/Http/Hooks/HooksImplementation.php b/src/Core/Http/Hooks/HooksImplementation.php index 12b8aca..e64cfda 100644 --- a/src/Core/Http/Hooks/HooksImplementation.php +++ b/src/Core/Http/Hooks/HooksImplementation.php @@ -24,6 +24,7 @@ use Illuminate\Http\Request; use LaravelJsonApi\Contracts\Http\Hooks\AttachRelationshipImplementation; use LaravelJsonApi\Contracts\Http\Hooks\DestroyImplementation; +use LaravelJsonApi\Contracts\Http\Hooks\DetachRelationshipImplementation; use LaravelJsonApi\Contracts\Http\Hooks\IndexImplementation; use LaravelJsonApi\Contracts\Http\Hooks\ShowImplementation; use LaravelJsonApi\Contracts\Http\Hooks\ShowRelatedImplementation; @@ -45,7 +46,8 @@ class HooksImplementation implements ShowRelatedImplementation, ShowRelationshipImplementation, UpdateRelationshipImplementation, - AttachRelationshipImplementation + AttachRelationshipImplementation, + DetachRelationshipImplementation { /** * HooksImplementation constructor @@ -313,4 +315,35 @@ public function attachedRelationship( $this($method, $model, $related, $request, $query); } + + /** + * @inheritDoc + */ + public function detachingRelationship( + object $model, + string $fieldName, + Request $request, + QueryParameters $query, + ): void + { + $method = 'detaching' . Str::classify($fieldName); + + $this($method, $model, $request, $query); + } + + /** + * @inheritDoc + */ + public function detachedRelationship( + object $model, + string $fieldName, + mixed $related, + Request $request, + QueryParameters $query, + ): void + { + $method = 'detached' . Str::classify($fieldName); + + $this($method, $model, $related, $request, $query); + } } diff --git a/tests/Unit/Bus/Commands/DetachRelationship/DetachRelationshipCommandHandlerTest.php b/tests/Unit/Bus/Commands/DetachRelationship/DetachRelationshipCommandHandlerTest.php new file mode 100644 index 0000000..6a4885a --- /dev/null +++ b/tests/Unit/Bus/Commands/DetachRelationship/DetachRelationshipCommandHandlerTest.php @@ -0,0 +1,167 @@ +handler = new DetachRelationshipCommandHandler( + $this->pipelineFactory = $this->createMock(PipelineFactory::class), + $this->store = $this->createMock(StoreContract::class), + ); + } + + /** + * @return void + */ + public function test(): void + { + $operation = new UpdateToMany( + OpCodeEnum::Remove, + new Ref(type: new ResourceType('posts'), id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ); + + $original = new DetachRelationshipCommand( + $request = $this->createMock(Request::class), + $operation, + ); + + $validated = [ + 'tags' => [ + ['type' => 'tags', 'id' => '1'], + ['type' => 'tags', 'id' => '2'], + ], + ]; + + $passed = DetachRelationshipCommand::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, + AuthorizeDetachRelationshipCommand::class, + ValidateRelationshipCommand::class, + TriggerDetachRelationshipHooks::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('detach') + ->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/DetachRelationship/Middleware/AuthorizeDetachRelationshipCommandTest.php b/tests/Unit/Bus/Commands/DetachRelationship/Middleware/AuthorizeDetachRelationshipCommandTest.php new file mode 100644 index 0000000..e7966d8 --- /dev/null +++ b/tests/Unit/Bus/Commands/DetachRelationship/Middleware/AuthorizeDetachRelationshipCommandTest.php @@ -0,0 +1,257 @@ +type = new ResourceType('posts'); + + $this->middleware = new AuthorizeDetachRelationshipCommand( + $this->authorizerFactory = $this->createMock(ResourceAuthorizerFactory::class), + ); + } + + /** + * @return void + */ + public function testItPassesAuthorizationWithRequest(): void + { + $command = DetachRelationshipCommand::make( + $request = $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Remove, + 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 = DetachRelationshipCommand::make( + null, + new UpdateToMany( + OpCodeEnum::Remove, + 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 = DetachRelationshipCommand::make( + $request = $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Remove, + 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 = DetachRelationshipCommand::make( + $request = $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Remove, + 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 = DetachRelationshipCommand::make( + $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Remove, + 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('detachRelationship') + ->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('detachRelationship') + ->with($this->identicalTo($request), $this->identicalTo($model), $this->identicalTo($fieldName)) + ->willThrowException($expected); + } +} diff --git a/tests/Unit/Bus/Commands/DetachRelationship/Middleware/TriggerDetachRelationshipHooksTest.php b/tests/Unit/Bus/Commands/DetachRelationship/Middleware/TriggerDetachRelationshipHooksTest.php new file mode 100644 index 0000000..feb6b98 --- /dev/null +++ b/tests/Unit/Bus/Commands/DetachRelationship/Middleware/TriggerDetachRelationshipHooksTest.php @@ -0,0 +1,196 @@ +middleware = new TriggerDetachRelationshipHooks(); + } + + /** + * @return void + */ + public function testItHasNoHooks(): void + { + $command = DetachRelationshipCommand::make( + $this->createMock(Request::class), + new UpdateToMany( + OpCodeEnum::Remove, + 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 (DetachRelationshipCommand $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(DetachRelationshipImplementation::class); + $query = $this->createMock(QueryParameters::class); + $model = new stdClass(); + $related = new ArrayObject(); + $sequence = []; + + $operation = new UpdateToMany( + OpCodeEnum::Remove, + new Ref(type: new ResourceType('posts'), id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ); + + $command = DetachRelationshipCommand::make($request, $operation) + ->withModel($model) + ->withHooks($hooks) + ->withQuery($query); + + $hooks + ->expects($this->once()) + ->method('detachingRelationship') + ->willReturnCallback(function ($m, $f, $req, $q) use (&$sequence, $model, $request, $query): void { + $sequence[] = 'detaching'; + $this->assertSame($model, $m); + $this->assertSame('tags', $f); + $this->assertSame($request, $req); + $this->assertSame($query, $q); + }); + + $hooks + ->expects($this->once()) + ->method('detachedRelationship') + ->willReturnCallback( + function ($m, $f, $rel, $req, $q) use (&$sequence, $model, $related, $request, $query): void { + $sequence[] = 'detached'; + $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 (DetachRelationshipCommand $cmd) use ($command, $expected, &$sequence): Result { + $this->assertSame($command, $cmd); + $this->assertSame(['detaching'], $sequence); + return $expected; + }, + ); + + $this->assertSame($expected, $actual); + $this->assertSame(['detaching', 'detached'], $sequence); + } + + /** + * @return void + */ + public function testItDoesNotTriggerAfterHooksIfItFails(): void + { + $request = $this->createMock(Request::class); + $hooks = $this->createMock(DetachRelationshipImplementation::class); + $query = $this->createMock(QueryParameters::class); + $model = new stdClass(); + $sequence = []; + + $operation = new UpdateToMany( + OpCodeEnum::Remove, + new Ref(type: new ResourceType('posts'), id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ); + + $command = DetachRelationshipCommand::make($request, $operation) + ->withModel($model) + ->withHooks($hooks) + ->withQuery($query); + + $hooks + ->expects($this->once()) + ->method('detachingRelationship') + ->willReturnCallback(function ($m, $f, $req, $q) use (&$sequence, $model, $request, $query): void { + $sequence[] = 'detaching'; + $this->assertSame($model, $m); + $this->assertSame('tags', $f); + $this->assertSame($request, $req); + $this->assertSame($query, $q); + }); + + $hooks + ->expects($this->never()) + ->method('detachedRelationship'); + + $expected = Result::failed(); + + $actual = $this->middleware->handle( + $command, + function (DetachRelationshipCommand $cmd) use ($command, $expected, &$sequence): Result { + $this->assertSame($command, $cmd); + $this->assertSame(['detaching'], $sequence); + return $expected; + }, + ); + + $this->assertSame($expected, $actual); + $this->assertSame(['detaching'], $sequence); + } +} diff --git a/tests/Unit/Bus/Commands/Middleware/ValidateRelationshipCommandTest.php b/tests/Unit/Bus/Commands/Middleware/ValidateRelationshipCommandTest.php index 3c8165c..2b296b8 100644 --- a/tests/Unit/Bus/Commands/Middleware/ValidateRelationshipCommandTest.php +++ b/tests/Unit/Bus/Commands/Middleware/ValidateRelationshipCommandTest.php @@ -29,6 +29,9 @@ use LaravelJsonApi\Contracts\Validation\RelationshipValidator; use LaravelJsonApi\Contracts\Validation\ResourceErrorFactory; use LaravelJsonApi\Core\Bus\Commands\AttachRelationship\AttachRelationshipCommand; +use LaravelJsonApi\Core\Bus\Commands\Command\Command; +use LaravelJsonApi\Core\Bus\Commands\Command\IsRelatable; +use LaravelJsonApi\Core\Bus\Commands\DetachRelationship\DetachRelationshipCommand; use LaravelJsonApi\Core\Bus\Commands\Middleware\ValidateRelationshipCommand; use LaravelJsonApi\Core\Bus\Commands\Result; use LaravelJsonApi\Core\Bus\Commands\UpdateRelationship\UpdateRelationshipCommand; @@ -130,11 +133,22 @@ function (ResourceType $type, Request $request = null): AttachRelationshipComman return new AttachRelationshipCommand($request, $operation); }, ], + 'detach' => [ + function (ResourceType $type, Request $request = null): DetachRelationshipCommand { + $operation = new UpdateToMany( + OpCodeEnum::Remove, + new Ref(type: $type, id: new ResourceId('123'), relationship: 'tags'), + new ListOfResourceIdentifiers(), + ); + + return new DetachRelationshipCommand($request, $operation); + }, + ], ]; } /** - * @param Closure(ResourceType, ?Request=): (UpdateRelationshipCommand|AttachRelationshipCommand) $factory + * @param Closure(ResourceType, ?Request=): (Command&IsRelatable) $factory * @return void * @dataProvider commandProvider */ @@ -168,8 +182,7 @@ public function testItPassesValidation(Closure $factory): void $actual = $this->middleware->handle( $command, - function (UpdateRelationshipCommand|AttachRelationshipCommand $cmd) - use ($command, $validated, $expected): Result { + function (Command&IsRelatable $cmd) use ($command, $validated, $expected): Result { $this->assertNotSame($command, $cmd); $this->assertSame($validated, $cmd->validated()); return $expected; @@ -180,7 +193,7 @@ function (UpdateRelationshipCommand|AttachRelationshipCommand $cmd) } /** - * @param Closure(ResourceType, ?Request=): (UpdateRelationshipCommand|AttachRelationshipCommand) $factory + * @param Closure(ResourceType, ?Request=): (Command&IsRelatable) $factory * @return void * @dataProvider commandProvider */ @@ -221,7 +234,7 @@ public function testItFailsValidation(Closure $factory): void } /** - * @param Closure(ResourceType, ?Request=): (UpdateRelationshipCommand|AttachRelationshipCommand) $factory + * @param Closure(ResourceType, ?Request=): (Command&IsRelatable) $factory * @return void * @dataProvider commandProvider */ @@ -245,8 +258,7 @@ public function testItSetsValidatedDataIfNotValidating(Closure $factory): void $actual = $this->middleware->handle( $command, - function (UpdateRelationshipCommand|AttachRelationshipCommand $cmd) - use ($command, $validated, $expected): Result { + function (Command&IsRelatable $cmd) use ($command, $validated, $expected): Result { $this->assertNotSame($command, $cmd); $this->assertSame($validated, $cmd->validated()); return $expected; @@ -257,7 +269,7 @@ function (UpdateRelationshipCommand|AttachRelationshipCommand $cmd) } /** - * @param Closure(ResourceType, ?Request=): (UpdateRelationshipCommand|AttachRelationshipCommand) $factory + * @param Closure(ResourceType, ?Request=): (Command&IsRelatable) $factory * @return void * @dataProvider commandProvider */ @@ -276,8 +288,7 @@ public function testItDoesNotValidateIfAlreadyValidated(Closure $factory): void $actual = $this->middleware->handle( $command, - function (UpdateRelationshipCommand|AttachRelationshipCommand $cmd) - use ($command, $validated, $expected): Result { + function (Command&IsRelatable $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 64a6a29..ff8ef7c 100644 --- a/tests/Unit/Http/Hooks/HooksImplementationTest.php +++ b/tests/Unit/Http/Hooks/HooksImplementationTest.php @@ -26,6 +26,7 @@ use Illuminate\Http\Request; use LaravelJsonApi\Contracts\Http\Hooks\AttachRelationshipImplementation; use LaravelJsonApi\Contracts\Http\Hooks\DestroyImplementation; +use LaravelJsonApi\Contracts\Http\Hooks\DetachRelationshipImplementation; use LaravelJsonApi\Contracts\Http\Hooks\IndexImplementation; use LaravelJsonApi\Contracts\Http\Hooks\ShowImplementation; use LaravelJsonApi\Contracts\Http\Hooks\ShowRelatedImplementation; @@ -158,6 +159,26 @@ static function (HooksImplementation $impl, Request $request, QueryParameters $q $impl->updatedRelationship(new stdClass(), 'comments', [], $request, $query); }, ], + 'attachingRelationship' => [ + static function (HooksImplementation $impl, Request $request, QueryParameters $query): void { + $impl->attachingRelationship(new stdClass(), 'comments', $request, $query); + }, + ], + 'attachedRelationship' => [ + static function (HooksImplementation $impl, Request $request, QueryParameters $query): void { + $impl->attachedRelationship(new stdClass(), 'comments', [], $request, $query); + }, + ], + 'detachingRelationship' => [ + static function (HooksImplementation $impl, Request $request, QueryParameters $query): void { + $impl->detachingRelationship(new stdClass(), 'comments', $request, $query); + }, + ], + 'detachedRelationship' => [ + static function (HooksImplementation $impl, Request $request, QueryParameters $query): void { + $impl->detachedRelationship(new stdClass(), 'comments', [], $request, $query); + }, + ], ]; } @@ -2538,4 +2559,270 @@ public function attachedTags( $this->assertSame($response, $ex->getResponse()); } } + + /** + * @return void + */ + public function testItInvokesDetachingRelationshipMethod(): void + { + $target = new class { + public ?stdClass $model = null; + public ?Request $request = null; + public ?QueryParameters $query = null; + + public function detachingBlogPosts( + stdClass $model, + Request $request, + QueryParameters $query, + ): void + { + $this->model = $model; + $this->request = $request; + $this->query = $query; + } + }; + + $model = new stdClass(); + + $implementation = new HooksImplementation($target); + $implementation->detachingRelationship($model, 'blog-posts', $this->request, $this->query); + + $this->assertInstanceOf(DetachRelationshipImplementation::class, $implementation); + $this->assertSame($model, $target->model); + $this->assertSame($this->request, $target->request); + $this->assertSame($this->query, $target->query); + } + + /** + * @return void + */ + public function testItInvokesDetachingRelationshipMethodAndThrowsResponse(): 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 detachingComments( + 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->detachingRelationship($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 testItInvokesDetachingRelationshipMethodAndThrowsResponseFromResponsable(): 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 detachingTags( + 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->detachingRelationship($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 testItInvokesDetachedRelationshipMethod(): void + { + $target = new class { + public ?stdClass $model = null; + public ?ArrayObject $related = null; + public ?Request $request = null; + public ?QueryParameters $query = null; + + public function detachedBlogPosts( + 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->detachedRelationship($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 testItInvokesDetachedRelationshipMethodAndThrowsResponse(): 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 detachedComments( + 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->detachedRelationship($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 testItInvokesDetachedRelationshipMethodAndThrowsResponseFromResponsable(): 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 detachedTags( + 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->detachedRelationship($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()); + } + } }