refactor(kernel): unify HTTP and CLI application lifecycle
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user