buckets/tests/Functional/ScenarioApiTest.php

626 lines
20 KiB
PHP

<?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\PasswordHasher\Hasher\NativePasswordHasher;
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
final class ScenarioApiTest extends WebTestCase
{
private const EMAIL = 'scenario-api-test@example.com';
private const PASSWORD = 'correct-horse-battery-staple';
private KernelBrowser $client;
private EntityManagerInterface $em;
private User $user;
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);
$this->user = new User();
$this->user->setEmail(self::EMAIL);
$this->user->setPassword($hasher->hashPassword($this->user, self::PASSWORD));
$this->em->persist($this->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);
$scenario->setOwner($this->user);
$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;
}
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 testGetItemEmbedsItsBuckets(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->persistBucket($scenario, 'Rent', 1);
$this->persistBucket($scenario, 'Groceries', 2);
$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 owner.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertArrayHasKey(
'buckets',
$body,
'The Scenario item representation must embed its buckets so the SPA can fetch one resource.',
);
self::assertIsArray($body['buckets'], 'The embedded buckets field must be an array.');
$names = array_column($body['buckets'], 'name');
self::assertContains('Rent', $names);
self::assertContains('Groceries', $names);
}
public function testEmbeddedBucketsExposeTheFieldsTheFrontendNeeds(): void
{
$scenario = $this->persistScenario('Household Budget');
$bucket = $this->persistBucket($scenario, 'Rent', 1);
$bucket->setSortOrder(3);
$bucket->setType(BucketType::NEED);
$bucket->setAllocationType(BucketAllocationType::FIXED_LIMIT);
$bucket->setAllocationValue(150000);
$bucket->setStartingAmount(5000);
$bucket->setBufferMultiplier('1.50');
$this->em->flush();
$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());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertIsArray($body['buckets'] ?? null);
$embedded = $body['buckets'][0] ?? null;
self::assertIsArray($embedded, 'The embedded bucket must be an object.');
self::assertArrayHasKey(
'@id',
$embedded,
'Each embedded bucket must expose its own IRI so the SPA can target it for inline PATCH/DELETE from the nested view.',
);
self::assertSame('/api/buckets/'.$bucket->getId(), $embedded['@id'] ?? null);
self::assertSame('Rent', $embedded['name'] ?? null);
self::assertSame(BucketType::NEED->value, $embedded['type'] ?? null);
self::assertSame(1, $embedded['priority'] ?? null);
self::assertSame(3, $embedded['sortOrder'] ?? null);
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $embedded['allocationType'] ?? null);
self::assertSame(
150000,
$embedded['allocationValue'] ?? null,
'allocationValue must serialize as an int literal, not a float.',
);
self::assertSame(
5000,
$embedded['startingAmount'] ?? null,
'startingAmount must serialize as an int literal, not a float.',
);
self::assertSame('1.50', $embedded['bufferMultiplier'] ?? null);
}
public function testEmbeddedBucketsAreOrderedBySortOrder(): void
{
$scenario = $this->persistScenario('Household Budget');
$last = $this->persistBucket($scenario, 'Last', 1);
$last->setSortOrder(2);
$first = $this->persistBucket($scenario, 'First', 2);
$first->setSortOrder(0);
$middle = $this->persistBucket($scenario, 'Middle', 3);
$middle->setSortOrder(1);
$this->em->flush();
$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());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
$names = array_column($body['buckets'] ?? [], 'name');
self::assertSame(
['First', 'Middle', 'Last'],
$names,
'Embedded buckets must be ordered by sortOrder ascending, regardless of persistence order.',
);
}
public function testScenarioWithNoBucketsEmbedsAnEmptyBucketsArray(): void
{
$scenario = $this->persistScenario('Empty Scenario');
$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(),
'A scenario with no buckets must still return 200, not error.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertArrayHasKey('buckets', $body);
self::assertSame(
[],
$body['buckets'],
'A bucket-less scenario must embed an empty buckets array, not null or a missing key.',
);
}
public function testGetCollectionDoesNotEmbedBuckets(): void
{
$scenario = $this->persistScenario('Household Budget');
$this->persistBucket($scenario, 'Rent', 1);
$this->login();
$this->client->request('GET', '/api/scenarios', server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(200, $response->getStatusCode());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The collection response must be a JSON-LD/Hydra object.');
self::assertArrayHasKey('member', $body);
$members = array_column($body['member'], null, 'name');
self::assertArrayHasKey('Household Budget', $members);
self::assertArrayNotHasKey(
'buckets',
$members['Household Budget'],
'GET /api/scenarios collection members must NOT embed buckets — only the item op does.',
);
}
/**
* CHARACTERIZATION TEST — pins the CURRENT item representation before
* serialization groups land (#64). Confirms name/description/@id/@var
* survive the group-flip. Owner-presence is intentionally NOT asserted
* here — it is dropped as part of #64, so pinning it now would make this
* test fail for the wrong reason once groups land. Born green.
*/
public function testGetItemExposesScenarioFieldsUnchanged(): void
{
$scenario = $this->persistScenario('Emergency Fund');
$scenario->setDescription('Six months of essential expenses.');
$this->em->flush();
$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 owner.',
);
$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);
self::assertSame(
'Six months of essential expenses.',
$body['description'] ?? null,
'The item representation must still expose description.',
);
}
public function testGetItemDoesNotExposeOwner(): 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());
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertArrayNotHasKey(
'owner',
$body,
'The Scenario item representation must not leak the owner relation — it is implicit from the '
.'authenticated session, not part of the public shape (#64).',
);
}
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 testPostThenGetOnReturnedIriRoundTripsForTheOwner(): void
{
$this->login();
$this->client->request(
'POST',
'/api/scenarios',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode(['name' => 'New Scenario From UI']),
);
$created = json_decode((string) $this->client->getResponse()->getContent(), true);
self::assertIsArray($created, 'The created resource response must be a JSON-LD object.');
self::assertArrayHasKey(
'@id',
$created,
'POST must return the created scenario IRI so the SPA can redirect to its show page.',
);
$iri = $created['@id'];
$this->client->request('GET', $iri, server: [
'HTTP_ACCEPT' => 'application/ld+json',
]);
$response = $this->client->getResponse();
self::assertSame(
200,
$response->getStatusCode(),
'A GET on the just-created IRI by the same owner must return 200 — the create->redirect->show flow must never land on an owner-scope 404.',
);
$body = json_decode((string) $response->getContent(), true);
self::assertIsArray($body, 'The item response must be a JSON-LD object.');
self::assertSame(
$iri,
$body['@id'] ?? null,
'The show page must resolve the same IRI the create response returned.',
);
self::assertSame('New Scenario From UI', $body['name'] ?? null);
}
public function testPostWithDescriptionPersistsAndEchoesIt(): void
{
$this->login();
$this->client->request(
'POST',
'/api/scenarios',
server: [
'CONTENT_TYPE' => 'application/ld+json',
'HTTP_ACCEPT' => 'application/ld+json',
],
content: json_encode([
'name' => 'Documented Fund',
'description' => 'Money set aside for the kitchen renovation.',
]),
);
$response = $this->client->getResponse();
self::assertSame(
201,
$response->getStatusCode(),
'POST /api/scenarios with name + optional description 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::assertSame(
'Money set aside for the kitchen renovation.',
$body['description'] ?? null,
'The create response must echo the supplied optional description back to the SPA.',
);
$this->em->clear();
$persisted = $this->em->getRepository(Scenario::class)->findOneBy(['name' => 'Documented Fund']);
self::assertNotNull(
$persisted,
'A successful POST with a description must persist the new Scenario.',
);
self::assertSame(
'Money set aside for the kitchen renovation.',
$persisted->getDescription(),
'The optional description must survive persistence.',
);
}
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.',
);
}
}