2026-06-20 10:00:54 +02:00
< ? 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).' ,
);
}
2026-06-20 10:15:31 +02:00
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.' ,
);
}
2026-06-20 10:00:54 +02:00
}