Files
server/tests/php/Unit/Runtime/HttpRuntimeTest.php
T
2026-07-27 00:48:11 -04:00

253 lines
7.3 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXT\Unit\Runtime;
use KTXC\Application\Execution\ExecutionContext;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Application\Execution\TerminationReport;
use KTXC\Context\IdentityContext;
use KTXC\Context\TenantContext;
use KTXC\Http\Middleware\AuthenticationMiddleware;
use KTXC\Http\Middleware\FirewallMiddleware;
use KTXC\Http\Middleware\MiddlewareInterface;
use KTXC\Http\Middleware\RequestHandlerInterface;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedResponse;
use KTXC\KernelInterface;
use KTXC\Runtime\Http\HttpRuntime;
use KTXC\Service\TenantService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Psr\Container\ContainerInterface;
final class HttpRuntimeTest extends TestCase
{
#[Test]
#[TestDox('Stream callbacks run before execution termination')]
public function streams(): void
{
$kernel = null;
$kernel = new RuntimeKernel(
new RuntimeContainer([
TenantMiddleware::class => new PassMiddleware(),
FirewallMiddleware::class => new PassMiddleware(),
AuthenticationMiddleware::class => new PassMiddleware(),
RouterMiddleware::class => new StreamMiddleware(
static function () use (&$kernel): bool {
return $kernel->active;
},
),
]),
new TenantContext($this->createStub(TenantService::class)),
new IdentityContext(),
);
ob_start();
try {
(new HttpRuntime($kernel, false))->run(Request::create('/stream'));
$content = ob_get_contents();
} finally {
ob_end_clean();
}
self::assertSame('active', $content);
self::assertFalse($kernel->active);
self::assertSame(1, $kernel->terminations);
}
#[Test]
#[TestDox('Successful HTTP handling terminates exactly once')]
public function succeeds(): void
{
$kernel = $this->kernel(new ResponseMiddleware(new Response('ok', 200)));
$response = (new HttpRuntime($kernel, false))->handle(Request::create('/'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('ok', $response->getContent());
self::assertSame(1, $kernel->terminations);
self::assertTrue($kernel->outcome?->successful);
}
#[Test]
#[TestDox('HTTP exceptions render a response and still terminate')]
public function fails(): void
{
$kernel = $this->kernel(new ThrowingMiddleware());
$previousLog = ini_get('error_log');
ini_set('error_log', '/dev/null');
try {
$response = (new HttpRuntime($kernel, false))->handle(Request::create('/'));
} finally {
ini_set('error_log', (string) $previousLog);
}
self::assertSame(500, $response->getStatusCode());
self::assertSame(1, $kernel->terminations);
self::assertFalse($kernel->outcome?->successful);
self::assertInstanceOf(\RuntimeException::class, $kernel->outcome?->error);
}
#[Test]
#[TestDox('Deferred termination failures do not replace the HTTP response')]
public function preservesResponses(): void
{
$kernel = $this->kernel(new ResponseMiddleware(new Response('accepted', 202)));
$kernel->terminationReport = new TerminationReport(
failures: [new \RuntimeException('Deferred listener failed.')],
);
$response = (new HttpRuntime($kernel, false))->handle(Request::create('/'));
self::assertSame(202, $response->getStatusCode());
self::assertSame('accepted', $response->getContent());
}
private function kernel(MiddlewareInterface $terminal): RuntimeKernel
{
return new RuntimeKernel(
new RuntimeContainer([
TenantMiddleware::class => new PassMiddleware(),
FirewallMiddleware::class => new PassMiddleware(),
AuthenticationMiddleware::class => new PassMiddleware(),
RouterMiddleware::class => $terminal,
]),
new TenantContext($this->createStub(TenantService::class)),
new IdentityContext(),
);
}
}
final class RuntimeKernel implements KernelInterface
{
public bool $active = false;
public int $terminations = 0;
public ?ExecutionOutcome $outcome = null;
public TerminationReport $terminationReport;
public function __construct(
private readonly ContainerInterface $services,
private readonly TenantContext $tenantContext,
private readonly IdentityContext $identityContext,
) {
$this->terminationReport = new TerminationReport();
}
public function boot(): void
{
}
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope
{
$this->active = true;
return new ExecutionScope(
$descriptor,
ExecutionContext::fromDescriptor($descriptor),
$this->tenantContext,
$this->identityContext,
);
}
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport {
$this->active = false;
$this->terminations++;
$this->outcome = $outcome;
$scope->dispose();
$scope->markTerminated();
return $this->terminationReport;
}
public function shutdown(): void
{
}
public function container(): ContainerInterface
{
return $this->services;
}
public function environment(): string
{
return 'test';
}
public function debug(): bool
{
return false;
}
}
final class RuntimeContainer implements ContainerInterface
{
public function __construct(
private readonly array $services,
) {
}
public function get(string $id): mixed
{
return $this->services[$id];
}
public function has(string $id): bool
{
return isset($this->services[$id]);
}
}
class PassMiddleware implements MiddlewareInterface
{
public function process(Request $request, RequestHandlerInterface $handler): Response
{
return $handler->handle($request);
}
}
final class StreamMiddleware extends PassMiddleware
{
public function __construct(
private readonly \Closure $active,
) {
}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
return new StreamedResponse(function (): void {
echo ($this->active)() ? 'active' : 'terminated';
});
}
}
final class ResponseMiddleware extends PassMiddleware
{
public function __construct(
private readonly Response $response,
) {
}
public function process(Request $request, RequestHandlerInterface $handler): Response
{
return $this->response;
}
}
final class ThrowingMiddleware extends PassMiddleware
{
public function process(Request $request, RequestHandlerInterface $handler): Response
{
throw new \RuntimeException('HTTP failed.');
}
}