Files
2026-08-05 22:09:53 -04:00

252 lines
8.1 KiB
PHP

<?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 KTXC\Event\DeferredEventProcessorInterface;
use KTXC\Event\DeferredProcessingResult;
use KTXC\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('Runner returns execution results and terminates')]
public function runs(): void
{
$processor = new RecordingProcessor();
[$kernel] = $this->kernel($processor);
$result = $kernel->executionRunner()->execute(
ExecutionDescriptor::cli(),
static fn(): string => 'complete',
);
self::assertSame('complete', $result);
self::assertSame(1, $processor->processed);
}
#[Test]
#[TestDox('Runner terminates before rethrowing execution failures')]
public function rethrows(): void
{
[$kernel] = $this->kernel();
try {
$kernel->executionRunner()->execute(
ExecutionDescriptor::cli(),
static fn() => throw new \RuntimeException('Execution failed.'),
);
self::fail('Expected execution to fail.');
} catch (\RuntimeException $error) {
self::assertSame('Execution failed.', $error->getMessage());
}
$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;
}
}