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
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user