From 964d6cdeb3177135e5498df88f17236040d72e99 Mon Sep 17 00:00:00 2001 From: darbs-claude Date: Mon, 23 Feb 2026 01:40:47 +0000 Subject: [PATCH] test: add AgentDetection service unit tests Adds tests/Unit/AgentDetectionTest.php covering: - User-Agent pattern matching for all AI providers (Anthropic, OpenAI, Google, Meta, Mistral) with model detection - Browser UA detection returning notAnAgent (Chrome, Firefox, Safari, Edge) - Non-agent bot detection (Googlebot, Bingbot, curl, python-requests, etc.) - Edge cases: null, empty, whitespace-only, and generic programmatic UAs - Structured MCP token parsing (provider:model:secret format) - MCP header priority over User-Agent in HTTP requests - Provider validation via isValidProvider() and getValidProviders() - isAgentUserAgent() shorthand behaviour - Each pattern documented with real-world UA examples Closes #13 Co-Authored-By: Claude Sonnet 4.6 --- tests/Unit/AgentDetectionTest.php | 785 ++++++++++++++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 tests/Unit/AgentDetectionTest.php diff --git a/tests/Unit/AgentDetectionTest.php b/tests/Unit/AgentDetectionTest.php new file mode 100644 index 0000000..069468b --- /dev/null +++ b/tests/Unit/AgentDetectionTest.php @@ -0,0 +1,785 @@ +identifyFromUserAgent(null); + + expect($identity->provider)->toBe('unknown') + ->and($identity->isAgent())->toBeTrue() + ->and($identity->isKnown())->toBeFalse() + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_LOW); + }); + + it('returns unknownAgent for empty string User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent(''); + + expect($identity->provider)->toBe('unknown') + ->and($identity->isAgent())->toBeTrue(); + }); + + it('returns unknownAgent for whitespace-only User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent(' '); + + expect($identity->provider)->toBe('unknown') + ->and($identity->isAgent())->toBeTrue(); + }); + + it('returns unknownAgent for generic programmatic client with no known indicators', function () { + $service = new AgentDetection; + // A plain HTTP client string without browser or bot keywords + $identity = $service->identifyFromUserAgent('my-custom-client/1.0'); + + expect($identity->provider)->toBe('unknown') + ->and($identity->isAgent())->toBeTrue() + ->and($identity->isKnown())->toBeFalse(); + }); + + it('returns unknownAgent for numeric-only User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('1.0'); + + expect($identity->provider)->toBe('unknown'); + }); +}); + +// ========================================================================= +// Anthropic / Claude Detection +// ========================================================================= + +describe('Anthropic/Claude detection', function () { + /** + * Pattern: /claude[\s\-_]?code/i + * Examples: "claude-code/1.2.3", "ClaudeCode/1.0", "claude_code" + */ + it('detects Claude Code User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('claude-code/1.2.3'); + + expect($identity->provider)->toBe('anthropic') + ->and($identity->isKnown())->toBeTrue() + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Pattern: /\banthropic[\s\-_]?api\b/i + * Examples: "anthropic-api/1.0", "Anthropic API Client/2.0" + */ + it('detects Anthropic API User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('anthropic-api/1.0 Python/3.11'); + + expect($identity->provider)->toBe('anthropic') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Pattern: /\bclaude\b.*\bai\b/i + * Examples: "Claude AI/2.0", "claude ai client" + */ + it('detects Claude AI User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Claude AI Agent/1.0'); + + expect($identity->provider)->toBe('anthropic'); + }); + + /** + * Pattern: /\bclaude\b.*\bassistant\b/i + * Examples: "claude assistant/1.0", "Claude Assistant integration" + */ + it('detects Claude Assistant User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('claude assistant integration/2.0'); + + expect($identity->provider)->toBe('anthropic'); + }); + + /** + * Model pattern: /claude[\s\-_]?opus/i + * Examples: "claude-opus", "Claude Opus", "claude_opus" + */ + it('detects claude-opus model from User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('claude-opus claude-code/1.0'); + + expect($identity->provider)->toBe('anthropic') + ->and($identity->model)->toBe('claude-opus'); + }); + + /** + * Model pattern: /claude[\s\-_]?sonnet/i + * Examples: "claude-sonnet", "Claude Sonnet", "claude_sonnet" + */ + it('detects claude-sonnet model from User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('claude-sonnet claude-code/1.0'); + + expect($identity->provider)->toBe('anthropic') + ->and($identity->model)->toBe('claude-sonnet'); + }); + + /** + * Model pattern: /claude[\s\-_]?haiku/i + * Examples: "claude-haiku", "Claude Haiku", "claude_haiku" + */ + it('detects claude-haiku model from User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Claude Haiku claude-code/1.0'); + + expect($identity->provider)->toBe('anthropic') + ->and($identity->model)->toBe('claude-haiku'); + }); + + it('returns null model when no Anthropic model pattern matches', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('claude-code/1.0'); + + expect($identity->provider)->toBe('anthropic') + ->and($identity->model)->toBeNull(); + }); +}); + +// ========================================================================= +// OpenAI / ChatGPT Detection +// ========================================================================= + +describe('OpenAI/ChatGPT detection', function () { + /** + * Pattern: /\bChatGPT\b/i + * Examples: "ChatGPT/1.2", "chatgpt-plugin/1.0" + */ + it('detects ChatGPT User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('ChatGPT/1.2 OpenAI'); + + expect($identity->provider)->toBe('openai') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Pattern: /\bOpenAI\b/i + * Examples: "OpenAI Python SDK/1.0", "openai-node/4.0" + */ + it('detects OpenAI User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('OpenAI Python SDK/1.0'); + + expect($identity->provider)->toBe('openai'); + }); + + /** + * Pattern: /\bGPT[\s\-_]?4\b/i + * Model pattern: /\bGPT[\s\-_]?4/i + * Examples: "GPT-4 Agent/1.0", "GPT4 client", "GPT 4" + */ + it('detects GPT-4 and sets gpt-4 model', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('GPT-4 Agent/1.0'); + + expect($identity->provider)->toBe('openai') + ->and($identity->model)->toBe('gpt-4'); + }); + + /** + * Pattern: /\bGPT[\s\-_]?3\.?5\b/i + * Model pattern: /\bGPT[\s\-_]?3\.?5/i + * Examples: "GPT-3.5 Turbo", "GPT35 client", "GPT 3.5" + */ + it('detects GPT-3.5 and sets gpt-3.5 model', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('GPT-3.5 Turbo client/1.0'); + + expect($identity->provider)->toBe('openai') + ->and($identity->model)->toBe('gpt-3.5'); + }); + + /** + * Pattern: /\bo1[\s\-_]?preview\b/i + * Examples: "o1-preview OpenAI client/1.0" + */ + it('detects o1-preview User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('o1-preview OpenAI client/1.0'); + + expect($identity->provider)->toBe('openai'); + }); + + /** + * Pattern: /\bo1[\s\-_]?mini\b/i + * Examples: "o1-mini OpenAI client/1.0" + */ + it('detects o1-mini User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('o1-mini OpenAI client/1.0'); + + expect($identity->provider)->toBe('openai'); + }); +}); + +// ========================================================================= +// Google / Gemini Detection +// ========================================================================= + +describe('Google/Gemini detection', function () { + /** + * Pattern: /\bGoogle[\s\-_]?AI\b/i + * Examples: "Google AI Studio/1.0", "GoogleAI/2.0" + */ + it('detects Google AI User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Google AI Studio/1.0'); + + expect($identity->provider)->toBe('google') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Pattern: /\bGemini\b/i + * Examples: "Gemini API Client/2.0", "gemini-client/1.0" + */ + it('detects Gemini User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Gemini API Client/2.0'); + + expect($identity->provider)->toBe('google'); + }); + + /** + * Pattern: /\bBard\b/i + * Examples: "Bard/1.0", "Google Bard client" + */ + it('detects Bard User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Bard/1.0'); + + expect($identity->provider)->toBe('google'); + }); + + /** + * Pattern: /\bPaLM\b/i + * Examples: "PaLM API/2.0", "Google PaLM" + */ + it('detects PaLM User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('PaLM API/2.0'); + + expect($identity->provider)->toBe('google'); + }); + + /** + * Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i + * Examples: "Gemini Pro client/1.0", "gemini-pro/1.0", "gemini-1.5-pro" + */ + it('detects gemini-pro model from User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Gemini Pro client/1.0'); + + expect($identity->provider)->toBe('google') + ->and($identity->model)->toBe('gemini-pro'); + }); + + /** + * Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i + * Examples: "gemini-flash/1.5", "Gemini Flash client", "gemini-1.5-flash" + */ + it('detects gemini-flash model from User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('gemini-flash/1.5'); + + expect($identity->provider)->toBe('google') + ->and($identity->model)->toBe('gemini-flash'); + }); + + /** + * Model pattern: /gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i + * Examples: "Gemini Ultra/1.0", "gemini-1.5-ultra" + */ + it('detects gemini-ultra model from User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Gemini Ultra/1.0'); + + expect($identity->provider)->toBe('google') + ->and($identity->model)->toBe('gemini-ultra'); + }); +}); + +// ========================================================================= +// Meta / LLaMA Detection +// ========================================================================= + +describe('Meta/LLaMA detection', function () { + /** + * Pattern: /\bMeta[\s\-_]?AI\b/i + * Examples: "Meta AI assistant/1.0", "MetaAI/1.0" + */ + it('detects Meta AI User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Meta AI assistant/1.0'); + + expect($identity->provider)->toBe('meta') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Pattern: /\bLLaMA\b/i + * Examples: "LLaMA model client/1.0", "llama-inference" + */ + it('detects LLaMA User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('LLaMA model client/1.0'); + + expect($identity->provider)->toBe('meta'); + }); + + /** + * Pattern: /\bLlama[\s\-_]?[23]\b/i + * Model pattern: /llama[\s\-_]?3/i + * Examples: "Llama-3 inference client/1.0", "Llama3/1.0", "Llama 3" + */ + it('detects Llama 3 and sets llama-3 model', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Llama-3 inference client/1.0'); + + expect($identity->provider)->toBe('meta') + ->and($identity->model)->toBe('llama-3'); + }); + + /** + * Pattern: /\bLlama[\s\-_]?[23]\b/i + * Model pattern: /llama[\s\-_]?2/i + * Examples: "Llama-2 inference client/1.0", "Llama2/1.0", "Llama 2" + */ + it('detects Llama 2 and sets llama-2 model', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Llama-2 inference client/1.0'); + + expect($identity->provider)->toBe('meta') + ->and($identity->model)->toBe('llama-2'); + }); +}); + +// ========================================================================= +// Mistral Detection +// ========================================================================= + +describe('Mistral detection', function () { + /** + * Pattern: /\bMistral\b/i + * Examples: "Mistral AI client/1.0", "mistral-python/1.0" + */ + it('detects Mistral User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Mistral AI client/1.0'); + + expect($identity->provider)->toBe('mistral') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Pattern: /\bMixtral\b/i + * Model pattern: /mixtral/i + * Examples: "Mixtral-8x7B client/1.0", "mixtral inference" + */ + it('detects Mixtral User-Agent and sets mixtral model', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Mixtral-8x7B client/1.0'); + + expect($identity->provider)->toBe('mistral') + ->and($identity->model)->toBe('mixtral'); + }); + + /** + * Model pattern: /mistral[\s\-_]?large/i + * Examples: "Mistral Large API/2.0", "mistral-large/1.0" + */ + it('detects mistral-large model from User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Mistral Large API/2.0'); + + expect($identity->provider)->toBe('mistral') + ->and($identity->model)->toBe('mistral-large'); + }); + + /** + * Model pattern: /mistral[\s\-_]?medium/i + * Examples: "Mistral Medium/1.0", "mistral-medium client" + */ + it('detects mistral-medium model from User-Agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('mistral-medium client/1.0'); + + expect($identity->provider)->toBe('mistral') + ->and($identity->model)->toBe('mistral-medium'); + }); +}); + +// ========================================================================= +// Browser Detection (not an agent) +// ========================================================================= + +describe('browser detection', function () { + /** + * Pattern: /\bMozilla\b/i + * All modern browsers include "Mozilla/5.0" in their UA string. + * Chrome example: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120..." + */ + it('detects Chrome browser as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + ); + + expect($identity->isNotAgent())->toBeTrue() + ->and($identity->provider)->toBe('not_agent') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Firefox example: "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0" + */ + it('detects Firefox browser as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent( + 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0' + ); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Safari example: "Mozilla/5.0 (Macintosh; ...) ... Version/17.0 Safari/605.1.15" + */ + it('detects Safari browser as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' + ); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Edge example: "Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0" + */ + it('detects Edge browser as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0' + ); + + expect($identity->isNotAgent())->toBeTrue(); + }); +}); + +// ========================================================================= +// Non-Agent Bot Detection +// ========================================================================= + +describe('non-agent bot detection', function () { + /** + * Pattern: /\bGooglebot\b/i + * Example: "Googlebot/2.1 (+http://www.google.com/bot.html)" + */ + it('detects Googlebot as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent( + 'Googlebot/2.1 (+http://www.google.com/bot.html)' + ); + + expect($identity->isNotAgent())->toBeTrue() + ->and($identity->provider)->toBe('not_agent'); + }); + + /** + * Pattern: /\bBingbot\b/i + * Example: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" + */ + it('detects Bingbot as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent( + 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)' + ); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Pattern: /\bcurl\b/i + * Example: "curl/7.68.0" + */ + it('detects curl as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('curl/7.68.0'); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Pattern: /\bpython-requests\b/i + * Example: "python-requests/2.28.0" + */ + it('detects python-requests as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('python-requests/2.28.0'); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Pattern: /\bPostman\b/i + * Example: "PostmanRuntime/7.32.0" + */ + it('detects Postman as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('PostmanRuntime/7.32.0'); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Pattern: /\bSlackbot\b/i + * Example: "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)" + */ + it('detects Slackbot as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent( + 'Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)' + ); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Pattern: /\bgo-http-client\b/i + * Example: "Go-http-client/1.1" + */ + it('detects Go-http-client as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('Go-http-client/1.1'); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Pattern: /\baxios\b/i + * Example: "axios/1.4.0" + */ + it('detects axios as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('axios/1.4.0'); + + expect($identity->isNotAgent())->toBeTrue(); + }); + + /** + * Pattern: /\bnode-fetch\b/i + * Example: "node-fetch/2.6.9" + */ + it('detects node-fetch as not an agent', function () { + $service = new AgentDetection; + $identity = $service->identifyFromUserAgent('node-fetch/2.6.9'); + + expect($identity->isNotAgent())->toBeTrue(); + }); +}); + +// ========================================================================= +// MCP Token Detection +// ========================================================================= + +describe('MCP token detection', function () { + /** + * Structured token format: "provider:model:secret" + * Example: "anthropic:claude-opus:abc123" + */ + it('identifies Anthropic from structured MCP token', function () { + $service = new AgentDetection; + $identity = $service->identifyFromMcpToken('anthropic:claude-opus:secret123'); + + expect($identity->provider)->toBe('anthropic') + ->and($identity->model)->toBe('claude-opus') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Structured token format: "provider:model:secret" + * Example: "openai:gpt-4:xyz789" + */ + it('identifies OpenAI from structured MCP token', function () { + $service = new AgentDetection; + $identity = $service->identifyFromMcpToken('openai:gpt-4:secret456'); + + expect($identity->provider)->toBe('openai') + ->and($identity->model)->toBe('gpt-4') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + /** + * Structured token format: "provider:model:secret" + * Example: "google:gemini-pro:zyx321" + */ + it('identifies Google from structured MCP token', function () { + $service = new AgentDetection; + $identity = $service->identifyFromMcpToken('google:gemini-pro:secret789'); + + expect($identity->provider)->toBe('google') + ->and($identity->model)->toBe('gemini-pro') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_HIGH); + }); + + it('accepts meta and mistral providers in structured tokens', function () { + $service = new AgentDetection; + + expect($service->identifyFromMcpToken('meta:llama-3:secret')->provider)->toBe('meta'); + expect($service->identifyFromMcpToken('mistral:mistral-large:secret')->provider)->toBe('mistral'); + }); + + it('returns medium-confidence unknown for unrecognised token string', function () { + $service = new AgentDetection; + // No colon separator — cannot parse as structured token + $identity = $service->identifyFromMcpToken('some-random-opaque-token'); + + expect($identity->provider)->toBe('unknown') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_MEDIUM); + }); + + it('returns medium-confidence unknown for structured token with invalid provider', function () { + $service = new AgentDetection; + $identity = $service->identifyFromMcpToken('facebook:llama:secret'); + + expect($identity->provider)->toBe('unknown') + ->and($identity->confidence)->toBe(AgentIdentity::CONFIDENCE_MEDIUM); + }); + + it('prioritises MCP token header over User-Agent in HTTP request', function () { + $service = new AgentDetection; + $request = Request::create('/test', 'GET', [], [], [], [ + 'HTTP_X_MCP_TOKEN' => 'anthropic:claude-sonnet:token123', + 'HTTP_USER_AGENT' => 'python-requests/2.28.0', + ]); + + // MCP token takes precedence; UA would indicate notAnAgent otherwise + $identity = $service->identify($request); + + expect($identity->provider)->toBe('anthropic') + ->and($identity->model)->toBe('claude-sonnet'); + }); + + it('falls back to User-Agent when no MCP token header is present', function () { + $service = new AgentDetection; + $request = Request::create('/test', 'GET', [], [], [], [ + 'HTTP_USER_AGENT' => 'claude-code/1.0', + ]); + + $identity = $service->identify($request); + + expect($identity->provider)->toBe('anthropic'); + }); +}); + +// ========================================================================= +// Provider Validation +// ========================================================================= + +describe('provider validation', function () { + it('accepts all known valid providers', function () { + $service = new AgentDetection; + $validProviders = ['anthropic', 'openai', 'google', 'meta', 'mistral', 'local', 'unknown']; + + foreach ($validProviders as $provider) { + expect($service->isValidProvider($provider)) + ->toBeTrue("Expected '{$provider}' to be a valid provider"); + } + }); + + it('rejects unknown provider names', function () { + $service = new AgentDetection; + + expect($service->isValidProvider('facebook'))->toBeFalse() + ->and($service->isValidProvider('huggingface'))->toBeFalse() + ->and($service->isValidProvider(''))->toBeFalse(); + }); + + it('rejects not_agent as a provider (it is a sentinel value, not a provider)', function () { + $service = new AgentDetection; + + expect($service->isValidProvider('not_agent'))->toBeFalse(); + }); + + it('returns all valid providers as an array', function () { + $service = new AgentDetection; + $providers = $service->getValidProviders(); + + expect($providers) + ->toContain('anthropic') + ->toContain('openai') + ->toContain('google') + ->toContain('meta') + ->toContain('mistral') + ->toContain('local') + ->toContain('unknown'); + }); +}); + +// ========================================================================= +// isAgentUserAgent Shorthand +// ========================================================================= + +describe('isAgentUserAgent shorthand', function () { + it('returns true for known AI agent User-Agents', function () { + $service = new AgentDetection; + + expect($service->isAgentUserAgent('claude-code/1.0'))->toBeTrue() + ->and($service->isAgentUserAgent('OpenAI Python/1.0'))->toBeTrue() + ->and($service->isAgentUserAgent('Gemini API/2.0'))->toBeTrue(); + }); + + it('returns false for browser User-Agents', function () { + $service = new AgentDetection; + $browserUA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0'; + + expect($service->isAgentUserAgent($browserUA))->toBeFalse(); + }); + + it('returns false for crawler User-Agents', function () { + $service = new AgentDetection; + + expect($service->isAgentUserAgent('Googlebot/2.1'))->toBeFalse() + ->and($service->isAgentUserAgent('curl/7.68.0'))->toBeFalse(); + }); + + it('returns true for null User-Agent (unknown programmatic access)', function () { + $service = new AgentDetection; + // Null UA returns unknownAgent; isAgent() is true because provider !== 'not_agent' + expect($service->isAgentUserAgent(null))->toBeTrue(); + }); + + it('returns true for unrecognised non-browser User-Agent', function () { + $service = new AgentDetection; + // No browser indicators → unknownAgent → isAgent() true + expect($service->isAgentUserAgent('custom-agent/0.1'))->toBeTrue(); + }); +});