diff --git a/src/Entity/Bucket.php b/src/Entity/Bucket.php index ec09345..bda8e03 100644 --- a/src/Entity/Bucket.php +++ b/src/Entity/Bucket.php @@ -2,6 +2,10 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; use App\Enum\BucketAllocationType; use App\Enum\BucketType; use App\Repository\BucketRepository; @@ -18,6 +22,10 @@ use Symfony\Component\Validator\Constraints as Assert; #[CompatibleAllocationType] #[SingleOverflowPerScenario] #[BufferOnlyForFixedLimit] +#[ApiResource( + operations: [new GetCollection(), new Get(), new Post()], + security: "is_granted('ROLE_USER')", +)] class Bucket { #[ORM\Id] @@ -29,6 +37,7 @@ class Bucket private Scenario $scenario; #[ORM\Column(length: 255)] + #[Assert\NotBlank] private string $name; #[ORM\Column(enumType: BucketType::class)] diff --git a/src/Entity/Stream.php b/src/Entity/Stream.php index ffbbf57..2cebbbd 100644 --- a/src/Entity/Stream.php +++ b/src/Entity/Stream.php @@ -2,15 +2,24 @@ namespace App\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; use App\Enum\StreamFrequency; use App\Enum\StreamType; use App\Repository\StreamRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\UuidV7; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Table(name: 'stream')] #[ORM\Entity(repositoryClass: StreamRepository::class)] +#[ApiResource( + operations: [new GetCollection(), new Get(), new Post()], + security: "is_granted('ROLE_USER')", +)] class Stream { #[ORM\Id] @@ -29,18 +38,22 @@ class Stream private string $name; #[ORM\Column] + #[Assert\NotNull] private int $amount; #[ORM\Column(enumType: StreamType::class)] + #[Assert\NotNull] private StreamType $type; #[ORM\Column(type: Types::BOOLEAN, options: ['default' => true])] private bool $isActive = true; #[ORM\Column(enumType: StreamFrequency::class)] + #[Assert\NotNull] private StreamFrequency $frequency; #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Assert\NotNull] private \DateTimeImmutable $startDate; #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] diff --git a/tests/Functional/BucketApiTest.php b/tests/Functional/BucketApiTest.php new file mode 100644 index 0000000..4469ba1 --- /dev/null +++ b/tests/Functional/BucketApiTest.php @@ -0,0 +1,399 @@ +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).', + ); + } +} diff --git a/tests/Functional/StreamApiTest.php b/tests/Functional/StreamApiTest.php new file mode 100644 index 0000000..e0682bb --- /dev/null +++ b/tests/Functional/StreamApiTest.php @@ -0,0 +1,287 @@ +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 Stream 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 persistStream(Scenario $scenario, string $name): Stream + { + $stream = new Stream(); + $stream->setScenario($scenario); + $stream->setName($name); + $stream->setAmount(300000); + $stream->setType(StreamType::INCOME); + $stream->setFrequency(StreamFrequency::MONTHLY); + $stream->setStartDate(new \DateTimeImmutable('2026-01-01')); + + $this->em->persist($stream); + $this->em->flush(); + + return $stream; + } + + private function scenarioIri(Scenario $scenario): string + { + return '/api/scenarios/'.$scenario->getId(); + } + + private function bucketIri(Bucket $bucket): string + { + return '/api/buckets/'.$bucket->getId(); + } + + public function testGetCollectionIsRejectedWhenUnauthenticated(): void + { + $this->client->request('GET', '/api/streams', server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + self::assertSame( + 401, + $this->client->getResponse()->getStatusCode(), + 'GET /api/streams must require authentication.', + ); + } + + public function testGetCollectionReturnsPersistedStreamsWhenAuthenticated(): void + { + $scenario = $this->persistScenario('Household Budget'); + $this->persistStream($scenario, 'Salary'); + + $this->login(); + + $this->client->request('GET', '/api/streams', server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + ]); + + $response = $this->client->getResponse(); + + self::assertSame( + 200, + $response->getStatusCode(), + 'GET /api/streams 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('Salary', $names); + } + + public function testPostCreatesAStreamWithoutABucketWhenAuthenticated(): void + { + $scenario = $this->persistScenario('Household Budget'); + + $this->login(); + + $this->client->request( + 'POST', + '/api/streams', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode([ + 'scenario' => $this->scenarioIri($scenario), + 'name' => 'Salary', + 'amount' => 300000, + 'type' => StreamType::INCOME->value, + 'frequency' => StreamFrequency::MONTHLY->value, + 'startDate' => '2026-01-01', + ]), + ); + + $response = $this->client->getResponse(); + + self::assertSame( + 201, + $response->getStatusCode(), + 'POST /api/streams with a valid body and no bucket 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('Salary', $body['name'] ?? null); + + $this->em->clear(); + + $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Salary']); + + self::assertNotNull( + $persisted, + 'A successful POST /api/streams must persist the new Stream to the database.', + ); + self::assertNull( + $persisted->getBucket(), + 'A Stream posted without a bucket must persist with a null bucket relation.', + ); + } + + public function testPostCreatesAStreamWithABucketWhenAuthenticated(): void + { + $scenario = $this->persistScenario('Household Budget'); + $bucket = $this->persistBucket($scenario, 'Groceries', 1); + + $this->login(); + + $this->client->request( + 'POST', + '/api/streams', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode([ + 'scenario' => $this->scenarioIri($scenario), + 'bucket' => $this->bucketIri($bucket), + 'name' => 'Grocery Spend', + 'amount' => 15000, + 'type' => StreamType::EXPENSE->value, + 'frequency' => StreamFrequency::WEEKLY->value, + 'startDate' => '2026-01-01', + ]), + ); + + $response = $this->client->getResponse(); + + self::assertSame( + 201, + $response->getStatusCode(), + 'POST /api/streams with a valid body including a bucket IRI must return 201 Created.', + ); + + $this->em->clear(); + + $persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Grocery Spend']); + + self::assertNotNull( + $persisted, + 'A successful POST /api/streams must persist the new Stream to the database.', + ); + self::assertNotNull( + $persisted->getBucket(), + 'The persisted Stream must resolve its nullable bucket relation from the posted IRI.', + ); + self::assertSame( + $bucket->getId()->toRfc4122(), + $persisted->getBucket()->getId()->toRfc4122(), + ); + } + + public function testPostWithMissingStartDateIsRejectedAsUnprocessable(): void + { + $scenario = $this->persistScenario('Household Budget'); + + $this->login(); + + $this->client->request( + 'POST', + '/api/streams', + server: [ + 'CONTENT_TYPE' => 'application/ld+json', + 'HTTP_ACCEPT' => 'application/ld+json', + ], + content: json_encode([ + 'scenario' => $this->scenarioIri($scenario), + 'name' => 'Salary', + 'amount' => 300000, + 'type' => StreamType::INCOME->value, + 'frequency' => StreamFrequency::MONTHLY->value, + ]), + ); + + self::assertSame( + 422, + $this->client->getResponse()->getStatusCode(), + 'POST /api/streams without a startDate must return 422 Unprocessable Entity (validator-rejected, not a 500).', + ); + } +}