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
+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')
);
}
}