Migrate outbound HTTP calls from Guzzle to the Http facade

Replaces every raw GuzzleHttp\Client usage (Nominatim, web.archive.org,
webmention fetch/discover/send, Bridgy syndication, IndieAuth client_id
lookup, contact avatar/h-card fetch, profile image download, and the
CloudConvert screenshot pipeline) with Illuminate\Support\Facades\Http,
and enables Http::preventStrayRequests() globally in tests so any
un-faked outbound call now fails loudly instead of silently hitting the
network.

The CloudConvert retry-until-finished middleware in AppServiceProvider
is replaced by a plain polling loop in SaveScreenshot, which also fixes
a latent bug where the old middleware decoded a response object instead
of its body. Bridgy syndication jobs keep their tries=1/no-retry
semantics unchanged to avoid duplicate publishes. Guzzle's PSR-7 helpers
(Header, UriResolver, Utils) stay in SendWebMentions since Http has no
equivalent for them.

Every affected test file's Guzzle MockHandler/HandlerStack boilerplate
is replaced with Http::fake()/Http::sequence().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8j7cJhCiYDsGUgB7obKNQ
This commit is contained in:
Jonny Barnes 2026-07-26 09:12:01 +01:00
commit e08763c526
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8
26 changed files with 337 additions and 630 deletions

View file

@ -6,10 +6,7 @@ namespace Tests\Unit;
use App\Exceptions\InternetArchiveException;
use App\Services\BookmarkService;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -29,12 +26,9 @@ class BookmarksTest extends TestCase
#[Test]
public function archive_link_method_calls_archive_service(): void
{
$mock = new MockHandler([
new Response(200, ['Content-Location' => '/web/1234/example.org']),
Http::fake([
'web.archive.org/*' => Http::response('', 200, ['Content-Location' => '/web/1234/example.org']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$url = (new BookmarkService)->getArchiveLink('https://example.org');
$this->assertEquals('/web/1234/example.org', $url);
}
@ -44,12 +38,9 @@ class BookmarksTest extends TestCase
{
$this->expectException(InternetArchiveException::class);
$mock = new MockHandler([
new Response(403),
Http::fake([
'web.archive.org/*' => Http::response('', 403),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
(new BookmarkService)->getArchiveLink('https://example.org');
}
@ -58,12 +49,9 @@ class BookmarksTest extends TestCase
{
$this->expectException(InternetArchiveException::class);
$mock = new MockHandler([
new Response(200),
Http::fake([
'web.archive.org/*' => Http::response('', 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
(new BookmarkService)->getArchiveLink('https://example.org');
}
}

View file

@ -5,11 +5,8 @@ declare(strict_types=1);
namespace Tests\Unit\Jobs;
use App\Jobs\DownloadWebMention;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\FileSystem\FileSystem;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -35,19 +32,16 @@ class DownloadWebMentionJobTest extends TestCase
</div>
HTML;
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
$mock = new MockHandler([
new Response(200, ['X-Foo' => 'Bar'], $html),
new Response(200, ['X-Foo' => 'Bar'], $html),
Http::fake([
'example.org/*' => Http::response($html, 200, ['X-Foo' => 'Bar']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$job = new DownloadWebMention($source);
$job->handle($client);
$job->handle();
$this->assertFileExists(storage_path('HTML/https'));
$job->handle($client);
$job->handle();
$this->assertFileDoesNotExist(storage_path('HTML/https/example.org/reply').'/1.'.date('Y-m-d').'.backup');
}
@ -70,19 +64,18 @@ class DownloadWebMentionJobTest extends TestCase
HTML;
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
$html2 = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html2);
$mock = new MockHandler([
new Response(200, ['X-Foo' => 'Bar'], $html),
new Response(200, ['X-Foo' => 'Bar'], $html2),
Http::fake([
'example.org/*' => Http::sequence()
->push($html, 200, ['X-Foo' => 'Bar'])
->push($html2, 200, ['X-Foo' => 'Bar']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$job = new DownloadWebMention($source);
$job->handle($client);
$job->handle();
$this->assertFileExists(storage_path('HTML/https'));
$job->handle($client);
$job->handle();
$this->assertFileExists(storage_path('HTML/https/example.org/reply').'/1.'.date('Y-m-d').'.backup');
}
@ -98,14 +91,12 @@ class DownloadWebMentionJobTest extends TestCase
</div>
HTML;
$html = str_replace('href=""', 'href="'.config('app.url').'/notes/A"', $html);
$mock = new MockHandler([
new Response(200, ['X-Foo' => 'Bar'], $html),
Http::fake([
'example.org/*' => Http::response($html, 200, ['X-Foo' => 'Bar']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$job = new DownloadWebMention($source);
$job->handle($client);
$job->handle();
$this->assertFileExists(storage_path('HTML/https/example.org/reply-one/index.html'));
}

View file

@ -9,12 +9,9 @@ use App\Jobs\ProcessWebMention;
use App\Jobs\SaveProfileImage;
use App\Models\Note;
use App\Models\WebMention;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\FileSystem\FileSystem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Jonnybarnes\WebmentionsParser\Parser;
use PHPUnit\Framework\Attributes\Test;
@ -39,17 +36,15 @@ class ProcessWebMentionJobTest extends TestCase
$this->expectException(RemoteContentNotFoundException::class);
$parser = new Parser;
$mock = new MockHandler([
new Response(404),
Http::fake([
'*' => Http::response('', 404),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create();
$source = 'https://example.org/mention/1/';
$job = new ProcessWebMention($note, $source);
$job->handle($parser, $client);
$job->handle($parser);
}
#[Test]
@ -65,17 +60,15 @@ class ProcessWebMentionJobTest extends TestCase
</div>
HTML;
$html = str_replace('href="', 'href="'.config('app.url'), $html);
$mock = new MockHandler([
new Response(200, [], $html),
Http::fake([
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create();
$source = 'https://example.org/mention/1/';
$job = new ProcessWebMention($note, $source);
$job->handle($parser, $client);
$job->handle($parser);
Queue::assertPushed(SaveProfileImage::class);
$this->assertDatabaseHas('webmentions', [
@ -103,14 +96,12 @@ class ProcessWebMentionJobTest extends TestCase
<div class="e-content">Updated reply</div>
</div>
HTML;
$mock = new MockHandler([
new Response(200, [], $html),
Http::fake([
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$job = new ProcessWebMention($note, $source);
$job->handle($parser, $client);
$job->handle($parser);
Queue::assertPushed(SaveProfileImage::class);
$this->assertDatabaseHas('webmentions', [
@ -132,11 +123,9 @@ class ProcessWebMentionJobTest extends TestCase
<div class="e-content">Replying to someone else</div>
</div>
HTML;
$mock = new MockHandler([
new Response(200, [], $html),
Http::fake([
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create();
$source = 'https://example.org/reply/1';
@ -151,7 +140,7 @@ class ProcessWebMentionJobTest extends TestCase
]);
$job = new ProcessWebMention($note, $source);
$job->handle($parser, $client);
$job->handle($parser);
$this->assertDatabaseMissing('webmentions', [
'source' => $source,
@ -169,11 +158,9 @@ class ProcessWebMentionJobTest extends TestCase
<div class="e-content">I like someone else now</div>
</div>
HTML;
$mock = new MockHandler([
new Response(200, [], $html),
Http::fake([
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create();
$source = 'https://example.org/reply/1';
@ -188,7 +175,7 @@ class ProcessWebMentionJobTest extends TestCase
]);
$job = new ProcessWebMention($note, $source);
$job->handle($parser, $client);
$job->handle($parser);
$this->assertDatabaseMissing('webmentions', [
'source' => $source,
@ -208,18 +195,16 @@ class ProcessWebMentionJobTest extends TestCase
</div>
HTML;
$html = str_replace('href="', 'href="'.config('app.url'), $html);
$mock = new MockHandler([
new Response(200, [], $html),
Http::fake([
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create();
// Simulate a long brid.gy Bluesky source URL (well over 255 characters)
$source = 'https://brid.gy/comment/bluesky/did:plc:n3jhgiq2ykctnpgzlm6p6b25/at%253A%252F%252Fdid%253Aplc%253An3jhgiq2ykctnpgzlm6p6b25%252Fapp.bsky.feed.post%252F3mfekjuhykb2k/at%253A%252F%252Fdid%253Aplc%253Afsfuwigo5juzdwp23hyianzg%252Fapp.bsky.feed.post%252F3mfemw4rrvs2t';
$job = new ProcessWebMention($note, $source);
$job->handle($parser, $client);
$job->handle($parser);
$this->assertGreaterThan(255, strlen($source));
$this->assertDatabaseHas('webmentions', [
@ -238,11 +223,9 @@ class ProcessWebMentionJobTest extends TestCase
<div class="e-content">Reposting someone else</div>
</div>
HTML;
$mock = new MockHandler([
new Response(200, [], $html),
Http::fake([
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create();
$source = 'https://example.org/reply/1';
@ -257,7 +240,7 @@ class ProcessWebMentionJobTest extends TestCase
]);
$job = new ProcessWebMention($note, $source);
$job->handle($parser, $client);
$job->handle($parser);
$this->assertDatabaseMissing('webmentions', [
'source' => $source,

View file

@ -5,10 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Jobs;
use App\Jobs\SaveProfileImage;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
use PHPUnit\Framework\Attributes\Test;
@ -58,12 +55,9 @@ class SaveProfileImageJobTest extends TestCase
#[Test]
public function remote_author_images_are_saved_locally(): void
{
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'),
Http::fake([
'*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$mf = ['items' => []];
$author = [
'properties' => [
@ -83,12 +77,9 @@ class SaveProfileImageJobTest extends TestCase
#[Test]
public function local_default_author_image_is_used_as_fallback(): void
{
$mock = new MockHandler([
new Response(404),
Http::fake([
'*' => Http::response('', 404),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$mf = ['items' => []];
$author = [
'properties' => [
@ -111,12 +102,9 @@ class SaveProfileImageJobTest extends TestCase
#[Test]
public function we_get_url_from_photo_object_if_alt_text_is_provided(): void
{
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'),
Http::fake([
'*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$mf = ['items' => []];
$author = [
'properties' => [
@ -139,12 +127,9 @@ class SaveProfileImageJobTest extends TestCase
#[Test]
public function use_first_url_if_multiple_homepages_are_provided(): void
{
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'image/jpeg'], 'fake jpeg image'),
Http::fake([
'*' => Http::response('fake jpeg image', 200, ['Content-Type' => 'image/jpeg']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$mf = ['items' => []];
$author = [
'properties' => [

View file

@ -6,13 +6,8 @@ namespace Tests\Unit\Jobs;
use App\Jobs\SaveScreenshot;
use App\Models\Bookmark;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -25,57 +20,34 @@ class SaveScreenshotJobTest extends TestCase
public function screenshot_is_saved_by_job(): void
{
Storage::fake('public');
$guzzleMock = new MockHandler([
new Response(201, ['Content-Type' => 'application/json'], '{"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":null,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","result":null,"created_at":"2023-01-07T21:05:48+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
new Response(201, ['Content-Type' => 'application/json'], '{"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":null,"code":null,"message":null,"percent":100,"operation":"export\/url","result":null,"created_at":"2023-01-07T21:10:02+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__.'/../../theverge.com.png', 'rb')),
Http::fake([
'api.cloudconvert.com/v2/capture-website' => Http::response([
'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished'],
], 201),
'api.cloudconvert.com/v2/tasks/68d52633-e170-465e-b13e-746c97d01ffb*' => Http::response([
'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished'],
], 200),
'api.cloudconvert.com/v2/export/url' => Http::response([
'data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'finished'],
], 201),
'api.cloudconvert.com/v2/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb*' => Http::response([
'data' => [
'id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb',
'status' => 'finished',
'result' => [
'files' => [[
'url' => 'https://storage.cloudconvert.com/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb/theverge.com.png',
]],
],
],
], 200),
'storage.cloudconvert.com/*' => Http::response(
file_get_contents(__DIR__.'/../../theverge.com.png'),
200,
['Content-Type' => 'image/png']
),
]);
$guzzleHandler = HandlerStack::create($guzzleMock);
$guzzleClient = new Client(['handler' => $guzzleHandler]);
$this->app->instance(Client::class, $guzzleClient);
$retryMock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":1,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":0,"code":null,"message":null,"percent":100,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"result":{"files":[{"filename":"theverge.com.png","size":811819,"url":"https:\/\/storage.cloudconvert.com\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb\/theverge.com.png?AWSAccessKeyId=cloudconvert-production&Expires=1673212203&Signature=xyz&response-content-disposition=attachment%3B%20filename%3D%22theverge.com.png%22&response-content-type=image%2Fpng"}]},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":"2023-01-07T21:10:03+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
]);
$retryHandler = HandlerStack::create($retryMock);
$retryHandler->push(Middleware::retry(
function ($retries, $request, $response, $exception) {
// Limit the number of retries to 5
if ($retries >= 5) {
return false;
}
// Retry connection exceptions
if ($exception instanceof ConnectException) {
return true;
}
// Retry on server errors
if ($response && $response->getStatusCode() >= 500) {
return true;
}
$responseBody = '';
if (is_string($response)) {
$responseBody = $response;
}
if ($response instanceof Response) {
$responseBody = $response->getBody()->getContents();
$response->getBody()->rewind();
}
// Finally for CloudConvert, retry if status is not final
return json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR)?->data?->status !== 'finished';
},
function () {
// Retry after 1 second
return 1000;
}
));
$retryClient = new Client(['handler' => $retryHandler]);
$this->app->instance('RetryGuzzle', $retryClient);
$bookmark = Bookmark::factory()->create();
$job = new SaveScreenshot($bookmark);
@ -84,68 +56,45 @@ class SaveScreenshotJobTest extends TestCase
$this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot);
Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png');
// capture-website, 1x poll (finished immediately), export/url, 1x poll (finished immediately), download
Http::assertSentCount(5);
}
#[Test]
public function screenshot_job_handles_unfinished_tasks(): void
{
Storage::fake('public');
$guzzleMock = new MockHandler([
new Response(201, ['Content-Type' => 'application/json'], '{"id":1,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"waiting","credits":null,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","result":null,"created_at":"2023-01-07T21:05:48+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
new Response(201, ['Content-Type' => 'application/json'], '{"id":2,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"waiting","credits":null,"code":null,"message":null,"percent":100,"operation":"export\/url","result":null,"created_at":"2023-01-07T21:10:02+00:00","started_at":null,"ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":null,"storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
new Response(200, ['Content-Type' => 'image/png'], fopen(__DIR__.'/../../theverge.com.png', 'rb')),
Http::fake([
'api.cloudconvert.com/v2/capture-website' => Http::response([
'data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'waiting'],
], 201),
'api.cloudconvert.com/v2/tasks/68d52633-e170-465e-b13e-746c97d01ffb*' => Http::sequence()
->push(['data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'waiting']], 200)
->push(['data' => ['id' => '68d52633-e170-465e-b13e-746c97d01ffb', 'status' => 'finished']], 200),
'api.cloudconvert.com/v2/export/url' => Http::response([
'data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'waiting'],
], 201),
'api.cloudconvert.com/v2/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb*' => Http::sequence()
->push(['data' => ['id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb', 'status' => 'waiting']], 200)
->push([
'data' => [
'id' => '27f33137-cc03-4468-aba4-1e1aa8c096fb',
'status' => 'finished',
'result' => [
'files' => [[
'url' => 'https://storage.cloudconvert.com/tasks/27f33137-cc03-4468-aba4-1e1aa8c096fb/theverge.com.png',
]],
],
],
], 200),
'storage.cloudconvert.com/*' => Http::response(
file_get_contents(__DIR__.'/../../theverge.com.png'),
200,
['Content-Type' => 'image/png']
),
]);
$guzzleHandler = HandlerStack::create($guzzleMock);
$guzzleClient = new Client(['handler' => $guzzleHandler]);
$this->app->instance(Client::class, $guzzleClient);
$container = [];
$history = Middleware::history($container);
$retryMock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], '{"id":3,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"waiting","credits":1,"code":null,"message":null,"percent":50,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
new Response(200, ['Content-Type' => 'application/json'], '{"id":4,"data":{"id":"68d52633-e170-465e-b13e-746c97d01ffb","job_id":null,"status":"finished","credits":1,"code":null,"message":null,"percent":100,"operation":"capture-website","engine":"chrome","engine_version":"107","payload":{"url":"https:\/\/theverge.com","output_format":"png","screen_width":1440,"screen_height":900,"wait_until":"networkidle0","wait_time":"100"},"result":{"files":[{"filename":"theverge.com.png","size":811819}]},"created_at":"2023-01-07T21:05:48+00:00","started_at":"2023-01-07T21:05:48+00:00","ended_at":"2023-01-07T21:05:55+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":[],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/68d52633-e170-465e-b13e-746c97d01ffb"}}}'),
new Response(200, ['Content-Type' => 'application/json'], '{"id":5,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"waiting","credits":0,"code":null,"message":null,"percent":50,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":null,"retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
new Response(200, ['Content-Type' => 'application/json'], '{"id":6,"data":{"id":"27f33137-cc03-4468-aba4-1e1aa8c096fb","job_id":null,"status":"finished","credits":0,"code":null,"message":null,"percent":100,"operation":"export\/url","payload":{"input":"68d52633-e170-465e-b13e-746c97d01ffb","archive_multiple_files":false},"result":{"files":[{"filename":"theverge.com.png","size":811819,"url":"https:\/\/storage.cloudconvert.com\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb\/theverge.com.png?AWSAccessKeyId=cloudconvert-production&Expires=1673212203&Signature=xyz&response-content-disposition=attachment%3B%20filename%3D%22theverge.com.png%22&response-content-type=image%2Fpng"}]},"created_at":"2023-01-07T21:10:02+00:00","started_at":"2023-01-07T21:10:03+00:00","ended_at":"2023-01-07T21:10:03+00:00","retry_of_task_id":null,"copy_of_task_id":null,"user_id":61485254,"priority":-10,"host_name":"virgie","storage":"ceph-fra","depends_on_task_ids":["68d52633-e170-465e-b13e-746c97d01ffb"],"links":{"self":"https:\/\/api.cloudconvert.com\/v2\/tasks\/27f33137-cc03-4468-aba4-1e1aa8c096fb"}}}'),
]);
$retryHandler = HandlerStack::create($retryMock);
$retryHandler->push($history);
$retryHandler->push(Middleware::retry(
function ($retries, $request, $response, $exception) {
// Limit the number of retries to 5
if ($retries >= 5) {
return false;
}
// Retry connection exceptions
if ($exception instanceof ConnectException) {
return true;
}
// Retry on server errors
if ($response && $response->getStatusCode() >= 500) {
return true;
}
$responseBody = '';
if (is_string($response)) {
$responseBody = $response;
}
if ($response instanceof Response) {
$responseBody = $response->getBody()->getContents();
$response->getBody()->rewind();
}
// Finally for CloudConvert, retry if status is not final
return json_decode($responseBody, false, 512, JSON_THROW_ON_ERROR)?->data?->status !== 'finished';
},
function () {
// Retry after 1 second
return 1000;
}
));
$retryClient = new Client(['handler' => $retryHandler]);
$this->app->instance('RetryGuzzle', $retryClient);
$bookmark = Bookmark::factory()->create();
$job = new SaveScreenshot($bookmark);
@ -154,9 +103,10 @@ class SaveScreenshotJobTest extends TestCase
$this->assertEquals('68d52633-e170-465e-b13e-746c97d01ffb', $bookmark->screenshot);
Storage::disk('public')->assertExists('/assets/img/bookmarks/'.$bookmark->screenshot.'.png');
// Also assert we made the correct number of requests
$this->assertCount(2, $container);
// However with retries there should be more than 4 responses for the 2 requests
$this->assertEquals(0, $retryMock->count());
// capture-website, 2x poll (waiting then finished), export/url, 2x poll (waiting then finished), download
Http::assertSentCount(7);
// Also assert every queued response in each sequence was consumed, no more no less
Http::assertSequencesAreEmpty();
}
}

View file

@ -6,10 +6,7 @@ namespace Tests\Unit\Jobs;
use App\Jobs\SendWebMentions;
use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -28,12 +25,9 @@ class SendWebMentionJobTest extends TestCase
public function discover_webmention_endpoint_from_header_links(): void
{
$url = 'https://example.org/webmention';
$mock = new MockHandler([
new Response(200, ['Link' => '<'.$url.'>; rel="webmention"']),
Http::fake([
'*' => Http::response('', 200, ['Link' => '<'.$url.'>; rel="webmention"']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$job = new SendWebMentions(new Note);
$this->assertEquals($url, $job->discoverWebmentionEndpoint('https://example.org'));
@ -43,12 +37,9 @@ class SendWebMentionJobTest extends TestCase
public function discover_webmention_endpoint_from_html_link_tags(): void
{
$html = '<link rel="webmention" href="https://example.org/webmention">';
$mock = new MockHandler([
new Response(200, [], $html),
Http::fake([
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$job = new SendWebMentions(new Note);
$this->assertEquals(
@ -61,12 +52,9 @@ class SendWebMentionJobTest extends TestCase
public function discover_webmention_endpoint_from_legacy_html_markup(): void
{
$html = '<link rel="http://webmention.org/" href="https://example.org/webmention">';
$mock = new MockHandler([
new Response(200, [], $html),
Http::fake([
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$job = new SendWebMentions(new Note);
$this->assertEquals(
@ -95,13 +83,10 @@ class SendWebMentionJobTest extends TestCase
public function we_send_a_webmention_for_a_note(): void
{
$html = '<link rel="http://webmention.org/" href="https://example.org/webmention">';
$mock = new MockHandler([
new Response(200, [], $html),
new Response(202),
Http::fake([
'example.org/webmention' => Http::response('', 202),
'*' => Http::response($html, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$note = new Note;
$note->note = 'Hi [Aaron](https://aaronparecki.com)';
@ -114,13 +99,10 @@ class SendWebMentionJobTest extends TestCase
#[Test]
public function links_in_notes_can_not_support_webmentions(): void
{
$mock = new MockHandler([
Http::fake([
// URLs with commas currently break the parse function Im using
new Response(200, ['Link' => '<https://example.org/foo,bar>; rel="preconnect"']),
'*' => Http::response('', 200, ['Link' => '<https://example.org/foo,bar>; rel="preconnect"']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$job = new SendWebMentions(new Note);
$this->assertNull($job->discoverWebmentionEndpoint('https://example.org'));

View file

@ -5,12 +5,9 @@ namespace Tests\Unit\Jobs;
use App\Jobs\SyndicateNoteToBluesky;
use App\Models\Note;
use Faker\Factory;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -24,19 +21,17 @@ class SyndicateNoteToBlueskyJobTest extends TestCase
$faker = Factory::create();
$randomNumber = $faker->randomNumber();
$blueskyUrl = 'https://bsky.app/profile/jonnybarnes.uk/'.$randomNumber;
$mock = new MockHandler([
new Response(201, ['Content-Type' => 'application/json'], json_encode([
Http::fake([
'brid.gy/*' => Http::response([
'url' => $blueskyUrl,
'id' => (string) $randomNumber,
'type' => ['h-entry'],
])),
], 201),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create();
$job = new SyndicateNoteToBluesky($note);
$job->handle($client);
$job->handle();
$this->assertDatabaseHas('notes', [
'bluesky_url' => $blueskyUrl,
@ -46,39 +41,31 @@ class SyndicateNoteToBlueskyJobTest extends TestCase
#[Test]
public function we_post_the_correct_source_and_target(): void
{
$container = [];
$history = Middleware::history($container);
$mock = new MockHandler([
new Response(201, ['Content-Type' => 'application/json'], json_encode([
Http::fake([
'brid.gy/*' => Http::response([
'url' => 'https://bsky.app/profile/jonnybarnes.uk/1',
])),
], 201),
]);
$handler = HandlerStack::create($mock);
$handler->push($history);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create(['note' => 'This is a **test**']);
$job = new SyndicateNoteToBluesky($note);
$job->handle($client);
$job->handle();
$request = $container[0]['request'];
$body = [];
parse_str((string) $request->getBody(), $body);
$this->assertSame('https://brid.gy/publish/webmention', (string) $request->getUri());
$this->assertSame($note->uri, $body['source']);
$this->assertSame('https://brid.gy/publish/bluesky', $body['target']);
Http::assertSent(function (Request $request) use ($note) {
return $request->url() === 'https://brid.gy/publish/webmention'
&& $request['source'] === $note->uri
&& $request['target'] === 'https://brid.gy/publish/bluesky';
});
}
#[Test]
public function a_bridgy_failure_throws_and_does_not_set_bluesky_url(): void
{
$mock = new MockHandler([
new Response(400, ['Content-Type' => 'application/json'], json_encode([
Http::fake([
'brid.gy/*' => Http::response([
'error' => 'Could not find target link',
])),
], 400),
]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$note = Note::factory()->create();
$job = new SyndicateNoteToBluesky($note);
@ -86,7 +73,7 @@ class SyndicateNoteToBlueskyJobTest extends TestCase
$this->expectException(\RuntimeException::class);
try {
$job->handle($client);
$job->handle();
} finally {
$this->assertDatabaseHas('notes', [
'id' => $note->id,

View file

@ -5,12 +5,9 @@ namespace Tests\Unit\Jobs;
use App\Jobs\SyndicateNoteToMastodon;
use App\Models\Note;
use Faker\Factory;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -24,19 +21,17 @@ class SyndicateNoteToMastodonJobTest extends TestCase
$faker = Factory::create();
$randomNumber = $faker->randomNumber();
$mastodonUrl = 'https://mastodon.example/@jonny/'.$randomNumber;
$mock = new MockHandler([
new Response(201, ['Content-Type' => 'application/json'], json_encode([
Http::fake([
'brid.gy/*' => Http::response([
'url' => $mastodonUrl,
'id' => (string) $randomNumber,
'type' => ['h-entry'],
])),
], 201),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create();
$job = new SyndicateNoteToMastodon($note);
$job->handle($client);
$job->handle();
$this->assertDatabaseHas('notes', [
'mastodon_url' => $mastodonUrl,
@ -46,39 +41,31 @@ class SyndicateNoteToMastodonJobTest extends TestCase
#[Test]
public function we_post_the_correct_source_and_target(): void
{
$container = [];
$history = Middleware::history($container);
$mock = new MockHandler([
new Response(201, ['Content-Type' => 'application/json'], json_encode([
Http::fake([
'brid.gy/*' => Http::response([
'url' => 'https://mastodon.example/@jonny/1',
])),
], 201),
]);
$handler = HandlerStack::create($mock);
$handler->push($history);
$client = new Client(['handler' => $handler]);
$note = Note::factory()->create(['note' => 'This is a **test**']);
$job = new SyndicateNoteToMastodon($note);
$job->handle($client);
$job->handle();
$request = $container[0]['request'];
$body = [];
parse_str((string) $request->getBody(), $body);
$this->assertSame('https://brid.gy/publish/webmention', (string) $request->getUri());
$this->assertSame($note->uri, $body['source']);
$this->assertSame('https://brid.gy/publish/mastodon', $body['target']);
Http::assertSent(function (Request $request) use ($note) {
return $request->url() === 'https://brid.gy/publish/webmention'
&& $request['source'] === $note->uri
&& $request['target'] === 'https://brid.gy/publish/mastodon';
});
}
#[Test]
public function a_bridgy_failure_throws_and_does_not_set_mastodon_url(): void
{
$mock = new MockHandler([
new Response(400, ['Content-Type' => 'application/json'], json_encode([
Http::fake([
'brid.gy/*' => Http::response([
'error' => 'Could not find target link',
])),
], 400),
]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$note = Note::factory()->create();
$job = new SyndicateNoteToMastodon($note);
@ -86,7 +73,7 @@ class SyndicateNoteToMastodonJobTest extends TestCase
$this->expectException(\RuntimeException::class);
try {
$job->handle($client);
$job->handle();
} finally {
$this->assertDatabaseHas('notes', [
'id' => $note->id,

View file

@ -9,13 +9,10 @@ use App\Models\Media;
use App\Models\Note;
use App\Models\Place;
use App\Models\Tag;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -182,13 +179,9 @@ class NotesTest extends TestCase
{"place_id":"198791063","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"relation","osm_id":"5208404","lat":"51.50084125","lon":"-0.142990166340849","display_name":"Buckingham Palace, Ambassador's Court, St. James's, Victoria, Westminster, London, Greater London, England, SW1E 6LA, United Kingdom","address":{"attraction":"Buckingham Palace","road":"Ambassador's Court","neighbourhood":"St. James's","suburb":"Victoria","city":"London","state_district":"Greater London","state":"England","postcode":"SW1E 6LA","country":"UK","country_code":"gb"},"boundingbox":["51.4997342","51.5019473","-0.143984","-0.1413002"]}
JSON;
// phpcs:enable Generic.Files.LineLength.TooLong
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], $json),
Http::fake([
'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$note = new Note;
$address = $note->reverseGeoCode(51.50084, -0.14264);
@ -207,13 +200,9 @@ class NotesTest extends TestCase
{"place_id":"96518506","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"way","osm_id":"94107885","lat":"51.0225764535969","lon":"0.906664040464189","display_name":"Melon Lane, Newchurch, Shepway, Kent, South East, England, TN29 0AS, United Kingdom","address":{"road":"Melon Lane","suburb":"Newchurch","city":"Shepway","county":"Kent","state_district":"South East","state":"England","postcode":"TN29 0AS","country":"UK","country_code":"gb"},"boundingbox":["51.0140377","51.0371494","0.8873312","0.9109506"]}
JSON;
// phpcs:enable Generic.Files.LineLength.TooLong
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], $json),
Http::fake([
'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$note = new Note;
$address = $note->reverseGeoCode(51.02, 0.91);
@ -234,13 +223,9 @@ class NotesTest extends TestCase
{"place_id":"198561071","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"relation","osm_id":"1839026","lat":"53.46600455","lon":"-2.23300880782987","display_name":"University of Manchester - Main Campus, Brunswick Street, Curry Mile, Ardwick, Manchester, Greater Manchester, North West England, England, M13 9NR, United Kingdom","address":{"university":"University of Manchester - Main Campus","city":"Manchester","county":"Greater Manchester","state_district":"North West England","state":"England","postcode":"M13 9NR","country":"UK","country_code":"gb"},"boundingbox":["53.4598667","53.4716848","-2.2390346","-2.2262754"]}
JSON;
// phpcs:enable Generic.Files.LineLength.TooLong
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], $json),
Http::fake([
'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$note = new Note;
$address = $note->reverseGeoCode(53.466277988406, -2.2304474827445);
@ -261,13 +246,9 @@ class NotesTest extends TestCase
{"place_id":"98085404","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"way","osm_id":"103703318","lat":"51.0997470194065","lon":"0.609897771085209","display_name":"Biddenden, Ashford, Kent, South East, England, TN27 8ET, United Kingdom","address":{"county":"Kent","state_district":"South East","state":"England","postcode":"TN27 8ET","country":"UK","country_code":"gb"},"boundingbox":["51.0986632","51.104459","0.5954434","0.6167775"]}
JSON;
// phpcs:enable Generic.Files.LineLength.TooLong
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], $json),
Http::fake([
'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$note = new Note;
$address = $note->reverseGeoCode(51.1, 0.61);
@ -285,13 +266,9 @@ class NotesTest extends TestCase
{"place_id":"120553244","licence":"Data © OpenStreetMap contributors, ODbL 1.0. https:\/\/osm.org\/copyright","osm_type":"way","osm_id":"191508282","lat":"54.3004150140189","lon":"-9.39993720828084","display_name":"R314, Doonfeeny Lower, Ballycastle ED, Ballina, County Mayo, Connacht, Ireland","address":{"country":"Ireland","country_code":"ie"},"boundingbox":["54.2964027","54.3045856","-9.4337961","-9.3960403"]}
JSON;
// phpcs:enable Generic.Files.LineLength.TooLong
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], $json),
Http::fake([
'nominatim.openstreetmap.org/*' => Http::response($json, 200, ['Content-Type' => 'application/json']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$note = new Note;
$address = $note->reverseGeoCode(54.3, 9.4);