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:
co-authored by
Claude Opus 5.5
parent
b511ef6348
commit
c85131603b
+116
@@ -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
@@ -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
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user