refactor(kernel): unify HTTP and CLI application lifecycle

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-27 00:48:11 -04:00
parent 98826143a8
commit 65c1b5fb75
67 changed files with 2737 additions and 1070 deletions
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Application;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\KernelOptions;
use KTXC\Application\ProjectPaths;
use KTXC\Context\IdentityContext;
use KTXC\Context\TenantContext;
use KTXC\Injection\Container;
use KTXC\Kernel;
use KTXC\Module\ModuleManager;
use KTXC\Service\TenantService;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXF\Event\DeferredEventProcessorInterface;
use KTXF\Event\DeferredProcessingResult;
use KTXF\Event\EventListenerRegistry;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class KernelTest extends TestCase
{
/** @var list<Kernel> */
private array $kernels = [];
protected function tearDown(): void
{
foreach ($this->kernels as $kernel) {
try {
$kernel->shutdown();
} catch (\LogicException) {
// Tests that intentionally leave a scope active terminate it themselves.
}
}
}
#[Test]
#[TestDox('Kernel boot is idempotent and freezes one registry')]
public function bootsOnce(): void
{
[$kernel, $modules, $registry] = $this->kernel();
$kernel->boot();
$kernel->boot();
self::assertTrue($registry->frozen());
}
#[Test]
#[TestDox('Beginning execution boots the kernel automatically')]
public function bootsOnExecution(): void
{
[$kernel, $modules] = $this->kernel();
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
$kernel->terminateExecution($scope, ExecutionOutcome::success());
self::assertTrue($scope->terminated());
}
#[Test]
#[TestDox('Only one execution scope can be active')]
public function rejectsConcurrentScopes(): void
{
[$kernel] = $this->kernel();
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
try {
$kernel->beginExecution(ExecutionDescriptor::http());
self::fail('Expected the second execution scope to be rejected.');
} catch (\LogicException) {
self::assertFalse($scope->terminated());
} finally {
$kernel->terminateExecution($scope, ExecutionOutcome::success());
}
}
#[Test]
#[TestDox('Repeated termination does not repeat side effects')]
public function terminatesOnce(): void
{
$processor = new RecordingProcessor();
[$kernel] = $this->kernel($processor);
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
$first = $kernel->terminateExecution($scope, ExecutionOutcome::success());
$second = $kernel->terminateExecution($scope, ExecutionOutcome::success());
self::assertSame(1, $processor->processed);
self::assertSame(1, $first->deferredProcessed);
self::assertSame(0, $second->deferredProcessed);
}
#[Test]
#[TestDox('Shutdown rejects active execution scopes')]
public function rejectsActiveShutdown(): void
{
[$kernel] = $this->kernel();
$scope = $kernel->beginExecution(ExecutionDescriptor::cli());
try {
$kernel->shutdown();
self::fail('Expected active shutdown to be rejected.');
} catch (\LogicException) {
self::assertFalse($scope->terminated());
} finally {
$kernel->terminateExecution($scope, ExecutionOutcome::success());
}
}
#[Test]
#[TestDox('Deferred failures are reported and the next execution can start')]
public function recoversFromDeferredFailure(): void
{
$processor = new RecordingProcessor();
$processor->failure = new \RuntimeException('Deferred processing failed.');
[$kernel] = $this->kernel($processor);
$first = $kernel->beginExecution(ExecutionDescriptor::cli());
$report = $kernel->terminateExecution($first, ExecutionOutcome::success());
$processor->failure = null;
$second = $kernel->beginExecution(ExecutionDescriptor::cli());
$kernel->terminateExecution($second, ExecutionOutcome::success());
self::assertCount(1, $report->failures);
self::assertTrue($first->terminated());
self::assertTrue($second->terminated());
}
/**
* @return array{TestKernel, ModuleManager&MockObject, EventListenerRegistry}
*/
private function kernel(?RecordingProcessor $processor = null): array
{
$modules = $this->createMock(ModuleManager::class);
$modules->expects(self::once())->method('modulesBoot');
$registry = new EventListenerRegistry();
$processor ??= new RecordingProcessor();
$container = new Container();
$container->set(ModuleManager::class, $modules);
$container->set(EventListenerRegistry::class, $registry);
$container->set(DeferredEventProcessorInterface::class, $processor);
$container->set(
TenantContext::class,
new TenantContext($this->createStub(TenantService::class)),
);
$container->set(IdentityContext::class, new IdentityContext());
$container->set(EphemeralCacheInterface::class, $this->createStub(EphemeralCacheInterface::class));
$container->set(PersistentCacheInterface::class, $this->createStub(PersistentCacheInterface::class));
$container->set(BlobCacheInterface::class, $this->createStub(BlobCacheInterface::class));
$kernel = new TestKernel(
new KernelOptions(ProjectPaths::resolve(dirname(__DIR__, 4)), 'test'),
$container,
);
$this->kernels[] = $kernel;
return [$kernel, $modules, $registry];
}
}
final class TestKernel extends Kernel
{
public function __construct(
KernelOptions $options,
private readonly Container $testContainer,
) {
parent::__construct($options, ['log' => ['driver' => 'null']]);
}
protected function initializeContainer(): Container
{
return $this->testContainer;
}
}
final class RecordingProcessor implements DeferredEventProcessorInterface
{
public int $processed = 0;
public ?\Throwable $failure = null;
private ?string $active = null;
public function beginExecution(string $executionId): void
{
if ($this->active !== null) {
throw new \LogicException('Execution already active.');
}
$this->active = $executionId;
}
public function processDeferred(string $executionId): DeferredProcessingResult
{
if ($this->failure !== null) {
throw $this->failure;
}
$this->active = null;
$this->processed++;
return new DeferredProcessingResult(1, 0, false);
}
public function discardDeferred(string $executionId): void
{
$this->active = null;
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Application;
use KTXC\Application\KernelOptions;
use KTXC\Application\ProjectPaths;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class ProjectPathsTest extends TestCase
{
#[Test]
#[TestDox('Project roots resolve identically from nested entrypoints')]
public function resolves(): void
{
$root = dirname(__DIR__, 4);
$fromRoot = ProjectPaths::resolve($root);
$fromCore = ProjectPaths::resolve($root . '/core/lib/index.php');
$fromConsole = ProjectPaths::resolve($root . '/bin/console');
self::assertSame($fromRoot->project, $fromCore->project);
self::assertSame($fromRoot->project, $fromConsole->project);
self::assertSame($fromRoot->modules(), $fromCore->modules());
self::assertSame($fromRoot->logs(), $fromConsole->logs());
}
#[Test]
#[TestDox('Missing project roots are rejected')]
public function rejectsMissingRoots(): void
{
$this->expectException(\InvalidArgumentException::class);
ProjectPaths::resolve(sys_get_temp_dir());
}
#[Test]
#[TestDox('Empty kernel environments are rejected')]
public function rejectsEmptyEnvironments(): void
{
$this->expectException(\InvalidArgumentException::class);
new KernelOptions(ProjectPaths::resolve(dirname(__DIR__, 4)), '');
}
}
@@ -7,23 +7,38 @@ namespace KTXT\Unit\Console\Tenant;
use KTXC\Console\Tenant\TenantCreateCommand;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
#[AllowMockObjectsWithoutExpectations]
class TenantCreateCommandTest extends TestCase
{
private TenantService&MockObject $tenantService;
private UserRolesStore $rolesStore;
private UserAccountsStore $userStore;
private CommandTester $tester;
private ?TenantObject $deposited = null;
protected function setUp(): void
{
$this->tenantService = $this->createMock(TenantService::class);
$this->rolesStore = $this->createStub(UserRolesStore::class);
$this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']);
$this->userStore = $this->createStub(UserAccountsStore::class);
$this->userStore->method('createUser')->willReturn(['uid' => 'admin']);
$this->tester = new CommandTester(
new TenantCreateCommand($this->tenantService, new NullLogger())
new TenantCreateCommand(
$this->tenantService,
$this->rolesStore,
$this->userStore,
new NullLogger(),
)
);
}
@@ -8,19 +8,18 @@ use KTXC\Console\Tenant\TenantListCommand;
use KTXC\Models\Tenant\DomainCollection;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
class TenantListCommandTest extends TestCase
{
private TenantService&MockObject $tenantService;
private TenantService $tenantService;
private CommandTester $tester;
protected function setUp(): void
{
$this->tenantService = $this->createMock(TenantService::class);
$this->tenantService = $this->createStub(TenantService::class);
$this->tester = new CommandTester(
new TenantListCommand($this->tenantService)
);
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\Event;
use KTXF\Event\EventDispatcher;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\FailurePolicy;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Psr\Container\ContainerInterface;
use Psr\Log\NullLogger;
final class EventDispatcherTest extends TestCase
{
#[Test]
#[TestDox('Listeners are lazy and deferred events stay in their execution')]
public function dispatches(): void
{
$listener = new RecordingListener();
$container = new RecordingContainer([RecordingListener::class => $listener]);
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
$registry->listen(
'test',
'test.event',
RecordingListener::class,
'deferred',
DeliveryMode::Deferred,
);
$registry->freeze();
self::assertSame(0, $container->resolutions);
$dispatcher = new EventDispatcher($registry, $container, new NullLogger());
$dispatcher->beginExecution('first');
$dispatcher->dispatch(new Event('test.event'));
self::assertSame(1, $listener->immediate);
self::assertSame(0, $listener->deferred);
$result = $dispatcher->processDeferred('first');
self::assertSame(1, $result->processed);
self::assertSame(1, $listener->deferred);
$dispatcher->beginExecution('second');
$second = $dispatcher->processDeferred('second');
self::assertSame(0, $second->processed);
self::assertSame(0, $second->remaining);
}
#[Test]
#[TestDox('Frozen registries reject new listeners')]
public function freezes(): void
{
$registry = new EventListenerRegistry();
$registry->freeze();
$this->expectException(\LogicException::class);
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
}
#[Test]
#[TestDox('Duplicate listener registrations are rejected')]
public function rejectsDuplicates(): void
{
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
$this->expectException(\LogicException::class);
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
}
#[Test]
#[TestDox('Invalid listener methods are rejected during registration')]
public function validatesMethods(): void
{
$registry = new EventListenerRegistry();
$this->expectException(\InvalidArgumentException::class);
$registry->listen('test', 'test.event', RecordingListener::class, 'missing');
}
#[Test]
#[TestDox('Unresolvable listener services fail registry compilation')]
public function validatesServices(): void
{
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
$this->expectException(\LogicException::class);
$registry->freeze(new RecordingContainer([]));
}
#[Test]
#[TestDox('Listener priority determines dispatch order')]
public function prioritizes(): void
{
$listener = new RecordingListener();
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', RecordingListener::class, 'low', priority: 1);
$registry->listen('test', 'test.event', RecordingListener::class, 'high', priority: 100);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([RecordingListener::class => $listener]),
new NullLogger(),
);
$dispatcher->beginExecution('test');
$dispatcher->dispatch(new Event('test.event'));
$dispatcher->processDeferred('test');
self::assertSame(['high', 'low'], $listener->order);
}
#[Test]
#[TestDox('Continue failures do not prevent later listeners')]
public function continues(): void
{
$listener = new RecordingListener();
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', FailingListener::class, 'fail');
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([
FailingListener::class => new FailingListener(),
RecordingListener::class => $listener,
]),
new NullLogger(),
);
$dispatcher->beginExecution('test');
$dispatcher->dispatch(new Event('test.event'));
self::assertSame(1, $listener->immediate);
}
#[Test]
#[TestDox('Propagate failures reach the publisher')]
public function propagates(): void
{
$registry = new EventListenerRegistry();
$registry->listen(
'test',
'test.event',
FailingListener::class,
'fail',
failurePolicy: FailurePolicy::Propagate,
);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([FailingListener::class => new FailingListener()]),
new NullLogger(),
);
$dispatcher->beginExecution('test');
$this->expectException(\RuntimeException::class);
$dispatcher->dispatch(new Event('test.event'));
}
#[Test]
#[TestDox('Deferred processing stops at its configured count limit')]
public function boundsDeferredWork(): void
{
$listener = new RecursiveListener();
$registry = new EventListenerRegistry();
$registry->listen(
'test',
'test.event',
RecursiveListener::class,
'deferred',
DeliveryMode::Deferred,
);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([RecursiveListener::class => $listener]),
new NullLogger(),
);
$listener->dispatcher = $dispatcher;
$dispatcher->beginExecution('test');
$dispatcher->dispatch(new Event('test.event'));
$result = $dispatcher->processDeferred('test');
self::assertSame(1000, $result->processed);
self::assertSame(1, $result->remaining);
self::assertFalse($result->deadlineExceeded);
self::assertTrue($result->limitExceeded);
}
}
final class RecordingListener
{
public int $immediate = 0;
public int $deferred = 0;
public array $order = [];
public function immediate(Event $event): void
{
$this->immediate++;
}
public function deferred(Event $event): void
{
$this->deferred++;
}
public function high(Event $event): void
{
$this->order[] = 'high';
}
public function low(Event $event): void
{
$this->order[] = 'low';
}
}
final class FailingListener
{
public function fail(Event $event): void
{
throw new \RuntimeException('Listener failed.');
}
}
final class RecursiveListener
{
public EventDispatcher $dispatcher;
public function deferred(Event $event): void
{
$this->dispatcher->dispatch(new Event('test.event'));
}
}
final class RecordingContainer implements ContainerInterface
{
public int $resolutions = 0;
public function __construct(
private readonly array $services,
) {
}
public function get(string $id): mixed
{
$this->resolutions++;
return $this->services[$id]
?? throw new \RuntimeException("Service not found: {$id}");
}
public function has(string $id): bool
{
return array_key_exists($id, $this->services);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Execution;
use KTXC\Application\Execution\ExecutionContext;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Context\IdentityContext;
use KTXC\Context\TenantContext;
use KTXC\Models\Identity\User;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class ExecutionScopeTest extends TestCase
{
#[Test]
#[TestDox('Disposal clears tenant and identity state')]
public function clears(): void
{
$tenantObject = (new TenantObject())
->setIdentifier('tenant-a')
->setEnabled(true);
$service = $this->createStub(TenantService::class);
$service->method('fetchById')->willReturn($tenantObject);
$tenant = new TenantContext($service);
$identity = new IdentityContext();
$descriptor = ExecutionDescriptor::cli();
$scope = new ExecutionScope(
$descriptor,
ExecutionContext::fromDescriptor($descriptor),
$tenant,
$identity,
);
$tenant->resolveIdentifier('tenant-a');
$user = new User();
$user->setId('user-a');
$identity->initialize($user);
$scope->dispose();
self::assertFalse($tenant->present());
self::assertFalse($identity->present());
}
#[Test]
#[TestDox('New scopes cannot see state from an earlier execution')]
public function isolates(): void
{
$tenantObject = (new TenantObject())
->setIdentifier('tenant-a')
->setEnabled(true);
$service = $this->createStub(TenantService::class);
$service->method('fetchById')->willReturn($tenantObject);
$tenant = new TenantContext($service);
$identity = new IdentityContext();
$tenant->resolveIdentifier('tenant-a');
$identity->initialize(new User());
$descriptor = ExecutionDescriptor::cli();
new ExecutionScope(
$descriptor,
ExecutionContext::fromDescriptor($descriptor),
$tenant,
$identity,
);
self::assertFalse($tenant->present());
self::assertFalse($identity->present());
}
#[Test]
#[TestDox('Execution scopes can only be marked terminated once')]
public function terminatesOnce(): void
{
$descriptor = ExecutionDescriptor::cli();
$scope = new ExecutionScope(
$descriptor,
ExecutionContext::fromDescriptor($descriptor),
new TenantContext($this->createStub(TenantService::class)),
new IdentityContext(),
);
$scope->markTerminated();
$this->expectException(\LogicException::class);
$scope->markTerminated();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Module;
use KTXC\Console\Event\EventsDebugCommand;
use KTXC\Module\Module;
use KTXC\Service\FirewallService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class CoreModuleTest extends TestCase
{
#[Test]
#[TestDox('Core boot registers owned listeners without resolving services')]
public function registersListeners(): void
{
$registry = new EventListenerRegistry();
$module = new Module($registry);
$module->boot();
$definitions = $registry->definitions();
self::assertCount(5, $definitions);
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
self::assertSame(
FirewallService::class,
$registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service,
);
self::assertFalse($registry->frozen());
}
#[Test]
#[TestDox('Core exposes the event registry debug command')]
public function exposesDebugCommand(): void
{
$module = new Module(new EventListenerRegistry());
self::assertContains(EventsDebugCommand::class, $module->registerCI());
}
}
@@ -0,0 +1,176 @@
<?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\KernelInterface;
use KTXC\Module\ModuleCollection;
use KTXC\Module\ModuleManager;
use KTXC\Runtime\Console\ConsoleRuntime;
use KTXC\Service\TenantService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
final class ConsoleRuntimeTest extends TestCase
{
#[Test]
#[TestDox('Successful commands preserve their exit code and terminate')]
public function succeeds(): void
{
$modules = $this->createStub(ModuleManager::class);
$modules->method('list')->willReturn(new ModuleCollection());
$kernel = $this->kernel(new ConsoleContainer([ModuleManager::class => $modules]));
$exitCode = (new ConsoleRuntime($kernel))->run(
new ArrayInput(['command' => 'list', '--raw' => true]),
new BufferedOutput(),
);
self::assertSame(0, $exitCode);
self::assertSame(1, $kernel->terminations);
self::assertTrue($kernel->outcome?->successful);
}
#[Test]
#[TestDox('Console failures terminate before being rethrown')]
public function fails(): void
{
$kernel = $this->kernel(new ConsoleContainer([], new \RuntimeException('Discovery failed.')));
try {
(new ConsoleRuntime($kernel))->run(
new ArrayInput(['command' => 'list']),
new BufferedOutput(),
);
self::fail('Expected console discovery to fail.');
} catch (\RuntimeException $error) {
self::assertSame('Discovery failed.', $error->getMessage());
}
self::assertSame(1, $kernel->terminations);
self::assertFalse($kernel->outcome?->successful);
}
#[Test]
#[TestDox('Deferred termination failures do not replace command exit codes')]
public function preservesExitCodes(): void
{
$modules = $this->createStub(ModuleManager::class);
$modules->method('list')->willReturn(new ModuleCollection());
$kernel = $this->kernel(new ConsoleContainer([ModuleManager::class => $modules]));
$kernel->terminationReport = new TerminationReport(
failures: [new \RuntimeException('Deferred listener failed.')],
);
$exitCode = (new ConsoleRuntime($kernel))->run(
new ArrayInput(['command' => 'list', '--raw' => true]),
new BufferedOutput(),
);
self::assertSame(0, $exitCode);
}
private function kernel(ContainerInterface $container): ConsoleKernel
{
return new ConsoleKernel(
$container,
new TenantContext($this->createStub(TenantService::class)),
new IdentityContext(),
);
}
}
final class ConsoleKernel implements KernelInterface
{
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
{
return new ExecutionScope(
$descriptor,
ExecutionContext::fromDescriptor($descriptor),
$this->tenantContext,
$this->identityContext,
);
}
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport {
$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 ConsoleContainer implements ContainerInterface
{
public function __construct(
private readonly array $services,
private readonly ?\Throwable $failure = null,
) {
}
public function get(string $id): mixed
{
if ($this->failure !== null) {
throw $this->failure;
}
return $this->services[$id];
}
public function has(string $id): bool
{
return isset($this->services[$id]);
}
}
+252
View File
@@ -0,0 +1,252 @@
<?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.');
}
}