Skip to content

Refactor SearchDocs tests to use Http facade #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ jobs:
tools: composer:v2
coverage: none

- name: Setup SSH Keys
run: |
mkdir -p ~/.ssh
echo "${{ secrets.MCP_DEPLOY_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan github.com >> ~/.ssh/known_hosts

- name: Install dependencies
run: |
composer update --prefer-dist --no-interaction --no-progress --with="illuminate/contracts:^${{ matrix.laravel }}"
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
],
"require": {
"php": "^8.1|^8.2",
"guzzlehttp/guzzle": "^7.9",
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/contracts": "^10.0|^11.0|^12.0",
"illuminate/routing": "^10.0|^11.0|^12.0",
Expand Down
4 changes: 1 addition & 3 deletions src/BoostServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,6 @@ private function registerRoutes(): void
/**
* Build a string message for the log based on various input types. Single-dimensional, and multi:
* "data": {"message":"Unhandled Promise Rejection","reason":{"name":"TypeError","message":"NetworkError when attempting to fetch resource.","stack":""}}]
*
* @param array $data
* @return string
*/
private function buildLogMessageFromData(array $data): string
{
Expand All @@ -141,6 +138,7 @@ private function buildLogMessageFromData(array $data): string
is_bool($value) => $value ? 'true' : 'false',
is_null($value) => 'null',
is_object($value) => json_encode($value),
default => $value,
};
}

Expand Down
4 changes: 2 additions & 2 deletions src/Concerns/MakesHttpRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ public function client(): PendingRequest
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0 Laravel Boost',
]);

// Disable SSL verification for local development URLs
if (app()->environment('local') || str_contains(config('boost.hosted.api_url', ''), '.test')) {
// Disable SSL verification for local development URLs and testing
if (app()->environment(['local', 'testing']) || str_contains(config('boost.hosted.api_url', ''), '.test')) {
$client = $client->withoutVerifying();
}

Expand Down
4 changes: 1 addition & 3 deletions src/Install/Contracts/DetectionStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ interface DetectionStrategy
/**
* Detect if the application is installed on the machine.
*
* @param array{command:string, ?files:array<string>, ?paths:array<string>} $config
* @param ?Platform $platform
* @return bool
* @param array{command?:string, basePath?:string, files?:array<string>, paths?:array<string>} $config
*/
public function detect(array $config, ?Platform $platform = null): bool;
}
2 changes: 1 addition & 1 deletion src/Middleware/InjectBoost.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function handle(Request $request, Closure $next): Response
$injectedContent = $this->injectScript($response->getContent());
$response->setContent($injectedContent);

if ($originalView instanceof View) {
if ($originalView instanceof View && property_exists($response, 'original')) {
$response->original = $originalView;
}
}
Expand Down
185 changes: 69 additions & 116 deletions tests/Feature/Mcp/Tools/SearchDocsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

declare(strict_types=1);

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Laravel\Boost\Mcp\Tools\SearchDocs;
use Laravel\Mcp\Server\Tools\ToolResult;
use Laravel\Roster\Enums\Packages;
Expand All @@ -20,34 +19,29 @@
$roster = Mockery::mock(Roster::class);
$roster->shouldReceive('packages')->andReturn($packages);

$mockResponse = Mockery::mock(Response::class);
$mockResponse->shouldReceive('successful')->andReturn(true);
$mockResponse->shouldReceive('json')->andReturn([
'results' => [
['content' => 'Laravel documentation content'],
['content' => 'Pest documentation content'],
],
Http::fake([
'https://boost.laravel.com/api/docs' => Http::response('Documentation search results', 200),
]);

$mockClient = Mockery::mock(PendingRequest::class);
$mockClient->shouldReceive('asJson')->andReturnSelf();
$mockClient->shouldReceive('post')->andReturn($mockResponse);

$tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial();
$tool->shouldReceive('client')->andReturn($mockClient);

$result = $tool->handle(['queries' => 'authentication, testing']);
$tool = new SearchDocs($roster);
$result = $tool->handle(['queries' => 'authentication###testing']);

expect($result)->toBeInstanceOf(ToolResult::class);

$data = $result->toArray();
expect($data['isError'])->toBeFalse();

$content = json_decode($data['content'][0]['text'], true);
expect($content['knowledge_count'])->toBe(2);
expect($content['knowledge'])->toContain('Laravel documentation content');
expect($content['knowledge'])->toContain('Pest documentation content');
expect($content['knowledge'])->toContain('---');
expect($data['isError'])->toBeFalse()
->and($data['content'][0]['text'])->toBe('Documentation search results');

Http::assertSent(function ($request) {
return $request->url() === 'https://boost.laravel.com/api/docs' &&
$request->data()['queries'] === ['authentication', 'testing'] &&
$request->data()['packages'] === [
['name' => 'laravel/framework', 'version' => '11.x'],
['name' => 'pestphp/pest', 'version' => '2.x'],
] &&
$request->data()['token_limit'] === 10000 &&
$request->data()['format'] === 'markdown';
});
});

test('it handles API error response', function () {
Expand All @@ -58,25 +52,18 @@
$roster = Mockery::mock(Roster::class);
$roster->shouldReceive('packages')->andReturn($packages);

$mockResponse = Mockery::mock(Response::class);
$mockResponse->shouldReceive('successful')->andReturn(false);
$mockResponse->shouldReceive('status')->andReturn(500);
$mockResponse->shouldReceive('body')->andReturn('API Error');

$mockClient = Mockery::mock(PendingRequest::class);
$mockClient->shouldReceive('asJson')->andReturnSelf();
$mockClient->shouldReceive('post')->andReturn($mockResponse);

$tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial();
$tool->shouldReceive('client')->andReturn($mockClient);
Http::fake([
'https://boost.laravel.com/api/docs' => Http::response('API Error', 500),
]);

$tool = new SearchDocs($roster);
$result = $tool->handle(['queries' => 'authentication']);

expect($result)->toBeInstanceOf(ToolResult::class);

$data = $result->toArray();
expect($data['isError'])->toBeTrue();
expect($data['content'][0]['text'])->toBe('Failed to search documentation: API Error');
expect($data['isError'])->toBeTrue()
->and($data['content'][0]['text'])->toBe('Failed to search documentation: API Error');
});

test('it filters empty queries', function () {
Expand All @@ -85,28 +72,24 @@
$roster = Mockery::mock(Roster::class);
$roster->shouldReceive('packages')->andReturn($packages);

$mockResponse = Mockery::mock(Response::class);
$mockResponse->shouldReceive('successful')->andReturn(true);
$mockResponse->shouldReceive('json')->andReturn(['results' => []]);

$mockClient = Mockery::mock(PendingRequest::class);
$mockClient->shouldReceive('asJson')->andReturnSelf();
$mockClient->shouldReceive('post')->withArgs(function ($url, $payload) {
return $url === 'https://boost.laravel.com/api/docs' &&
$payload['queries'] === ['test'] &&
empty($payload['packages']) &&
$payload['token_limit'] === 10000;
})->andReturn($mockResponse);

$tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial();
$tool->shouldReceive('client')->andReturn($mockClient);
Http::fake([
'https://boost.laravel.com/api/docs' => Http::response('Empty results', 200),
]);

$tool = new SearchDocs($roster);
$result = $tool->handle(['queries' => 'test### ###*### ']);

expect($result)->toBeInstanceOf(ToolResult::class);

$data = $result->toArray();
expect($data['isError'])->toBeFalse();

Http::assertSent(function ($request) {
return $request->url() === 'https://boost.laravel.com/api/docs' &&
$request->data()['queries'] === ['test'] &&
empty($request->data()['packages']) &&
$request->data()['token_limit'] === 10000;
});
});

test('it formats package data correctly', function () {
Expand All @@ -118,28 +101,21 @@
$roster = Mockery::mock(Roster::class);
$roster->shouldReceive('packages')->andReturn($packages);

$mockResponse = Mockery::mock(Response::class);
$mockResponse->shouldReceive('successful')->andReturn(true);
$mockResponse->shouldReceive('json')->andReturn(['results' => []]);

$mockClient = Mockery::mock(PendingRequest::class);
$mockClient->shouldReceive('asJson')->andReturnSelf();
$mockClient->shouldReceive('post')->with(
'https://boost.laravel.com/api/docs',
Mockery::on(function ($payload) {
return $payload['packages'] === [
['name' => 'laravel/framework', 'version' => '11.x'],
['name' => 'livewire/livewire', 'version' => '3.x'],
] && $payload['token_limit'] === 10000;
})
)->andReturn($mockResponse);

$tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial();
$tool->shouldReceive('client')->andReturn($mockClient);
Http::fake([
'https://boost.laravel.com/api/docs' => Http::response('Package data results', 200),
]);

$tool = new SearchDocs($roster);
$result = $tool->handle(['queries' => 'test']);

expect($result)->toBeInstanceOf(ToolResult::class);

Http::assertSent(function ($request) {
return $request->data()['packages'] === [
['name' => 'laravel/framework', 'version' => '11.x'],
['name' => 'livewire/livewire', 'version' => '3.x'],
] && $request->data()['token_limit'] === 10000;
});
});

test('it handles empty results', function () {
Expand All @@ -148,27 +124,18 @@
$roster = Mockery::mock(Roster::class);
$roster->shouldReceive('packages')->andReturn($packages);

$mockResponse = Mockery::mock(Response::class);
$mockResponse->shouldReceive('successful')->andReturn(true);
$mockResponse->shouldReceive('json')->andReturn(['results' => []]);

$mockClient = Mockery::mock(PendingRequest::class);
$mockClient->shouldReceive('asJson')->andReturnSelf();
$mockClient->shouldReceive('post')->andReturn($mockResponse);

$tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial();
$tool->shouldReceive('client')->andReturn($mockClient);
Http::fake([
'https://boost.laravel.com/api/docs' => Http::response('Empty response', 200),
]);

$tool = new SearchDocs($roster);
$result = $tool->handle(['queries' => 'nonexistent']);

expect($result)->toBeInstanceOf(ToolResult::class);

$data = $result->toArray();
expect($data['isError'])->toBeFalse();

$content = json_decode($data['content'][0]['text'], true);
expect($content['knowledge_count'])->toBe(0);
expect($content['knowledge'])->toBe('');
expect($data['isError'])->toBeFalse()
->and($data['content'][0]['text'])->toBe('Empty response');
});

test('it uses custom token_limit when provided', function () {
Expand All @@ -177,25 +144,18 @@
$roster = Mockery::mock(Roster::class);
$roster->shouldReceive('packages')->andReturn($packages);

$mockResponse = Mockery::mock(Response::class);
$mockResponse->shouldReceive('successful')->andReturn(true);
$mockResponse->shouldReceive('json')->andReturn(['results' => []]);

$mockClient = Mockery::mock(PendingRequest::class);
$mockClient->shouldReceive('asJson')->andReturnSelf();
$mockClient->shouldReceive('post')->with(
'https://boost.laravel.com/api/docs',
Mockery::on(function ($payload) {
return $payload['token_limit'] === 5000;
})
)->andReturn($mockResponse);

$tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial();
$tool->shouldReceive('client')->andReturn($mockClient);
Http::fake([
'https://boost.laravel.com/api/docs' => Http::response('Custom token limit results', 200),
]);

$tool = new SearchDocs($roster);
$result = $tool->handle(['queries' => 'test', 'token_limit' => 5000]);

expect($result)->toBeInstanceOf(ToolResult::class);

Http::assertSent(function ($request) {
return $request->data()['token_limit'] === 5000;
});
});

test('it caps token_limit at maximum of 1000000', function () {
Expand All @@ -204,23 +164,16 @@
$roster = Mockery::mock(Roster::class);
$roster->shouldReceive('packages')->andReturn($packages);

$mockResponse = Mockery::mock(Response::class);
$mockResponse->shouldReceive('successful')->andReturn(true);
$mockResponse->shouldReceive('json')->andReturn(['results' => []]);

$mockClient = Mockery::mock(PendingRequest::class);
$mockClient->shouldReceive('asJson')->andReturnSelf();
$mockClient->shouldReceive('post')->with(
'https://boost.laravel.com/api/docs',
Mockery::on(function ($payload) {
return $payload['token_limit'] === 1000000; // Should be capped at 1M
})
)->andReturn($mockResponse);

$tool = Mockery::mock(SearchDocs::class, [$roster])->makePartial();
$tool->shouldReceive('client')->andReturn($mockClient);
Http::fake([
'https://boost.laravel.com/api/docs' => Http::response('Capped token limit results', 200),
]);

$result = $tool->handle(['queries' => 'test', 'token_limit' => 2000000]); // Request 2M but get capped at 1M
$tool = new SearchDocs($roster);
$result = $tool->handle(['queries' => 'test', 'token_limit' => 2000000]);

expect($result)->toBeInstanceOf(ToolResult::class);

Http::assertSent(function ($request) {
return $request->data()['token_limit'] === 1000000;
});
});