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 Bucket API.', ); } private function persistScenario(string $name): Scenario { $scenario = new Scenario(); $scenario->setName($name); $this->em->persist($scenario); $this->em->flush(); return $scenario; } private function persistBucket(Scenario $scenario, string $name, int $priority): Bucket { $bucket = new Bucket(); $bucket->setScenario($scenario); $bucket->setName($name); $bucket->setPriority($priority); $this->em->persist($bucket); $this->em->flush(); return $bucket; } private function scenarioIri(Scenario $scenario): string { return '/api/scenarios/'.$scenario->getId(); } /** * Asserts that the JSON-LD 422 response body contains a Hydra * ConstraintViolationList violation at the given propertyPath, pinning * down WHICH validator rejected the request (not just that *some* * validator did). */ private function assertHasViolationAt(string $propertyPath, Response $response): void { $body = json_decode((string) $response->getContent(), true); self::assertIsArray($body, 'The 422 response must be a JSON-LD object.'); self::assertArrayHasKey( 'violations', $body, 'A 422 validation failure must expose a Hydra ConstraintViolationList violations array.', ); $paths = array_column($body['violations'], 'propertyPath'); self::assertContains( $propertyPath, $paths, \sprintf( 'Expected a violation at propertyPath "%s", got: %s', $propertyPath, json_encode($paths), ), ); } public function testGetCollectionIsRejectedWhenUnauthenticated(): void { $this->client->request('GET', '/api/buckets', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/buckets must require authentication.', ); } public function testPostIsRejectedWhenUnauthenticated(): void { $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode(['name' => 'Groceries']), ); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'POST /api/buckets must require authentication.', ); } public function testGetCollectionReturnsPersistedBucketsWhenAuthenticated(): void { $scenario = $this->persistScenario('Household Budget'); $this->persistBucket($scenario, 'Rent', 1); $this->persistBucket($scenario, 'Groceries', 2); $this->login(); $this->client->request('GET', '/api/buckets', server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'GET /api/buckets 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('Rent', $names); self::assertContains('Groceries', $names); } public function testGetItemReturnsTheBucketWhenAuthenticated(): void { $scenario = $this->persistScenario('Household Budget'); $bucket = $this->persistBucket($scenario, 'Rent', 1); $this->login(); $this->client->request('GET', '/api/buckets/'.$bucket->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); $response = $this->client->getResponse(); self::assertSame( 200, $response->getStatusCode(), 'GET /api/buckets/{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('Bucket', $body['@type'] ?? null, 'The item @type must be Bucket.'); self::assertSame('Rent', $body['name'] ?? null); } public function testPostCreatesABucketWhenAuthenticatedAndResolvesScenarioIri(): void { $scenario = $this->persistScenario('Household Budget'); $this->login(); $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'name' => 'Groceries', 'priority' => 1, ]), ); $response = $this->client->getResponse(); self::assertSame( 201, $response->getStatusCode(), 'POST /api/buckets 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('Groceries', $body['name'] ?? null); $this->em->clear(); $persisted = $this->em->getRepository(Bucket::class)->findOneBy(['name' => 'Groceries']); self::assertNotNull( $persisted, 'A successful POST /api/buckets must persist the new Bucket to the database.', ); self::assertSame( $scenario->getId()->toRfc4122(), $persisted->getScenario()->getId()->toRfc4122(), 'The persisted Bucket must resolve its scenario relation from the posted IRI.', ); } public function testPostWithIncompatibleAllocationTypeIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario('Household Budget'); $this->login(); $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'name' => 'Bad Overflow', 'priority' => 1, 'type' => BucketType::OVERFLOW->value, 'allocationType' => BucketAllocationType::FIXED_LIMIT->value, ]), ); $response = $this->client->getResponse(); self::assertSame( 422, $response->getStatusCode(), 'POST /api/buckets with an overflow bucket using a non-unlimited allocation type must be rejected by the CompatibleAllocationType validator (422, not 500).', ); $this->assertHasViolationAt('allocationType', $response); } public function testPostWithSecondOverflowBucketOnSameScenarioIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario('Household Budget'); $existingOverflow = new Bucket(); $existingOverflow->setScenario($scenario); $existingOverflow->setName('Overflow'); $existingOverflow->setPriority(1); $existingOverflow->setType(BucketType::OVERFLOW); $existingOverflow->setAllocationType(BucketAllocationType::UNLIMITED); $this->em->persist($existingOverflow); $this->em->flush(); $this->login(); $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'name' => 'Second Overflow', 'priority' => 2, 'type' => BucketType::OVERFLOW->value, 'allocationType' => BucketAllocationType::UNLIMITED->value, ]), ); $response = $this->client->getResponse(); self::assertSame( 422, $response->getStatusCode(), 'POST /api/buckets adding a second overflow bucket to a scenario that already has one must be rejected by the SingleOverflowPerScenario validator (422, not 500).', ); $this->assertHasViolationAt('type', $response); } public function testPostWithBufferOnNonFixedLimitBucketIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario('Household Budget'); $this->login(); $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'name' => 'Wants', 'priority' => 1, 'type' => BucketType::WANT->value, 'allocationType' => BucketAllocationType::PERCENTAGE->value, 'allocationValue' => 2000, 'bufferMultiplier' => '1.50', ]), ); $response = $this->client->getResponse(); self::assertSame( 422, $response->getStatusCode(), 'POST /api/buckets with a non-zero buffer on a non-fixed-limit bucket must be rejected by the BufferOnlyForFixedLimit validator (422, not 500).', ); $this->assertHasViolationAt('bufferMultiplier', $response); } public function testPostWithMissingNameIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario('Household Budget'); $this->login(); $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'priority' => 1, ]), ); self::assertSame( 422, $this->client->getResponse()->getStatusCode(), 'POST /api/buckets without a name must return 422 Unprocessable Entity (validator-rejected, not a 500).', ); } public function testPostWithMissingPriorityIsRejectedAsUnprocessable(): void { $scenario = $this->persistScenario('Household Budget'); $this->login(); $this->client->request( 'POST', '/api/buckets', server: [ 'CONTENT_TYPE' => 'application/ld+json', 'HTTP_ACCEPT' => 'application/ld+json', ], content: json_encode([ 'scenario' => $this->scenarioIri($scenario), 'name' => 'Groceries', ]), ); $response = $this->client->getResponse(); self::assertSame( 422, $response->getStatusCode(), 'POST /api/buckets without a priority must return 422 Unprocessable Entity (validator-rejected, not a 500).', ); $this->assertHasViolationAt('priority', $response); } public function testGetItemIsRejectedWhenUnauthenticated(): void { $scenario = $this->persistScenario('Household Budget'); $bucket = $this->persistBucket($scenario, 'Rent', 1); $this->client->request('GET', '/api/buckets/'.$bucket->getId(), server: [ 'HTTP_ACCEPT' => 'application/ld+json', ]); self::assertSame( 401, $this->client->getResponse()->getStatusCode(), 'GET /api/buckets/{id} must require authentication.', ); } public function testPostWithNonJsonContentTypeIsRejected(): void { $this->login(); $this->client->request( 'POST', '/api/buckets', ['name' => 'Groceries'], ); self::assertSame( 415, $this->client->getResponse()->getStatusCode(), 'POST /api/buckets without a JSON Content-Type must be rejected with 415 Unsupported Media Type.', ); $this->em->clear(); $persisted = $this->em->getRepository(Bucket::class)->findOneBy(['name' => 'Groceries']); self::assertNull( $persisted, 'A rejected non-JSON POST must not persist a Bucket.', ); } }