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)), '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user