57 - Add DELETE to the Bucket API

This commit is contained in:
myrmidex 2026-06-27 20:03:54 +02:00
parent b932228759
commit cb4af693b1
3 changed files with 161 additions and 1 deletions

View file

@ -3,6 +3,7 @@
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
@ -26,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[UniquePriorityPerScenario] #[UniquePriorityPerScenario]
#[BufferOnlyForFixedLimit] #[BufferOnlyForFixedLimit]
#[ApiResource( #[ApiResource(
operations: [new GetCollection(), new Get(), new Post(), new Patch()], operations: [new GetCollection(), new Get(), new Post(), new Patch(), new Delete()],
security: "is_granted('ROLE_USER')", security: "is_granted('ROLE_USER')",
)] )]
class Bucket class Bucket

View file

@ -4,9 +4,12 @@ namespace App\Tests\Functional;
use App\Entity\Bucket; use App\Entity\Bucket;
use App\Entity\Scenario; use App\Entity\Scenario;
use App\Entity\Stream;
use App\Entity\User; use App\Entity\User;
use App\Enum\BucketAllocationType; use App\Enum\BucketAllocationType;
use App\Enum\BucketType; use App\Enum\BucketType;
use App\Enum\StreamFrequency;
use App\Enum\StreamType;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser; use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
@ -89,6 +92,23 @@ final class BucketApiTest extends WebTestCase
return '/api/scenarios/'.$scenario->getId(); return '/api/scenarios/'.$scenario->getId();
} }
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;
}
/** /**
* Asserts that the JSON-LD 422 response body contains a Hydra * Asserts that the JSON-LD 422 response body contains a Hydra
* ConstraintViolationList violation at the given propertyPath, pinning * ConstraintViolationList violation at the given propertyPath, pinning
@ -688,6 +708,112 @@ final class BucketApiTest extends WebTestCase
); );
} }
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.',
);
}
public function testPostWithNonJsonContentTypeIsRejected(): void public function testPostWithNonJsonContentTypeIsRejected(): void
{ {
$this->login(); $this->login();

View file

@ -317,6 +317,39 @@ final class BucketOwnerApiTest extends WebTestCase
); );
} }
public function testDeleteOnABucketOwnedBySomeoneElseReturnsNotFound(): void
{
$othersScenario = $this->persistScenarioFor($this->otherUser, 'Someone Else\'s Fund');
$othersBucket = $this->persistBucket($othersScenario, 'Their Rent', 1);
$this->login();
$this->client->request(
'DELETE',
'/api/buckets/'.$othersBucket->getId(),
server: [
'HTTP_ACCEPT' => 'application/ld+json',
],
);
self::assertSame(
404,
$this->client->getResponse()->getStatusCode(),
'DELETE /api/buckets/{id} for a bucket whose scenario is owned by another user must return 404, not '
.'403 — no existence oracle for buckets you do not own, scoped transitively via scenario.owner.',
);
$this->em->clear();
$persisted = $this->em->getRepository(Bucket::class)->find($othersBucket->getId());
self::assertNotNull(
$persisted,
'A rejected DELETE against a non-owned bucket must never delete it — no cross-tenant deletion must '
.'leak through the 404 item-resolution failure.',
);
}
/** /**
* Mirrors the class-level docblock on {@see testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound()}: * Mirrors the class-level docblock on {@see testPostUnderAForeignScenarioCurrentlyReturnsBadRequestNotFound()}:
* this pins OBSERVED behaviour for an attempted cross-tenant re-parent via PATCH, not an assumed contract. * this pins OBSERVED behaviour for an attempted cross-tenant re-parent via PATCH, not an assumed contract.