31 - Expose Bucket and Stream via API
This commit is contained in:
parent
e4ae6dabf6
commit
1a90a18b49
4 changed files with 708 additions and 0 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
399
tests/Functional/BucketApiTest.php
Normal file
399
tests/Functional/BucketApiTest.php
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\User;
|
||||
use App\Enum\BucketAllocationType;
|
||||
use App\Enum\BucketType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||
|
||||
final class BucketApiTest extends WebTestCase
|
||||
{
|
||||
private const EMAIL = 'bucket-api-test@example.com';
|
||||
private const PASSWORD = 'correct-horse-battery-staple';
|
||||
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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).',
|
||||
);
|
||||
}
|
||||
}
|
||||
287
tests/Functional/StreamApiTest.php
Normal file
287
tests/Functional/StreamApiTest.php
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<?php
|
||||
|
||||
namespace App\Tests\Functional;
|
||||
|
||||
use App\Entity\Bucket;
|
||||
use App\Entity\Scenario;
|
||||
use App\Entity\Stream;
|
||||
use App\Entity\User;
|
||||
use App\Enum\StreamFrequency;
|
||||
use App\Enum\StreamType;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
|
||||
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
||||
|
||||
final class StreamApiTest extends WebTestCase
|
||||
{
|
||||
private const EMAIL = 'stream-api-test@example.com';
|
||||
private const PASSWORD = 'correct-horse-battery-staple';
|
||||
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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).',
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue