buckets/tests/Functional/BucketApiTest.php

473 lines
15 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\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;
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 Bucket 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;
}
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.',
);
}
}