a73ca3abd6
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
252 lines
8.1 KiB
PHP
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;
|
|
}
|
|
}
|