From e4ae6dabf64c8911c750d8461cdbb45f9e42364e Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 20 Jun 2026 09:34:07 +0200 Subject: [PATCH] 31 - Expose Scenario via API with ROLE_USER gating --- config/packages/api_platform.yaml | 4 +- src/Entity/Scenario.php | 10 ++ tests/Functional/ScenarioApiTest.php | 259 +++++++++++++++++++++++++++ 3 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/ScenarioApiTest.php diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 02f295a..f3060c5 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -2,6 +2,8 @@ api_platform: title: Hello API Platform version: 1.0.0 defaults: - stateless: true + stateless: false cache_headers: vary: ['Content-Type', 'Authorization', 'Origin'] + formats: + jsonld: ['application/ld+json'] diff --git a/src/Entity/Scenario.php b/src/Entity/Scenario.php index e02ef52..9d9abc1 100644 --- a/src/Entity/Scenario.php +++ b/src/Entity/Scenario.php @@ -2,13 +2,22 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; use App\Enum\DistributionMode; use App\Repository\ScenarioRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\UuidV7; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: ScenarioRepository::class)] +#[ApiResource( + operations: [new GetCollection(), new Get(), new Post()], + security: "is_granted('ROLE_USER')", +)] class Scenario { #[ORM\Id] @@ -19,6 +28,7 @@ class Scenario private DistributionMode $distributionMode = DistributionMode::EVEN; #[ORM\Column(length: 255)] + #[Assert\NotBlank] private string $name; #[ORM\Column(type: Types::TEXT, nullable: true)] diff --git a/tests/Functional/ScenarioApiTest.php b/tests/Functional/ScenarioApiTest.php new file mode 100644 index 0000000..56ff79a --- /dev/null +++ b/tests/Functional/ScenarioApiTest.php @@ -0,0 +1,259 @@ +client = static::createClient(); + + /** @var EntityManagerInterface $em */ + $em = static::getContainer()->get(EntityManagerInterface::class); + $this->em = $em; + + $factory = new PasswordHasherFactory([ + User::class => new NativePasswordHasher(cost: 4), + ]); + $hasher = new UserPasswordHasher($factory); + + $user = new User(); + $user->setEmail(self::EMAIL); + $user->setPassword($hasher->hashPassword($user, self::PASSWORD)); + + $this->em->persist($user); + $this->em->flush(); + } + + private function login(): void + { + $this->client->jsonRequest('POST', '/api/login', [ + 'username' => self::EMAIL, + 'password' => self::PASSWORD, + ]); + + self::assertLessThan( + 300, + $this->client->getResponse()->getStatusCode(), + 'Test setup precondition: login must succeed before exercising the Scenario API.', + ); + } + + private function persistScenario(string $name): Scenario + { + $scenario = new Scenario(); + $scenario->setName($name); + + $this->em->persist($scenario); + $this->em->flush(); + + return $scenario; + } + + public function testGetCollectionIsRejectedWhenUnauthenticated(): void + { + $this->client->request('GET', '/api/scenarios', server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + 'GET /api/scenarios must require authentication.', + ); + } + + public function testGetItemIsRejectedWhenUnauthenticated(): void + { + $scenario = $this->persistScenario('Emergency Fund'); + + $this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + 'GET /api/scenarios/{id} must require authentication.', + ); + } + + public function testPostIsRejectedWhenUnauthenticated(): void + { + $this->client->request( + 'POST', + '/api/scenarios', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode(['name' => 'Holiday Fund']), + ); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + 'POST /api/scenarios must require authentication.', + ); + } + + public function testGetCollectionReturnsPersistedScenariosWhenAuthenticated(): void + { + $this->persistScenario('Household Budget'); + $this->persistScenario('Travel Fund'); + + $this->login(); + + $this->client->request('GET', '/api/scenarios', server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + $response = $this->client->getResponse(); + + self::assertSame( + 200, + $response->getStatusCode(), + 'GET /api/scenarios must return 200 for an authenticated user.', + ); + + $body = json_decode((string) $response->getContent(), true); + + self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.'); + self::assertArrayHasKey( + 'member', + $body, + 'A Hydra collection response must expose its items under the member key.', + ); + + $names = array_column($body['member'], 'name'); + + self::assertContains('Household Budget', $names); + self::assertContains('Travel Fund', $names); + } + + public function testGetItemReturnsTheScenarioWhenAuthenticated(): void + { + $scenario = $this->persistScenario('Emergency Fund'); + + $this->login(); + + $this->client->request('GET', '/api/scenarios/'.$scenario->getId(), server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + $response = $this->client->getResponse(); + + self::assertSame( + 200, + $response->getStatusCode(), + 'GET /api/scenarios/{id} must return 200 for an authenticated user.', + ); + + $body = json_decode((string) $response->getContent(), true); + + self::assertIsArray($body, 'The item response must be a JSON-LD object.'); + self::assertArrayHasKey('@id', $body, 'A JSON-LD item must expose its IRI under @id.'); + self::assertSame('Scenario', $body['@type'] ?? null, 'The item @type must be Scenario.'); + self::assertSame('Emergency Fund', $body['name'] ?? null); + } + + public function testPostCreatesAScenarioWhenAuthenticated(): void + { + $this->login(); + + $this->client->request( + 'POST', + '/api/scenarios', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode(['name' => 'Holiday Fund']), + ); + + $response = $this->client->getResponse(); + + self::assertSame( + 201, + $response->getStatusCode(), + 'POST /api/scenarios with a valid body must return 201 Created.', + ); + + $body = json_decode((string) $response->getContent(), true); + + self::assertIsArray($body, 'The created resource response must be a JSON-LD object.'); + self::assertArrayHasKey('@id', $body, 'The created resource must expose its IRI under @id.'); + self::assertSame('Holiday Fund', $body['name'] ?? null); + + $this->em->clear(); + + $persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Holiday Fund']); + + self::assertNotNull( + $persisted, + 'A successful POST /api/scenarios must persist the new Scenario to the database.', + ); + } + + public function testPostWithMissingNameIsRejectedAsUnprocessable(): void + { + $this->login(); + + $this->client->request( + 'POST', + '/api/scenarios', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode([]), + ); + + self::assertSame( + 422, + $this->client->getResponse()->getStatusCode(), + 'POST /api/scenarios without a name must return 422 Unprocessable Entity (validator-rejected, not a 500).', + ); + } + + public function testPostWithNonJsonContentTypeIsRejected(): void + { + $this->login(); + + $this->client->request( + 'POST', + '/api/scenarios', + ['name' => 'Holiday Fund'], + ); + + self::assertSame( + 415, + $this->client->getResponse()->getStatusCode(), + 'POST /api/scenarios without a JSON Content-Type must be rejected with 415 Unsupported Media Type.', + ); + + $this->em->clear(); + + $persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Holiday Fund']); + + self::assertNull( + $persisted, + 'A rejected non-JSON POST must not persist a Scenario.', + ); + } +}