From b5458424119e36b6eda5367548ca14e7fb16f4e1 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 10 Aug 2023 09:31:19 +0200 Subject: [PATCH] test: test with soyuka:feat/handle-links-hook --- api/composer.json | 4 +- api/composer.lock | 16 +- api/config/packages/api_platform.yaml | 2 + api/src/Entity/Book.php | 2 - api/src/Entity/Review.php | 28 +++- .../State/Processor/BookPersistProcessor.php | 3 +- .../State/Processor/BookRemoveProcessor.php | 5 +- api/src/State/Processor/MercureProcessor.php | 6 +- .../Processor/ReviewPersistProcessor.php | 10 +- .../State/Processor/ReviewRemoveProcessor.php | 5 +- api/tests/Api/Admin/BookTest.php | 101 ++++++++---- api/tests/Api/Admin/ReviewTest.php | 6 +- api/tests/Api/BookmarkTest.php | 18 +-- api/tests/Api/ReviewTest.php | 150 ++++++++++++------ api/tests/Api/Trait/SerializerTrait.php | 2 +- pwa/pages/books/[id]/[slug]/index.tsx | 1 + 16 files changed, 232 insertions(+), 127 deletions(-) diff --git a/api/composer.json b/api/composer.json index 6946b8ee2..aec335601 100644 --- a/api/composer.json +++ b/api/composer.json @@ -4,7 +4,7 @@ "repositories": [ { "type": "vcs", - "url": "git@github.com:vincentchalamon/core.git" + "url": "git@github.com:soyuka/core.git" } ], "require": { @@ -12,7 +12,7 @@ "ext-ctype": "*", "ext-iconv": "*", "ext-xml": "*", - "api-platform/core": "dev-demo", + "api-platform/core": "dev-feat/handle-links-hook", "doctrine/doctrine-bundle": "^2.7", "doctrine/doctrine-migrations-bundle": "^3.2", "doctrine/orm": "^2.12", diff --git a/api/composer.lock b/api/composer.lock index 92ba08bc9..3d87cbcc2 100644 --- a/api/composer.lock +++ b/api/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "556e2634ee5ee54f81424ff814ccaa8f", + "content-hash": "61110d3f2a58a726525b93a07d4dffe5", "packages": [ { "name": "api-platform/core", - "version": "dev-demo", + "version": "dev-feat/handle-links-hook", "source": { "type": "git", - "url": "https://github.com/vincentchalamon/core.git", - "reference": "daed45503aaa6cabfe22374654a54dc32615956d" + "url": "https://github.com/soyuka/core.git", + "reference": "e13f29d57f37bc9b5a71c8c24baa51d63d2ff8e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vincentchalamon/core/zipball/daed45503aaa6cabfe22374654a54dc32615956d", - "reference": "daed45503aaa6cabfe22374654a54dc32615956d", + "url": "https://api.github.com/repos/soyuka/core/zipball/e13f29d57f37bc9b5a71c8c24baa51d63d2ff8e9", + "reference": "e13f29d57f37bc9b5a71c8c24baa51d63d2ff8e9", "shasum": "" }, "require": { @@ -169,7 +169,7 @@ "Swagger" ], "support": { - "source": "https://github.com/vincentchalamon/core/tree/demo" + "source": "https://github.com/soyuka/core/tree/feat/handle-links-hook" }, "funding": [ { @@ -177,7 +177,7 @@ "url": "https://tidelift.com/funding/github/packagist/api-platform/core" } ], - "time": "2023-08-07T07:08:54+00:00" + "time": "2023-08-09T08:01:47+00:00" }, { "name": "brick/math", diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 7552c905f..5fe081e2d 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -13,6 +13,8 @@ api_platform: stateless: true cache_headers: vary: ['Content-Type', 'Authorization', 'Origin'] + extra_properties: + standard_put: true oauth: enabled: true clientId: '%env(OIDC_SWAGGER_CLIENT_ID)%' diff --git a/api/src/Entity/Book.php b/api/src/Entity/Book.php index 84bfe958a..a6e49cf0d 100644 --- a/api/src/Entity/Book.php +++ b/api/src/Entity/Book.php @@ -60,7 +60,6 @@ ), ], normalizationContext: [ - 'item_uri_template' => '/admin/books/{id}{._format}', 'groups' => ['Book:read:admin', 'Enum:read'], 'skip_null_values' => true, ], @@ -76,7 +75,6 @@ new Get(), ], normalizationContext: [ - 'item_uri_template' => '/books/{id}{._format}', 'groups' => ['Book:read', 'Enum:read'], 'skip_null_values' => true, ] diff --git a/api/src/Entity/Review.php b/api/src/Entity/Review.php index 71e0611e6..0af742ee2 100644 --- a/api/src/Entity/Review.php +++ b/api/src/Entity/Review.php @@ -4,6 +4,9 @@ namespace App\Entity; +use ApiPlatform\Doctrine\Orm\State\ItemProvider; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -11,15 +14,18 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\State\CreateProvider; use App\Repository\ReviewRepository; use App\Serializer\IriTransformerNormalizer; use App\State\Processor\ReviewPersistProcessor; use App\State\Processor\ReviewRemoveProcessor; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\QueryBuilder; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Uid\Uuid; @@ -58,7 +64,6 @@ 'book' => '/admin/books/{id}{._format}', 'user' => '/admin/users/{id}{._format}', ], - 'item_uri_template' => '/admin/reviews/{id}{._format}', 'skip_null_values' => true, 'groups' => ['Review:read', 'Review:read:admin'], ], @@ -87,7 +92,9 @@ security: 'is_granted("ROLE_USER")', // Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor processor: ReviewPersistProcessor::class, - itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}' + itemUriTemplate: '/books/{bookId}/reviews/{id}{._format}', + provider: ItemProvider::class, + stateOptions: new Options(handleLinks: [Review::class, 'handleLinks']) ), new Patch( uriTemplate: '/books/{bookId}/reviews/{id}{._format}', @@ -115,7 +122,6 @@ 'book' => '/books/{id}{._format}', 'user' => '/users/{id}{._format}', ], - 'item_uri_template' => '/books/{bookId}/reviews/{id}{._format}', 'skip_null_values' => true, 'groups' => ['Review:read'], ], @@ -193,4 +199,20 @@ public function getId(): ?Uuid { return $this->id; } + + public static function handleLinks( + QueryBuilder $queryBuilder, + array $uriVariables, + QueryNameGeneratorInterface $queryNameGenerator, + array $context, + string $entityClass, + Operation $operation + ): QueryBuilder { + return $queryBuilder + ->resetDQLParts() + ->select('b') + ->from(Book::class, 'b') + ->where('b.id = :id') + ->setParameters(['id' => $uriVariables['bookId']]); + } } diff --git a/api/src/State/Processor/BookPersistProcessor.php b/api/src/State/Processor/BookPersistProcessor.php index 4b0af418b..a6e9f41d3 100644 --- a/api/src/State/Processor/BookPersistProcessor.php +++ b/api/src/State/Processor/BookPersistProcessor.php @@ -42,10 +42,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } // save entity - $this->persistProcessor->process($data, $operation, $uriVariables, $context); + $data = $this->persistProcessor->process($data, $operation, $uriVariables, $context); // publish on Mercure - // todo find a way to do it in API Platform foreach (['/admin/books/{id}{._format}', '/books/{id}{._format}'] as $uriTemplate) { $this->mercureProcessor->process( $data, diff --git a/api/src/State/Processor/BookRemoveProcessor.php b/api/src/State/Processor/BookRemoveProcessor.php index 60154b9b7..de79ac002 100644 --- a/api/src/State/Processor/BookRemoveProcessor.php +++ b/api/src/State/Processor/BookRemoveProcessor.php @@ -6,7 +6,7 @@ use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Doctrine\Common\State\RemoveProcessor as DoctrineRemoveProcessor; +use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProcessorInterface; @@ -16,7 +16,7 @@ final readonly class BookRemoveProcessor implements ProcessorInterface { public function __construct( - #[Autowire(service: DoctrineRemoveProcessor::class)] + #[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor, #[Autowire(service: MercureProcessor::class)] private ProcessorInterface $mercureProcessor, @@ -36,7 +36,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $this->removeProcessor->process($data, $operation, $uriVariables, $context); // publish on Mercure - // todo find a way to do it in API Platform foreach (['/admin/books/{id}{._format}', '/books/{id}{._format}'] as $uriTemplate) { $iri = $this->iriConverter->getIriFromResource( $object, diff --git a/api/src/State/Processor/MercureProcessor.php b/api/src/State/Processor/MercureProcessor.php index 5ee7ad674..8604b46e6 100644 --- a/api/src/State/Processor/MercureProcessor.php +++ b/api/src/State/Processor/MercureProcessor.php @@ -46,9 +46,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $context['data'] = $this->serializer->serialize( $data, key($this->formats), - ($operation->getNormalizationContext() ?? [] + [ - 'item_uri_template' => $context['item_uri_template'] ?? null, - ]) + ($operation->getNormalizationContext() ?? []) + (isset($context['item_uri_template']) ? [ + 'item_uri_template' => $context['item_uri_template'], + ] : []) ); } diff --git a/api/src/State/Processor/ReviewPersistProcessor.php b/api/src/State/Processor/ReviewPersistProcessor.php index 0f7407453..5bf4af7e9 100644 --- a/api/src/State/Processor/ReviewPersistProcessor.php +++ b/api/src/State/Processor/ReviewPersistProcessor.php @@ -4,19 +4,18 @@ namespace App\State\Processor; +use ApiPlatform\Doctrine\Common\State\PersistProcessor; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Review; -use App\Repository\ReviewRepository; -use Doctrine\Persistence\ObjectRepository; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\DependencyInjection\Attribute\Autowire; final readonly class ReviewPersistProcessor implements ProcessorInterface { public function __construct( - #[Autowire(service: ReviewRepository::class)] - private ObjectRepository $repository, + #[Autowire(service: PersistProcessor::class)] + private ProcessorInterface $persistProcessor, private Security $security, #[Autowire(service: MercureProcessor::class)] private ProcessorInterface $mercureProcessor @@ -32,10 +31,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $data->publishedAt = new \DateTimeImmutable(); // save entity - $this->repository->save($data, true); + $data = $this->persistProcessor->process($data, $operation, $uriVariables, $context); // publish on Mercure - // todo find a way to do it in API Platform foreach (['/admin/reviews/{id}{._format}', '/books/{bookId}/reviews/{id}{._format}'] as $uriTemplate) { $this->mercureProcessor->process( $data, diff --git a/api/src/State/Processor/ReviewRemoveProcessor.php b/api/src/State/Processor/ReviewRemoveProcessor.php index 81228e224..05efb2ce9 100644 --- a/api/src/State/Processor/ReviewRemoveProcessor.php +++ b/api/src/State/Processor/ReviewRemoveProcessor.php @@ -6,7 +6,7 @@ use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Doctrine\Common\State\RemoveProcessor as DoctrineRemoveProcessor; +use ApiPlatform\Doctrine\Common\State\RemoveProcessor; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProcessorInterface; @@ -16,7 +16,7 @@ final readonly class ReviewRemoveProcessor implements ProcessorInterface { public function __construct( - #[Autowire(service: DoctrineRemoveProcessor::class)] + #[Autowire(service: RemoveProcessor::class)] private ProcessorInterface $removeProcessor, #[Autowire(service: MercureProcessor::class)] private ProcessorInterface $mercureProcessor, @@ -36,7 +36,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $this->removeProcessor->process($data, $operation, $uriVariables, $context); // publish on Mercure - // todo find a way to do it in API Platform foreach (['/admin/reviews/{id}{._format}', '/books/{bookId}/reviews/{id}{._format}'] as $uriTemplate) { $iri = $this->iriConverter->getIriFromResource( $object, diff --git a/api/tests/Api/Admin/BookTest.php b/api/tests/Api/Admin/BookTest.php index 1f9e56f47..47931d3fe 100644 --- a/api/tests/Api/Admin/BookTest.php +++ b/api/tests/Api/Admin/BookTest.php @@ -250,9 +250,9 @@ public function testAsNonAdminUserICannotCreateABook(int $expectedCode, string $ } /** - * @dataProvider getInvalidData + * @dataProvider getInvalidDataOnCreate */ - public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, array $violations): void + public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, int $statusCode, array $expected): void { $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ 'email' => UserFactory::createOneAdmin()->email, @@ -263,42 +263,80 @@ public function testAsAdminUserICannotCreateABookWithInvalidData(array $data, ar 'json' => $data, ]); - self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + self::assertResponseStatusCodeSame($statusCode); self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonContains([ - '@context' => '/contexts/ConstraintViolationList', - '@type' => 'ConstraintViolationList', - 'hydra:title' => 'An error occurred', - 'violations' => $violations, - ]); + self::assertJsonContains($expected); } - public function getInvalidData(): iterable + public function getInvalidDataOnCreate(): iterable { - yield [ + yield 'no data' => [ [], + Response::HTTP_UNPROCESSABLE_ENTITY, [ - [ - 'propertyPath' => 'book', - 'message' => 'This value should not be blank.', - ], - [ - 'propertyPath' => 'condition', - 'message' => 'This value should not be null.', + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'violations' => [ + [ + 'propertyPath' => 'book', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'condition', + 'message' => 'This value should not be null.', + ], ], + ] + ]; + yield from $this->getInvalidData(); + } + + public function getInvalidData(): iterable + { + yield 'empty data' => [ + [ + 'book' => '', + 'condition' => '', ], + Response::HTTP_BAD_REQUEST, + [ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'The data must belong to a backed enumeration of type '.BookCondition::class, + ] ]; - yield [ + yield 'invalid condition' => [ + [ + 'book' => 'https://openlibrary.org/books/OL28346544M.json', + 'condition' => 'invalid condition', + ], + Response::HTTP_BAD_REQUEST, + [ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'The data must belong to a backed enumeration of type '.BookCondition::class, + ] + ]; + yield 'invalid book' => [ [ 'book' => 'invalid book', 'condition' => BookCondition::NewCondition->value, ], + Response::HTTP_UNPROCESSABLE_ENTITY, [ - [ - 'propertyPath' => 'book', - 'message' => 'This value is not a valid URL.', + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'violations' => [ + [ + 'propertyPath' => 'book', + 'message' => 'This value is not a valid URL.', + ], ], - ], + ] ]; } @@ -408,27 +446,22 @@ public function testAsAdminUserICannotUpdateAnInvalidBook(): void /** * @dataProvider getInvalidData */ - public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, array $violations): void + public function testAsAdminUserICannotUpdateABookWithInvalidData(array $data, int $statusCode, array $expected): void { - BookFactory::createOne(); + $book = BookFactory::createOne(); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ 'email' => UserFactory::createOneAdmin()->email, ]); - $this->client->request('PUT', '/admin/books/invalid', [ + $this->client->request('PUT', '/admin/books/'.$book->getId(), [ 'auth_bearer' => $token, 'json' => $data, ]); - self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + self::assertResponseStatusCodeSame($statusCode); self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonContains([ - '@context' => '/contexts/ConstraintViolationList', - '@type' => 'ConstraintViolationList', - 'hydra:title' => 'An error occurred', - 'violations' => $violations, - ]); + self::assertJsonContains($expected); } /** @@ -449,6 +482,8 @@ public function testAsAdminUserICanUpdateABook(): void $this->client->request('PUT', '/admin/books/'.$book->getId(), [ 'auth_bearer' => $token, 'json' => [ + // Must set all data because of standard PUT + 'book' => 'https://openlibrary.org/books/OL28346544M.json', 'condition' => BookCondition::DamagedCondition->value, ], ]); diff --git a/api/tests/Api/Admin/ReviewTest.php b/api/tests/Api/Admin/ReviewTest.php index 49e36bd36..ae5e10566 100644 --- a/api/tests/Api/Admin/ReviewTest.php +++ b/api/tests/Api/Admin/ReviewTest.php @@ -249,7 +249,8 @@ public function testAsAdminUserICannotUpdateAnInvalidReview(): void */ public function testAsAdminUserICanUpdateAReview(): void { - $review = ReviewFactory::createOne(); + $book = BookFactory::createOne(); + $review = ReviewFactory::createOne(['book' => $book]); $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ 'email' => UserFactory::createOneAdmin()->email, @@ -258,6 +259,9 @@ public function testAsAdminUserICanUpdateAReview(): void $this->client->request('PUT', '/admin/reviews/'.$review->getId(), [ 'auth_bearer' => $token, 'json' => [ + // Must set all data because of standard PUT + 'book' => '/admin/books/'.$book->getId(), + 'letter' => null, 'body' => 'Very good book!', 'rating' => 5, ], diff --git a/api/tests/Api/BookmarkTest.php b/api/tests/Api/BookmarkTest.php index a030ecf67..8a17b8841 100644 --- a/api/tests/Api/BookmarkTest.php +++ b/api/tests/Api/BookmarkTest.php @@ -15,6 +15,7 @@ use App\Tests\Api\Trait\MercureTrait; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Mercure\Update; +use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -97,25 +98,22 @@ public function testAsAUserICannotCreateABookmarkWithInvalidData(): void 'email' => UserFactory::createOne()->email, ]); + $uuid = Uuid::v7()->__toString(); + $this->client->request('POST', '/bookmarks', [ 'json' => [ - 'book' => '/books/invalid', + 'book' => '/books/'.$uuid, ], 'auth_bearer' => $token, ]); - self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); self::assertJsonContains([ - '@context' => '/contexts/ConstraintViolationList', - '@type' => 'ConstraintViolationList', + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', 'hydra:title' => 'An error occurred', - 'violations' => [ - [ - 'propertyPath' => 'book', - 'message' => 'This value is not valid.', - ], - ], + 'hydra:description' => 'Item not found for "/books/'.$uuid.'".', ]); } diff --git a/api/tests/Api/ReviewTest.php b/api/tests/Api/ReviewTest.php index 5dec70043..402cac14f 100644 --- a/api/tests/Api/ReviewTest.php +++ b/api/tests/Api/ReviewTest.php @@ -17,6 +17,7 @@ use App\Tests\Api\Trait\MercureTrait; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Mercure\Update; +use Symfony\Component\Uid\Uuid; use Zenstruck\Foundry\FactoryCollection; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; @@ -152,7 +153,7 @@ public function testAsAnonymousICannotAddAReviewOnABook(): void /** * @dataProvider getInvalidData */ - public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data, array $violations): void + public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data, int $statusCode, array $expected): void { $book = BookFactory::createOne(); @@ -165,57 +166,104 @@ public function testAsAUserICannotAddAReviewOnABookWithInvalidData(array $data, 'json' => $data, ]); - self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + self::assertResponseStatusCodeSame($statusCode); self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertJsonContains([ - '@context' => '/contexts/ConstraintViolationList', - '@type' => 'ConstraintViolationList', - 'hydra:title' => 'An error occurred', - 'violations' => $violations, - ]); + self::assertJsonContains($expected); } public function getInvalidData(): iterable { - yield [ + $uuid = Uuid::v7()->__toString(); + + yield 'empty data' => [ [], + Response::HTTP_UNPROCESSABLE_ENTITY, [ - [ - 'propertyPath' => 'book', - 'message' => 'This value should not be null.', - ], - [ - 'propertyPath' => 'body', - 'message' => 'This value should not be blank.', - ], - [ - 'propertyPath' => 'rating', - 'message' => 'This value should not be null.', + '@context' => '/contexts/ConstraintViolationList', + '@type' => 'ConstraintViolationList', + 'hydra:title' => 'An error occurred', + 'violations' => [ + [ + 'propertyPath' => 'book', + 'message' => 'This value should not be null.', + ], + [ + 'propertyPath' => 'body', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'rating', + 'message' => 'This value should not be null.', + ], ], ], ]; - yield [ + yield 'invalid book data' => [ [ 'book' => 'invalid book', 'body' => 'Very good book!', 'rating' => 5, ], + Response::HTTP_BAD_REQUEST, [ - [ - 'propertyPath' => 'book', - 'message' => 'This value is not a valid URL.', - ], + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Invalid IRI "invalid book".', + ], + ]; + yield 'invalid book identifier' => [ + [ + 'book' => '/books/'.$uuid, + 'body' => 'Very good book!', + 'rating' => 5, + ], + Response::HTTP_BAD_REQUEST, + [ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Item not found for "/books/'.$uuid.'".', ], ]; } + public function testAsAUserICannotAddAReviewWithValidDataOnAnInvalidBook(): void + { + $book = BookFactory::createOne(); + ReviewFactory::createMany(5, ['book' => $book]); + $user = UserFactory::createOne(); + self::getMercureHub()->reset(); + + $token = self::getContainer()->get(OidcTokenGenerator::class)->generate([ + 'email' => $user->email, + ]); + + $this->client->request('POST', '/books/invalid/reviews', [ + 'auth_bearer' => $token, + 'json' => [ + 'book' => '/books/'.$book->getId(), + 'body' => 'Very good book!', + 'rating' => 5, + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'Invalid identifier value or configuration.', + ]); + } + /** * @group mercure */ public function testAsAUserICanAddAReviewOnABook(): void { - $this->markTestIncomplete(); - $book = BookFactory::createOne()->disableAutoRefresh(); + $book = BookFactory::createOne(); ReviewFactory::createMany(5, ['book' => $book]); $user = UserFactory::createOne(); self::getMercureHub()->reset(); @@ -245,33 +293,24 @@ public function testAsAUserICanAddAReviewOnABook(): void ]); self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json')); // if I add a review on a book with reviews, it doesn't erase the existing reviews - $reviews = self::getContainer()->get(ReviewRepository::class)->findBy(['book' => $book]); + $reviews = self::getContainer()->get(ReviewRepository::class)->findBy(['book' => $book->object()]); self::assertCount(6, $reviews); $id = preg_replace('/^.*\/(.+)$/', '$1', $response->toArray()['@id']); /** @var Review $review */ $review = self::getContainer()->get(ReviewRepository::class)->find($id); self::assertCount(2, self::getMercureMessages()); - // self::assertMercureUpdateMatches( - // self::getMercureMessage(), - // ['http://localhost/admin/reviews/'.$review->getId()], - // self::serialize( - // $review, - // 'jsonld', - // self::getOperationNormalizationContext(Review::class, '/admin/reviews/{id}{._format}') - // ) - // ); - // self::assertMercureUpdateMatches( - // self::getMercureMessage(1), - // ['http://localhost/books/'.$review->book->getId().'/reviews/'.$review->getId()], - // self::serialize( - // $review, - // 'jsonld', - // self::getOperationNormalizationContext(Review::class, '/books/{bookId}/reviews/{id}{._format}') - // ) - // ); + self::assertMercureUpdateMatchesJsonSchema( + update: self::getMercureMessage(), + topics: ['http://localhost/admin/reviews/'.$review->getId()], + jsonSchema: file_get_contents(__DIR__.'/Admin/schemas/Review/item.json') + ); + self::assertMercureUpdateMatchesJsonSchema( + update: self::getMercureMessage(1), + topics: ['http://localhost/books/'.$book->getId().'/reviews/'.$review->getId()], + jsonSchema: file_get_contents(__DIR__.'/schemas/Review/item.json') + ); } - // todo invalid test, should return 405 or similar public function testAsAnonymousICannotGetAnInvalidReview(): void { $book = BookFactory::createOne(); @@ -279,18 +318,29 @@ public function testAsAnonymousICannotGetAnInvalidReview(): void $this->client->request('GET', '/books/'.$book->getId().'/reviews/invalid'); self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'This route does not aim to be called.', + ]); } - // todo invalid test, should return 405 or similar public function testAsAnonymousICanGetABookReview(): void { $review = ReviewFactory::createOne(); $this->client->request('GET', '/books/'.$review->book->getId().'/reviews/'.$review->getId()); - self::assertResponseIsSuccessful(); + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); self::assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); - self::assertMatchesJsonSchema(file_get_contents(__DIR__.'/schemas/Review/item.json')); + self::assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'This route does not aim to be called.', + ]); } public function testAsAnonymousICannotUpdateABookReview(): void diff --git a/api/tests/Api/Trait/SerializerTrait.php b/api/tests/Api/Trait/SerializerTrait.php index 6f90ef019..02711a195 100644 --- a/api/tests/Api/Trait/SerializerTrait.php +++ b/api/tests/Api/Trait/SerializerTrait.php @@ -32,7 +32,7 @@ public static function getOperationNormalizationContext(string $resourceClass, s $operation = $operationName ? (new Get())->withName($operationName) : new Get(); } - return $operation->getNormalizationContext() ?? []; + return ($operation->getNormalizationContext() ?? []) + ['item_uri_template' => $operation->getUriTemplate()]; } /** diff --git a/pwa/pages/books/[id]/[slug]/index.tsx b/pwa/pages/books/[id]/[slug]/index.tsx index d8e88cf95..ab3db2e0c 100644 --- a/pwa/pages/books/[id]/[slug]/index.tsx +++ b/pwa/pages/books/[id]/[slug]/index.tsx @@ -14,6 +14,7 @@ export const getServerSideProps: GetServerSideProps<{ if (!response?.data) { throw new Error(`Unable to retrieve data from /books/${id}.`); } + console.log(response.data); return { props: { data: response.data, hubURL: response.hubURL, page: page ? Number(page) : 1 } }; } catch (error) {