feat(compat): Profile, Gates und Pods über den Kompatibilitätskern

Neu: Tier, Profile, Compatibility, CapabilityLocked, CapabilityGate, CompatibilityComponent, ProvidesCompatibility, DiviProfile sowie PodsFeature, PodsCompatibility und PodsIntegration. Das CapabilityGate sperrt gesperrte und unbekannte Fähigkeits-IDs (übernimmt die Sperre unbekannter IDs vom DiviFeatureGate) und delegiert nur Divi ausdrücklich. Pods-Schema, -Lesen, -Schreiben und -Feldrechte laufen über usable(PodsFeature::…); Schreiben verlangt FieldRead und FieldWrite, damit ein Rollback keine Werte verliert. lit-site-status meldet Pods über die CompatibilityComponent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-11-06 12:26:54 +01:00
co-authored by Claude Opus 5.5
parent b511ef6348
commit c85131603b
38 changed files with 2259 additions and 135 deletions
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Abilities;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\AbilityRegistrar;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Compatibility\CapabilityGate;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Pods\PodsCompatibility;
use LIT\MCP\Server\RequestContext;
use LIT\MCP\Tests\Support\DiviFeatureFixtures;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;
/**
* The two guards Plugin hands to AbilityRegistrar, [DiviFeatureGate, CapabilityGate], through the real
* permission callback: each ID is judged by exactly one of them and none passes both unjudged.
*/
final class CompatibilityGatesTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
RequestContext::reset(true);
Functions\when('current_user_can')->justReturn(true);
}
protected function tearDown(): void
{
RequestContext::reset();
parent::tearDown();
}
/** @param list<string> $ids */
private function permission(array $ids): bool|WP_Error
{
$ability = Mockery::mock(Ability::class, DeclaresRequirements::class);
$ability->allows('name')->andReturn('lit/test');
$ability->allows('label')->andReturn('Test');
$ability->allows('description')->andReturn('Test');
$ability->allows('inputSchema')->andReturn(['type' => 'object', 'properties' => []]);
$ability->allows('outputSchema')->andReturn(null);
$ability->allows('annotations')->andReturn(['readonly' => true]);
$ability->allows('isDirect')->andReturn(true);
$ability->allows('requirements')->andReturn($ids);
$ability->allows('permission')->andReturn(true);
$free = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Read]);
$compat = new Compatibility([DiviProfile::profile(), PodsCompatibility::profile(), $free]);
$integration = Mockery::mock(Integration::class);
$integration->allows('id')->andReturn('core');
$integration->allows('isAvailable')->andReturn(true);
$integration->allows('abilities')->andReturn([$ability]);
$registrar = new AbilityRegistrar(
new IntegrationRegistry($integration),
null,
[new DiviFeatureGate(FakeChangeLog::forbidden()), new CapabilityGate($compat, FakeChangeLog::forbidden())]
);
return ($registrar->argsFor($ability)['permission_callback'])([]);
}
public function testAFreeNonDiviIdPassesTheDiviGateAndTheCapabilityGate(): void
{
self::assertTrue($this->permission(['fake-read']));
}
public function testALockedPodsIdPassesTheDiviGateAndIsRefusedByTheCapabilityGate(): void
{
DiviFeatureFixtures::useStates([]);
$error = $this->permission(['pods-field-read']);
self::assertToolError($error, 'compat_incompatible');
}
public function testAnUnknownIdIsRefusedByTheCapabilityGate(): void
{
DiviFeatureFixtures::useStates([]);
$payload = self::assertToolError($this->permission(['page-write', 'nobody-knows-me']), 'compat_incompatible');
self::assertSame(['nobody-knows-me'], $payload['unknown']);
}
public function testAReleasedDiviIdPassesBothGates(): void
{
DiviFeatureFixtures::useStates([]);
self::assertTrue($this->permission(['page-write']));
}
public function testALockedDiviIdIsRefusedByTheDiviGateAloneNotTwice(): void
{
DiviFeatureFixtures::lock(DiviFeature::PageWrite);
self::assertToolError($this->permission(['page-write']), 'divi_untested');
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\DeclaresRequirements;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\CapabilityGate;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class CapabilityGateTest extends TestCase
{
/** @param list<string>|null $ids null = the ability does not implement DeclaresRequirements. */
private function tool(bool $readonly, ?array $ids): Ability
{
$ability = $ids === null
? Mockery::mock(Ability::class)
: Mockery::mock(Ability::class, DeclaresRequirements::class);
$ability->allows('name')->andReturn('lit/fake');
$ability->allows('annotations')->andReturn(['readonly' => $readonly]);
if ($ids !== null) {
$ability->allows('requirements')->andReturn($ids);
}
return $ability;
}
/** fake-write is locked (missing call), fake-read free, fake-other belongs to a release-per-version profile. */
/** @param list<string> $delegated */
private function gate(?FakeChangeLog $log = null, array $delegated = []): CapabilityGate
{
$lock = new Profile(
'fake',
'Fake Plugin',
Tier::LockOnFailure,
[FakeCapability::Read, FakeCapability::Write],
[new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])]
);
$release = new Profile(
'release',
'Release',
Tier::ReleasePerVersion,
[FakeCapability::Other],
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(
[FakeCapability::Other],
'would lock'
)
);
return new CapabilityGate(new Compatibility([$lock, $release]), $log, $delegated);
}
public function testAFreeCapabilityPassesAndAWriteIsNotLogged(): void
{
self::assertNull($this->gate(FakeChangeLog::forbidden())->refusal($this->tool(false, ['fake-read']), []));
}
public function testALockedCapabilityIsRefusedWith423(): void
{
$error = $this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-read', 'fake-write']), []);
$payload = self::assertToolError($error, 'compat_incompatible');
self::assertSame(['fake-write'], array_column($payload['features'], 'id'));
}
public function testTheIdOfADelegatedProfileIsLeftToItsOwnGate(): void
{
$gate = $this->gate(FakeChangeLog::forbidden(), ['release']);
self::assertNull($gate->refusal($this->tool(false, ['fake-other']), []));
}
public function testAReleasePerVersionProfileWithoutDelegationIsLockedByItsMatrix(): void
{
$payload = self::assertToolError(
$this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-other']), []),
'compat_incompatible'
);
self::assertSame(['fake-other'], array_column($payload['features'], 'id'));
}
public function testAnUnknownIdLocksTheCall(): void
{
$payload = self::assertToolError(
$this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-read', 'fake-nope']), []),
'compat_incompatible'
);
self::assertSame(['fake-nope'], $payload['unknown']);
}
public function testAnUnknownIdOfAWriteIsRefusedWith423AndLogged(): void
{
$log = new FakeChangeLog();
$error = $this->gate($log)->refusal($this->tool(false, ['fake-read', 'something-else']), []);
self::assertSame(423, $error->get_error_data()['status'] ?? null);
$log->assertRecorded(
['ability' => 'lit/fake', 'object_type' => 'compat', 'object_id' => 'something-else', 'result' => 'refused']
);
self::assertStringContainsString('unbekannte Fähigkeit something-else', $log->summaries()[0]);
}
public function testARefusedWriteIsLoggedWithTheProfileId(): void
{
$log = new FakeChangeLog();
$this->gate($log)->refusal($this->tool(false, ['fake-write']), []);
$log->assertRecorded(
['ability' => 'lit/fake', 'object_type' => 'compat', 'object_id' => 'fake', 'result' => 'refused']
);
self::assertStringContainsString('Fake Plugin', $log->summaries()[0]);
}
public function testARefusedWriteWithAnUnknownIdIsLogged(): void
{
$log = new FakeChangeLog();
$this->gate($log)->refusal($this->tool(false, ['fake-nope']), []);
$log->assertRecorded(['object_type' => 'compat', 'object_id' => 'fake-nope', 'result' => 'refused']);
}
public function testAnAbilityWithoutRequirementsIsUntouched(): void
{
$gate = $this->gate(FakeChangeLog::forbidden());
$tool = $this->tool(false, null);
self::assertNull($gate->refusal($tool, []));
self::assertSame(['ok' => true], $gate->annotate($tool, ['ok' => true]));
self::assertSame(['type' => 'object'], $gate->outputSchema($tool, ['type' => 'object']));
self::assertNull($gate->outputSchema($tool, null));
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Abilities\ToolRefusal;
use LIT\MCP\Compatibility\CapabilityLocked;
use LIT\MCP\Compatibility\FeatureStatus;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
final class CapabilityLockedTest extends TestCase
{
public function testTheErrorNamesPluginCapabilitiesAndProblems(): void
{
$profile = new Profile('fake', 'Fake Plugin', Tier::LockOnFailure, [FakeCapability::Write]);
$locked = CapabilityLocked::of(
[new FeatureStatus(FakeCapability::Write, ReleaseState::Incompatible, ['Fake: x() fehlt'])],
[$profile]
);
self::assertInstanceOf(ToolRefusal::class, $locked);
self::assertSame('Fake Plugin locked – fake-write refused', $locked->getMessage());
$error = $locked->toError();
$payload = self::assertToolError($error, 'compat_incompatible');
self::assertSame(423, $error->get_error_data()['status']);
self::assertStringContainsString('Fake Plugin', $payload['message']);
self::assertStringContainsString('Fake fake-write', $payload['message']);
self::assertNotSame('', $payload['hint']);
self::assertSame(['fake-write'], array_column($payload['features'], 'id'));
self::assertSame(['Fake: x() fehlt'], $payload['problems']);
}
public function testALockWithoutLockedCapabilitiesIsAProgrammingError(): void
{
$this->expectException(LogicException::class);
CapabilityLocked::of([], []);
}
public function testUnknownIdsLockTheCall(): void
{
$locked = CapabilityLocked::unknown(['fake-x', 'fake-y']);
$error = $locked->toError();
$payload = self::assertToolError($error, 'compat_incompatible');
self::assertSame(423, $error->get_error_data()['status']);
self::assertSame(['fake-x', 'fake-y'], $payload['unknown']);
self::assertStringContainsString('fake-x, fake-y', $payload['message']);
self::assertSame(['fake-x', 'fake-y'], $locked->unknown);
}
public function testUnknownNeedsAtLeastOneId(): void
{
$this->expectException(LogicException::class);
CapabilityLocked::unknown([]);
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use Closure;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityComponent;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
final class CompatibilityComponentTest extends TestCase
{
private function profile(bool $active, bool $broken = false): Profile
{
return new Profile(
'fake',
'Fake Plugin',
Tier::LockOnFailure,
[FakeCapability::Read, FakeCapability::Write],
[new Manifest(
'Fake',
calls: $broken ? [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])] : []
)],
static fn (): bool => $active,
static fn (): string => '4.2.0'
);
}
private function component(Profile $profile): EnvironmentComponent
{
return new CompatibilityComponent($profile, static fn (): Compatibility => new Compatibility([$profile]));
}
public function testAnInactivePluginIsReportedAsNotInstalledWithoutProblems(): void
{
self::assertSame(
['id' => 'fake', 'active' => false, 'version' => null, 'problems' => [], 'release' => null],
$this->component($this->profile(false))->describe()
);
}
public function testAnActiveWorkingPluginHasItsVersionAndNoProblems(): void
{
self::assertSame(
['id' => 'fake', 'active' => true, 'version' => '4.2.0', 'problems' => [], 'release' => null],
$this->component($this->profile(true))->describe()
);
}
public function testAnActiveButBrokenPluginListsItsProblems(): void
{
$described = $this->component($this->profile(true, broken: true))->describe();
self::assertTrue($described['active']);
self::assertSame('4.2.0', $described['version']);
self::assertNotSame([], $described['problems']);
self::assertContainsOnly('string', $described['problems']);
self::assertNull($described['release']);
}
public function testTheCompatibilityIsAskedOnlyWhenDescribing(): void
{
$asked = 0;
$profile = $this->profile(true);
$component = new CompatibilityComponent($profile, static function () use (&$asked, $profile): Compatibility {
$asked++;
return new Compatibility([$profile]);
});
self::assertSame(0, $asked);
$component->describe();
self::assertSame(1, $asked);
}
public function testAReleasePerVersionProfileHasItsOwnComponent(): void
{
$profile = new Profile(
'rel',
'Rel',
Tier::ReleasePerVersion,
[FakeCapability::Other],
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other])
);
$this->expectException(LogicException::class);
new CompatibilityComponent($profile, static fn (): Compatibility => new Compatibility([$profile]));
}
public function testForProfilesBuildsAComponentPerProfileWithoutReleasePerVersion(): void
{
$plain = $this->profile(true);
$release = new Profile(
'rel',
'Rel',
Tier::ReleasePerVersion,
[FakeCapability::Other],
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Other])
);
$components = CompatibilityComponent::forProfiles(new Compatibility([$release, $plain]));
self::assertSame(['fake'], array_map(static fn (CompatibilityComponent $c): string => $c->describe()['id'], $components));
}
}
@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use Closure;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Manifest;
use LIT\MCP\Compatibility\ManifestCheck;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
use RuntimeException;
final class CompatibilityTest extends TestCase
{
private const CAPS = [FakeCapability::Read, FakeCapability::Write];
/** A manifest whose only call is missing and belongs to fake-write: a hard problem for that capability. */
private static function brokenManifest(): Manifest
{
return new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])]);
}
private static function lockProfile(
bool $broken = false,
?Closure $active = null,
?Closure $version = null,
?string $minVersion = null,
Tier $tier = Tier::LockOnFailure
): Profile {
return new Profile(
'fake',
'Fake Plugin',
$tier,
self::CAPS,
$broken ? [self::brokenManifest()] : [],
$active,
$version,
$minVersion
);
}
public function testReportOnlyNeverLocksNotEvenWhenInactiveOrBroken(): void
{
$profile = new Profile(
'fake',
'Fake',
Tier::ReportOnly,
self::CAPS,
[self::brokenManifest()],
static fn (): bool => false,
minVersion: '9'
);
$compat = new Compatibility([$profile]);
self::assertSame(ReleaseState::Released, $compat->matrix($profile)->state());
self::assertNull($compat->lockOf(self::CAPS));
self::assertFalse($compat->active($profile));
self::assertSame(['Fake: lit_mcp_missing_function()'], $compat->problems($profile));
}
public function testLockOnFailureLocksOnlyWhatAHardProblemConcerns(): void
{
$compat = new Compatibility([self::lockProfile(broken: true)]);
self::assertTrue($compat->usable(FakeCapability::Read));
self::assertFalse($compat->usable(FakeCapability::Write));
self::assertSame([FakeCapability::Write], array_map(
static fn ($status) => $status->feature,
$compat->lockOf(self::CAPS)?->locked ?? []
));
}
public function testLockOnFailureWithoutProblemsIsFree(): void
{
$compat = new Compatibility([self::lockProfile()]);
self::assertNull($compat->lockOf(self::CAPS));
self::assertSame([], $compat->problems($compat->profile('fake')));
}
public function testAnInactivePluginLocksEverythingWithLockOnFailure(): void
{
$profile = self::lockProfile(active: static fn (): bool => false);
$compat = new Compatibility([$profile]);
self::assertFalse($compat->active($profile));
self::assertFalse($compat->usable(FakeCapability::Read));
self::assertSame(['Fake Plugin ist nicht installiert oder nicht aktiv.'], $compat->problems($profile));
}
public function testAVersionBelowTheMinimumLocksEverything(): void
{
$profile = self::lockProfile(version: static fn (): string => '2.7.9', minVersion: '2.8');
$compat = new Compatibility([$profile]);
self::assertSame('2.7.9', $compat->version($profile));
self::assertFalse($compat->usable(FakeCapability::Read));
self::assertFalse($compat->usable(FakeCapability::Write));
self::assertSame(
['Fake Plugin 2.7.9 ist älter als die Mindestversion 2.8.'],
$compat->problems($profile)
);
}
public function testTheMinimumVersionItselfIsFine(): void
{
$profile = self::lockProfile(version: static fn (): string => '2.8', minVersion: '2.8');
self::assertTrue((new Compatibility([$profile]))->usable(FakeCapability::Read));
}
public function testAnUnknownVersionWithAMinimumLocksFailClosed(): void
{
$profile = self::lockProfile(version: static fn (): ?string => null, minVersion: '2.8');
self::assertFalse((new Compatibility([$profile]))->usable(FakeCapability::Read));
}
public function testAThrowingActiveOrVersionClosureCountsAsInactiveAndUnknown(): void
{
$boom = static function (): never {
throw new RuntimeException('boom');
};
$profile = self::lockProfile(active: $boom, version: $boom);
$compat = new Compatibility([$profile]);
self::assertFalse($compat->active($profile));
self::assertNull($compat->version($profile));
self::assertFalse($compat->usable(FakeCapability::Read));
}
public function testAThrowingCheckLocksEverythingFailClosed(): void
{
$profile = self::lockProfile();
$compat = new Compatibility([$profile], static function (): never {
throw new RuntimeException('boom');
});
self::assertFalse($compat->usable(FakeCapability::Read));
self::assertFalse($compat->usable(FakeCapability::Write));
self::assertSame(
['Die Kompatibilitätsprüfung von Fake Plugin ist fehlgeschlagen.'],
$compat->problems($profile)
);
}
public function testReleasePerVersionTakesItsMatrixFromTheClosure(): void
{
$profile = new Profile(
'fake',
'Fake',
Tier::ReleasePerVersion,
self::CAPS,
matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible(self::CAPS, 'gesperrt')
);
$compat = new Compatibility([$profile]);
self::assertFalse($compat->usable(FakeCapability::Read));
self::assertSame(['gesperrt'], $compat->problems($profile));
}
public function testMatrixAndCheckAreComputedOncePerProfile(): void
{
$checks = 0;
$matrices = 0;
$lock = self::lockProfile();
$release = new Profile(
'release',
'Release',
Tier::ReleasePerVersion,
[FakeCapability::Other],
matrix: static function () use (&$matrices): CompatibilityMatrix {
$matrices++;
return CompatibilityMatrix::allReleased([FakeCapability::Other]);
}
);
$count = static function (Profile $profile) use (&$checks): ManifestCheck {
$checks++;
return new ManifestCheck($profile->manifests, signatures: false);
};
$compat = new Compatibility([$lock, $release], $count);
foreach ([1, 2, 3] as $ignored) {
$compat->usable(FakeCapability::Read);
$compat->usable(FakeCapability::Other);
$compat->problems($lock);
}
self::assertSame(1, $checks);
self::assertSame(1, $matrices);
}
public function testLookups(): void
{
$lock = self::lockProfile();
$other = new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Other]);
$compat = new Compatibility([$lock, $other]);
self::assertSame([$lock, $other], $compat->profiles());
self::assertSame($other, $compat->profile('other'));
self::assertNull($compat->profile('nope'));
self::assertSame(FakeCapability::Other, $compat->capability('fake-other'));
self::assertNull($compat->capability('fake-nope'));
self::assertSame($other, $compat->owner(FakeCapability::Other));
self::assertSame($lock, $compat->owner(FakeCapability::Read));
}
public function testAnIdTwoProfilesClaimIsAProgrammingError(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('fake-read');
new Compatibility([
self::lockProfile(),
new Profile('other', 'Other', Tier::ReportOnly, [FakeCapability::Read]),
]);
}
public function testACapabilityNoProfileOwnsIsNotUsable(): void
{
self::assertFalse((new Compatibility([]))->usable(FakeCapability::Read));
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Compatibility;
use LIT\MCP\Compatibility\CompatibilityMatrix;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Tests\Support\FakeCapability;
use LIT\MCP\Tests\Unit\TestCase;
use LogicException;
final class ProfileTest extends TestCase
{
public function testAProfileKeepsWhatItWasGiven(): void
{
$matrix = static fn (): CompatibilityMatrix => CompatibilityMatrix::allReleased([FakeCapability::Read]);
$profile = new Profile(
'fake',
'Fake Plugin',
Tier::ReleasePerVersion,
[FakeCapability::Read],
matrix: $matrix,
minVersion: '2.0'
);
self::assertSame('fake', $profile->id);
self::assertSame('Fake Plugin', $profile->label);
self::assertSame(Tier::ReleasePerVersion, $profile->tier);
self::assertSame([FakeCapability::Read], $profile->capabilities);
self::assertSame($matrix, $profile->matrix);
self::assertSame('2.0', $profile->minVersion);
self::assertSame([], $profile->manifests);
}
public function testAReleasePerVersionProfileWithoutMatrixIsAProgrammingError(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('needs a matrix closure');
new Profile('fake', 'Fake', Tier::ReleasePerVersion, [FakeCapability::Read]);
}
public function testCapabilityIdsMustBeUniqueWithinAProfile(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('fake-read');
new Profile('fake', 'Fake', Tier::ReportOnly, [FakeCapability::Read, FakeCapability::Read]);
}
public function testTheTiersHaveStableValues(): void
{
self::assertSame(
['report', 'lock', 'release'],
array_map(static fn (Tier $t): string => $t->value, Tier::cases())
);
}
}
@@ -36,10 +36,10 @@ final class CoreIntegrationTest extends TestCase
return ['id' => 'builder', 'active' => true, 'version' => '9', 'problems' => [], 'release' => null];
}
};
$core = new CoreIntegration(dirname(__DIR__, 3) . '/Fixtures/guides', [$component]);
$core = new CoreIntegration(dirname(__DIR__, 3) . '/Fixtures/guides', static fn (): array => [$component]);
$status = $core->abilities()[1]->execute([]);
self::assertSame(['builder', 'pods', 'the-events-calendar'], array_column($status['components'], 'id'));
self::assertSame(['builder', 'the-events-calendar'], array_column($status['components'], 'id'));
}
}
@@ -30,25 +30,38 @@ final class EnvironmentProbeTest extends TestCase
};
}
public function testGivenComponentsComeFirstInTheOrderGivenFollowedByPodsAndTheEventsCalendar(): void
public function testGivenComponentsComeFirstInTheOrderGivenFollowedByTheEventsCalendar(): void
{
$probe = new EnvironmentProbe([$this->component('builder', ['Etwas fehlt']), $this->component('shop')]);
$probe = new EnvironmentProbe(fn (): array => [$this->component('builder', ['Etwas fehlt']), $this->component('shop')]);
$components = $probe->components();
self::assertSame(['builder', 'shop', 'pods', 'the-events-calendar'], array_column($components, 'id'));
self::assertSame(['builder', 'shop', 'the-events-calendar'], array_column($components, 'id'));
self::assertSame(['Etwas fehlt'], $components[0]['problems']);
self::assertSame(['state' => 'released'], $components[1]['release']);
}
public function testWithoutComponentsOnlyPodsAndTheEventsCalendarAreReported(): void
public function testComponentsMayBeGivenLazilyAndAreAskedPerCall(): void
{
$asked = 0;
$probe = new EnvironmentProbe(function () use (&$asked): array {
$asked++;
return [$this->component('lazy')];
});
self::assertSame(0, $asked);
self::assertSame(['lazy', 'the-events-calendar'], array_column($probe->components(), 'id'));
$probe->components();
self::assertSame(2, $asked, 'asked per call, never cached: the state may change within a request');
}
public function testWithoutComponentsOnlyTheEventsCalendarIsReported(): void
{
$components = array_column((new EnvironmentProbe())->components(), null, 'id');
self::assertSame(['pods', 'the-events-calendar'], array_keys($components));
self::assertFalse($components['pods']['active'], 'no Pods in unit tests');
self::assertSame([], $components['pods']['problems']);
self::assertNull($components['pods']['release']);
self::assertSame(['the-events-calendar'], array_keys($components));
self::assertNull($components['the-events-calendar']['version']);
self::assertNull($components['the-events-calendar']['release']);
}
}
@@ -13,6 +13,7 @@ use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\AdminTab;
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Integrations\Core\EnvironmentComponent;
use LIT\MCP\Integrations\Divi\DiviBuilderDetector;
use LIT\MCP\Integrations\Divi\DiviEnvironmentComponent;
@@ -21,6 +22,7 @@ use LIT\MCP\Integrations\Divi\DiviLogoUsage;
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
use LIT\MCP\Integrations\Divi\Release\DiviCheckRoute;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\ReleaseResolver;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
@@ -121,6 +123,14 @@ final class DiviIntegrationTest extends TestCase
self::assertFalse(DiviIntegration::builderEnabledFor('post'));
}
public function testProvidesTheDiviCompatibilityProfile(): void
{
$integration = new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden());
self::assertInstanceOf(ProvidesCompatibility::class, $integration);
self::assertEquals([DiviProfile::profile()], $integration->compatibilities());
}
public function testProvidesTheLibraryGroup(): void
{
Functions\when('__')->returnArg();
@@ -265,18 +265,18 @@ final class DiviFeatureGateTest extends TestCase
);
}
public function testAnUnknownRequirementOfTheCoreLocksTheToolAndIsLogged(): void
/** The lock of IDs that are no Divi capability is CapabilityGate's (unknown ones fail closed there). */
public function testIdsThatAreNoDiviCapabilityAreIgnored(): void
{
$log = new FakeChangeLog();
$tool = $this->contentCreate($this->composerNeeding('page-write', 'something-else'));
DiviFeatureFixtures::useStates([
'page-write' => ReleaseState::Incompatible,
'catalog' => ReleaseState::Incompatible,
]);
$tool = $this->contentCreate($this->composerNeeding('pods-field-read', 'something-else'));
$error = $this->gate($log)->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]);
$payload = self::assertToolError($error, 'divi_incompatible');
self::assertSame(['something-else'], $payload['unknown']);
self::assertSame(423, $error->get_error_data()['status'] ?? null);
self::assertSame('lit/content-create', $log->records[0]['ability']);
self::assertSame('refused', $log->records[0]['result']);
self::assertNull(
$this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]])
);
}
public function testAnnotateNeverChangesAResult(): void
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;
use Brain\Monkey\Functions;
use LIT\MCP\Compatibility\ReleaseState;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
final class DiviProfileTest extends TestCase
{
private function theme(string $template, string $version = '5.2.1'): void
{
$theme = Mockery::mock();
$theme->allows('get_template')->andReturn($template);
$theme->allows('parent')->andReturn(false);
$theme->allows('get')->with('Version')->andReturn($version);
Functions\when('wp_get_theme')->justReturn($theme);
}
public function testDescribesDiviAsAReleasePerVersionPlugin(): void
{
$profile = DiviProfile::profile();
self::assertSame('divi', $profile->id);
self::assertSame('Divi', $profile->label);
self::assertSame(Tier::ReleasePerVersion, $profile->tier);
self::assertSame(DiviFeature::cases(), $profile->capabilities);
self::assertEquals(array_values(DiviAdapters::manifests()), $profile->manifests);
}
public function testTheMatrixIsTheReleaseGatesMatrix(): void
{
$status = ReleaseMatrices::status([DiviFeature::PageWrite], ReleaseState::Incompatible);
ReleaseGate::useResolver(static fn () => $status);
self::assertSame($status->matrix, (DiviProfile::profile()->matrix)());
}
public function testTheVersionComesFromTheDiviTemplateAndThereIsNoActiveCheck(): void
{
$this->theme('Divi', '5.3.0');
$profile = DiviProfile::profile();
self::assertNull($profile->active, 'Divi has its own component; DiviIntegration decides availability');
self::assertSame('5.3.0', ($profile->version)());
$this->theme('twentytwentyfive');
self::assertNull(($profile->version)());
}
}
@@ -119,20 +119,12 @@ final class FeatureSetsTest extends TestCase
);
}
public function testUnknownIdsNamesTheIdsOfACoreToolThatAreNoDiviFeature(): void
public function testDeclaredByIgnoresIdsThatAreNoDiviFeature(): void
{
$tool = Mockery::mock(Ability::class, DeclaresRequirements::class);
$tool->allows('requirements')->with(['divi' => []])->andReturn(['page-write', 'other-integration', 'catalog']);
$tool->allows('requirements')->andReturn(['pods-field-read', 'page-write']);
self::assertSame(['other-integration'], FeatureSets::unknownIds($tool, ['divi' => []]));
}
public function testUnknownIdsIsEmptyForDiviToolsAndAnyOtherTool(): void
{
$divi = Mockery::mock(Ability::class, RequiresDiviFeatures::class);
self::assertSame([], FeatureSets::unknownIds($divi, []));
self::assertSame([], FeatureSets::unknownIds(Mockery::mock(Ability::class), []));
self::assertSame([DiviFeature::PageWrite], FeatureSets::declaredBy($tool, ['divi' => []]));
}
public function testDeclaredByIsEmptyForAnyOtherTool(): void
@@ -6,6 +6,9 @@ namespace LIT\MCP\Tests\Unit\Integrations;
use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Bootable;
use LIT\MCP\Integrations\Core\CoreIntegration;
use LIT\MCP\Integrations\Divi\DiviIntegration;
@@ -107,6 +110,34 @@ final class IntegrationRegistryTest extends TestCase
self::assertSame(['divi', 'pods'], $booted);
}
public function testCompatibilitiesOfAllProvidersInRegistryOrderEvenUnavailable(): void
{
$first = new Profile('a', 'A', Tier::ReportOnly, []);
$second = new Profile('b', 'B', Tier::ReportOnly, []);
$provider = static function (string $id, bool $available, array $profiles): Integration {
$integration = Mockery::mock(Integration::class . ', ' . ProvidesCompatibility::class);
$integration->allows('id')->andReturn($id);
$integration->allows('isAvailable')->andReturn($available);
$integration->allows('compatibilities')->andReturn($profiles);
return $integration;
};
$registry = new IntegrationRegistry(
$this->integration('core', true, []),
$provider('x', false, [$first]),
$provider('y', true, [$second])
);
self::assertSame([$first, $second], $registry->compatibilities());
self::assertSame(['a', 'b'], array_map(
static fn (Profile $profile): string => $profile->id,
$registry->compatibility()->profiles()
));
self::assertSame($registry->compatibility(), $registry->compatibility(), 'one shared instance');
self::assertSame([], (new IntegrationRegistry($this->integration('core', true, [])))->compatibilities());
}
private function ability(string $name, bool $direct): Ability
{
$ability = Mockery::mock(Ability::class);
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
use LIT\MCP\Compatibility\Call;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviProfile;
use LIT\MCP\Integrations\Pods\PodsCompatibility;
use LIT\MCP\Integrations\Pods\PodsFeature;
use LIT\MCP\Tests\Unit\TestCase;
final class PodsCompatibilityTest extends TestCase
{
/** @return array<string, Call> symbol => call */
private function calls(): array
{
$calls = [];
foreach (PodsCompatibility::profile()->manifests as $manifest) {
foreach ($manifest->calls as $call) {
$calls[$call->symbol] = $call;
}
}
return $calls;
}
public function testDescribesPodsAsALockOnFailurePlugin(): void
{
$profile = PodsCompatibility::profile();
self::assertSame('pods', $profile->id);
self::assertSame('Pods', $profile->label);
self::assertSame(Tier::LockOnFailure, $profile->tier);
self::assertSame('2.8', $profile->minVersion);
self::assertSame(PodsFeature::cases(), $profile->capabilities);
}
/** @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function testActiveAndVersionFollowThePodsVersionConstant(): void
{
$profile = PodsCompatibility::profile();
self::assertFalse(($profile->active)());
self::assertNull(($profile->version)());
define('PODS_VERSION', '3.3.9.2');
self::assertTrue(($profile->active)());
self::assertSame('3.3.9.2', ($profile->version)());
}
public function testEveryPodsCallOfTheFieldSourceIsInTheManifest(): void
{
$code = '';
foreach (token_get_all((string) file_get_contents(dirname(__DIR__, 3) . '/../src/Integrations/Pods/PodsFieldSource.php')) as $token) {
if (is_array($token) && ! in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
$code .= $token[1];
} elseif (is_string($token)) {
$code .= $token;
}
}
preg_match_all('/\b(pods(?:_[a-z_]+)?)\(/', $code, $found);
$used = array_values(array_unique(array_diff($found[1], ['pods_permission_denied'])));
self::assertContains('pods_api', $used);
foreach ($used as $function) {
self::assertArrayHasKey($function, $this->calls(), $function . ' is missing in the manifest');
}
foreach (['PodsAPI::load_pod', 'Pods::field', 'Pods::save'] as $method) {
self::assertArrayHasKey($method, $this->calls());
}
}
public function testArgumentCountsAreThoseOfTheFieldSource(): void
{
$args = array_map(static fn (Call $call): ?int => $call->args, $this->calls());
self::assertSame([
'pods_api' => 0,
'PodsAPI::load_pod' => 1,
'pods' => 2,
'Pods::field' => 1,
'Pods::save' => 4,
'pods_has_permissions' => 1,
'pods_permission' => 1,
], $args);
}
public function testEveryCapabilityHasACallOrTheConstant(): void
{
$manifest = PodsCompatibility::profile()->manifests[0];
$constantIds = array_map(static fn ($c) => array_map(static fn ($f) => $f->id(), $c->features()), $manifest->constants);
self::assertSame(['PODS_VERSION'], array_map(static fn ($c) => $c->name, $manifest->constants));
self::assertEqualsCanonicalizing(
array_map(static fn (PodsFeature $f): string => $f->id(), PodsFeature::cases()),
$constantIds[0]
);
foreach (PodsFeature::cases() as $feature) {
$owners = array_filter($manifest->calls, static fn (Call $call): bool => in_array($feature, $call->features(), true));
self::assertNotSame([], $owners, $feature->id() . ' has no call');
}
}
public function testPodsAndDiviProfilesCoexistWithoutIdCollision(): void
{
$compatibility = new Compatibility([DiviProfile::profile(), PodsCompatibility::profile()]);
self::assertSame(['divi', 'pods'], array_map(static fn ($p) => $p->id, $compatibility->profiles()));
self::assertSame(PodsFeature::FieldRead, $compatibility->capability('pods-field-read'));
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
use LIT\MCP\Compatibility\Capability;
use LIT\MCP\Integrations\Pods\PodsFeature;
use LIT\MCP\Tests\Unit\TestCase;
final class PodsFeatureTest extends TestCase
{
public function testEveryFeatureIsACapabilityWithAPrefixedId(): void
{
foreach (PodsFeature::cases() as $feature) {
self::assertInstanceOf(Capability::class, $feature);
self::assertStringStartsWith('pods-', $feature->id());
self::assertSame($feature->value, $feature->id());
self::assertNotSame('', $feature->label());
}
self::assertSame(
['pods-schema-read', 'pods-field-read', 'pods-field-write', 'pods-field-permissions'],
array_map(static fn (PodsFeature $f): string => $f->id(), PodsFeature::cases())
);
}
public function testOnlyFieldWriteWrites(): void
{
$writing = array_filter(PodsFeature::cases(), static fn (PodsFeature $f): bool => $f->isWrite());
self::assertSame([PodsFeature::FieldWrite], array_values($writing));
}
}
+139 -7
View File
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
use Brain\Monkey\Functions;
use Closure;
use Exception;
use LIT\MCP\Integrations\Pods\PodsFeature;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Tests\Unit\TestCase;
@@ -30,9 +32,20 @@ final class PodsFieldSourceTest extends TestCase
return $field;
}
private function source(?object $pod): PodsFieldSource
/** @return Closure(PodsFeature): bool Every capability usable except the given ones. */
private static function usableExcept(PodsFeature ...$locked): Closure
{
return new PodsFieldSource(static fn (string $name) => $pod ?? false, null, static fn (object $field): ?bool => null);
return static fn (PodsFeature $feature): bool => ! in_array($feature, $locked, true);
}
private function source(?object $pod, ?Closure $usable = null): PodsFieldSource
{
return new PodsFieldSource(
$usable ?? self::usableExcept(),
static fn (string $name) => $pod ?? false,
null,
static fn (object $field): ?bool => null
);
}
/** @return array<string, array<string, mixed>> */
@@ -87,7 +100,7 @@ final class PodsFieldSourceTest extends TestCase
$pod->allows('get_type')->andReturn('post_type');
$pod->allows('get_fields')->andReturn(['role' => $this->field('role', 'text')]);
$fields = (new PodsFieldSource(static fn (string $name) => $pod))->fieldsFor('lit_person');
$fields = (new PodsFieldSource(self::usableExcept(), static fn (string $name) => $pod))->fieldsFor('lit_person');
self::assertSame(FieldDefinition::HIDDEN, $fields[0]->status);
}
@@ -120,7 +133,7 @@ final class PodsFieldSourceTest extends TestCase
$pod->expects('field')->with(['name' => 'mentor', 'output' => 'ids', 'single' => true])->andReturn('12');
$pod->expects('field')->with(['name' => 'photos', 'output' => 'ids', 'single' => false])->andReturn(['3', '4']);
$pod->expects('field')->with(['name' => 'role', 'single' => true, 'raw' => true])->andReturn('Leitung');
$source = new PodsFieldSource(null, static fn (string $type, int $id) => $pod);
$source = new PodsFieldSource(self::usableExcept(), null, static fn (string $type, int $id) => $pod);
self::assertSame(12, $source->read(5, new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', 'writable')));
self::assertSame(
@@ -141,7 +154,7 @@ final class PodsFieldSourceTest extends TestCase
->with(['role' => '', 'photos' => []], null, null, ['error_mode' => 'exception'])
->andReturn(5);
(new PodsFieldSource(null, static fn () => $pod))
(new PodsFieldSource(self::usableExcept(), null, static fn () => $pod))
->write(5, 'lit_person', ['role' => null, 'photos' => null], $definitions);
}
@@ -152,7 +165,8 @@ final class PodsFieldSourceTest extends TestCase
$this->expectException(RuntimeException::class);
(new PodsFieldSource(null, static fn () => $pod))->write(5, 'lit_person', ['role' => 'x'], []);
(new PodsFieldSource(self::usableExcept(), null, static fn () => $pod))
->write(5, 'lit_person', ['role' => 'x'], []);
}
/**
@@ -176,6 +190,7 @@ final class PodsFieldSourceTest extends TestCase
$this->field('cap', 'number', ['restrict_capability' => '1']),
]);
$source = new PodsFieldSource(
self::usableExcept(),
static fn (string $name) => $pod,
null,
static fn (object $field): ?bool => $permitted[$field->get_name()] ?? null
@@ -225,7 +240,124 @@ final class PodsFieldSourceTest extends TestCase
$this->expectException(RuntimeException::class);
(new PodsFieldSource(null, static fn () => $pod))
(new PodsFieldSource(self::usableExcept(), null, static fn () => $pod))
->write(5, 'lit_person', ['role' => 'x', 'boss' => 'y'], $definitions);
}
// Capabilities (compatibility layer): a locked capability switches exactly its part of the adapter off.
public function testWithoutSchemaReadThePodsSchemaIsNotAskedAndEmpty(): void
{
$loaded = false;
$source = new PodsFieldSource(
self::usableExcept(PodsFeature::SchemaRead),
static function () use (&$loaded): bool {
$loaded = true;
return false;
}
);
self::assertSame([], $source->fieldsFor('lit_person'));
self::assertFalse($loaded, 'Pods\' schema API is not touched while it is locked');
}
public function testWithoutFieldReadAFieldReadsAsNullAndPodsIsNotAsked(): void
{
$source = new PodsFieldSource(
self::usableExcept(PodsFeature::FieldRead),
null,
static fn () => self::fail('Pods must not be asked')
);
self::assertNull($source->read(5, new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable')));
}
public function testWithoutFieldWriteWriteThrowsBeforeAnythingReachesPods(): void
{
$source = new PodsFieldSource(
self::usableExcept(PodsFeature::FieldWrite),
null,
static fn () => self::fail('Pods must not be asked')
);
$this->expectException(RuntimeException::class);
$source->write(5, 'lit_person', ['role' => 'x'], [
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE),
]);
}
public function testWithoutFieldWriteTheFieldsAreReadonlyButStayInTheSchema(): void
{
$pod = Mockery::mock();
$pod->allows('get_type')->andReturn('post_type');
$pod->allows('get_fields')->andReturn([
$this->field('open', 'text'),
$this->field('internal', 'text', ['hidden' => '1']),
]);
$fields = $this->source($pod, self::usableExcept(PodsFeature::FieldWrite))->fieldsFor('lit_person');
self::assertSame(
['open' => FieldDefinition::READONLY, 'internal' => FieldDefinition::HIDDEN],
array_column(array_map(
static fn (FieldDefinition $f): array => ['key' => $f->key, 'status' => $f->status],
$fields
), 'status', 'key')
);
}
/** Writing needs FieldRead too: the snapshot of a write would be null and a rollback would wipe the value. */
public function testWithoutFieldReadTheFieldsAreReadonlyAndWriteThrows(): void
{
$pod = Mockery::mock();
$pod->allows('get_type')->andReturn('post_type');
$pod->allows('get_fields')->andReturn([$this->field('open', 'text'), $this->field('odd', 'mystery')]);
$usable = self::usableExcept(PodsFeature::FieldRead);
$fields = $this->source($pod, $usable)->fieldsFor('lit_person');
self::assertSame([FieldDefinition::READONLY, FieldDefinition::READONLY], array_column($fields, 'status'));
$this->expectException(RuntimeException::class);
(new PodsFieldSource($usable, null, static fn () => self::fail('Pods must not be asked')))->write(
5,
'lit_person',
['role' => 'x'],
['role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE)]
);
}
public function testAnUnknownFieldIsReadonlyWhileWritingIsLocked(): void
{
$pod = Mockery::mock();
$pod->allows('get_type')->andReturn('post_type');
$pod->allows('get_fields')->andReturn([$this->field('odd', 'mystery')]);
$fields = $this->source($pod, self::usableExcept(PodsFeature::FieldWrite))->fieldsFor('lit_person');
self::assertSame(FieldDefinition::READONLY, $fields[0]->status);
}
/** Fail-closed: without field permissions no field counts as permitted, whatever the injected check says. */
public function testWithoutFieldPermissionsNoFieldCountsAsPermitted(): void
{
$pod = Mockery::mock();
$pod->allows('get_type')->andReturn('post_type');
$pod->allows('get_fields')->andReturn([
$this->field('open', 'text'),
$this->field('team', 'text', ['restrict_role' => '1', 'read_only_restricted' => '1']),
]);
$fields = $this->source($pod, self::usableExcept(PodsFeature::FieldPermissions))->fieldsFor('lit_person');
self::assertSame(
['open' => FieldDefinition::HIDDEN, 'team' => FieldDefinition::READONLY],
array_column(array_map(
static fn (FieldDefinition $f): array => ['key' => $f->key, 'status' => $f->status],
$fields
), 'status', 'key')
);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Pods;
use LIT\MCP\Compatibility\ProvidesCompatibility;
use LIT\MCP\Guides\InstructionsBuilder;
use LIT\MCP\Integrations\Integration;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\Pods\PodsCompatibility;
use LIT\MCP\Integrations\Pods\PodsIntegration;
use LIT\MCP\Tests\Unit\TestCase;
final class PodsIntegrationTest extends TestCase
{
public function testIsAnIntegrationWithoutAbilitiesThatProvidesItsProfile(): void
{
$integration = new PodsIntegration();
self::assertInstanceOf(Integration::class, $integration);
self::assertInstanceOf(ProvidesCompatibility::class, $integration);
self::assertSame('pods', $integration->id());
self::assertSame('Pods', $integration->label());
self::assertSame([], $integration->abilities());
self::assertEquals([PodsCompatibility::profile()], $integration->compatibilities());
}
public function testIsAvailableOnlyWithPods(): void
{
self::assertSame(defined('PODS_VERSION'), (new PodsIntegration())->isAvailable());
}
/** An available integration without abilities is no tool group, but it is an active integration. */
public function testAnAvailableIntegrationWithoutAbilitiesIsNoGroupButIsNamedAsActive(): void
{
$pods = new class implements Integration {
public function id(): string
{
return 'pods';
}
public function label(): string
{
return 'Pods';
}
public function isAvailable(): bool
{
return true;
}
public function abilities(): array
{
return [];
}
};
$registry = new IntegrationRegistry($pods);
self::assertSame([], $registry->grouped());
self::assertSame([], $registry->groupMap());
self::assertStringContainsString('Aktive Integrationen: pods.', (new InstructionsBuilder($registry))->build());
}
}
+115 -1
View File
@@ -10,6 +10,19 @@ use Brain\Monkey\Functions;
use LIT\MCP\Abilities\ExposurePolicy;
use LIT\MCP\Abilities\ToolAvailability;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Compatibility\CapabilityGate;
use LIT\MCP\Compatibility\Compatibility;
use LIT\MCP\Compatibility\Profile;
use LIT\MCP\Compatibility\Tier;
use LIT\MCP\Integrations\Divi\Release\DiviFeatureGate;
use LIT\MCP\Integrations\Pods\PodsFeature;
use LIT\MCP\Integrations\Pods\PodsFieldSource;
use LIT\MCP\Integrations\WordPress\Schema\RegisteredMetaSource;
use LIT\MCP\Integrations\WordPress\Schema\SampledMetaSource;
use LIT\MCP\Integrations\Core\SiteStatusAbility;
use LIT\MCP\Access;
use ReflectionProperty;
use WP_User;
use LIT\MCP\Integrations\IntegrationRegistry;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Limits;
@@ -34,7 +47,7 @@ final class PluginTest extends TestCase
public function testIntegrationsInRegistryOrder(): void
{
self::assertSame(
['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder'],
['core', 'wordpress', 'media', 'divi', 'divi-library', 'divi-theme-builder', 'pods'],
self::registry()->ids()
);
}
@@ -68,4 +81,105 @@ final class PluginTest extends TestCase
self::assertTrue($tools->groupWritable('divi-theme-builder'));
self::assertFalse($tools->allows('lit/divi-insert'), 'no Divi in unit tests: lit/divi-insert not registered');
}
/** Pods has no tools: it is in the registry for its profile only and shows up as no group. */
public function testPodsIsRegisteredWithoutBecomingAToolGroup(): void
{
$registry = self::registry();
self::assertContains('pods', $registry->ids());
self::assertArrayNotHasKey('pods', $registry->grouped());
}
/** lit-site-status keeps its order: Divi, Pods, The Events Calendar. */
public function testSiteStatusListsDiviPodsAndTheEventsCalendarInThatOrder(): void
{
Functions\when('get_bloginfo')->justReturn('x');
Functions\when('home_url')->justReturn('https://example.test');
Functions\when('wp_get_current_user')->justReturn(new WP_User(1, ['administrator']));
Functions\when('get_option')->justReturn(['administrator']);
Functions\when('apply_filters')->returnArg(2);
$theme = Mockery::mock();
$theme->allows('get_template')->andReturn('twentytwentyfive');
Functions\when('wp_get_theme')->justReturn($theme);
$status = array_values(array_filter(
self::registry()->abilities(),
static fn ($ability): bool => $ability instanceof SiteStatusAbility
))[0];
$components = $status->execute([])['components'];
self::assertSame(['divi', 'pods', 'the-events-calendar'], array_column($components, 'id'));
$pods = array_column($components, null, 'id')['pods'];
self::assertFalse($pods['active'], 'no Pods in unit tests');
self::assertSame([], $pods['problems']);
}
public function testTheCompatibilityCoversAllProfilesWithoutIdCollision(): void
{
$compat = self::registry()->compatibility();
self::assertSame(['divi', 'pods'], array_map(static fn (Profile $p): string => $p->id, $compat->profiles()));
self::assertNotNull($compat->capability('page-write'));
self::assertNotNull($compat->capability('pods-field-read'));
self::assertSame('divi', $compat->owner($compat->capability('page-write'))?->id);
self::assertSame('pods', $compat->owner($compat->capability('pods-field-write'))?->id);
self::assertNull($compat->capability('nobody-knows-me'));
}
/** The lock of unknown IDs lives in CapabilityGate: without it a core tool with an odd ID would run unlocked. */
public function testBothGuardsAreWiredDiviFirst(): void
{
$guards = Plugin::guards(self::registry(), FakeChangeLog::forbidden());
self::assertCount(2, $guards);
self::assertInstanceOf(DiviFeatureGate::class, $guards[0]);
self::assertInstanceOf(CapabilityGate::class, $guards[1]);
}
private static function readOnly(object $source): bool
{
return (new ReflectionProperty($source, 'readOnly'))->getValue($source);
}
private static function podsCompat(bool $usable): Compatibility
{
return new Compatibility([new Profile(
'pods',
'Pods',
Tier::LockOnFailure,
PodsFeature::cases(),
active: static fn (): bool => $usable
)]);
}
public function testWithoutPodsTheMetaSourcesStayWritableAndPodsIsNotASource(): void
{
$sources = Plugin::schemaSources(self::podsCompat(false), false);
self::assertCount(2, $sources);
self::assertInstanceOf(RegisteredMetaSource::class, $sources[0]);
self::assertInstanceOf(SampledMetaSource::class, $sources[1]);
self::assertFalse(self::readOnly($sources[0]));
self::assertFalse(self::readOnly($sources[1]));
}
public function testWithUsablePodsItsSourceComesFirstAndTheMetaSourcesStayWritable(): void
{
$sources = Plugin::schemaSources(self::podsCompat(true), true);
self::assertInstanceOf(PodsFieldSource::class, $sources[0]);
self::assertFalse(self::readOnly($sources[1]));
self::assertFalse(self::readOnly($sources[2]));
}
/** Pods active but its schema locked: its meta keys must not be written past Pods. */
public function testWithActiveButLockedPodsTheMetaSourcesAreReadOnly(): void
{
$sources = Plugin::schemaSources(self::podsCompat(false), true);
self::assertInstanceOf(PodsFieldSource::class, $sources[0]);
self::assertTrue(self::readOnly($sources[1]));
self::assertTrue(self::readOnly($sources[2]));
}
}