915 lines
30 KiB
PHP
915 lines
30 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Functional;
|
|
|
|
use App\Entity\Bucket;
|
|
use App\Entity\Scenario;
|
|
use App\Entity\Stream;
|
|
use App\Entity\User;
|
|
use App\Enum\BucketAllocationType;
|
|
use App\Enum\BucketType;
|
|
use App\Enum\StreamFrequency;
|
|
use App\Enum\StreamType;
|
|
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();
|
|
}
|
|
|
|
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
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* CHARACTERIZATION TEST — pins the CURRENT flat Bucket item AND collection
|
|
* representations before #64 introduces serialization groups elsewhere.
|
|
* #64 does not touch GET /api/buckets or GET /api/buckets/{id} — this
|
|
* test guards against a group definition accidentally narrowing the
|
|
* flat Bucket representation as a side effect. Born green.
|
|
*/
|
|
public function testGetBucketItemAndCollectionFieldsUnchanged(): void
|
|
{
|
|
$scenario = $this->persistScenario('Household Budget');
|
|
$bucket = $this->persistBucket($scenario, 'Rent', 1);
|
|
$bucket->setSortOrder(4);
|
|
$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/buckets/'.$bucket->getId(), server: [
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
]);
|
|
|
|
$itemResponse = $this->client->getResponse();
|
|
|
|
self::assertSame(200, $itemResponse->getStatusCode());
|
|
|
|
$item = json_decode((string) $itemResponse->getContent(), true);
|
|
|
|
self::assertIsArray($item, 'The item response must be a JSON-LD object.');
|
|
self::assertArrayHasKey('@id', $item);
|
|
self::assertSame('Bucket', $item['@type'] ?? null);
|
|
self::assertSame('Rent', $item['name'] ?? null);
|
|
self::assertSame(BucketType::NEED->value, $item['type'] ?? null);
|
|
self::assertSame(1, $item['priority'] ?? null);
|
|
self::assertSame(4, $item['sortOrder'] ?? null);
|
|
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $item['allocationType'] ?? null);
|
|
self::assertSame(150000, $item['allocationValue'] ?? null);
|
|
self::assertSame(5000, $item['startingAmount'] ?? null);
|
|
self::assertSame('1.50', $item['bufferMultiplier'] ?? null);
|
|
|
|
$this->client->request('GET', '/api/buckets', server: [
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
]);
|
|
|
|
$collectionResponse = $this->client->getResponse();
|
|
|
|
self::assertSame(200, $collectionResponse->getStatusCode());
|
|
|
|
$collection = json_decode((string) $collectionResponse->getContent(), true);
|
|
|
|
self::assertIsArray($collection, 'The collection response must be a JSON-LD/Hydra object.');
|
|
self::assertArrayHasKey('member', $collection);
|
|
|
|
$members = array_column($collection['member'], null, 'name');
|
|
|
|
self::assertArrayHasKey('Rent', $members, 'GET /api/buckets must still list the persisted bucket.');
|
|
|
|
$member = $members['Rent'];
|
|
|
|
self::assertArrayHasKey('@id', $member);
|
|
self::assertSame('Bucket', $member['@type'] ?? null);
|
|
self::assertSame(BucketType::NEED->value, $member['type'] ?? null);
|
|
self::assertSame(1, $member['priority'] ?? null);
|
|
self::assertSame(4, $member['sortOrder'] ?? null);
|
|
self::assertSame(BucketAllocationType::FIXED_LIMIT->value, $member['allocationType'] ?? null);
|
|
self::assertSame(150000, $member['allocationValue'] ?? null);
|
|
self::assertSame(5000, $member['startingAmount'] ?? null);
|
|
self::assertSame('1.50', $member['bufferMultiplier'] ?? 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 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.',
|
|
);
|
|
}
|
|
|
|
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
|
|
{
|
|
$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.',
|
|
);
|
|
}
|
|
}
|