Files
WP-LIT-MCP/tests/Unit/Compatibility/CapabilityGateTest.php
T
Jan-Niclas LoosenandClaude Opus 5.5 7d6a97a54e refactor(compat): Freigabe-Bausteine generisch
ReleaseStore, CheckReportStore, CheckReport, CheckStep und CompatibilityCheck liegen im Kern und arbeiten je Profil (Optionen lit_mcp_<id>_releases|_check_report; Divi behält Namen und Schlüssel divi_version). Divi hängt Katalog-, Format- und Schreibtest über Profile::extraSteps an. Ein roter Prüfbericht für den aktuellen Stand sperrt LockOnFailure-Profile bis zur nächsten grünen Prüfung; Sperrgründe nennen nur Schritt-Labels, Ausnahmen gehen ins Error-Log. Profile bekommt testedVersions, stateKey, extraSteps, manifestCheck und reportVersionKey und wird mit benannten Argumenten gebaut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:51:03 +01:00

148 lines
5.4 KiB
PHP

<?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(
id: 'fake',
label: 'Fake Plugin',
tier: Tier::LockOnFailure,
capabilities: [FakeCapability::Read, FakeCapability::Write],
manifests: [new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])]
);
$release = new Profile(
id: 'release',
label: 'Release',
tier: Tier::ReleasePerVersion,
capabilities: [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));
}
}