php-agentic/tests/Unit/AgentToolRegistryTest.php
Claude 6cd9ca09d7
Some checks failed
CI / PHP 8.3 (push) Failing after 1m50s
CI / PHP 8.4 (push) Failing after 1m50s
style: fix pint issues in ContentService and AgentToolRegistryTest
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:58:52 +00:00

305 lines
9.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
/**
* Tests for AgentToolRegistry caching behaviour (PERF-002).
*
* Verifies that forApiKey() caches results, that the cache is invalidated
* when permissions change or a key is revoked, and that the TTL is honoured.
*/
use Core\Api\Models\ApiKey;
use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
use Core\Mod\Agentic\Services\AgentToolRegistry;
use Illuminate\Support\Facades\Cache;
// =========================================================================
// Helpers
// =========================================================================
/**
* Build a minimal AgentToolInterface stub.
*/
function makeTool(string $name, array $scopes = [], string $category = 'test'): AgentToolInterface
{
return new class($name, $scopes, $category) implements AgentToolInterface
{
public function __construct(
private readonly string $toolName,
private readonly array $toolScopes,
private readonly string $toolCategory,
) {}
public function name(): string
{
return $this->toolName;
}
public function description(): string
{
return 'Test tool';
}
public function inputSchema(): array
{
return [];
}
public function handle(array $args, array $context = []): array
{
return ['success' => true];
}
public function requiredScopes(): array
{
return $this->toolScopes;
}
public function category(): string
{
return $this->toolCategory;
}
};
}
/**
* Build a minimal ApiKey stub with controllable scopes and tool_scopes.
*
* Extends the real ApiKey so the type-hint in AgentToolRegistry is satisfied.
* Eloquent attribute storage means $key->tool_scopes flows through __get/__set as normal.
*/
function makeApiKey(int $id, array $scopes = [], ?array $toolScopes = null): ApiKey
{
$key = new class($id, $scopes, $toolScopes) extends ApiKey
{
private int $keyId;
private array $keyScopes;
public function __construct(int $id, array $scopes, ?array $toolScopes)
{
$this->keyId = $id;
$this->keyScopes = $scopes;
// Store via Eloquent attributes so __get('tool_scopes') returns it correctly
$this->attributes['tool_scopes'] = $toolScopes;
}
public function getKey(): mixed
{
return $this->keyId;
}
public function hasScope(string $scope): bool
{
return in_array($scope, $this->keyScopes, true);
}
};
return $key;
}
// =========================================================================
// Caching basic behaviour
// =========================================================================
describe('forApiKey caching', function () {
beforeEach(function () {
Cache::fake();
});
it('returns the correct tools on first call (cache miss)', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$registry->register(makeTool('session.start', ['sessions.write']));
$apiKey = makeApiKey(1, ['plans.write', 'sessions.write']);
$tools = $registry->forApiKey($apiKey);
expect($tools->keys()->sort()->values()->all())
->toBe(['plan.create', 'session.start']);
});
it('stores permitted tool names in cache after first call', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$apiKey = makeApiKey(42, ['plans.write']);
$registry->forApiKey($apiKey);
$cached = Cache::get('agent_tool_registry:api_key:42');
expect($cached)->toBe(['plan.create']);
});
it('returns same result on second call (cache hit)', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$registry->register(makeTool('session.start', ['sessions.write']));
$apiKey = makeApiKey(1, ['plans.write']);
$first = $registry->forApiKey($apiKey)->keys()->all();
$second = $registry->forApiKey($apiKey)->keys()->all();
expect($second)->toBe($first);
});
it('filters tools whose required scopes the key lacks', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$registry->register(makeTool('session.start', ['sessions.write']));
$apiKey = makeApiKey(1, ['plans.write']); // only plans.write
$tools = $registry->forApiKey($apiKey);
expect($tools->has('plan.create'))->toBeTrue()
->and($tools->has('session.start'))->toBeFalse();
});
it('respects tool_scopes allowlist on the api key', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$registry->register(makeTool('session.start', []));
$apiKey = makeApiKey(5, [], ['plan.create']); // explicitly restricted
$tools = $registry->forApiKey($apiKey);
expect($tools->has('plan.create'))->toBeTrue()
->and($tools->has('session.start'))->toBeFalse();
});
it('allows all tools when tool_scopes is null', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$registry->register(makeTool('session.start', []));
$apiKey = makeApiKey(7, [], null); // null = unrestricted
$tools = $registry->forApiKey($apiKey);
expect($tools)->toHaveCount(2);
});
it('caches separately per api key id', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', ['plans.write']));
$registry->register(makeTool('session.start', ['sessions.write']));
$keyA = makeApiKey(100, ['plans.write']);
$keyB = makeApiKey(200, ['sessions.write']);
$toolsA = $registry->forApiKey($keyA)->keys()->all();
$toolsB = $registry->forApiKey($keyB)->keys()->all();
expect($toolsA)->toBe(['plan.create'])
->and($toolsB)->toBe(['session.start']);
expect(Cache::get('agent_tool_registry:api_key:100'))->toBe(['plan.create'])
->and(Cache::get('agent_tool_registry:api_key:200'))->toBe(['session.start']);
});
});
// =========================================================================
// Cache TTL
// =========================================================================
describe('cache TTL', function () {
it('declares CACHE_TTL constant as 3600 (1 hour)', function () {
expect(AgentToolRegistry::CACHE_TTL)->toBe(3600);
});
it('stores entries in cache after first call', function () {
Cache::fake();
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$apiKey = makeApiKey(99, []);
$registry->forApiKey($apiKey);
expect(Cache::has('agent_tool_registry:api_key:99'))->toBeTrue();
});
});
// =========================================================================
// Cache invalidation flushCacheForApiKey
// =========================================================================
describe('flushCacheForApiKey', function () {
beforeEach(function () {
Cache::fake();
});
it('removes the cached entry for the given key id', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$apiKey = makeApiKey(10, []);
$registry->forApiKey($apiKey);
expect(Cache::has('agent_tool_registry:api_key:10'))->toBeTrue();
$registry->flushCacheForApiKey(10);
expect(Cache::has('agent_tool_registry:api_key:10'))->toBeFalse();
});
it('re-fetches permitted tools after cache flush', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$apiKey = makeApiKey(11, []);
// Prime the cache (only plan.create at this point)
expect($registry->forApiKey($apiKey)->keys()->all())->toBe(['plan.create']);
$registry->flushCacheForApiKey(11);
// Register an additional tool should appear now that cache is gone
$registry->register(makeTool('session.start', []));
$after = $registry->forApiKey($apiKey)->keys()->sort()->values()->all();
expect($after)->toBe(['plan.create', 'session.start']);
});
it('does not affect cache entries for other key ids', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$key12 = makeApiKey(12, []);
$key13 = makeApiKey(13, []);
$registry->forApiKey($key12);
$registry->forApiKey($key13);
$registry->flushCacheForApiKey(12);
expect(Cache::has('agent_tool_registry:api_key:12'))->toBeFalse()
->and(Cache::has('agent_tool_registry:api_key:13'))->toBeTrue();
});
it('accepts a string key id', function () {
$registry = new AgentToolRegistry;
$registry->register(makeTool('plan.create', []));
$apiKey = makeApiKey(20, []);
$registry->forApiKey($apiKey);
$registry->flushCacheForApiKey('20');
expect(Cache::has('agent_tool_registry:api_key:20'))->toBeFalse();
});
it('is a no-op when cache entry does not exist', function () {
$registry = new AgentToolRegistry;
// Should not throw when nothing is cached
$registry->flushCacheForApiKey(999);
expect(Cache::has('agent_tool_registry:api_key:999'))->toBeFalse();
});
});