Split testsuite for unit and inspector based tests (#82)
This commit is contained in:
parent
7d599f4f64
commit
3ec4cf612a
19 changed files with 188 additions and 85 deletions
98
tests/Unit/Capability/Discovery/CachedDiscovererTest.php
Normal file
98
tests/Unit/Capability/Discovery/CachedDiscovererTest.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Discovery\CachedDiscoverer;
|
||||
use Mcp\Capability\Discovery\Discoverer;
|
||||
use Mcp\Capability\Discovery\DiscoveryState;
|
||||
use Mcp\Capability\Registry;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
class CachedDiscovererTest extends TestCase
|
||||
{
|
||||
public function testCachedDiscovererUsesCacheOnSecondCall(): void
|
||||
{
|
||||
$registry = new Registry(null, new NullLogger());
|
||||
$discoverer = new Discoverer($registry, new NullLogger());
|
||||
|
||||
$cache = $this->createMock(CacheInterface::class);
|
||||
$cache->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn(null);
|
||||
|
||||
$cache->expects($this->once())
|
||||
->method('set')
|
||||
->willReturn(true);
|
||||
|
||||
$cachedDiscoverer = new CachedDiscoverer(
|
||||
$discoverer,
|
||||
$cache,
|
||||
new NullLogger()
|
||||
);
|
||||
|
||||
$result = $cachedDiscoverer->discover('/test/path', ['.'], []);
|
||||
$this->assertInstanceOf(DiscoveryState::class, $result);
|
||||
}
|
||||
|
||||
public function testCachedDiscovererReturnsCachedResults(): void
|
||||
{
|
||||
$registry = new Registry(null, new NullLogger());
|
||||
$discoverer = new Discoverer($registry, new NullLogger());
|
||||
|
||||
$cache = $this->createMock(CacheInterface::class);
|
||||
$cachedState = new DiscoveryState();
|
||||
$cache->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn($cachedState);
|
||||
|
||||
$cache->expects($this->never())
|
||||
->method('set');
|
||||
|
||||
$cachedDiscoverer = new CachedDiscoverer(
|
||||
$discoverer,
|
||||
$cache,
|
||||
new NullLogger()
|
||||
);
|
||||
|
||||
$result = $cachedDiscoverer->discover('/test/path', ['.'], []);
|
||||
$this->assertInstanceOf(DiscoveryState::class, $result);
|
||||
}
|
||||
|
||||
public function testCacheKeyGeneration(): void
|
||||
{
|
||||
$registry = new Registry(null, new NullLogger());
|
||||
$discoverer = new Discoverer($registry, new NullLogger());
|
||||
|
||||
$cache = $this->createMock(CacheInterface::class);
|
||||
|
||||
$cache->expects($this->exactly(2))
|
||||
->method('get')
|
||||
->willReturn(null);
|
||||
|
||||
$cache->expects($this->exactly(2))
|
||||
->method('set')
|
||||
->willReturn(true);
|
||||
|
||||
$cachedDiscoverer = new CachedDiscoverer(
|
||||
$discoverer,
|
||||
$cache,
|
||||
new NullLogger()
|
||||
);
|
||||
|
||||
$result1 = $cachedDiscoverer->discover('/path1', ['.'], []);
|
||||
$result2 = $cachedDiscoverer->discover('/path2', ['.'], []);
|
||||
$this->assertInstanceOf(DiscoveryState::class, $result1);
|
||||
$this->assertInstanceOf(DiscoveryState::class, $result2);
|
||||
}
|
||||
}
|
||||
183
tests/Unit/Capability/Discovery/DiscoveryTest.php
Normal file
183
tests/Unit/Capability/Discovery/DiscoveryTest.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Discovery\Discoverer;
|
||||
use Mcp\Capability\Prompt\Completion\EnumCompletionProvider;
|
||||
use Mcp\Capability\Prompt\Completion\ListCompletionProvider;
|
||||
use Mcp\Capability\Registry;
|
||||
use Mcp\Capability\Registry\PromptReference;
|
||||
use Mcp\Capability\Registry\ResourceReference;
|
||||
use Mcp\Capability\Registry\ResourceTemplateReference;
|
||||
use Mcp\Capability\Registry\ToolReference;
|
||||
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture;
|
||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\DiscoverableToolHandler;
|
||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocablePromptFixture;
|
||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableResourceFixture;
|
||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableResourceTemplateFixture;
|
||||
use Mcp\Tests\Unit\Capability\Discovery\Fixtures\InvocableToolFixture;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DiscoveryTest extends TestCase
|
||||
{
|
||||
private Registry $registry;
|
||||
private Discoverer $discoverer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->registry = new Registry();
|
||||
$this->discoverer = new Discoverer($this->registry);
|
||||
}
|
||||
|
||||
public function testDiscoversAllElementTypesCorrectlyFromFixtureFiles()
|
||||
{
|
||||
$this->discoverer->discover(__DIR__, ['Fixtures']);
|
||||
|
||||
$tools = $this->registry->getTools();
|
||||
$this->assertCount(4, $tools);
|
||||
|
||||
$greetUserTool = $this->registry->getTool('greet_user');
|
||||
$this->assertInstanceOf(ToolReference::class, $greetUserTool);
|
||||
$this->assertFalse($greetUserTool->isManual);
|
||||
$this->assertEquals('greet_user', $greetUserTool->tool->name);
|
||||
$this->assertEquals('Greets a user by name.', $greetUserTool->tool->description);
|
||||
$this->assertEquals([DiscoverableToolHandler::class, 'greet'], $greetUserTool->handler);
|
||||
$this->assertArrayHasKey('name', $greetUserTool->tool->inputSchema['properties'] ?? []);
|
||||
|
||||
$repeatActionTool = $this->registry->getTool('repeatAction');
|
||||
$this->assertInstanceOf(ToolReference::class, $repeatActionTool);
|
||||
$this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description);
|
||||
$this->assertTrue($repeatActionTool->tool->annotations->readOnlyHint);
|
||||
$this->assertEquals(['count', 'loudly', 'mode'], array_keys($repeatActionTool->tool->inputSchema['properties'] ?? []));
|
||||
|
||||
$invokableCalcTool = $this->registry->getTool('InvokableCalculator');
|
||||
$this->assertInstanceOf(ToolReference::class, $invokableCalcTool);
|
||||
$this->assertFalse($invokableCalcTool->isManual);
|
||||
$this->assertEquals([InvocableToolFixture::class, '__invoke'], $invokableCalcTool->handler);
|
||||
|
||||
$this->assertNull($this->registry->getTool('private_tool_should_be_ignored'));
|
||||
$this->assertNull($this->registry->getTool('protected_tool_should_be_ignored'));
|
||||
$this->assertNull($this->registry->getTool('static_tool_should_be_ignored'));
|
||||
|
||||
$resources = $this->registry->getResources();
|
||||
$this->assertCount(3, $resources);
|
||||
|
||||
$appVersionRes = $this->registry->getResource('app://info/version');
|
||||
$this->assertInstanceOf(ResourceReference::class, $appVersionRes);
|
||||
$this->assertFalse($appVersionRes->isManual);
|
||||
$this->assertEquals('app_version', $appVersionRes->schema->name);
|
||||
$this->assertEquals('text/plain', $appVersionRes->schema->mimeType);
|
||||
|
||||
$invokableStatusRes = $this->registry->getResource('invokable://config/status');
|
||||
$this->assertInstanceOf(ResourceReference::class, $invokableStatusRes);
|
||||
$this->assertFalse($invokableStatusRes->isManual);
|
||||
$this->assertEquals([InvocableResourceFixture::class, '__invoke'], $invokableStatusRes->handler);
|
||||
|
||||
$prompts = $this->registry->getPrompts();
|
||||
$this->assertCount(4, $prompts);
|
||||
|
||||
$storyPrompt = $this->registry->getPrompt('creative_story_prompt');
|
||||
$this->assertInstanceOf(PromptReference::class, $storyPrompt);
|
||||
$this->assertFalse($storyPrompt->isManual);
|
||||
$this->assertCount(2, $storyPrompt->prompt->arguments);
|
||||
$this->assertEquals(CompletionProviderFixture::class, $storyPrompt->completionProviders['genre']);
|
||||
|
||||
$simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt');
|
||||
$this->assertInstanceOf(PromptReference::class, $simplePrompt);
|
||||
$this->assertFalse($simplePrompt->isManual);
|
||||
|
||||
$invokableGreeter = $this->registry->getPrompt('InvokableGreeterPrompt');
|
||||
$this->assertInstanceOf(PromptReference::class, $invokableGreeter);
|
||||
$this->assertFalse($invokableGreeter->isManual);
|
||||
$this->assertEquals([InvocablePromptFixture::class, '__invoke'], $invokableGreeter->handler);
|
||||
|
||||
$contentCreatorPrompt = $this->registry->getPrompt('content_creator');
|
||||
$this->assertInstanceOf(PromptReference::class, $contentCreatorPrompt);
|
||||
$this->assertFalse($contentCreatorPrompt->isManual);
|
||||
$this->assertCount(3, $contentCreatorPrompt->completionProviders);
|
||||
|
||||
$templates = $this->registry->getResourceTemplates();
|
||||
$this->assertCount(4, $templates);
|
||||
|
||||
$productTemplate = $this->registry->getResourceTemplate('product://{region}/details/{productId}');
|
||||
$this->assertInstanceOf(ResourceTemplateReference::class, $productTemplate);
|
||||
$this->assertFalse($productTemplate->isManual);
|
||||
$this->assertEquals('product_details_template', $productTemplate->resourceTemplate->name);
|
||||
$this->assertEquals(CompletionProviderFixture::class, $productTemplate->completionProviders['region']);
|
||||
$this->assertEqualsCanonicalizing(['region', 'productId'], $productTemplate->getVariableNames());
|
||||
|
||||
$invokableUserTemplate = $this->registry->getResourceTemplate('invokable://user-profile/{userId}');
|
||||
$this->assertInstanceOf(ResourceTemplateReference::class, $invokableUserTemplate);
|
||||
$this->assertFalse($invokableUserTemplate->isManual);
|
||||
$this->assertEquals([InvocableResourceTemplateFixture::class, '__invoke'], $invokableUserTemplate->handler);
|
||||
}
|
||||
|
||||
public function testDoesNotDiscoverElementsFromExcludedDirectories()
|
||||
{
|
||||
$this->discoverer->discover(__DIR__, ['Fixtures']);
|
||||
$this->assertInstanceOf(ToolReference::class, $this->registry->getTool('hidden_subdir_tool'));
|
||||
|
||||
$this->registry->clear();
|
||||
|
||||
$this->discoverer->discover(__DIR__, ['Fixtures'], ['SubDir']);
|
||||
$this->assertNull($this->registry->getTool('hidden_subdir_tool'));
|
||||
}
|
||||
|
||||
public function testHandlesEmptyDirectoriesOrDirectoriesWithNoPhpFiles()
|
||||
{
|
||||
$this->discoverer->discover(__DIR__, ['EmptyDir']);
|
||||
$tools = $this->registry->getTools();
|
||||
$this->assertEmpty($tools->references);
|
||||
}
|
||||
|
||||
public function testCorrectlyInfersNamesAndDescriptionsFromMethodsOrClassesIfNotSetInAttribute()
|
||||
{
|
||||
$this->discoverer->discover(__DIR__, ['Fixtures']);
|
||||
|
||||
$repeatActionTool = $this->registry->getTool('repeatAction');
|
||||
$this->assertEquals('repeatAction', $repeatActionTool->tool->name);
|
||||
$this->assertEquals('A tool with more complex parameters and inferred name/description.', $repeatActionTool->tool->description);
|
||||
|
||||
$simplePrompt = $this->registry->getPrompt('simpleQuestionPrompt');
|
||||
$this->assertEquals('simpleQuestionPrompt', $simplePrompt->prompt->name);
|
||||
$this->assertNull($simplePrompt->prompt->description);
|
||||
|
||||
$invokableCalc = $this->registry->getTool('InvokableCalculator');
|
||||
$this->assertEquals('InvokableCalculator', $invokableCalc->tool->name);
|
||||
$this->assertEquals('An invokable calculator tool.', $invokableCalc->tool->description);
|
||||
}
|
||||
|
||||
public function testDiscoversEnhancedCompletionProvidersWithValuesAndEnumAttributes()
|
||||
{
|
||||
$this->discoverer->discover(__DIR__, ['Fixtures']);
|
||||
|
||||
$contentPrompt = $this->registry->getPrompt('content_creator');
|
||||
$this->assertInstanceOf(PromptReference::class, $contentPrompt);
|
||||
$this->assertCount(3, $contentPrompt->completionProviders);
|
||||
|
||||
$typeProvider = $contentPrompt->completionProviders['type'];
|
||||
$this->assertInstanceOf(ListCompletionProvider::class, $typeProvider);
|
||||
|
||||
$statusProvider = $contentPrompt->completionProviders['status'];
|
||||
$this->assertInstanceOf(EnumCompletionProvider::class, $statusProvider);
|
||||
|
||||
$priorityProvider = $contentPrompt->completionProviders['priority'];
|
||||
$this->assertInstanceOf(EnumCompletionProvider::class, $priorityProvider);
|
||||
|
||||
$contentTemplate = $this->registry->getResourceTemplate('content://{category}/{slug}');
|
||||
$this->assertInstanceOf(ResourceTemplateReference::class, $contentTemplate);
|
||||
$this->assertCount(1, $contentTemplate->completionProviders);
|
||||
|
||||
$categoryProvider = $contentTemplate->completionProviders['category'];
|
||||
$this->assertInstanceOf(ListCompletionProvider::class, $categoryProvider);
|
||||
}
|
||||
}
|
||||
143
tests/Unit/Capability/Discovery/DocBlockParserTest.php
Normal file
143
tests/Unit/Capability/Discovery/DocBlockParserTest.php
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Discovery\DocBlockParser;
|
||||
use phpDocumentor\Reflection\DocBlock;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\Deprecated;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\Param;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\See;
|
||||
use phpDocumentor\Reflection\DocBlock\Tags\Throws;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DocBlockParserTest extends TestCase
|
||||
{
|
||||
private DocBlockParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new DocBlockParser();
|
||||
}
|
||||
|
||||
public function testGetSummaryReturnsCorrectSummary()
|
||||
{
|
||||
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly');
|
||||
$docComment = $method->getDocComment() ?: null;
|
||||
$docBlock = $this->parser->parseDocBlock($docComment);
|
||||
$this->assertEquals('Simple summary line.', $this->parser->getSummary($docBlock));
|
||||
|
||||
$method2 = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription');
|
||||
$docComment2 = $method2->getDocComment() ?: null;
|
||||
$docBlock2 = $this->parser->parseDocBlock($docComment2);
|
||||
$this->assertEquals('Summary line here.', $this->parser->getSummary($docBlock2));
|
||||
}
|
||||
|
||||
public function testGetDescriptionReturnsCorrectDescription()
|
||||
{
|
||||
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryAndDescription');
|
||||
$docComment = $method->getDocComment() ?: null;
|
||||
$docBlock = $this->parser->parseDocBlock($docComment);
|
||||
$expectedDesc = "Summary line here.\n\nThis is a longer description spanning\nmultiple lines.\nIt might contain *markdown* or `code`.";
|
||||
$this->assertEquals($expectedDesc, $this->parser->getDescription($docBlock));
|
||||
|
||||
$method2 = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithSummaryOnly');
|
||||
$docComment2 = $method2->getDocComment() ?: null;
|
||||
$docBlock2 = $this->parser->parseDocBlock($docComment2);
|
||||
$this->assertEquals('Simple summary line.', $this->parser->getDescription($docBlock2));
|
||||
}
|
||||
|
||||
public function testGetParamTagsReturnsStructuredParamInfo()
|
||||
{
|
||||
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithParams');
|
||||
$docComment = $method->getDocComment() ?: null;
|
||||
$docBlock = $this->parser->parseDocBlock($docComment);
|
||||
$params = $this->parser->getParamTags($docBlock);
|
||||
|
||||
$this->assertCount(6, $params);
|
||||
$this->assertArrayHasKey('$param1', $params);
|
||||
$this->assertArrayHasKey('$param2', $params);
|
||||
$this->assertArrayHasKey('$param3', $params);
|
||||
$this->assertArrayHasKey('$param4', $params);
|
||||
$this->assertArrayHasKey('$param5', $params);
|
||||
$this->assertArrayHasKey('$param6', $params);
|
||||
|
||||
$this->assertInstanceOf(Param::class, $params['$param1']);
|
||||
$this->assertEquals('param1', $params['$param1']->getVariableName());
|
||||
$this->assertEquals('string', $this->parser->getParamTypeString($params['$param1']));
|
||||
$this->assertEquals('description for string param', $this->parser->getParamDescription($params['$param1']));
|
||||
|
||||
$this->assertInstanceOf(Param::class, $params['$param2']);
|
||||
$this->assertEquals('param2', $params['$param2']->getVariableName());
|
||||
$this->assertEquals('int|null', $this->parser->getParamTypeString($params['$param2']));
|
||||
$this->assertEquals('description for nullable int param', $this->parser->getParamDescription($params['$param2']));
|
||||
|
||||
$this->assertInstanceOf(Param::class, $params['$param3']);
|
||||
$this->assertEquals('param3', $params['$param3']->getVariableName());
|
||||
$this->assertEquals('bool', $this->parser->getParamTypeString($params['$param3']));
|
||||
$this->assertEquals('nothing to say', $this->parser->getParamDescription($params['$param3']));
|
||||
|
||||
$this->assertInstanceOf(Param::class, $params['$param4']);
|
||||
$this->assertEquals('param4', $params['$param4']->getVariableName());
|
||||
$this->assertEquals('mixed', $this->parser->getParamTypeString($params['$param4']));
|
||||
$this->assertEquals('Missing type', $this->parser->getParamDescription($params['$param4']));
|
||||
|
||||
$this->assertInstanceOf(Param::class, $params['$param5']);
|
||||
$this->assertEquals('param5', $params['$param5']->getVariableName());
|
||||
$this->assertEquals('array<string,mixed>', $this->parser->getParamTypeString($params['$param5']));
|
||||
$this->assertEquals('array description', $this->parser->getParamDescription($params['$param5']));
|
||||
|
||||
$this->assertInstanceOf(Param::class, $params['$param6']);
|
||||
$this->assertEquals('param6', $params['$param6']->getVariableName());
|
||||
$this->assertEquals('stdClass', $this->parser->getParamTypeString($params['$param6']));
|
||||
$this->assertEquals('object param', $this->parser->getParamDescription($params['$param6']));
|
||||
}
|
||||
|
||||
public function testGetTagsByNameReturnsSpecificTags()
|
||||
{
|
||||
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithMultipleTags');
|
||||
$docComment = $method->getDocComment() ?: null;
|
||||
$docBlock = $this->parser->parseDocBlock($docComment);
|
||||
|
||||
$this->assertInstanceOf(DocBlock::class, $docBlock);
|
||||
|
||||
$throwsTags = $docBlock->getTagsByName('throws');
|
||||
$this->assertCount(1, $throwsTags);
|
||||
$this->assertInstanceOf(Throws::class, $throwsTags[0]);
|
||||
$this->assertEquals('\\RuntimeException', (string) $throwsTags[0]->getType());
|
||||
$this->assertEquals('if processing fails', $throwsTags[0]->getDescription()->render());
|
||||
|
||||
$deprecatedTags = $docBlock->getTagsByName('deprecated');
|
||||
$this->assertCount(1, $deprecatedTags);
|
||||
$this->assertInstanceOf(Deprecated::class, $deprecatedTags[0]);
|
||||
$this->assertEquals('use newMethod() instead', $deprecatedTags[0]->getDescription()->render());
|
||||
|
||||
$seeTags = $docBlock->getTagsByName('see');
|
||||
$this->assertCount(1, $seeTags);
|
||||
$this->assertInstanceOf(See::class, $seeTags[0]);
|
||||
$this->assertStringContainsString('DocBlockTestFixture::newMethod()', (string) $seeTags[0]->getReference());
|
||||
|
||||
$nonExistentTags = $docBlock->getTagsByName('nosuchtag');
|
||||
$this->assertEmpty($nonExistentTags);
|
||||
}
|
||||
|
||||
public function testHandlesMethodWithNoDocblockGracefully()
|
||||
{
|
||||
$method = new \ReflectionMethod(DocBlockTestFixture::class, 'methodWithNoDocBlock');
|
||||
$docComment = $method->getDocComment() ?: null;
|
||||
$docBlock = $this->parser->parseDocBlock($docComment);
|
||||
|
||||
$this->assertNull($docBlock);
|
||||
$this->assertNull($this->parser->getSummary($docBlock));
|
||||
$this->assertNull($this->parser->getDescription($docBlock));
|
||||
$this->assertEmpty($this->parser->getParamTags($docBlock));
|
||||
}
|
||||
}
|
||||
98
tests/Unit/Capability/Discovery/DocBlockTestFixture.php
Normal file
98
tests/Unit/Capability/Discovery/DocBlockTestFixture.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Tests\Capability\Discovery\Missing;
|
||||
|
||||
/**
|
||||
* A stub class for testing DocBlock parsing.
|
||||
*/
|
||||
class DocBlockTestFixture
|
||||
{
|
||||
/**
|
||||
* Simple summary line.
|
||||
*/
|
||||
public function methodWithSummaryOnly(): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary line here.
|
||||
*
|
||||
* This is a longer description spanning
|
||||
* multiple lines.
|
||||
* It might contain *markdown* or `code`.
|
||||
*
|
||||
* @since 1.0
|
||||
*/
|
||||
public function methodWithSummaryAndDescription(): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Method with various parameter tags.
|
||||
*
|
||||
* @param string $param1 description for string param
|
||||
* @param int|null $param2 description for nullable int param
|
||||
* @param bool $param3 nothing to say
|
||||
* @param $param4 Missing type
|
||||
* @param array<string, mixed> $param5 array description
|
||||
* @param \stdClass $param6 object param
|
||||
*/
|
||||
/* @phpstan-ignore-next-line missingType.parameter */
|
||||
public function methodWithParams(string $param1, ?int $param2, bool $param3, $param4, array $param5, \stdClass $param6): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Method with return tag.
|
||||
*
|
||||
* @return string the result of the operation
|
||||
*/
|
||||
public function methodWithReturn(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Method with multiple tags.
|
||||
*
|
||||
* @param float $value the value to process
|
||||
*
|
||||
* @return bool status of the operation
|
||||
*
|
||||
* @throws \RuntimeException if processing fails
|
||||
*
|
||||
* @deprecated use newMethod() instead
|
||||
* @see DocBlockTestFixture::newMethod()
|
||||
*/
|
||||
public function methodWithMultipleTags(float $value): bool /* @phpstan-ignore throws.unusedType */
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Malformed docblock - missing closing.
|
||||
*/
|
||||
public function methodWithMalformedDocBlock(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithNoDocBlock(): void
|
||||
{
|
||||
}
|
||||
|
||||
// Some other method needed for a @see tag perhaps
|
||||
public function newMethod(): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\CompletionProvider;
|
||||
use Mcp\Capability\Attribute\McpPrompt;
|
||||
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture;
|
||||
|
||||
class DiscoverablePromptHandler
|
||||
{
|
||||
/**
|
||||
* Generates a creative story prompt.
|
||||
*
|
||||
* @param string $genre the genre of the story
|
||||
* @param int $lengthWords approximate length in words
|
||||
*
|
||||
* @return array the prompt messages
|
||||
*/
|
||||
#[McpPrompt(name: 'creative_story_prompt')]
|
||||
public function generateStoryPrompt(
|
||||
#[CompletionProvider(provider: CompletionProviderFixture::class)]
|
||||
string $genre,
|
||||
int $lengthWords = 200,
|
||||
): array {
|
||||
return [
|
||||
['role' => 'user', 'content' => "Write a {$genre} story about a lost robot, approximately {$lengthWords} words long."],
|
||||
];
|
||||
}
|
||||
|
||||
#[McpPrompt]
|
||||
public function simpleQuestionPrompt(string $question): array
|
||||
{
|
||||
return [
|
||||
['role' => 'user', 'content' => $question],
|
||||
['role' => 'assistant', 'content' => 'I will try to answer that.'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Annotations;
|
||||
|
||||
class DiscoverableResourceHandler
|
||||
{
|
||||
/**
|
||||
* Provides the application's current version.
|
||||
*
|
||||
* @return string the version string
|
||||
*/
|
||||
#[McpResource(
|
||||
uri: 'app://info/version',
|
||||
name: 'app_version',
|
||||
description: 'The current version of the application.',
|
||||
mimeType: 'text/plain',
|
||||
size: 10
|
||||
)]
|
||||
public function getAppVersion(): string
|
||||
{
|
||||
return '1.2.3-discovered';
|
||||
}
|
||||
|
||||
#[McpResource(
|
||||
uri: 'config://settings/ui',
|
||||
name: 'ui_settings_discovered',
|
||||
mimeType: 'application/json',
|
||||
annotations: new Annotations(priority: 0.5)
|
||||
)]
|
||||
public function getUiSettings(): array
|
||||
{
|
||||
return ['theme' => 'dark', 'fontSize' => 14];
|
||||
}
|
||||
|
||||
public function someOtherMethod(): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\CompletionProvider;
|
||||
use Mcp\Capability\Attribute\McpResourceTemplate;
|
||||
use Mcp\Tests\Unit\Capability\Attribute\CompletionProviderFixture;
|
||||
|
||||
class DiscoverableTemplateHandler
|
||||
{
|
||||
/**
|
||||
* Retrieves product details based on ID and region.
|
||||
*
|
||||
* @param string $productId the ID of the product
|
||||
* @param string $region the sales region
|
||||
*
|
||||
* @return array product details
|
||||
*/
|
||||
#[McpResourceTemplate(
|
||||
uriTemplate: 'product://{region}/details/{productId}',
|
||||
name: 'product_details_template',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
public function getProductDetails(
|
||||
string $productId,
|
||||
#[CompletionProvider(provider: CompletionProviderFixture::class)]
|
||||
string $region,
|
||||
): array {
|
||||
return [
|
||||
'id' => $productId,
|
||||
'name' => 'Product '.$productId,
|
||||
'region' => $region,
|
||||
'price' => ('EU' === $region ? '€' : '$').(hexdec(substr(md5($productId), 0, 4)) / 100),
|
||||
];
|
||||
}
|
||||
|
||||
#[McpResourceTemplate(uriTemplate: 'file://{path}/{filename}.{extension}')]
|
||||
public function getFileContent(string $path, string $filename, string $extension): string
|
||||
{
|
||||
return "Content of {$path}/{$filename}.{$extension}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Schema\ToolAnnotations;
|
||||
use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum;
|
||||
|
||||
class DiscoverableToolHandler
|
||||
{
|
||||
/**
|
||||
* A basic discoverable tool.
|
||||
*
|
||||
* @param string $name the name to greet
|
||||
*
|
||||
* @return string the greeting
|
||||
*/
|
||||
#[McpTool(name: 'greet_user', description: 'Greets a user by name.')]
|
||||
public function greet(string $name): string
|
||||
{
|
||||
return "Hello, {$name}!";
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool with more complex parameters and inferred name/description.
|
||||
*
|
||||
* @param int $count the number of times to repeat
|
||||
* @param bool $loudly Should it be loud?
|
||||
* @param BackedStringEnum $mode the mode of operation
|
||||
*
|
||||
* @return array an array with results
|
||||
*/
|
||||
#[McpTool(annotations: new ToolAnnotations(readOnlyHint: true))]
|
||||
public function repeatAction(int $count, bool $loudly = false, BackedStringEnum $mode = BackedStringEnum::OptionA): array
|
||||
{
|
||||
return ['count' => $count, 'loudly' => $loudly, 'mode' => $mode->value, 'message' => 'Action repeated.'];
|
||||
}
|
||||
|
||||
// This method should NOT be discovered as a tool
|
||||
public function internalHelperMethod(int $value): int
|
||||
{
|
||||
return $value * 2;
|
||||
}
|
||||
|
||||
#[McpTool(name: 'private_tool_should_be_ignored')] // On private method
|
||||
private function aPrivateTool(): void
|
||||
{
|
||||
}
|
||||
|
||||
#[McpTool(name: 'protected_tool_should_be_ignored')] // On protected method
|
||||
protected function aProtectedTool(): void
|
||||
{
|
||||
}
|
||||
|
||||
#[McpTool(name: 'static_tool_should_be_ignored')] // On static method
|
||||
public static function aStaticTool(): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\CompletionProvider;
|
||||
use Mcp\Capability\Attribute\McpPrompt;
|
||||
use Mcp\Capability\Attribute\McpResourceTemplate;
|
||||
use Mcp\Tests\Unit\Fixtures\Enum\PriorityEnum;
|
||||
use Mcp\Tests\Unit\Fixtures\Enum\StatusEnum;
|
||||
|
||||
class EnhancedCompletionHandler
|
||||
{
|
||||
/**
|
||||
* Create content with list and enum completion providers.
|
||||
*/
|
||||
#[McpPrompt(name: 'content_creator')]
|
||||
public function createContent(
|
||||
#[CompletionProvider(values: ['blog', 'article', 'tutorial', 'guide'])]
|
||||
string $type,
|
||||
#[CompletionProvider(enum: StatusEnum::class)]
|
||||
string $status,
|
||||
#[CompletionProvider(enum: PriorityEnum::class)]
|
||||
string $priority,
|
||||
): array {
|
||||
return [
|
||||
['role' => 'user', 'content' => "Create a {$type} with status {$status} and priority {$priority}"],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource template with list completion for categories.
|
||||
*/
|
||||
#[McpResourceTemplate(
|
||||
uriTemplate: 'content://{category}/{slug}',
|
||||
name: 'content_template'
|
||||
)]
|
||||
public function getContent(
|
||||
#[CompletionProvider(values: ['news', 'blog', 'docs', 'api'])]
|
||||
string $category,
|
||||
string $slug,
|
||||
): array {
|
||||
return [
|
||||
'category' => $category,
|
||||
'slug' => $slug,
|
||||
'url' => "https://example.com/{$category}/{$slug}",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\McpPrompt;
|
||||
|
||||
#[McpPrompt(name: 'InvokableGreeterPrompt')]
|
||||
class InvocablePromptFixture
|
||||
{
|
||||
public function __invoke(string $personName): array
|
||||
{
|
||||
return [['role' => 'user', 'content' => "Generate a short greeting for {$personName}."]];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
|
||||
#[McpResource(uri: 'invokable://config/status', name: 'invokable_app_status')]
|
||||
class InvocableResourceFixture
|
||||
{
|
||||
public function __invoke(): array
|
||||
{
|
||||
return ['status' => 'OK', 'load' => rand(1, 100) / 100.0];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResourceTemplate;
|
||||
|
||||
#[McpResourceTemplate(uriTemplate: 'invokable://user-profile/{userId}')]
|
||||
class InvocableResourceTemplateFixture
|
||||
{
|
||||
public function __invoke(string $userId): array
|
||||
{
|
||||
return ['id' => $userId, 'email' => "user{$userId}@example-invokable.com"];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'InvokableCalculator', description: 'An invokable calculator tool.')]
|
||||
class InvocableToolFixture
|
||||
{
|
||||
/**
|
||||
* Adds two numbers.
|
||||
*/
|
||||
public function __invoke(int $a, int $b): int
|
||||
{
|
||||
return $a + $b;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures;
|
||||
|
||||
class NonDiscoverableClass
|
||||
{
|
||||
public function someMethod(): string
|
||||
{
|
||||
return 'Just a regular method.';
|
||||
}
|
||||
}
|
||||
|
||||
interface MyDiscoverableInterface
|
||||
{
|
||||
}
|
||||
|
||||
trait MyDiscoverableTrait
|
||||
{
|
||||
public function traitMethod()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
enum MyDiscoverableEnum
|
||||
{
|
||||
case Alpha;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery\Fixtures\SubDir;
|
||||
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
class HiddenTool
|
||||
{
|
||||
#[McpTool(name: 'hidden_subdir_tool')]
|
||||
public function run()
|
||||
{
|
||||
}
|
||||
}
|
||||
207
tests/Unit/Capability/Discovery/HandlerResolverTest.php
Normal file
207
tests/Unit/Capability/Discovery/HandlerResolverTest.php
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Discovery\HandlerResolver;
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HandlerResolverTest extends TestCase
|
||||
{
|
||||
public function testResolvesClosuresToReflectionFunction()
|
||||
{
|
||||
$closure = function (string $input): string {
|
||||
return "processed: $input";
|
||||
};
|
||||
$resolved = HandlerResolver::resolve($closure);
|
||||
$this->assertInstanceOf(\ReflectionFunction::class, $resolved);
|
||||
$this->assertEquals(1, $resolved->getNumberOfParameters());
|
||||
$this->assertInstanceOf(\ReflectionNamedType::class, $returnType = $resolved->getReturnType());
|
||||
$this->assertEquals('string', $returnType->getName());
|
||||
}
|
||||
|
||||
public function testResolvesValidArrayHandler()
|
||||
{
|
||||
$handler = [ValidHandlerClass::class, 'publicMethod'];
|
||||
$resolved = HandlerResolver::resolve($handler);
|
||||
$this->assertInstanceOf(\ReflectionMethod::class, $resolved);
|
||||
$this->assertEquals('publicMethod', $resolved->getName());
|
||||
$this->assertEquals(ValidHandlerClass::class, $resolved->getDeclaringClass()->getName());
|
||||
}
|
||||
|
||||
public function testResolvesValidInvokableClassStringHandler()
|
||||
{
|
||||
$handler = ValidInvokableClass::class;
|
||||
$resolved = HandlerResolver::resolve($handler);
|
||||
$this->assertInstanceOf(\ReflectionMethod::class, $resolved);
|
||||
$this->assertEquals('__invoke', $resolved->getName());
|
||||
$this->assertEquals(ValidInvokableClass::class, $resolved->getDeclaringClass()->getName());
|
||||
}
|
||||
|
||||
public function testResolvesStaticMethodsForManualRegistration()
|
||||
{
|
||||
$handler = [ValidHandlerClass::class, 'staticMethod'];
|
||||
$resolved = HandlerResolver::resolve($handler);
|
||||
$this->assertInstanceOf(\ReflectionMethod::class, $resolved);
|
||||
$this->assertEquals('staticMethod', $resolved->getName());
|
||||
$this->assertTrue($resolved->isStatic());
|
||||
}
|
||||
|
||||
public function testThrowsForInvalidArrayHandlerFormatCount()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName'].");
|
||||
HandlerResolver::resolve([ValidHandlerClass::class]); /* @phpstan-ignore argument.type */
|
||||
}
|
||||
|
||||
public function testThrowsForInvalidArrayHandlerFormatTypes()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("Invalid array handler format. Expected [ClassName::class, 'methodName'].");
|
||||
HandlerResolver::resolve([ValidHandlerClass::class, 123]); /* @phpstan-ignore argument.type */
|
||||
}
|
||||
|
||||
public function testThrowsForNonExistentClassInArrayHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Handler class "NonExistentClass" not found');
|
||||
HandlerResolver::resolve(['NonExistentClass', 'method']);
|
||||
}
|
||||
|
||||
public function testThrowsForNonExistentMethodInArrayHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Handler method "nonExistentMethod" not found in class');
|
||||
HandlerResolver::resolve([ValidHandlerClass::class, 'nonExistentMethod']);
|
||||
}
|
||||
|
||||
public function testThrowsForNonExistentClassInStringHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid handler format. Expected Closure, [ClassName::class, \'methodName\'] or InvokableClassName::class string.');
|
||||
HandlerResolver::resolve('NonExistentInvokableClass');
|
||||
}
|
||||
|
||||
public function testThrowsForNonInvokableClassStringHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invokable handler class "Mcp\Tests\Unit\Capability\Discovery\NonInvokableClass" must have a public "__invoke" method.');
|
||||
HandlerResolver::resolve(NonInvokableClass::class);
|
||||
}
|
||||
|
||||
public function testThrowsForProtectedMethodHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('must be public');
|
||||
HandlerResolver::resolve([ValidHandlerClass::class, 'protectedMethod']);
|
||||
}
|
||||
|
||||
public function testThrowsForPrivateMethodHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('must be public');
|
||||
HandlerResolver::resolve([ValidHandlerClass::class, 'privateMethod']);
|
||||
}
|
||||
|
||||
public function testThrowsForConstructorAsHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('cannot be a constructor or destructor');
|
||||
HandlerResolver::resolve([ValidHandlerClass::class, '__construct']);
|
||||
}
|
||||
|
||||
public function testThrowsForDestructorAsHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('cannot be a constructor or destructor');
|
||||
HandlerResolver::resolve([ValidHandlerClass::class, '__destruct']);
|
||||
}
|
||||
|
||||
public function testThrowsForAbstractMethodHandler()
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Handler method "Mcp\Tests\Unit\Capability\Discovery\AbstractHandlerClass::abstractMethod" must be abstract.');
|
||||
HandlerResolver::resolve([AbstractHandlerClass::class, 'abstractMethod']);
|
||||
}
|
||||
|
||||
public function testResolvesClosuresWithDifferentSignatures()
|
||||
{
|
||||
$noParams = function () {
|
||||
return 'test';
|
||||
};
|
||||
$withParams = function (int $a, string $b = 'default') {
|
||||
return $a.$b;
|
||||
};
|
||||
$variadic = function (...$args) {
|
||||
return $args;
|
||||
};
|
||||
$this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($noParams));
|
||||
$this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($withParams));
|
||||
$this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($variadic));
|
||||
$this->assertEquals(0, HandlerResolver::resolve($noParams)->getNumberOfParameters());
|
||||
$this->assertEquals(2, HandlerResolver::resolve($withParams)->getNumberOfParameters());
|
||||
$this->assertTrue(HandlerResolver::resolve($variadic)->isVariadic());
|
||||
}
|
||||
|
||||
public function testDistinguishesBetweenClosuresAndCallableArrays()
|
||||
{
|
||||
$closure = function () {
|
||||
return 'closure';
|
||||
};
|
||||
$array = [ValidHandlerClass::class, 'publicMethod'];
|
||||
$string = ValidInvokableClass::class;
|
||||
$this->assertInstanceOf(\ReflectionFunction::class, HandlerResolver::resolve($closure));
|
||||
$this->assertInstanceOf(\ReflectionMethod::class, HandlerResolver::resolve($array));
|
||||
$this->assertInstanceOf(\ReflectionMethod::class, HandlerResolver::resolve($string));
|
||||
}
|
||||
}
|
||||
|
||||
// Helper classes
|
||||
class ValidHandlerClass
|
||||
{
|
||||
public function publicMethod(): void
|
||||
{
|
||||
}
|
||||
|
||||
protected function protectedMethod(): void
|
||||
{
|
||||
}
|
||||
|
||||
private function privateMethod(): void /* @phpstan-ignore method.unused */
|
||||
{
|
||||
}
|
||||
|
||||
public static function staticMethod(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
}
|
||||
}
|
||||
class ValidInvokableClass
|
||||
{
|
||||
public function __invoke(): void
|
||||
{
|
||||
}
|
||||
}
|
||||
class NonInvokableClass
|
||||
{
|
||||
}
|
||||
abstract class AbstractHandlerClass
|
||||
{
|
||||
abstract public function abstractMethod(): void;
|
||||
}
|
||||
415
tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php
Normal file
415
tests/Unit/Capability/Discovery/SchemaGeneratorFixture.php
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Attribute\Schema;
|
||||
use Mcp\Tests\Unit\Fixtures\Enum\BackedIntEnum;
|
||||
use Mcp\Tests\Unit\Fixtures\Enum\BackedStringEnum;
|
||||
use Mcp\Tests\Unit\Fixtures\Enum\UnitEnum;
|
||||
|
||||
/**
|
||||
* Comprehensive fixture for testing SchemaGenerator with various scenarios.
|
||||
*/
|
||||
class SchemaGeneratorFixture
|
||||
{
|
||||
// ===== BASIC SCENARIOS =====
|
||||
|
||||
public function noParams(): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Type hints only - no Schema attributes.
|
||||
*/
|
||||
public function typeHintsOnly(string $name, int $age, bool $active, array $tags, ?\stdClass $config = null): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* DocBlock types only - no PHP type hints, no Schema attributes.
|
||||
*
|
||||
* @param string $username The username
|
||||
* @param int $count Number of items
|
||||
* @param bool $enabled Whether enabled
|
||||
* @param array $data Some data
|
||||
*/
|
||||
public function docBlockOnly($username, $count, $enabled, $data): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Type hints with DocBlock descriptions.
|
||||
*
|
||||
* @param string $email User email address
|
||||
* @param int $score User score
|
||||
* @param bool $verified Whether user is verified
|
||||
*/
|
||||
public function typeHintsWithDocBlock(string $email, int $score, bool $verified): void
|
||||
{
|
||||
}
|
||||
|
||||
// ===== METHOD-LEVEL SCHEMA SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Method-level Schema with complete definition.
|
||||
*/
|
||||
#[Schema(definition: [
|
||||
'type' => 'object',
|
||||
'description' => 'Creates a custom filter with complete definition',
|
||||
'properties' => [
|
||||
'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']],
|
||||
'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']],
|
||||
'value' => ['description' => 'Value to filter by, type depends on field and operator'],
|
||||
],
|
||||
'required' => ['field', 'operator', 'value'],
|
||||
'if' => [
|
||||
'properties' => ['field' => ['const' => 'date']],
|
||||
],
|
||||
'then' => [
|
||||
'properties' => ['value' => ['type' => 'string', 'format' => 'date']],
|
||||
],
|
||||
])]
|
||||
public function methodLevelCompleteDefinition(string $field, string $operator, mixed $value): array
|
||||
{
|
||||
return compact('field', 'operator', 'value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Method-level Schema defining properties.
|
||||
*/
|
||||
#[Schema(
|
||||
description: 'Creates a new user with detailed information.',
|
||||
properties: [
|
||||
'username' => ['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'],
|
||||
'email' => ['type' => 'string', 'format' => 'email'],
|
||||
'age' => ['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'],
|
||||
'isActive' => ['type' => 'boolean', 'default' => true],
|
||||
],
|
||||
required: ['username', 'email']
|
||||
)]
|
||||
public function methodLevelWithProperties(string $username, string $email, int $age, bool $isActive = true): array
|
||||
{
|
||||
return compact('username', 'email', 'age', 'isActive');
|
||||
}
|
||||
|
||||
/**
|
||||
* Method-level Schema for complex array argument.
|
||||
*/
|
||||
#[Schema(
|
||||
properties: [
|
||||
'profiles' => [
|
||||
'type' => 'array',
|
||||
'description' => 'An array of user profiles to update.',
|
||||
'minItems' => 1,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer'],
|
||||
'data' => ['type' => 'object', 'additionalProperties' => true],
|
||||
],
|
||||
'required' => ['id', 'data'],
|
||||
],
|
||||
],
|
||||
],
|
||||
required: ['profiles']
|
||||
)]
|
||||
public function methodLevelArrayArgument(array $profiles): array
|
||||
{
|
||||
return ['updated_count' => \count($profiles)];
|
||||
}
|
||||
|
||||
// ===== PARAMETER-LEVEL SCHEMA SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Parameter-level Schema attributes only.
|
||||
*/
|
||||
public function parameterLevelOnly(
|
||||
#[Schema(description: 'Recipient ID', pattern: '^user_')]
|
||||
string $recipientId,
|
||||
#[Schema(maxLength: 1024)]
|
||||
string $messageBody,
|
||||
#[Schema(type: 'integer', enum: [1, 2, 5])]
|
||||
int $priority = 1,
|
||||
#[Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']],
|
||||
'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'],
|
||||
],
|
||||
required: ['type']
|
||||
)]
|
||||
?array $notificationConfig = null,
|
||||
): array {
|
||||
return compact('recipientId', 'messageBody', 'priority', 'notificationConfig');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter-level Schema with string constraints.
|
||||
*/
|
||||
public function parameterStringConstraints(
|
||||
#[Schema(format: 'email')]
|
||||
string $email,
|
||||
#[Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$')]
|
||||
string $password,
|
||||
string $regularString,
|
||||
): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter-level Schema with numeric constraints.
|
||||
*/
|
||||
public function parameterNumericConstraints(
|
||||
#[Schema(minimum: 18, maximum: 120)]
|
||||
int $age,
|
||||
#[Schema(minimum: 0, maximum: 5, exclusiveMaximum: true)]
|
||||
float $rating,
|
||||
#[Schema(multipleOf: 10)]
|
||||
int $count,
|
||||
): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter-level Schema with array constraints.
|
||||
*/
|
||||
public function parameterArrayConstraints(
|
||||
#[Schema(type: 'array', items: ['type' => 'string'], minItems: 1, uniqueItems: true)]
|
||||
array $tags,
|
||||
#[Schema(type: 'array', items: ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], minItems: 1, maxItems: 5)]
|
||||
array $scores,
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== COMBINED SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Method-level + Parameter-level Schema combination.
|
||||
*
|
||||
* @param string $settingKey The key of the setting
|
||||
* @param mixed $newValue The new value for the setting
|
||||
*/
|
||||
#[Schema(
|
||||
properties: [
|
||||
'settingKey' => ['type' => 'string', 'description' => 'The key of the setting.'],
|
||||
'newValue' => ['description' => 'The new value for the setting (any type).'],
|
||||
],
|
||||
required: ['settingKey', 'newValue']
|
||||
)]
|
||||
public function methodAndParameterLevel(
|
||||
string $settingKey,
|
||||
#[Schema(description: 'The specific new boolean value.', type: 'boolean')]
|
||||
mixed $newValue,
|
||||
): array {
|
||||
return compact('settingKey', 'newValue');
|
||||
}
|
||||
|
||||
/**
|
||||
* Type hints + DocBlock + Parameter-level Schema.
|
||||
*
|
||||
* @param string $username The user's name
|
||||
* @param int $priority Task priority level
|
||||
*/
|
||||
public function typeHintDocBlockAndParameterSchema(
|
||||
#[Schema(minLength: 3, pattern: '^[a-zA-Z0-9_]+$')]
|
||||
string $username,
|
||||
#[Schema(minimum: 1, maximum: 10)]
|
||||
int $priority,
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== ENUM SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Various enum parameter types.
|
||||
*
|
||||
* @param BackedStringEnum $stringEnum Backed string enum
|
||||
* @param BackedIntEnum $intEnum Backed int enum
|
||||
* @param UnitEnum $unitEnum Unit enum
|
||||
*/
|
||||
public function enumParameters(
|
||||
BackedStringEnum $stringEnum,
|
||||
BackedIntEnum $intEnum,
|
||||
UnitEnum $unitEnum,
|
||||
?BackedStringEnum $nullableEnum = null,
|
||||
BackedIntEnum $enumWithDefault = BackedIntEnum::First,
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== ARRAY TYPE SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Various array type scenarios.
|
||||
*
|
||||
* @param array $genericArray Generic array
|
||||
* @param string[] $stringArray Array of strings
|
||||
* @param int[] $intArray Array of integers
|
||||
* @param array<string, mixed> $mixedMap Mixed array map
|
||||
* @param array{name: string, age: int} $objectLikeArray Object-like array
|
||||
* @param array{user: array{id: int, name: string}, items: int[]} $nestedObjectArray Nested object array
|
||||
*/
|
||||
public function arrayTypeScenarios(
|
||||
array $genericArray,
|
||||
array $stringArray,
|
||||
array $intArray,
|
||||
array $mixedMap,
|
||||
array $objectLikeArray,
|
||||
array $nestedObjectArray,
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== NULLABLE AND OPTIONAL SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Nullable and optional parameter scenarios.
|
||||
*
|
||||
* @param string|null $nullableString Nullable string
|
||||
* @param int|null $nullableInt Nullable integer
|
||||
*/
|
||||
public function nullableAndOptional(
|
||||
?string $nullableString,
|
||||
?int $nullableInt = null,
|
||||
string $optionalString = 'default',
|
||||
bool $optionalBool = true,
|
||||
array $optionalArray = [],
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== UNION TYPE SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Union type parameters.
|
||||
*
|
||||
* @param string|int $stringOrInt String or integer
|
||||
* @param bool|string|null $multiUnion Bool, string or null
|
||||
*/
|
||||
public function unionTypes(
|
||||
string|int $stringOrInt,
|
||||
bool|string|null $multiUnion,
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== VARIADIC SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Variadic parameter scenarios.
|
||||
*
|
||||
* @param string ...$items Variadic strings
|
||||
*/
|
||||
public function variadicStrings(string ...$items): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Variadic with Schema constraints.
|
||||
*
|
||||
* @param int ...$numbers Variadic integers
|
||||
*/
|
||||
public function variadicWithConstraints(
|
||||
#[Schema(items: ['type' => 'integer', 'minimum' => 0])]
|
||||
int ...$numbers,
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== MIXED TYPE SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Mixed type scenarios.
|
||||
*
|
||||
* @param mixed $anyValue Any value
|
||||
* @param mixed $optionalAny Optional any value
|
||||
*/
|
||||
public function mixedTypes(
|
||||
mixed $anyValue,
|
||||
mixed $optionalAny = 'default',
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== COMPLEX NESTED SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Complex nested Schema constraints.
|
||||
*/
|
||||
public function complexNestedSchema(
|
||||
#[Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'customer' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
|
||||
'name' => ['type' => 'string', 'minLength' => 2],
|
||||
'email' => ['type' => 'string', 'format' => 'email'],
|
||||
],
|
||||
'required' => ['id', 'name'],
|
||||
],
|
||||
'items' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
|
||||
'quantity' => ['type' => 'integer', 'minimum' => 1],
|
||||
'price' => ['type' => 'number', 'minimum' => 0],
|
||||
],
|
||||
'required' => ['product_id', 'quantity', 'price'],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
],
|
||||
],
|
||||
required: ['customer', 'items']
|
||||
)]
|
||||
array $order,
|
||||
): array {
|
||||
return ['order_id' => uniqid()];
|
||||
}
|
||||
|
||||
// ===== TYPE PRECEDENCE SCENARIOS =====
|
||||
|
||||
/**
|
||||
* Testing type precedence between PHP, DocBlock, and Schema.
|
||||
*
|
||||
* @param int $numericString DocBlock says integer despite string type hint
|
||||
* @param string $stringWithConstraints String with Schema constraints
|
||||
* @param array<string> $arrayWithItems Array with Schema item overrides
|
||||
*/
|
||||
public function typePrecedenceTest(
|
||||
string $numericString,
|
||||
#[Schema(format: 'email', minLength: 5)]
|
||||
string $stringWithConstraints,
|
||||
#[Schema(items: ['type' => 'integer', 'minimum' => 1, 'maximum' => 100])]
|
||||
array $arrayWithItems,
|
||||
): void {
|
||||
}
|
||||
|
||||
// ===== ERROR EDGE CASES =====
|
||||
|
||||
/**
|
||||
* Method with no parameters but Schema description.
|
||||
*/
|
||||
#[Schema(description: 'Gets server status. Takes no arguments.', properties: [])]
|
||||
public function noParamsWithSchema(): array
|
||||
{
|
||||
return ['status' => 'OK'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter with Schema but inferred type.
|
||||
*/
|
||||
public function parameterSchemaInferredType(
|
||||
#[Schema(description: 'Some parameter', minLength: 3)]
|
||||
$inferredParam,
|
||||
): void {
|
||||
}
|
||||
}
|
||||
330
tests/Unit/Capability/Discovery/SchemaGeneratorTest.php
Normal file
330
tests/Unit/Capability/Discovery/SchemaGeneratorTest.php
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Discovery\DocBlockParser;
|
||||
use Mcp\Capability\Discovery\SchemaGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SchemaGeneratorTest extends TestCase
|
||||
{
|
||||
private SchemaGenerator $schemaGenerator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->schemaGenerator = new SchemaGenerator(new DocBlockParser());
|
||||
}
|
||||
|
||||
public function testGeneratesEmptyPropertiesObjectForMethodWithNoParameters()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParams');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals([
|
||||
'type' => 'object',
|
||||
'properties' => new \stdClass(),
|
||||
], $schema);
|
||||
$this->assertArrayNotHasKey('required', $schema);
|
||||
}
|
||||
|
||||
public function testInfersBasicTypesFromPhpTypeHints()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsOnly');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'string'], $schema['properties']['name']);
|
||||
$this->assertEquals(['type' => 'integer'], $schema['properties']['age']);
|
||||
$this->assertEquals(['type' => 'boolean'], $schema['properties']['active']);
|
||||
$this->assertEquals(['type' => 'array'], $schema['properties']['tags']);
|
||||
$this->assertEquals(['type' => ['null', 'object'], 'default' => null], $schema['properties']['config']);
|
||||
$this->assertEqualsCanonicalizing(['name', 'age', 'active', 'tags'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testInfersTypesAndDescriptionsFromDocBlockTags()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'docBlockOnly');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'string', 'description' => 'The username'], $schema['properties']['username']);
|
||||
$this->assertEquals(['type' => 'integer', 'description' => 'Number of items'], $schema['properties']['count']);
|
||||
$this->assertEquals(['type' => 'boolean', 'description' => 'Whether enabled'], $schema['properties']['enabled']);
|
||||
$this->assertEquals(['type' => 'array', 'description' => 'Some data'], $schema['properties']['data']);
|
||||
$this->assertEqualsCanonicalizing(['username', 'count', 'enabled', 'data'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testUsesPhpTypeHintsForTypeAndDocBlockForDescriptions()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintsWithDocBlock');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'string', 'description' => 'User email address'], $schema['properties']['email']);
|
||||
$this->assertEquals(['type' => 'integer', 'description' => 'User score'], $schema['properties']['score']);
|
||||
$this->assertEquals(['type' => 'boolean', 'description' => 'Whether user is verified'], $schema['properties']['verified']);
|
||||
$this->assertEqualsCanonicalizing(['email', 'score', 'verified'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testUsesCompleteSchemaDefinitionFromMethodLevelSchemaAttribute()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelCompleteDefinition');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals([
|
||||
'type' => 'object',
|
||||
'description' => 'Creates a custom filter with complete definition',
|
||||
'properties' => [
|
||||
'field' => ['type' => 'string', 'enum' => ['name', 'date', 'status']],
|
||||
'operator' => ['type' => 'string', 'enum' => ['eq', 'gt', 'lt', 'contains']],
|
||||
'value' => ['description' => 'Value to filter by, type depends on field and operator'],
|
||||
],
|
||||
'required' => ['field', 'operator', 'value'],
|
||||
'if' => [
|
||||
'properties' => ['field' => ['const' => 'date']],
|
||||
],
|
||||
'then' => [
|
||||
'properties' => ['value' => ['type' => 'string', 'format' => 'date']],
|
||||
],
|
||||
], $schema);
|
||||
}
|
||||
|
||||
public function testGeneratesSchemaFromMethodLevelSchemaAttributeWithProperties()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelWithProperties');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals('Creates a new user with detailed information.', $schema['description']);
|
||||
$this->assertEquals(['type' => 'string', 'minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$'], $schema['properties']['username']);
|
||||
$this->assertEquals(['type' => 'string', 'format' => 'email'], $schema['properties']['email']);
|
||||
$this->assertEquals(['type' => 'integer', 'minimum' => 18, 'description' => 'Age in years.'], $schema['properties']['age']);
|
||||
$this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['isActive']);
|
||||
$this->assertEqualsCanonicalizing(['age', 'username', 'email'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testGeneratesSchemaForSingleArrayArgumentFromMethodLevelSchemaAttribute()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelArrayArgument');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals([
|
||||
'type' => 'array',
|
||||
'description' => 'An array of user profiles to update.',
|
||||
'minItems' => 1,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'integer'],
|
||||
'data' => ['type' => 'object', 'additionalProperties' => true],
|
||||
],
|
||||
'required' => ['id', 'data'],
|
||||
],
|
||||
], $schema['properties']['profiles']);
|
||||
$this->assertEquals(['profiles'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testGeneratesSchemaFromIndividualParameterLevelSchemaAttributes()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterLevelOnly');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['description' => 'Recipient ID', 'pattern' => '^user_', 'type' => 'string'], $schema['properties']['recipientId']);
|
||||
$this->assertEquals(['maxLength' => 1024, 'type' => 'string'], $schema['properties']['messageBody']);
|
||||
$this->assertEquals(['type' => 'integer', 'enum' => [1, 2, 5], 'default' => 1], $schema['properties']['priority']);
|
||||
$this->assertEquals([
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'enum' => ['sms', 'email', 'push']],
|
||||
'deviceToken' => ['type' => 'string', 'description' => 'Required if type is push'],
|
||||
],
|
||||
'required' => ['type'],
|
||||
'default' => null,
|
||||
], $schema['properties']['notificationConfig']);
|
||||
$this->assertEqualsCanonicalizing(['recipientId', 'messageBody'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testAppliesStringConstraintsFromParameterLevelSchemaAttributes()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterStringConstraints');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['format' => 'email', 'type' => 'string'], $schema['properties']['email']);
|
||||
$this->assertEquals(['minLength' => 8, 'pattern' => '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$', 'type' => 'string'], $schema['properties']['password']);
|
||||
$this->assertEquals(['type' => 'string'], $schema['properties']['regularString']);
|
||||
$this->assertEqualsCanonicalizing(['email', 'password', 'regularString'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testAppliesNumericConstraintsFromParameterLevelSchemaAttributes()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterNumericConstraints');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['minimum' => 18, 'maximum' => 120, 'type' => 'integer'], $schema['properties']['age']);
|
||||
$this->assertEquals(['minimum' => 0, 'maximum' => 5, 'exclusiveMaximum' => true, 'type' => 'number'], $schema['properties']['rating']);
|
||||
$this->assertEquals(['multipleOf' => 10, 'type' => 'integer'], $schema['properties']['count']);
|
||||
$this->assertEqualsCanonicalizing(['age', 'rating', 'count'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testAppliesArrayConstraintsFromParameterLevelSchemaAttributes()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterArrayConstraints');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'array', 'items' => ['type' => 'string'], 'minItems' => 1, 'uniqueItems' => true], $schema['properties']['tags']);
|
||||
$this->assertEquals(['type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100], 'minItems' => 1, 'maxItems' => 5], $schema['properties']['scores']);
|
||||
$this->assertEqualsCanonicalizing(['tags', 'scores'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testMergesMethodLevelAndParameterLevelSchemaAttributes()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'methodAndParameterLevel');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'string', 'description' => 'The key of the setting.'], $schema['properties']['settingKey']);
|
||||
$this->assertEquals(['description' => 'The specific new boolean value.', 'type' => 'boolean'], $schema['properties']['newValue']);
|
||||
$this->assertEqualsCanonicalizing(['settingKey', 'newValue'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testCombinesPhpTypeHintsDocBlockDescriptionsAndParameterLevelSchemaConstraints()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typeHintDocBlockAndParameterSchema');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['minLength' => 3, 'pattern' => '^[a-zA-Z0-9_]+$', 'type' => 'string', 'description' => "The user's name"], $schema['properties']['username']);
|
||||
$this->assertEquals(['minimum' => 1, 'maximum' => 10, 'type' => 'integer', 'description' => 'Task priority level'], $schema['properties']['priority']);
|
||||
$this->assertEqualsCanonicalizing(['username', 'priority'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testGeneratesCorrectSchemaForEnumParameters()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'enumParameters');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'string', 'description' => 'Backed string enum', 'enum' => ['A', 'B']], $schema['properties']['stringEnum']);
|
||||
$this->assertEquals(['type' => 'integer', 'description' => 'Backed int enum', 'enum' => [1, 2]], $schema['properties']['intEnum']);
|
||||
$this->assertEquals(['type' => 'string', 'description' => 'Unit enum', 'enum' => ['Yes', 'No']], $schema['properties']['unitEnum']);
|
||||
$this->assertEquals(['type' => ['null', 'string'], 'enum' => ['A', 'B'], 'default' => null], $schema['properties']['nullableEnum']);
|
||||
$this->assertEquals(['type' => 'integer', 'enum' => [1, 2], 'default' => 1], $schema['properties']['enumWithDefault']);
|
||||
$this->assertEqualsCanonicalizing(['stringEnum', 'intEnum', 'unitEnum'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testGeneratesCorrectSchemaForArrayTypeDeclarations()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'arrayTypeScenarios');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'array', 'description' => 'Generic array'], $schema['properties']['genericArray']);
|
||||
$this->assertEquals(['type' => 'array', 'description' => 'Array of strings', 'items' => ['type' => 'string']], $schema['properties']['stringArray']);
|
||||
$this->assertEquals(['type' => 'array', 'description' => 'Array of integers', 'items' => ['type' => 'integer']], $schema['properties']['intArray']);
|
||||
$this->assertEquals(['type' => 'array', 'description' => 'Mixed array map'], $schema['properties']['mixedMap']);
|
||||
$this->assertArrayHasKey('type', $schema['properties']['objectLikeArray']);
|
||||
$this->assertEquals('object', $schema['properties']['objectLikeArray']['type']);
|
||||
$this->assertArrayHasKey('properties', $schema['properties']['objectLikeArray']);
|
||||
$this->assertArrayHasKey('name', $schema['properties']['objectLikeArray']['properties']);
|
||||
$this->assertArrayHasKey('age', $schema['properties']['objectLikeArray']['properties']);
|
||||
$this->assertEqualsCanonicalizing(['genericArray', 'stringArray', 'intArray', 'mixedMap', 'objectLikeArray', 'nestedObjectArray'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testHandlesNullableTypeHintsAndOptionalParameters()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'nullableAndOptional');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => ['null', 'string'], 'description' => 'Nullable string'], $schema['properties']['nullableString']);
|
||||
$this->assertEquals(['type' => ['null', 'integer'], 'description' => 'Nullable integer', 'default' => null], $schema['properties']['nullableInt']);
|
||||
$this->assertEquals(['type' => 'string', 'default' => 'default'], $schema['properties']['optionalString']);
|
||||
$this->assertEquals(['type' => 'boolean', 'default' => true], $schema['properties']['optionalBool']);
|
||||
$this->assertEquals(['type' => 'array', 'default' => []], $schema['properties']['optionalArray']);
|
||||
$this->assertEqualsCanonicalizing(['nullableString'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testGeneratesSchemaForPhpUnionTypes()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'unionTypes');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => ['integer', 'string'], 'description' => 'String or integer'], $schema['properties']['stringOrInt']);
|
||||
$this->assertEquals(['type' => ['null', 'boolean', 'string'], 'description' => 'Bool, string or null'], $schema['properties']['multiUnion']);
|
||||
$this->assertEqualsCanonicalizing(['stringOrInt', 'multiUnion'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testRepresentsVariadicStringParametersAsArrayOfStrings()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicStrings');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'array', 'description' => 'Variadic strings', 'items' => ['type' => 'string']], $schema['properties']['items']);
|
||||
$this->assertArrayNotHasKey('required', $schema);
|
||||
}
|
||||
|
||||
public function testAppliesItemConstraintsToVariadicParameters()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'variadicWithConstraints');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['items' => ['type' => 'integer', 'minimum' => 0], 'type' => 'array', 'description' => 'Variadic integers'], $schema['properties']['numbers']);
|
||||
$this->assertArrayNotHasKey('required', $schema);
|
||||
}
|
||||
|
||||
public function testHandlesMixedTypeHintsOmittingExplicitType()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'mixedTypes');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['description' => 'Any value'], $schema['properties']['anyValue']);
|
||||
$this->assertEquals(['description' => 'Optional any value', 'default' => 'default'], $schema['properties']['optionalAny']);
|
||||
$this->assertEqualsCanonicalizing(['anyValue'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testGeneratesSchemaForComplexNestedObjectAndArrayStructures()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'complexNestedSchema');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals([
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'customer' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
|
||||
'name' => ['type' => 'string', 'minLength' => 2],
|
||||
'email' => ['type' => 'string', 'format' => 'email'],
|
||||
],
|
||||
'required' => ['id', 'name'],
|
||||
],
|
||||
'items' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
|
||||
'quantity' => ['type' => 'integer', 'minimum' => 1],
|
||||
'price' => ['type' => 'number', 'minimum' => 0],
|
||||
],
|
||||
'required' => ['product_id', 'quantity', 'price'],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
],
|
||||
],
|
||||
'required' => ['customer', 'items'],
|
||||
], $schema['properties']['order']);
|
||||
$this->assertEquals(['order'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testTypePrecedenceParameterSchemaOverridesDocBlockOverridesPhpTypeHint()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'typePrecedenceTest');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['type' => 'integer', 'description' => 'DocBlock says integer despite string type hint'], $schema['properties']['numericString']);
|
||||
$this->assertEquals(['format' => 'email', 'minLength' => 5, 'type' => 'string', 'description' => 'String with Schema constraints'], $schema['properties']['stringWithConstraints']);
|
||||
$this->assertEquals(['items' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'type' => 'array', 'description' => 'Array with Schema item overrides'], $schema['properties']['arrayWithItems']);
|
||||
$this->assertEqualsCanonicalizing(['numericString', 'stringWithConstraints', 'arrayWithItems'], $schema['required']);
|
||||
}
|
||||
|
||||
public function testGeneratesEmptyPropertiesObjectForMethodWithNoParametersEvenWithMethodLevelSchema()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'noParamsWithSchema');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals('Gets server status. Takes no arguments.', $schema['description']);
|
||||
$this->assertInstanceOf(\stdClass::class, $schema['properties']);
|
||||
$this->assertArrayNotHasKey('required', $schema);
|
||||
}
|
||||
|
||||
public function testInfersParameterTypeAsAnyIfOnlyConstraintsAreGiven()
|
||||
{
|
||||
$method = new \ReflectionMethod(SchemaGeneratorFixture::class, 'parameterSchemaInferredType');
|
||||
$schema = $this->schemaGenerator->generate($method);
|
||||
$this->assertEquals(['description' => 'Some parameter', 'minLength' => 3], $schema['properties']['inferredParam']);
|
||||
$this->assertEquals(['inferredParam'], $schema['required']);
|
||||
}
|
||||
}
|
||||
505
tests/Unit/Capability/Discovery/SchemaValidatorTest.php
Normal file
505
tests/Unit/Capability/Discovery/SchemaValidatorTest.php
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Unit\Capability\Discovery;
|
||||
|
||||
use Mcp\Capability\Attribute\Schema;
|
||||
use Mcp\Capability\Discovery\SchemaValidator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SchemaValidatorTest extends TestCase
|
||||
{
|
||||
private SchemaValidator $validator;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->validator = new SchemaValidator();
|
||||
}
|
||||
|
||||
// --- Basic Validation Tests ---
|
||||
|
||||
public function testValidDataPassesValidation()
|
||||
{
|
||||
$schema = $this->getSimpleSchema();
|
||||
$data = $this->getValidData();
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
|
||||
$this->assertEmpty($errors);
|
||||
}
|
||||
|
||||
public function testInvalidTypeGeneratesTypeError()
|
||||
{
|
||||
$schema = $this->getSimpleSchema();
|
||||
$data = $this->getValidData();
|
||||
$data['age'] = 'thirty'; // Invalid type
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('/age', $errors[0]['pointer']);
|
||||
$this->assertEquals('type', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('Expected `integer`', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testMissingRequiredPropertyGeneratesRequiredError()
|
||||
{
|
||||
$schema = $this->getSimpleSchema();
|
||||
$data = $this->getValidData();
|
||||
unset($data['name']); // Missing required
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('required', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('Missing required properties: `name`', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testAdditionalPropertyGeneratesAdditionalPropertiesError()
|
||||
{
|
||||
$schema = $this->getSimpleSchema();
|
||||
$data = $this->getValidData();
|
||||
$data['extra'] = 'not allowed'; // Additional property
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('/', $errors[0]['pointer']); // Error reported at the object root
|
||||
$this->assertEquals('additionalProperties', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('Additional object properties are not allowed: ["extra"]', $errors[0]['message']);
|
||||
}
|
||||
|
||||
// --- Keyword Constraint Tests ---
|
||||
|
||||
public function testEnumConstraintViolation()
|
||||
{
|
||||
$schema = ['type' => 'string', 'enum' => ['A', 'B']];
|
||||
$data = 'C';
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('enum', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('must be one of the allowed values: "A", "B"', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testMinimumConstraintViolation()
|
||||
{
|
||||
$schema = ['type' => 'integer', 'minimum' => 10];
|
||||
$data = 5;
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('minimum', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('must be greater than or equal to 10', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testMaxLengthConstraintViolation()
|
||||
{
|
||||
$schema = ['type' => 'string', 'maxLength' => 5];
|
||||
$data = 'toolong';
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('maxLength', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('Maximum string length is 5, found 7', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testPatternConstraintViolation()
|
||||
{
|
||||
$schema = ['type' => 'string', 'pattern' => '^[a-z]+$'];
|
||||
$data = '123';
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('pattern', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('does not match the required pattern: `^[a-z]+$`', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testMinItemsConstraintViolation()
|
||||
{
|
||||
$schema = ['type' => 'array', 'minItems' => 2];
|
||||
$data = ['one'];
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('minItems', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('Array should have at least 2 items, 1 found', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testUniqueItemsConstraintViolation()
|
||||
{
|
||||
$schema = ['type' => 'array', 'uniqueItems' => true];
|
||||
$data = ['a', 'b', 'a'];
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('uniqueItems', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('Array must have unique items', $errors[0]['message']);
|
||||
}
|
||||
|
||||
// --- Nested Structures and Pointers ---
|
||||
public function testNestedObjectValidationErrorPointer()
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'user' => [
|
||||
'type' => 'object',
|
||||
'properties' => ['id' => ['type' => 'integer']],
|
||||
'required' => ['id'],
|
||||
],
|
||||
],
|
||||
'required' => ['user'],
|
||||
];
|
||||
$data = ['user' => ['id' => 'abc']]; // Invalid nested type
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('/user/id', $errors[0]['pointer']);
|
||||
}
|
||||
|
||||
public function testArrayItemValidationErrorPointer()
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'integer'],
|
||||
];
|
||||
$data = [1, 2, 'three', 4]; // Invalid item type
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('/2', $errors[0]['pointer']); // Pointer to the index of the invalid item
|
||||
}
|
||||
|
||||
// --- Data Conversion Tests ---
|
||||
public function testValidatesDataPassedAsStdClassObject()
|
||||
{
|
||||
$schema = $this->getSimpleSchema();
|
||||
$dataObj = json_decode(json_encode($this->getValidData())); // Convert to stdClass
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($dataObj, $schema);
|
||||
$this->assertEmpty($errors);
|
||||
}
|
||||
|
||||
public function testValidatesDataWithNestedAssociativeArraysCorrectly()
|
||||
{
|
||||
$schema = [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'nested' => [
|
||||
'type' => 'object',
|
||||
'properties' => ['key' => ['type' => 'string']],
|
||||
'required' => ['key'],
|
||||
],
|
||||
],
|
||||
'required' => ['nested'],
|
||||
];
|
||||
$data = ['nested' => ['key' => 'value']]; // Nested assoc array
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertEmpty($errors);
|
||||
}
|
||||
|
||||
// --- Edge Cases ---
|
||||
public function testHandlesInvalidSchemaStructureGracefully()
|
||||
{
|
||||
$schema = ['type' => 'object', 'properties' => ['name' => ['type' => 123]]]; // Invalid type value
|
||||
$data = ['name' => 'test'];
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
$this->assertCount(1, $errors);
|
||||
$this->assertEquals('internal', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('Schema validation process failed', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testHandlesEmptyDataObjectAgainstSchemaRequiringProperties()
|
||||
{
|
||||
$schema = $this->getSimpleSchema(); // Requires name, age etc.
|
||||
$data = []; // Empty data
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertEquals('required', $errors[0]['keyword']);
|
||||
}
|
||||
|
||||
public function testHandlesEmptySchemaAllowsAnything()
|
||||
{
|
||||
$schema = []; // Empty schema object/array implies no constraints
|
||||
$data = ['anything' => [1, 2], 'goes' => true];
|
||||
|
||||
$errors = $this->validator->validateAgainstJsonSchema($data, $schema);
|
||||
|
||||
$this->assertNotEmpty($errors);
|
||||
$this->assertEquals('internal', $errors[0]['keyword']);
|
||||
$this->assertStringContainsString('Invalid schema', $errors[0]['message']);
|
||||
}
|
||||
|
||||
public function testValidatesSchemaWithStringFormatConstraintsFromSchemaAttribute()
|
||||
{
|
||||
$emailSchema = (new Schema(format: 'email'))->toArray();
|
||||
|
||||
// Valid email
|
||||
$validErrors = $this->validator->validateAgainstJsonSchema('user@example.com', $emailSchema);
|
||||
$this->assertEmpty($validErrors);
|
||||
|
||||
// Invalid email
|
||||
$invalidErrors = $this->validator->validateAgainstJsonSchema('not-an-email', $emailSchema);
|
||||
$this->assertNotEmpty($invalidErrors);
|
||||
$this->assertEquals('format', $invalidErrors[0]['keyword']);
|
||||
$this->assertStringContainsString('email', $invalidErrors[0]['message']);
|
||||
}
|
||||
|
||||
public function testValidatesSchemaWithStringLengthConstraintsFromSchemaAttribute()
|
||||
{
|
||||
$passwordSchema = (new Schema(minLength: 8, pattern: '^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$'))->toArray();
|
||||
|
||||
// Valid password (meets length and pattern)
|
||||
$validErrors = $this->validator->validateAgainstJsonSchema('Password123', $passwordSchema);
|
||||
$this->assertEmpty($validErrors);
|
||||
|
||||
// Invalid - too short
|
||||
$shortErrors = $this->validator->validateAgainstJsonSchema('Pass1', $passwordSchema);
|
||||
$this->assertNotEmpty($shortErrors);
|
||||
$this->assertEquals('minLength', $shortErrors[0]['keyword']);
|
||||
|
||||
// Invalid - no digit
|
||||
$noDigitErrors = $this->validator->validateAgainstJsonSchema('PasswordXYZ', $passwordSchema);
|
||||
$this->assertNotEmpty($noDigitErrors);
|
||||
$this->assertEquals('pattern', $noDigitErrors[0]['keyword']);
|
||||
}
|
||||
|
||||
public function testValidatesSchemaWithNumericConstraintsFromSchemaAttribute()
|
||||
{
|
||||
$ageSchema = (new Schema(minimum: 18, maximum: 120))->toArray();
|
||||
|
||||
// Valid age
|
||||
$validErrors = $this->validator->validateAgainstJsonSchema(25, $ageSchema);
|
||||
$this->assertEmpty($validErrors);
|
||||
|
||||
// Invalid - too low
|
||||
$tooLowErrors = $this->validator->validateAgainstJsonSchema(15, $ageSchema);
|
||||
$this->assertNotEmpty($tooLowErrors);
|
||||
$this->assertEquals('minimum', $tooLowErrors[0]['keyword']);
|
||||
|
||||
// Invalid - too high
|
||||
$tooHighErrors = $this->validator->validateAgainstJsonSchema(150, $ageSchema);
|
||||
$this->assertNotEmpty($tooHighErrors);
|
||||
$this->assertEquals('maximum', $tooHighErrors[0]['keyword']);
|
||||
}
|
||||
|
||||
public function testValidatesSchemaWithArrayConstraintsFromSchemaAttribute()
|
||||
{
|
||||
$tagsSchema = (new Schema(uniqueItems: true, minItems: 2))->toArray();
|
||||
|
||||
// Valid tags array
|
||||
$validErrors = $this->validator->validateAgainstJsonSchema(['php', 'javascript', 'python'], $tagsSchema);
|
||||
$this->assertEmpty($validErrors);
|
||||
|
||||
// Invalid - duplicate items
|
||||
$duplicateErrors = $this->validator->validateAgainstJsonSchema(['php', 'php', 'javascript'], $tagsSchema);
|
||||
$this->assertNotEmpty($duplicateErrors);
|
||||
$this->assertEquals('uniqueItems', $duplicateErrors[0]['keyword']);
|
||||
|
||||
// Invalid - too few items
|
||||
$tooFewErrors = $this->validator->validateAgainstJsonSchema(['php'], $tagsSchema);
|
||||
$this->assertNotEmpty($tooFewErrors);
|
||||
$this->assertEquals('minItems', $tooFewErrors[0]['keyword']);
|
||||
}
|
||||
|
||||
public function testValidatesSchemaWithObjectConstraintsFromSchemaAttribute()
|
||||
{
|
||||
$userSchema = (new Schema(
|
||||
properties: [
|
||||
'name' => ['type' => 'string', 'minLength' => 2],
|
||||
'email' => ['type' => 'string', 'format' => 'email'],
|
||||
'age' => ['type' => 'integer', 'minimum' => 18],
|
||||
],
|
||||
required: ['name', 'email']
|
||||
))->toArray();
|
||||
|
||||
// Valid user object
|
||||
$validUser = [
|
||||
'name' => 'John',
|
||||
'email' => 'john@example.com',
|
||||
'age' => 25,
|
||||
];
|
||||
$validErrors = $this->validator->validateAgainstJsonSchema($validUser, $userSchema);
|
||||
$this->assertEmpty($validErrors);
|
||||
|
||||
// Invalid - missing required email
|
||||
$missingEmailUser = [
|
||||
'name' => 'John',
|
||||
'age' => 25,
|
||||
];
|
||||
$missingErrors = $this->validator->validateAgainstJsonSchema($missingEmailUser, $userSchema);
|
||||
$this->assertNotEmpty($missingErrors);
|
||||
$this->assertEquals('required', $missingErrors[0]['keyword']);
|
||||
|
||||
// Invalid - name too short
|
||||
$shortNameUser = [
|
||||
'name' => 'J',
|
||||
'email' => 'john@example.com',
|
||||
'age' => 25,
|
||||
];
|
||||
$nameErrors = $this->validator->validateAgainstJsonSchema($shortNameUser, $userSchema);
|
||||
$this->assertNotEmpty($nameErrors);
|
||||
$this->assertEquals('minLength', $nameErrors[0]['keyword']);
|
||||
|
||||
// Invalid - age too low
|
||||
$youngUser = [
|
||||
'name' => 'John',
|
||||
'email' => 'john@example.com',
|
||||
'age' => 15,
|
||||
];
|
||||
$ageErrors = $this->validator->validateAgainstJsonSchema($youngUser, $userSchema);
|
||||
$this->assertNotEmpty($ageErrors);
|
||||
$this->assertEquals('minimum', $ageErrors[0]['keyword']);
|
||||
}
|
||||
|
||||
public function testValidatesSchemaWithNestedConstraintsFromSchemaAttribute()
|
||||
{
|
||||
$orderSchema = (new Schema(
|
||||
properties: [
|
||||
'customer' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'id' => ['type' => 'string', 'pattern' => '^CUS-[0-9]{6}$'],
|
||||
'name' => ['type' => 'string', 'minLength' => 2],
|
||||
],
|
||||
],
|
||||
'items' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'product_id' => ['type' => 'string', 'pattern' => '^PRD-[0-9]{4}$'],
|
||||
'quantity' => ['type' => 'integer', 'minimum' => 1],
|
||||
],
|
||||
'required' => ['product_id', 'quantity'],
|
||||
],
|
||||
],
|
||||
],
|
||||
required: ['customer', 'items']
|
||||
))->toArray();
|
||||
|
||||
// Valid order
|
||||
$validOrder = [
|
||||
'customer' => [
|
||||
'id' => 'CUS-123456',
|
||||
'name' => 'John',
|
||||
],
|
||||
'items' => [
|
||||
[
|
||||
'product_id' => 'PRD-1234',
|
||||
'quantity' => 2,
|
||||
],
|
||||
],
|
||||
];
|
||||
$validErrors = $this->validator->validateAgainstJsonSchema($validOrder, $orderSchema);
|
||||
$this->assertEmpty($validErrors);
|
||||
|
||||
// Invalid - bad customer ID format
|
||||
$badCustomerIdOrder = [
|
||||
'customer' => [
|
||||
'id' => 'CUST-123', // Wrong format
|
||||
'name' => 'John',
|
||||
],
|
||||
'items' => [
|
||||
[
|
||||
'product_id' => 'PRD-1234',
|
||||
'quantity' => 2,
|
||||
],
|
||||
],
|
||||
];
|
||||
$customerIdErrors = $this->validator->validateAgainstJsonSchema($badCustomerIdOrder, $orderSchema);
|
||||
$this->assertNotEmpty($customerIdErrors);
|
||||
$this->assertEquals('pattern', $customerIdErrors[0]['keyword']);
|
||||
|
||||
// Invalid - empty items array
|
||||
$emptyItemsOrder = [
|
||||
'customer' => [
|
||||
'id' => 'CUS-123456',
|
||||
'name' => 'John',
|
||||
],
|
||||
'items' => [],
|
||||
];
|
||||
$emptyItemsErrors = $this->validator->validateAgainstJsonSchema($emptyItemsOrder, $orderSchema);
|
||||
$this->assertNotEmpty($emptyItemsErrors);
|
||||
$this->assertEquals('minItems', $emptyItemsErrors[0]['keyword']);
|
||||
|
||||
// Invalid - missing required property in items
|
||||
$missingProductIdOrder = [
|
||||
'customer' => [
|
||||
'id' => 'CUS-123456',
|
||||
'name' => 'John',
|
||||
],
|
||||
'items' => [
|
||||
[
|
||||
// Missing product_id
|
||||
'quantity' => 2,
|
||||
],
|
||||
],
|
||||
];
|
||||
$missingProductIdErrors = $this->validator->validateAgainstJsonSchema($missingProductIdOrder, $orderSchema);
|
||||
$this->assertNotEmpty($missingProductIdErrors);
|
||||
$this->assertEquals('required', $missingProductIdErrors[0]['keyword']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* type: 'object',
|
||||
* properties: array<string, array<string, mixed>>,
|
||||
* required: string[],
|
||||
* additionalProperties: false,
|
||||
* }
|
||||
*/
|
||||
private function getSimpleSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string', 'description' => 'The name'],
|
||||
'age' => ['type' => 'integer', 'minimum' => 0],
|
||||
'active' => ['type' => 'boolean'],
|
||||
'score' => ['type' => 'number'],
|
||||
'items' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'nullableValue' => ['type' => ['string', 'null']],
|
||||
'optionalValue' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['name', 'age', 'active', 'score', 'items', 'nullableValue'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* name: string,
|
||||
* age: int,
|
||||
* active: bool,
|
||||
* score: float,
|
||||
* items: string[],
|
||||
* nullableValue: null,
|
||||
* optionalValue: string
|
||||
* }
|
||||
*/
|
||||
private function getValidData(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Tester',
|
||||
'age' => 30,
|
||||
'active' => true,
|
||||
'score' => 99.5,
|
||||
'items' => ['a', 'b'],
|
||||
'nullableValue' => null,
|
||||
'optionalValue' => 'present',
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue