feat: Divi release status, store, resolver and gate

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 01:46:05 +02:00
co-authored by Claude Opus 5.5
parent 6b93df94c3
commit cf3c5dfe76
14 changed files with 838 additions and 1 deletions
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;
final class ReleaseGateTest extends TestCase
{
private function untested(): void
{
ReleaseGate::useResolver(static fn (): ReleaseStatus => new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
}
public function testTheTestCaseDefaultIsReleased(): void
{
self::assertTrue(ReleaseGate::status()->writable());
ReleaseGate::assertWritable(7);
}
public function testAssertWritableThrowsTheLockWithTheStatus(): void
{
$this->untested();
try {
ReleaseGate::assertWritable(7);
self::fail('an untested Divi must lock writes');
} catch (DiviLocked $locked) {
self::assertSame('5.3.0', $locked->status->version);
self::assertToolError($locked->toError(), 'divi_untested');
}
}
public function testProbingAllowsOnlyThatPostAndOnlyMeanwhile(): void
{
$this->untested();
$result = ReleaseGate::whileProbing(41, static function (): string {
ReleaseGate::assertWritable(41);
return 'written';
});
self::assertSame('written', $result);
foreach ([static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable(42)),
static fn () => ReleaseGate::whileProbing(41, static fn () => ReleaseGate::assertWritable()),
static fn () => ReleaseGate::assertWritable(41)] as $attempt) {
try {
$attempt();
self::fail('only post 41, only while probing');
} catch (DiviLocked) {
self::addToAssertionCount(1);
}
}
}
public function testProbingEndsEvenWhenTheWriteThrows(): void
{
$this->untested();
try {
ReleaseGate::whileProbing(41, static function (): never {
throw new RuntimeException('boom');
});
} catch (RuntimeException) {
}
$this->expectException(DiviLocked::class);
ReleaseGate::assertWritable(41);
}
public function testStatusIsComputedOncePerRequestUntilForgotten(): void
{
$calls = 0;
ReleaseGate::useResolver(static function () use (&$calls): ReleaseStatus {
++$calls;
return ReleaseStatus::released('5.2.1');
});
ReleaseGate::status();
ReleaseGate::status();
self::assertSame(1, $calls);
ReleaseGate::forget();
ReleaseGate::status();
self::assertSame(2, $calls);
}
public function testAFailingResolverLocksWrites(): void
{
ReleaseGate::useResolver(static function (): never {
throw new RuntimeException('theme API gone');
});
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
$previous = ini_set('error_log', $log);
try {
$status = ReleaseGate::status();
} finally {
ini_set('error_log', (string) $previous);
}
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertStringContainsString('theme API gone', (string) file_get_contents($log));
unlink($log);
$this->expectException(DiviLocked::class);
ReleaseGate::assertWritable();
}
public function testFindsTheLockInsideAWrappedFailure(): void
{
$locked = new DiviLocked(new ReleaseStatus(ReleaseState::Untested, '5.3.0'));
$wrapped = WriteFailed::rolledBack('Nicht gespeichert.', new RuntimeException('draft', 0, $locked));
self::assertSame($locked, DiviLocked::in($wrapped));
self::assertNull(DiviLocked::in(new RuntimeException('other')));
}
}
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ManifestCheck;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Plugin;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class ReleaseResolverTest extends TestCase
{
/** @var array<string, mixed> */
private array $transients = [];
protected function setUp(): void
{
parent::setUp();
$this->transients = [];
Functions\when('get_transient')->alias(fn (string $key): mixed => $this->transients[$key] ?? false);
Functions\when('set_transient')->alias(function (string $key, mixed $value): bool {
$this->transients[$key] = $value;
return true;
});
}
/**
* @param array<string, callable(): bool> $code
* @param array<string, callable(): bool> $mirrors
*/
private function resolver(
string $version,
array $code = [],
array $mirrors = [],
?array $entry = null
): ReleaseResolver {
$check = Mockery::mock(ManifestCheck::class);
$check->allows('codeChecks')->andReturn($code);
$check->allows('mirrorChecks')->andReturn($mirrors);
$store = Mockery::mock(ReleaseStore::class);
$store->allows('find')->with($version, Plugin::VERSION)->andReturn($entry);
return new ReleaseResolver(
$check,
$store,
static fn (): ?string => $version,
static fn (string $v): string => 'key-' . $v
);
}
public function testATestedVersionWithGreenChecksIsReleasedFromCode(): void
{
$status = $this->resolver('5.2.1')->resolve();
self::assertSame(ReleaseState::Released, $status->state);
self::assertSame('code', $status->source);
}
public function testAnUnknownVersionIsUntestedUnlessTheDatabaseReleasedIt(): void
{
self::assertSame(ReleaseState::Untested, $this->resolver('5.3.0')->resolve()->state);
$released = $this->resolver('5.3.0', entry: ['user_id' => 1, 'released_at' => 'd'])->resolve();
self::assertSame(ReleaseState::Released, $released->state);
self::assertSame('database', $released->source);
}
public function testAFailedCheckMakesEvenATestedVersionIncompatible(): void
{
$status = $this->resolver('5.2.1', ['Seiten: et_x()' => static fn (): bool => false])->resolve();
self::assertSame(ReleaseState::Incompatible, $status->state);
self::assertSame(['Seiten: et_x()'], $status->problems);
}
public function testMirrorResultsAreCachedByStateKey(): void
{
$calls = 0;
$mirrors = ['Theme Builder: Nachbau von y()' => static function () use (&$calls): bool {
++$calls;
return false;
}];
$first = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
$second = $this->resolver('5.2.1', mirrors: $mirrors)->resolve();
self::assertSame(1, $calls, 'the second resolve reads the transient');
self::assertSame(ReleaseState::Incompatible, $second->state);
self::assertSame($first->problems, $second->problems);
self::assertSame('key-5.2.1', $this->transients[ReleaseResolver::CACHE]['key']);
$this->resolver('5.2.2', mirrors: $mirrors)->resolve();
self::assertSame(2, $calls, 'another state key checks again');
}
public function testWithoutDiviTheVersionIsEmptyAndUntested(): void
{
self::assertSame(ReleaseState::Untested, $this->resolver('')->resolve()->state);
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
final class ReleaseStatusTest extends TestCase
{
public function testOnlyAReleasedVersionIsWritable(): void
{
self::assertTrue(ReleaseStatus::released('5.2.1')->writable());
self::assertFalse((new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->writable());
self::assertFalse((new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['x']))->writable());
}
public function testUntestedRefusalNamesTheVersionAndTheWayOut(): void
{
$payload = self::assertToolError(
(new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->refusal(),
'divi_untested'
);
self::assertSame(
'Achtung: Divi 5.3.0 ist auf dieser Website noch nicht für LIT MCP freigegeben. Schreibende '
. 'Divi-Werkzeuge sind gesperrt, bis ein Administrator unter Einstellungen → LIT MCP → Divi die '
. 'Prüfung ausführt und die Version freigibt. Es wurde nichts geändert.',
$payload['message']
);
self::assertStringContainsString('lesende Divi-Werkzeuge funktionieren weiter', $payload['hint']);
}
public function testIncompatibleRefusalListsTheProblems(): void
{
$status = new ReleaseStatus(ReleaseState::Incompatible, '5.3.0', null, ['Theme Builder: Nachbau von x()']);
$payload = self::assertToolError($status->refusal(), 'divi_incompatible');
self::assertSame(
'Divi 5.3.0 hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind gesperrt, bis '
. 'LIT MCP aktualisiert wurde. Es wurde nichts geändert.',
$payload['message']
);
self::assertSame(['Theme Builder: Nachbau von x()'], $payload['problems']);
}
public function testNoticesForReadingTools(): void
{
self::assertSame(
'Achtung: Divi 5.3.0 ist für LIT MCP ungetestet; Lesen funktioniert, Schreiben ist gesperrt.',
(new ReleaseStatus(ReleaseState::Untested, '5.3.0'))->notice()
);
self::assertSame(
'Achtung: Divi (Version unbekannt) ist für LIT MCP inkompatibel; Lesen funktioniert, Schreiben ist '
. 'gesperrt.',
(new ReleaseStatus(ReleaseState::Incompatible, ''))->notice()
);
}
public function testAReleasedStatusHasNoRefusal(): void
{
$this->expectException(LogicException::class);
ReleaseStatus::released('5.2.1')->refusal();
}
public function testToArrayForSiteStatus(): void
{
$status = new ReleaseStatus(
ReleaseState::Released,
'5.3.0',
ReleaseStatus::SOURCE_DATABASE,
[],
['released_at' => '2026-10-01T08:00:00+00:00', 'user_id' => 1]
);
self::assertSame(
['state' => 'released', 'version' => '5.3.0', 'source' => 'database',
'released_at' => '2026-10-01T08:00:00+00:00', 'released_by' => 1, 'problems' => []],
$status->toArray()
);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Release\ReleaseStore;
use LIT\MCP\Tests\Unit\TestCase;
final class ReleaseStoreTest extends TestCase
{
/** @var array<string, mixed> */
private array $options = [];
protected function setUp(): void
{
parent::setUp();
$this->options = [];
Functions\when('get_option')->alias(
fn (string $name, mixed $default = false): mixed => $this->options[$name] ?? $default
);
Functions\when('update_option')->alias(function (string $name, mixed $value, mixed $autoload = null): bool {
$this->options[$name] = $value;
self::assertFalse($autoload, 'releases are read only by the gate – no autoload');
return true;
});
}
public function testAddsFindsAndWithdrawsPerDiviAndPluginVersion(): void
{
$store = new ReleaseStore();
$store->add('5.3.0', '0.6.0', 1, 'r1');
$store->add('5.3.0', '0.6.0', 2, 'r2');
self::assertCount(1, $store->entries(), 'one entry per version pair');
self::assertSame(2, $store->find('5.3.0', '0.6.0')['user_id'] ?? null);
self::assertNull($store->find('5.3.0', '0.7.0'), 'another plugin version needs its own check');
self::assertTrue($store->withdraw('5.3.0', '0.6.0'));
self::assertFalse($store->withdraw('5.3.0', '0.6.0'));
self::assertSame([], $store->entries());
}
public function testIgnoresBrokenRows(): void
{
$this->options[ReleaseStore::OPTION] = ['x', ['divi_version' => '5.3.0'], [
'divi_version' => '5.2.2', 'plugin_version' => '0.6.0', 'user_id' => 3,
'released_at' => 'd', 'report_id' => 'r',
]];
self::assertSame(['5.2.2'], array_column((new ReleaseStore())->entries(), 'divi_version'));
}
}