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:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** A write refused because the installed Divi is not released (spec §5.5); AbilityRegistrar returns toError(). */
|
||||
final class DiviLocked extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly ReleaseStatus $status)
|
||||
{
|
||||
parent::__construct(sprintf(
|
||||
'%s is %s for LIT MCP – write refused',
|
||||
$status->versionLabel(),
|
||||
$status->state->value
|
||||
));
|
||||
}
|
||||
|
||||
public function toError(): WP_Error
|
||||
{
|
||||
return $this->status->refusal();
|
||||
}
|
||||
|
||||
/** The lock anywhere in $e's chain of previous exceptions (callers wrap failures in WriteFailed). */
|
||||
public static function in(Throwable $e): ?self
|
||||
{
|
||||
for ($cause = $e; $cause !== null; $cause = $cause->getPrevious()) {
|
||||
if ($cause instanceof self) {
|
||||
return $cause;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Divi versions checked with this plugin version (spec §5.3, source "code"): add a version after
|
||||
* wp lit-mcp divi-check ran green against it. Any other version needs a release in the Divi tab of each site.
|
||||
*/
|
||||
final class DiviReleases
|
||||
{
|
||||
public const TESTED = ['5.2.1'];
|
||||
}
|
||||
@@ -16,8 +16,10 @@ defined('ABSPATH') || exit;
|
||||
* The checks of all adapter manifests (spec §4.3), as label => check. Code checks (calls with the argument count
|
||||
* the adapter passes, constants) and mirror checks decide the release gate; registration checks (post types,
|
||||
* taxonomies, block types) depend on the request context (stage3 finding 6) and only show in reports.
|
||||
*
|
||||
* Not final so the resolver can be tested with a mock.
|
||||
*/
|
||||
final class ManifestCheck
|
||||
class ManifestCheck
|
||||
{
|
||||
/** @var array<string, Manifest> */
|
||||
private array $manifests;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Log;
|
||||
use Throwable;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* The release status of this request and the lock every writing Divi path asks (spec §5.5). whileProbing() lets the
|
||||
* admin check write its throwaway page whatever the status – only that post, only during the call, and only from
|
||||
* code (there is no parameter or option for it).
|
||||
*/
|
||||
final class ReleaseGate
|
||||
{
|
||||
private static ?ReleaseStatus $status = null;
|
||||
|
||||
/** @var (Closure(): ReleaseStatus)|null */
|
||||
private static ?Closure $resolver = null;
|
||||
|
||||
private static ?int $probe = null;
|
||||
|
||||
/** Spec §9: a status that cannot be determined locks writes like an incompatible Divi (and is logged). */
|
||||
public static function status(): ReleaseStatus
|
||||
{
|
||||
if (self::$status !== null) {
|
||||
return self::$status;
|
||||
}
|
||||
|
||||
try {
|
||||
self::$status = (self::$resolver ?? static fn (): ReleaseStatus => (new ReleaseResolver())->resolve())();
|
||||
} catch (Throwable $e) {
|
||||
Log::failure('divi release status', $e);
|
||||
self::$status = new ReleaseStatus(
|
||||
ReleaseState::Incompatible,
|
||||
'',
|
||||
null,
|
||||
['Der Freigabe-Zustand ließ sich nicht ermitteln (Details im PHP-Error-Log).']
|
||||
);
|
||||
}
|
||||
|
||||
return self::$status;
|
||||
}
|
||||
|
||||
/** @param (Closure(): ReleaseStatus)|null $resolver null = ReleaseResolver; tests set their own. */
|
||||
public static function useResolver(?Closure $resolver): void
|
||||
{
|
||||
self::$resolver = $resolver;
|
||||
self::$status = null;
|
||||
self::$probe = null;
|
||||
}
|
||||
|
||||
/** The next status() computes again (after a check, a release or a withdrawal). */
|
||||
public static function forget(): void
|
||||
{
|
||||
self::$status = null;
|
||||
}
|
||||
|
||||
/** @throws DiviLocked */
|
||||
public static function assertWritable(?int $postId = null): void
|
||||
{
|
||||
if (self::$probe !== null && $postId === self::$probe) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = self::status();
|
||||
if (! $status->writable()) {
|
||||
throw new DiviLocked($status);
|
||||
}
|
||||
}
|
||||
|
||||
public static function whileProbing(int $postId, callable $write): mixed
|
||||
{
|
||||
$previous = self::$probe;
|
||||
self::$probe = $postId;
|
||||
|
||||
try {
|
||||
return $write();
|
||||
} finally {
|
||||
self::$probe = $previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Plugin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Computes the release status (spec §5.2–§5.4): code checks live (cheap), mirror checks cached in a transient under
|
||||
* stateKey() – Divi version, plugin version, fingerprints.json and the modification times of the mirrored Divi
|
||||
* files, so a Divi update or hotfix is checked again at once.
|
||||
*/
|
||||
final class ReleaseResolver
|
||||
{
|
||||
public const CACHE = 'lit_mcp_divi_state';
|
||||
|
||||
/**
|
||||
* @param (Closure(): ?string)|null $version null = DiviEnvironment::version().
|
||||
* @param (Closure(string): string)|null $stateKey null = self::stateKey().
|
||||
*/
|
||||
public function __construct(
|
||||
private ?ManifestCheck $check = null,
|
||||
private ?ReleaseStore $store = null,
|
||||
private ?Closure $version = null,
|
||||
private ?Closure $stateKey = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolve(): ReleaseStatus
|
||||
{
|
||||
$version = ($this->version ?? DiviEnvironment::version(...))() ?? '';
|
||||
$check = $this->check ?? new ManifestCheck();
|
||||
$problems = array_merge(
|
||||
ManifestCheck::failed($check->codeChecks()),
|
||||
$this->mirrorProblems($check, $version)
|
||||
);
|
||||
|
||||
if ($problems !== []) {
|
||||
return new ReleaseStatus(ReleaseState::Incompatible, $version, null, $problems);
|
||||
}
|
||||
if (in_array($version, DiviReleases::TESTED, true)) {
|
||||
return ReleaseStatus::released($version);
|
||||
}
|
||||
$entry = ($this->store ?? new ReleaseStore())->find($version, Plugin::VERSION);
|
||||
|
||||
return $entry !== null
|
||||
? new ReleaseStatus(ReleaseState::Released, $version, ReleaseStatus::SOURCE_DATABASE, [], $entry)
|
||||
: new ReleaseStatus(ReleaseState::Untested, $version);
|
||||
}
|
||||
|
||||
/** What the cached mirror results and a check report are valid for. */
|
||||
public static function stateKey(string $version, ?string $themeDir = null): string
|
||||
{
|
||||
$themeDir ??= DiviEnvironment::themeDir();
|
||||
$parts = [
|
||||
$version,
|
||||
Plugin::VERSION,
|
||||
is_readable(DiviReference::FILE) ? (string) md5_file(DiviReference::FILE) : '',
|
||||
];
|
||||
foreach (DiviAdapters::mirrors() as $mirror) {
|
||||
$file = $themeDir === null ? '' : $themeDir . '/' . $mirror->file;
|
||||
$parts[] = $mirror->file . '@' . ($file !== '' && is_file($file) ? (string) filemtime($file) : '-');
|
||||
}
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function mirrorProblems(ManifestCheck $check, string $version): array
|
||||
{
|
||||
$key = ($this->stateKey ?? self::stateKey(...))($version);
|
||||
$cached = get_transient(self::CACHE);
|
||||
if (is_array($cached) && ($cached['key'] ?? null) === $key && is_array($cached['problems'] ?? null)) {
|
||||
return array_values(array_filter($cached['problems'], 'is_string'));
|
||||
}
|
||||
|
||||
$problems = ManifestCheck::failed($check->mirrorChecks());
|
||||
set_transient(self::CACHE, ['key' => $key, 'problems' => $problems], DAY_IN_SECONDS);
|
||||
|
||||
return $problems;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** Spec §5.2. */
|
||||
enum ReleaseState: string
|
||||
{
|
||||
case Released = 'released';
|
||||
case Untested = 'untested';
|
||||
case Incompatible = 'incompatible';
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
use LIT\MCP\Abilities\Errors;
|
||||
use LogicException;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/** Whether LIT MCP may write with the installed Divi (spec §5.2), with the texts of spec §5.6. */
|
||||
final class ReleaseStatus
|
||||
{
|
||||
public const SOURCE_CODE = 'code';
|
||||
public const SOURCE_DATABASE = 'database';
|
||||
|
||||
private const UNTESTED = 'Achtung: %s 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.';
|
||||
private const UNTESTED_HINT = 'Den Nutzer bitten, die Divi-Prüfung auszuführen und die Version freizugeben; '
|
||||
. 'lesende Divi-Werkzeuge funktionieren weiter.';
|
||||
private const INCOMPATIBLE = '%s hat Stellen geändert, die LIT MCP nutzt. Schreibende Divi-Werkzeuge sind '
|
||||
. 'gesperrt, bis LIT MCP aktualisiert wurde. Es wurde nichts geändert.';
|
||||
private const INCOMPATIBLE_HINT = 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss; lesende '
|
||||
. 'Divi-Werkzeuge funktionieren weiter.';
|
||||
private const NOTICE = 'Achtung: %s ist für LIT MCP %s; Lesen funktioniert, Schreiben ist gesperrt.';
|
||||
|
||||
/**
|
||||
* @param string $version Divi version, '' when unknown (Divi is not the active template).
|
||||
* @param string|null $source SOURCE_CODE or SOURCE_DATABASE for a released version.
|
||||
* @param list<string> $problems Failed code and mirror checks (ManifestCheck labels).
|
||||
* @param array<string, mixed>|null $release The database entry of a SOURCE_DATABASE release.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ReleaseState $state,
|
||||
public readonly string $version,
|
||||
public readonly ?string $source = null,
|
||||
public readonly array $problems = [],
|
||||
public readonly ?array $release = null
|
||||
) {
|
||||
}
|
||||
|
||||
public static function released(string $version, string $source = self::SOURCE_CODE): self
|
||||
{
|
||||
return new self(ReleaseState::Released, $version, $source);
|
||||
}
|
||||
|
||||
public function writable(): bool
|
||||
{
|
||||
return $this->state === ReleaseState::Released;
|
||||
}
|
||||
|
||||
public function versionLabel(): string
|
||||
{
|
||||
return $this->version === '' ? 'Divi (Version unbekannt)' : 'Divi ' . $this->version;
|
||||
}
|
||||
|
||||
/** The error of a refused write. @throws LogicException For a released status. */
|
||||
public function refusal(): WP_Error
|
||||
{
|
||||
return match ($this->state) {
|
||||
ReleaseState::Untested => Errors::make(
|
||||
'divi_untested',
|
||||
sprintf(self::UNTESTED, $this->versionLabel()),
|
||||
self::UNTESTED_HINT,
|
||||
423
|
||||
),
|
||||
ReleaseState::Incompatible => Errors::make(
|
||||
'divi_incompatible',
|
||||
sprintf(self::INCOMPATIBLE, $this->versionLabel()),
|
||||
self::INCOMPATIBLE_HINT,
|
||||
423,
|
||||
['problems' => $this->problems]
|
||||
),
|
||||
ReleaseState::Released => throw new LogicException('a released Divi refuses nothing'),
|
||||
};
|
||||
}
|
||||
|
||||
/** The warning a reading Divi tool carries while writes are locked. */
|
||||
public function notice(): string
|
||||
{
|
||||
return sprintf(
|
||||
self::NOTICE,
|
||||
$this->versionLabel(),
|
||||
$this->state === ReleaseState::Incompatible ? 'inkompatibel' : 'ungetestet'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{state: string, version: string, source: ?string, released_at: ?string, released_by: ?int,
|
||||
* problems: list<string>}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'state' => $this->state->value,
|
||||
'version' => $this->version,
|
||||
'source' => $this->source,
|
||||
'released_at' => is_string($this->release['released_at'] ?? null)
|
||||
? $this->release['released_at']
|
||||
: null,
|
||||
'released_by' => is_int($this->release['user_id'] ?? null) ? $this->release['user_id'] : null,
|
||||
'problems' => $this->problems,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Release;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Divi versions released on this site after a green admin check (spec §5.3, source "database"). An entry holds for
|
||||
* the plugin version that ran the check only.
|
||||
*
|
||||
* Not final so the resolver can be tested with a mock.
|
||||
*/
|
||||
class ReleaseStore
|
||||
{
|
||||
public const OPTION = 'lit_mcp_divi_releases';
|
||||
|
||||
/**
|
||||
* @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
|
||||
* report_id: string}>
|
||||
*/
|
||||
public function entries(): array
|
||||
{
|
||||
$raw = get_option(self::OPTION, []);
|
||||
$entries = [];
|
||||
foreach (is_array($raw) ? $raw : [] as $row) {
|
||||
if (
|
||||
is_array($row)
|
||||
&& is_string($row['divi_version'] ?? null)
|
||||
&& is_string($row['plugin_version'] ?? null)
|
||||
&& is_int($row['user_id'] ?? null)
|
||||
&& is_string($row['released_at'] ?? null)
|
||||
&& is_string($row['report_id'] ?? null)
|
||||
) {
|
||||
$entries[] = [
|
||||
'divi_version' => $row['divi_version'],
|
||||
'plugin_version' => $row['plugin_version'],
|
||||
'user_id' => $row['user_id'],
|
||||
'released_at' => $row['released_at'],
|
||||
'report_id' => $row['report_id'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
|
||||
* report_id: string}|null
|
||||
*/
|
||||
public function find(string $diviVersion, string $pluginVersion): ?array
|
||||
{
|
||||
foreach ($this->entries() as $entry) {
|
||||
if ($entry['divi_version'] === $diviVersion && $entry['plugin_version'] === $pluginVersion) {
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function add(string $diviVersion, string $pluginVersion, int $userId, string $reportId): void
|
||||
{
|
||||
$entries = $this->without($diviVersion, $pluginVersion);
|
||||
$entries[] = [
|
||||
'divi_version' => $diviVersion,
|
||||
'plugin_version' => $pluginVersion,
|
||||
'user_id' => $userId,
|
||||
'released_at' => gmdate('c'),
|
||||
'report_id' => $reportId,
|
||||
];
|
||||
update_option(self::OPTION, $entries, false);
|
||||
}
|
||||
|
||||
public function withdraw(string $diviVersion, string $pluginVersion): bool
|
||||
{
|
||||
if ($this->find($diviVersion, $pluginVersion) === null) {
|
||||
return false;
|
||||
}
|
||||
update_option(self::OPTION, $this->without($diviVersion, $pluginVersion), false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{divi_version: string, plugin_version: string, user_id: int, released_at: string,
|
||||
* report_id: string}>
|
||||
*/
|
||||
private function without(string $diviVersion, string $pluginVersion): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->entries(),
|
||||
static fn (array $entry): bool => $entry['divi_version'] !== $diviVersion
|
||||
|| $entry['plugin_version'] !== $pluginVersion
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace LIT\MCP\Tests\Unit;
|
||||
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
|
||||
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
|
||||
use LIT\MCP\Tests\Support\AssertsToolErrors;
|
||||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||||
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||||
@@ -32,10 +34,14 @@ abstract class TestCase extends BaseTestCase
|
||||
'wp_unslash' => static fn ($value) => $value,
|
||||
'sanitize_text_field' => static fn ($value): string => trim(strip_tags((string) $value)),
|
||||
]);
|
||||
|
||||
// Divi is released unless a test says otherwise (the release gate would ask WordPress and Divi).
|
||||
ReleaseGate::useResolver(static fn (): ReleaseStatus => ReleaseStatus::released('5.2.1'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
ReleaseGate::useResolver(null);
|
||||
Monkey\tearDown();
|
||||
parent::tearDown();
|
||||
FakeChangeLog::verify();
|
||||
|
||||
@@ -6,6 +6,10 @@ if (! defined('ABSPATH')) {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
}
|
||||
|
||||
if (! defined('DAY_IN_SECONDS')) {
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/Stubs/WpError.php';
|
||||
require_once __DIR__ . '/Stubs/WpUser.php';
|
||||
|
||||
Reference in New Issue
Block a user