Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions tests/Application/Actions/ActionErrorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);

namespace Tests\Application\Actions;

use App\Application\Actions\ActionError;
use PHPUnit\Framework\TestCase;

class ActionErrorTest extends TestCase
{
public function testExposesTypeAndDescription(): void
{
$error = new ActionError(ActionError::VALIDATION_ERROR, 'Name is required');

$this->assertSame(ActionError::VALIDATION_ERROR, $error->getType());
$this->assertSame('Name is required', $error->getDescription());
}

public function testSettersAreFluentAndMutateState(): void
{
$error = new ActionError(ActionError::SERVER_ERROR, 'Boom');

$this->assertSame($error, $error->setType(ActionError::BAD_REQUEST));
$this->assertSame($error, $error->setDescription('Bad input'));

$this->assertSame(ActionError::BAD_REQUEST, $error->getType());
$this->assertSame('Bad input', $error->getDescription());
}

public function testSerializesTypeAndDescription(): void
{
$error = new ActionError(ActionError::NOT_ALLOWED, 'Use POST');

$this->assertSame([
'type' => ActionError::NOT_ALLOWED,
'description' => 'Use POST',
], $error->jsonSerialize());
}

public function testEncodesToJson(): void
{
$error = new ActionError(ActionError::UNAUTHENTICATED, 'Log in first');

$this->assertSame(
'{"type":"UNAUTHENTICATED","description":"Log in first"}',
json_encode($error)
);
}
}
79 changes: 79 additions & 0 deletions tests/Application/Actions/ActionPayloadTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);

namespace Tests\Application\Actions;

use App\Application\Actions\ActionError;
use App\Application\Actions\ActionPayload;
use PHPUnit\Framework\TestCase;

class ActionPayloadTest extends TestCase
{
public function testDefaultsToOkWithNoDataOrError(): void
{
$payload = new ActionPayload();

$this->assertSame(200, $payload->getStatusCode());
$this->assertNull($payload->getData());
$this->assertNull($payload->getError());
}

public function testExposesConstructorArguments(): void
{
$error = new ActionError(ActionError::BAD_REQUEST, 'Nope');
$payload = new ActionPayload(400, ['id' => 'C137'], $error);

$this->assertSame(400, $payload->getStatusCode());
$this->assertSame(['id' => 'C137'], $payload->getData());
$this->assertSame($error, $payload->getError());
}

public function testSerializesDataWhenPresent(): void
{
$payload = new ActionPayload(200, ['name' => 'Rick Sanchez']);

$this->assertSame([
'statusCode' => 200,
'data' => ['name' => 'Rick Sanchez'],
], $payload->jsonSerialize());
}

/**
* Data wins over error when both are set — only one key is ever emitted.
*/
public function testDataTakesPrecedenceOverError(): void
{
$payload = new ActionPayload(200, ['ok' => true], new ActionError(ActionError::SERVER_ERROR, 'Boom'));

$serialized = $payload->jsonSerialize();

$this->assertArrayHasKey('data', $serialized);
$this->assertArrayNotHasKey('error', $serialized);
}

public function testSerializesErrorWhenThereIsNoData(): void
{
$error = new ActionError(ActionError::RESOURCE_NOT_FOUND, 'Not here');
$payload = new ActionPayload(404, null, $error);

$this->assertSame([
'statusCode' => 404,
'error' => $error,
], $payload->jsonSerialize());
}

public function testSerializesOnlyStatusCodeWhenEmpty(): void
{
$this->assertSame(['statusCode' => 204], (new ActionPayload(204))->jsonSerialize());
}

public function testEncodesToJson(): void
{
$payload = new ActionPayload(404, null, new ActionError(ActionError::RESOURCE_NOT_FOUND, 'Not here'));

$this->assertSame(
'{"statusCode":404,"error":{"type":"RESOURCE_NOT_FOUND","description":"Not here"}}',
json_encode($payload)
);
}
}
110 changes: 110 additions & 0 deletions tests/Application/Actions/ActionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);

namespace Tests\Application\Actions;

use App\Application\Actions\Action;
use App\Domain\Todo\TodoNotFoundException;
use Closure;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Log\NullLogger;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Factory\ResponseFactory;
use Tests\TestCase;

/**
* Action is abstract, so these exercise the shared behaviour through a minimal
* concrete subclass rather than through any one route.
*/
class ActionTest extends TestCase
{
/**
* @param Closure(ActionTestDouble): Response $action
*/
private function invoke(Closure $action, array $args = []): Response
{
$double = new ActionTestDouble(new NullLogger(), $action);

return $double(
$this->createRequest('GET', '/todos'),
(new ResponseFactory())->createResponse(),
$args
);
}

public function testResolveArgReturnsAKnownArgument(): void
{
$response = $this->invoke(
fn (ActionTestDouble $a): Response => $a->respondWith(['id' => $a->arg('id')]),
['id' => 'C137']
);

$response->getBody()->rewind();
$this->assertSame('C137', json_decode((string) $response->getBody(), true)['data']['id']);
}

public function testResolveArgRejectsAnUnknownArgument(): void
{
$this->expectException(HttpBadRequestException::class);
$this->expectExceptionMessage('Could not resolve argument `missing`.');

$this->invoke(fn (ActionTestDouble $a): Response => $a->respondWith($a->arg('missing')));
}

/**
* Domain "not found" errors are translated into HTTP 404 by the base class, so
* actions never have to know about HTTP.
*/
public function testDomainNotFoundBecomesHttpNotFound(): void
{
$this->expectException(HttpNotFoundException::class);

$this->invoke(function (): Response {
throw new TodoNotFoundException();
});
}

public function testRespondWithDataWrapsPayloadAsJson(): void
{
$response = $this->invoke(fn (ActionTestDouble $a): Response => $a->respondWith(['name' => 'Rick Sanchez']));

$response->getBody()->rewind();

$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
$this->assertSame(
['statusCode' => 200, 'data' => ['name' => 'Rick Sanchez']],
json_decode((string) $response->getBody(), true)
);
}
}

/**
* Concrete Action that runs a supplied closure and exposes the protected helpers.
*/
class ActionTestDouble extends Action
{
/** @var Closure(self): Response */
private Closure $action;

public function __construct(\Psr\Log\LoggerInterface $logger, Closure $action)
{
parent::__construct($logger);
$this->action = $action;
}

protected function action(): Response
{
return ($this->action)($this);
}

public function arg(string $name): mixed
{
return $this->resolveArg($name);
}

public function respondWith(mixed $data): Response
{
return $this->respondWithData($data);
}
}
133 changes: 133 additions & 0 deletions tests/Application/Handlers/HttpErrorHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);

namespace Tests\Application\Handlers;

use App\Application\Actions\ActionError;
use App\Application\Handlers\HttpErrorHandler;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Message\ServerRequestInterface as Request;
use RuntimeException;
use Slim\CallableResolver;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpException;
use Slim\Exception\HttpForbiddenException;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Exception\HttpNotImplementedException;
use Slim\Exception\HttpUnauthorizedException;
use Slim\Psr7\Factory\ResponseFactory;
use Tests\TestCase;
use Throwable;

class HttpErrorHandlerTest extends TestCase
{
private HttpErrorHandler $handler;

protected function setUp(): void
{
$this->handler = new HttpErrorHandler(new CallableResolver(), new ResponseFactory());
}

/**
* Runs the handler and returns the decoded JSON body alongside the response.
*
* @return array{0: \Psr\Http\Message\ResponseInterface, 1: array<string, mixed>}
*/
private function handle(Throwable $exception, bool $displayErrorDetails = false): array
{
$request = $this->createRequest('GET', '/todos');

$response = ($this->handler)($request, $exception, $displayErrorDetails, false, false);
$response->getBody()->rewind();

return [$response, json_decode((string) $response->getBody(), true)];
}

/**
* Each Slim HTTP exception maps to its own ActionError type and status code.
*
* @return array<string, array{0: class-string<HttpException>, 1: int, 2: string}>
*/
public static function httpExceptionProvider(): array
{
return [
'not found' => [HttpNotFoundException::class, 404, ActionError::RESOURCE_NOT_FOUND],
'method not allowed' => [HttpMethodNotAllowedException::class, 405, ActionError::NOT_ALLOWED],
'unauthorized' => [HttpUnauthorizedException::class, 401, ActionError::UNAUTHENTICATED],
'forbidden' => [HttpForbiddenException::class, 403, ActionError::INSUFFICIENT_PRIVILEGES],
'bad request' => [HttpBadRequestException::class, 400, ActionError::BAD_REQUEST],
'not implemented' => [HttpNotImplementedException::class, 501, ActionError::NOT_IMPLEMENTED],
];
}

/**
* @param class-string<HttpException> $exceptionClass
*/
#[DataProvider('httpExceptionProvider')]
public function testHttpExceptionMapsToStatusCodeAndErrorType(
string $exceptionClass,
int $expectedStatus,
string $expectedType
): void {
$request = $this->createRequest('GET', '/todos');
$exception = new $exceptionClass($request, 'Something went wrong');

[$response, $payload] = $this->handle($exception);

$this->assertSame($expectedStatus, $response->getStatusCode());
$this->assertSame($expectedStatus, $payload['statusCode']);
$this->assertSame($expectedType, $payload['error']['type']);
$this->assertSame('Something went wrong', $payload['error']['description']);
}

public function testResponseIsJson(): void
{
$request = $this->createRequest('GET', '/todos');

[$response] = $this->handle(new HttpNotFoundException($request));

$this->assertSame('application/json', $response->getHeaderLine('Content-Type'));
}

public function testGenericExceptionIsReportedAsServerError(): void
{
[$response, $payload] = $this->handle(new RuntimeException('Database exploded'));

$this->assertSame(500, $response->getStatusCode());
$this->assertSame(ActionError::SERVER_ERROR, $payload['error']['type']);
}

public function testGenericExceptionHidesDetailsByDefault(): void
{
[, $payload] = $this->handle(new RuntimeException('Database exploded'), false);

$this->assertSame(
'An internal error has occurred while processing your request.',
$payload['error']['description']
);
$this->assertStringNotContainsString('exploded', $payload['error']['description']);
}

public function testGenericExceptionExposesDetailsWhenEnabled(): void
{
[, $payload] = $this->handle(new RuntimeException('Database exploded'), true);

$this->assertSame('Database exploded', $payload['error']['description']);
}

/**
* displayErrorDetails must not leak details for HTTP exceptions, which carry
* their own user-facing message.
*/
public function testHttpExceptionKeepsItsOwnMessageWhenDetailsEnabled(): void
{
$request = $this->createRequest('GET', '/todos');
$exception = new HttpNotFoundException($request, 'No such todo');

[, $payload] = $this->handle($exception, true);

$this->assertSame('No such todo', $payload['error']['description']);
}
}
Loading