383 lines
12 KiB
PHP
383 lines
12 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\StreamFrequency;
|
|
use App\Enum\StreamType;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher;
|
|
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
|
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasher;
|
|
|
|
final class StreamApiTest extends WebTestCase
|
|
{
|
|
private const EMAIL = 'stream-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 Stream 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 persistStream(Scenario $scenario, string $name): Stream
|
|
{
|
|
$stream = new Stream();
|
|
$stream->setScenario($scenario);
|
|
$stream->setName($name);
|
|
$stream->setAmount(300000);
|
|
$stream->setType(StreamType::INCOME);
|
|
$stream->setFrequency(StreamFrequency::MONTHLY);
|
|
$stream->setStartDate(new \DateTimeImmutable('2026-01-01'));
|
|
|
|
$this->em->persist($stream);
|
|
$this->em->flush();
|
|
|
|
return $stream;
|
|
}
|
|
|
|
private function scenarioIri(Scenario $scenario): string
|
|
{
|
|
return '/api/scenarios/'.$scenario->getId();
|
|
}
|
|
|
|
private function bucketIri(Bucket $bucket): string
|
|
{
|
|
return '/api/buckets/'.$bucket->getId();
|
|
}
|
|
|
|
public function testGetCollectionIsRejectedWhenUnauthenticated(): void
|
|
{
|
|
$this->client->request('GET', '/api/streams', server: [
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
]);
|
|
|
|
self::assertSame(
|
|
401,
|
|
$this->client->getResponse()->getStatusCode(),
|
|
'GET /api/streams must require authentication.',
|
|
);
|
|
}
|
|
|
|
public function testGetCollectionReturnsPersistedStreamsWhenAuthenticated(): void
|
|
{
|
|
$scenario = $this->persistScenario('Household Budget');
|
|
$this->persistStream($scenario, 'Salary');
|
|
|
|
$this->login();
|
|
|
|
$this->client->request('GET', '/api/streams', server: [
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
]);
|
|
|
|
$response = $this->client->getResponse();
|
|
|
|
self::assertSame(
|
|
200,
|
|
$response->getStatusCode(),
|
|
'GET /api/streams 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('Salary', $names);
|
|
}
|
|
|
|
public function testPostCreatesAStreamWithoutABucketWhenAuthenticated(): void
|
|
{
|
|
$scenario = $this->persistScenario('Household Budget');
|
|
|
|
$this->login();
|
|
|
|
$this->client->request(
|
|
'POST',
|
|
'/api/streams',
|
|
server: [
|
|
'CONTENT_TYPE' => 'application/ld+json',
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
],
|
|
content: json_encode([
|
|
'scenario' => $this->scenarioIri($scenario),
|
|
'name' => 'Salary',
|
|
'amount' => 300000,
|
|
'type' => StreamType::INCOME->value,
|
|
'frequency' => StreamFrequency::MONTHLY->value,
|
|
'startDate' => '2026-01-01',
|
|
]),
|
|
);
|
|
|
|
$response = $this->client->getResponse();
|
|
|
|
self::assertSame(
|
|
201,
|
|
$response->getStatusCode(),
|
|
'POST /api/streams with a valid body and no bucket 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('Salary', $body['name'] ?? null);
|
|
|
|
$this->em->clear();
|
|
|
|
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Salary']);
|
|
|
|
self::assertNotNull(
|
|
$persisted,
|
|
'A successful POST /api/streams must persist the new Stream to the database.',
|
|
);
|
|
self::assertNull(
|
|
$persisted->getBucket(),
|
|
'A Stream posted without a bucket must persist with a null bucket relation.',
|
|
);
|
|
}
|
|
|
|
public function testPostCreatesAStreamWithABucketWhenAuthenticated(): void
|
|
{
|
|
$scenario = $this->persistScenario('Household Budget');
|
|
$bucket = $this->persistBucket($scenario, 'Groceries', 1);
|
|
|
|
$this->login();
|
|
|
|
$this->client->request(
|
|
'POST',
|
|
'/api/streams',
|
|
server: [
|
|
'CONTENT_TYPE' => 'application/ld+json',
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
],
|
|
content: json_encode([
|
|
'scenario' => $this->scenarioIri($scenario),
|
|
'bucket' => $this->bucketIri($bucket),
|
|
'name' => 'Grocery Spend',
|
|
'amount' => 15000,
|
|
'type' => StreamType::EXPENSE->value,
|
|
'frequency' => StreamFrequency::WEEKLY->value,
|
|
'startDate' => '2026-01-01',
|
|
]),
|
|
);
|
|
|
|
$response = $this->client->getResponse();
|
|
|
|
self::assertSame(
|
|
201,
|
|
$response->getStatusCode(),
|
|
'POST /api/streams with a valid body including a bucket IRI must return 201 Created.',
|
|
);
|
|
|
|
$this->em->clear();
|
|
|
|
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Grocery Spend']);
|
|
|
|
self::assertNotNull(
|
|
$persisted,
|
|
'A successful POST /api/streams must persist the new Stream to the database.',
|
|
);
|
|
self::assertNotNull(
|
|
$persisted->getBucket(),
|
|
'The persisted Stream must resolve its nullable bucket relation from the posted IRI.',
|
|
);
|
|
self::assertSame(
|
|
$bucket->getId()->toRfc4122(),
|
|
$persisted->getBucket()->getId()->toRfc4122(),
|
|
);
|
|
}
|
|
|
|
public function testPostWithMissingStartDateIsRejectedAsUnprocessable(): void
|
|
{
|
|
$scenario = $this->persistScenario('Household Budget');
|
|
|
|
$this->login();
|
|
|
|
$this->client->request(
|
|
'POST',
|
|
'/api/streams',
|
|
server: [
|
|
'CONTENT_TYPE' => 'application/ld+json',
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
],
|
|
content: json_encode([
|
|
'scenario' => $this->scenarioIri($scenario),
|
|
'name' => 'Salary',
|
|
'amount' => 300000,
|
|
'type' => StreamType::INCOME->value,
|
|
'frequency' => StreamFrequency::MONTHLY->value,
|
|
]),
|
|
);
|
|
|
|
self::assertSame(
|
|
422,
|
|
$this->client->getResponse()->getStatusCode(),
|
|
'POST /api/streams without a startDate must return 422 Unprocessable Entity (validator-rejected, not a 500).',
|
|
);
|
|
}
|
|
|
|
public function testPostWithMissingNameIsRejectedAsUnprocessable(): void
|
|
{
|
|
$scenario = $this->persistScenario('Household Budget');
|
|
|
|
$this->login();
|
|
|
|
$this->client->request(
|
|
'POST',
|
|
'/api/streams',
|
|
server: [
|
|
'CONTENT_TYPE' => 'application/ld+json',
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
],
|
|
content: json_encode([
|
|
'scenario' => $this->scenarioIri($scenario),
|
|
'amount' => 300000,
|
|
'type' => StreamType::INCOME->value,
|
|
'frequency' => StreamFrequency::MONTHLY->value,
|
|
'startDate' => '2026-01-01',
|
|
]),
|
|
);
|
|
|
|
self::assertSame(
|
|
422,
|
|
$this->client->getResponse()->getStatusCode(),
|
|
'POST /api/streams without a name must return 422 Unprocessable Entity (validator-rejected, not a 500).',
|
|
);
|
|
}
|
|
|
|
public function testGetItemIsRejectedWhenUnauthenticated(): void
|
|
{
|
|
$scenario = $this->persistScenario('Household Budget');
|
|
$stream = $this->persistStream($scenario, 'Salary');
|
|
|
|
$this->client->request('GET', '/api/streams/'.$stream->getId(), server: [
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
]);
|
|
|
|
self::assertSame(
|
|
401,
|
|
$this->client->getResponse()->getStatusCode(),
|
|
'GET /api/streams/{id} must require authentication.',
|
|
);
|
|
}
|
|
|
|
public function testPostIsRejectedWhenUnauthenticated(): void
|
|
{
|
|
$this->client->request(
|
|
'POST',
|
|
'/api/streams',
|
|
server: [
|
|
'CONTENT_TYPE' => 'application/ld+json',
|
|
'HTTP_ACCEPT' => 'application/ld+json',
|
|
],
|
|
content: json_encode([
|
|
'name' => 'Salary',
|
|
'amount' => 300000,
|
|
'type' => StreamType::INCOME->value,
|
|
'frequency' => StreamFrequency::MONTHLY->value,
|
|
'startDate' => '2026-01-01',
|
|
]),
|
|
);
|
|
|
|
self::assertSame(
|
|
401,
|
|
$this->client->getResponse()->getStatusCode(),
|
|
'POST /api/streams must require authentication.',
|
|
);
|
|
}
|
|
|
|
public function testPostWithNonJsonContentTypeIsRejected(): void
|
|
{
|
|
$this->login();
|
|
|
|
$this->client->request(
|
|
'POST',
|
|
'/api/streams',
|
|
['name' => 'Salary'],
|
|
);
|
|
|
|
self::assertSame(
|
|
415,
|
|
$this->client->getResponse()->getStatusCode(),
|
|
'POST /api/streams without a JSON Content-Type must be rejected with 415 Unsupported Media Type.',
|
|
);
|
|
|
|
$this->em->clear();
|
|
|
|
$persisted = $this->em->getRepository(Stream::class)->findOneBy(['name' => 'Salary']);
|
|
|
|
self::assertNull(
|
|
$persisted,
|
|
'A rejected non-JSON POST must not persist a Stream.',
|
|
);
|
|
}
|
|
}
|