2026-06-20 10:00:54 +02:00
< ? php
namespace App\Tests\Functional ;
use App\Entity\Bucket ;
use App\Entity\Scenario ;
2026-06-27 20:03:54 +02:00
use App\Entity\Stream ;
2026-06-20 10:00:54 +02:00
use App\Entity\User ;
use App\Enum\BucketAllocationType ;
use App\Enum\BucketType ;
2026-06-27 20:03:54 +02:00
use App\Enum\StreamFrequency ;
use App\Enum\StreamType ;
2026-06-20 10:00:54 +02:00
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 ;
2026-06-24 23:26:46 +02:00
private User $user ;
2026-06-20 10:00:54 +02:00
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 );
2026-06-24 23:26:46 +02:00
$this -> user = new User ();
$this -> user -> setEmail ( self :: EMAIL );
$this -> user -> setPassword ( $hasher -> hashPassword ( $this -> user , self :: PASSWORD ));
2026-06-20 10:00:54 +02:00
2026-06-24 23:26:46 +02:00
$this -> em -> persist ( $this -> user );
2026-06-20 10:00:54 +02:00
$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 );
2026-06-24 23:26:46 +02:00
$scenario -> setOwner ( $this -> user );
2026-06-20 10:00:54 +02:00
$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 ();
}
2026-06-27 20:03:54 +02:00
private function persistStream ( Scenario $scenario , Bucket $bucket , string $name ) : Stream
{
$stream = new Stream ();
$stream -> setScenario ( $scenario );
$stream -> setBucket ( $bucket );
$stream -> setName ( $name );
$stream -> setAmount ( 1000 );
$stream -> setType ( StreamType :: INCOME );
$stream -> setFrequency ( StreamFrequency :: MONTHLY );
$stream -> setStartDate ( new \DateTimeImmutable ( '2026-01-01' ));
$this -> em -> persist ( $stream );
$this -> em -> flush ();
return $stream ;
}
2026-06-20 10:00:54 +02:00
/**
* 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.' ,
);
}
2026-06-27 17:33:25 +02:00
public function testPatchUpdatesAnOwnedBucketPartially () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$bucket = $this -> persistBucket ( $scenario , 'Rent' , 1 );
$this -> login ();
$this -> client -> request (
'PATCH' ,
'/api/buckets/' . $bucket -> getId (),
server : [
'CONTENT_TYPE' => 'application/merge-patch+json' ,
'HTTP_ACCEPT' => 'application/ld+json' ,
],
content : json_encode ([ 'name' => 'Rent (updated)' ]),
);
$response = $this -> client -> getResponse ();
self :: assertSame (
200 ,
$response -> getStatusCode (),
'PATCH /api/buckets/{id} with a valid merge-patch body for an owned bucket must return 200.' ,
);
$this -> em -> clear ();
$persisted = $this -> em -> getRepository ( Bucket :: class ) -> find ( $bucket -> getId ());
self :: assertNotNull ( $persisted , 'The patched bucket must still exist.' );
self :: assertSame (
'Rent (updated)' ,
$persisted -> getName (),
'The patched field (name) must be updated.' ,
);
self :: assertSame (
1 ,
$persisted -> getPriority (),
'A field NOT included in the PATCH body (priority) must remain unchanged — proves a partial merge, not a full replace.' ,
);
self :: assertSame (
BucketType :: NEED ,
$persisted -> getType (),
'A field NOT included in the PATCH body (type) must remain unchanged — proves a partial merge, not a full replace.' ,
);
}
public function testPatchRequiresMergePatchContentType () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$bucket = $this -> persistBucket ( $scenario , 'Rent' , 1 );
$this -> login ();
$this -> client -> request (
'PATCH' ,
'/api/buckets/' . $bucket -> getId (),
server : [
'CONTENT_TYPE' => 'application/ld+json' ,
'HTTP_ACCEPT' => 'application/ld+json' ,
],
content : json_encode ([ 'name' => 'Rent (updated)' ]),
);
self :: assertSame (
415 ,
$this -> client -> getResponse () -> getStatusCode (),
'PATCH /api/buckets/{id} with the wrong content type (application/ld+json instead of application/merge-patch+json) must be rejected with 415.' ,
);
$this -> em -> clear ();
$persisted = $this -> em -> getRepository ( Bucket :: class ) -> find ( $bucket -> getId ());
self :: assertNotNull ( $persisted );
self :: assertSame (
'Rent' ,
$persisted -> getName (),
'A rejected PATCH (wrong content type) must not mutate the bucket.' ,
);
}
public function testPatchIsRejectedWhenUnauthenticated () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$bucket = $this -> persistBucket ( $scenario , 'Rent' , 1 );
$this -> client -> request (
'PATCH' ,
'/api/buckets/' . $bucket -> getId (),
server : [
'CONTENT_TYPE' => 'application/merge-patch+json' ,
'HTTP_ACCEPT' => 'application/ld+json' ,
],
content : json_encode ([ 'name' => 'Rent (updated)' ]),
);
self :: assertSame (
401 ,
$this -> client -> getResponse () -> getStatusCode (),
'PATCH /api/buckets/{id} must require authentication.' ,
);
}
public function testPatchIntroducingSecondOverflowIsRejectedAsUnprocessable () : 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 ();
$secondBucket = $this -> persistBucket ( $scenario , 'Wants' , 2 );
$this -> login ();
$this -> client -> request (
'PATCH' ,
'/api/buckets/' . $secondBucket -> getId (),
server : [
'CONTENT_TYPE' => 'application/merge-patch+json' ,
'HTTP_ACCEPT' => 'application/ld+json' ,
],
content : json_encode ([
'type' => BucketType :: OVERFLOW -> value ,
'allocationType' => BucketAllocationType :: UNLIMITED -> value ,
]),
);
$response = $this -> client -> getResponse ();
self :: assertSame (
422 ,
$response -> getStatusCode (),
'PATCH /api/buckets/{id} flipping a second bucket to overflow on a scenario that already has one must be rejected by the SingleOverflowPerScenario validator (422, not 500).' ,
);
$this -> assertHasViolationAt ( 'type' , $response );
}
public function testPatchSettingBufferOnNonFixedLimitIsRejectedAsUnprocessable () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$bucket = new Bucket ();
$bucket -> setScenario ( $scenario );
$bucket -> setName ( 'Wants' );
$bucket -> setPriority ( 1 );
$bucket -> setType ( BucketType :: WANT );
$bucket -> setAllocationType ( BucketAllocationType :: PERCENTAGE );
$bucket -> setAllocationValue ( 2000 );
$this -> em -> persist ( $bucket );
$this -> em -> flush ();
$this -> login ();
$this -> client -> request (
'PATCH' ,
'/api/buckets/' . $bucket -> getId (),
server : [
'CONTENT_TYPE' => 'application/merge-patch+json' ,
'HTTP_ACCEPT' => 'application/ld+json' ,
],
content : json_encode ([
'bufferMultiplier' => '1.50' ,
]),
);
$response = $this -> client -> getResponse ();
self :: assertSame (
422 ,
$response -> getStatusCode (),
'PATCH /api/buckets/{id} setting a non-zero buffer on a bucket whose allocation type is not fixed-limit must be rejected by the BufferOnlyForFixedLimit validator (422, not 500).' ,
);
$this -> assertHasViolationAt ( 'bufferMultiplier' , $response );
}
public function testPatchToIncompatibleAllocationTypeIsRejectedAsUnprocessable () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$bucket = $this -> persistBucket ( $scenario , 'Rent' , 1 );
$this -> login ();
$this -> client -> request (
'PATCH' ,
'/api/buckets/' . $bucket -> getId (),
server : [
'CONTENT_TYPE' => 'application/merge-patch+json' ,
'HTTP_ACCEPT' => 'application/ld+json' ,
],
content : json_encode ([
'allocationType' => BucketAllocationType :: UNLIMITED -> value ,
]),
);
$response = $this -> client -> getResponse ();
self :: assertSame (
422 ,
$response -> getStatusCode (),
'PATCH /api/buckets/{id} setting allocationType to a value incompatible with the bucket type (NEED can only be fixed_limit/percentage) must be rejected by the CompatibleAllocationType validator (422, not 500).' ,
);
$this -> assertHasViolationAt ( 'allocationType' , $response );
}
public function testPatchCollidingOnPriorityIsRejected () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$this -> persistBucket ( $scenario , 'Rent' , 1 );
$groceries = $this -> persistBucket ( $scenario , 'Groceries' , 2 );
$this -> login ();
$this -> client -> request (
'PATCH' ,
'/api/buckets/' . $groceries -> getId (),
server : [
'CONTENT_TYPE' => 'application/merge-patch+json' ,
'HTTP_ACCEPT' => 'application/ld+json' ,
],
content : json_encode ([
'priority' => 1 ,
]),
);
self :: assertSame (
422 ,
$this -> client -> getResponse () -> getStatusCode (),
'PATCH /api/buckets/{id} colliding on priority with another bucket in the same scenario must be rejected as a validation error (422), not surfaced as an unhandled server error.' ,
);
}
2026-06-27 20:03:54 +02:00
public function testDeleteRemovesAnOwnedBucket () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$bucket = $this -> persistBucket ( $scenario , 'Rent' , 1 );
$this -> login ();
$this -> client -> request (
'DELETE' ,
'/api/buckets/' . $bucket -> getId (),
server : [
'HTTP_ACCEPT' => 'application/ld+json' ,
],
);
self :: assertSame (
204 ,
$this -> client -> getResponse () -> getStatusCode (),
'DELETE /api/buckets/{id} for an owned bucket must return 204 No Content.' ,
);
$this -> em -> clear ();
$persisted = $this -> em -> getRepository ( Bucket :: class ) -> find ( $bucket -> getId ());
self :: assertNull (
$persisted ,
'A successful DELETE /api/buckets/{id} must remove the Bucket from the database.' ,
);
}
/**
* CHARACTERIZATION TEST — pins existing DB - level behaviour , not new logic .
*
* `stream.bucket_id` has an `ON DELETE SET NULL` foreign key ( migration
* Version20260619175824 , predating #57). This test confirms the
* API - level / ORM consequence : deleting a Bucket that a Stream points at
* does NOT cascade - delete the Stream — the Stream survives with its
* `bucket` relation nulled out . The cascade already works ; this test
* was born green .
*/
public function testDeletingABucketReferencedByAStreamNullsTheStreamsBucket () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$bucket = $this -> persistBucket ( $scenario , 'Rent' , 1 );
$stream = $this -> persistStream ( $scenario , $bucket , 'Paycheck' );
$this -> login ();
$this -> client -> request (
'DELETE' ,
'/api/buckets/' . $bucket -> getId (),
server : [
'HTTP_ACCEPT' => 'application/ld+json' ,
],
);
self :: assertSame (
204 ,
$this -> client -> getResponse () -> getStatusCode (),
'DELETE /api/buckets/{id} for an owned bucket referenced by a stream must still return 204 No Content.' ,
);
$this -> em -> clear ();
$persistedBucket = $this -> em -> find ( Bucket :: class , $bucket -> getId ());
self :: assertNull (
$persistedBucket ,
'The deleted Bucket must no longer exist.' ,
);
$persistedStream = $this -> em -> find ( Stream :: class , $stream -> getId ());
self :: assertNotNull (
$persistedStream ,
'The Stream that referenced the deleted Bucket must survive the delete — only its bucket relation '
. 'is nulled out, the stream itself is not cascade-deleted.' ,
);
self :: assertNull (
$persistedStream -> getBucket (),
'After deleting the referenced Bucket, the Stream\'s bucket relation must be SET NULL by the '
. 'database foreign key (ON DELETE SET NULL on stream.bucket_id).' ,
);
}
public function testDeleteIsRejectedWhenUnauthenticated () : void
{
$scenario = $this -> persistScenario ( 'Household Budget' );
$bucket = $this -> persistBucket ( $scenario , 'Rent' , 1 );
$this -> client -> request (
'DELETE' ,
'/api/buckets/' . $bucket -> getId (),
server : [
'HTTP_ACCEPT' => 'application/ld+json' ,
],
);
self :: assertSame (
401 ,
$this -> client -> getResponse () -> getStatusCode (),
'DELETE /api/buckets/{id} must require authentication.' ,
);
}
2026-06-20 10:15:31 +02:00
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
}