MTM Improve app by leveraging more Laravel code #111

Merged
jonny merged 4 commits from develop into main 2026-08-01 20:37:16 +02:00
26 changed files with 337 additions and 630 deletions
Showing only changes of commit e08763c526 - Show all commits

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
Jonny Barnes 2026-07-26 09:12:01 +01:00
Signed by: jonny
SSH key fingerprint: SHA256:CTuSlns5U7qlD9jqHvtnVmfYV3Zwl2Z7WnJ4/dqOaL8

View file

@ -6,11 +6,11 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Contact;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
class ContactsController extends Controller
@ -113,14 +113,13 @@ class ContactsController extends Controller
$avatar = null;
$contact = Contact::findOrFail($contactId);
if ($contact->homepage !== null && mb_strlen($contact->homepage) !== 0) {
$client = resolve(Client::class);
try {
$response = $client->get($contact->homepage);
} catch (BadResponseException $e) {
$response = Http::throw()->get($contact->homepage);
} catch (RequestException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Bad resposne from contact’s homepage');
}
$mf2 = \Mf2\parse((string) $response->getBody(), $contact->homepage);
$mf2 = \Mf2\parse($response->body(), $contact->homepage);
foreach ($mf2['items'] as $microformat) {
if (Arr::get($microformat, 'type.0') === 'h-card') {
$avatarURL = Arr::get($microformat, 'properties.photo.0.value');
@ -129,8 +128,8 @@ class ContactsController extends Controller
}
if ($avatarURL !== null) {
try {
$avatar = $client->get($avatarURL);
} catch (BadResponseException $e) {
$avatar = Http::throw()->get($avatarURL);
} catch (RequestException $e) {
return redirect('/admin/contacts/'.$contactId.'/edit')
->with('error', 'Unable to download avatar');
}
@ -141,7 +140,7 @@ class ContactsController extends Controller
if ($filesystem->isDirectory($directory) === false) {
$filesystem->makeDirectory($directory);
}
$filesystem->put($directory.'/image', $avatar->getBody());
$filesystem->put($directory.'/image', $avatar->body());
return view('admin.contacts.getavatarsuccess', [
'homepage' => parse_url($contact->homepage, PHP_URL_HOST),

View file

@ -5,13 +5,12 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\TokenService;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Uri;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Illuminate\View\View;
use Random\RandomException;
@ -199,15 +198,13 @@ class IndieAuthController extends Controller
}
// Otherwise we need to check the redirect_uri is in the client_id's redirect_uris
$guzzle = resolve(Client::class);
try {
$clientInfo = $guzzle->get($clientId);
} catch (Exception) {
$clientInfo = Http::throw()->get($clientId);
} catch (\Throwable) {
return false;
}
$clientInfoParsed = \Mf2\parse($clientInfo->getBody()->getContents(), $clientId);
$clientInfoParsed = \Mf2\parse($clientInfo->body(), $clientId);
$redirectUris = $clientInfoParsed['rels']['redirect_uri'] ?? [];

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Jobs;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\FileSystem\FileSystem;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class DownloadWebMention implements ShouldQueue
{
@ -29,15 +29,15 @@ class DownloadWebMention implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
* @throws RequestException
* @throws FileNotFoundException
*/
public function handle(Client $guzzle): void
public function handle(): void
{
$response = $guzzle->request('GET', $this->source);
// 4XX and 5XX responses should get Guzzle to throw an exception,
// Laravel should catch and retry these automatically.
if ($response->getStatusCode() === 200) {
// 4XX and 5XX responses should throw so Laravel can catch and
// retry these automatically.
$response = Http::throw()->get($this->source);
if ($response->status() === 200) {
$filesystem = new FileSystem;
$filename = storage_path('HTML').'/'.$this->createFilenameFromURL($this->source);
// backup file first
@ -56,7 +56,7 @@ class DownloadWebMention implements ShouldQueue
// save new HTML
$filesystem->put(
$filename,
(string) $response->getBody()
$response->body()
);
// remove backup if the same
if ($filesystem->exists($filenameBackup)) {

View file

@ -5,14 +5,13 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Like;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
@ -32,13 +31,11 @@ class ProcessLike implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(Client $client, Authorship $authorship): int
public function handle(Authorship $authorship): int
{
$response = $client->request('GET', $this->like->url);
$mf2 = \Mf2\parse((string) $response->getBody(), $this->like->url);
$response = Http::throw()->get($this->like->url);
$mf2 = \Mf2\parse($response->body(), $this->like->url);
if (Arr::has($mf2, 'items.0.properties.content')) {
$this->like->content = $mf2['items'][0]['properties']['content'][0]['html'];
}

View file

@ -7,13 +7,12 @@ namespace App\Jobs;
use App\Exceptions\RemoteContentNotFoundException;
use App\Models\Note;
use App\Models\WebMention;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Exceptions\InvalidMentionException;
use Jonnybarnes\WebmentionsParser\Parser;
use Mf2;
@ -36,18 +35,20 @@ class ProcessWebMention implements ShouldQueue
* Execute the job.
*
* @throws RemoteContentNotFoundException
* @throws GuzzleException
* @throws InvalidMentionException
*/
public function handle(Parser $parser, Client $guzzle): void
public function handle(Parser $parser): void
{
try {
$response = $guzzle->request('GET', $this->source);
} catch (RequestException $e) {
$response = Http::get($this->source);
} catch (ConnectionException) {
throw new RemoteContentNotFoundException;
}
$this->saveRemoteContent((string) $response->getBody(), $this->source);
$microformats = Mf2\parse((string) $response->getBody(), $this->source);
if ($response->failed()) {
throw new RemoteContentNotFoundException;
}
$this->saveRemoteContent($response->body(), $this->source);
$microformats = Mf2\parse($response->body(), $this->source);
$webmentions = WebMention::where('source', $this->source)->get();
foreach ($webmentions as $webmention) {
// check webmention still references target

View file

@ -4,13 +4,14 @@ declare(strict_types=1);
namespace App\Jobs;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\WebmentionsParser\Authorship;
use Jonnybarnes\WebmentionsParser\Exceptions\AuthorshipParserException;
@ -55,12 +56,10 @@ class SaveProfileImage implements ShouldQueue
&& parse_url($photo, PHP_URL_HOST) !== 'pbs.twimg.com'
&& parse_url($photo, PHP_URL_HOST) !== 'twitter.com'
) {
$client = resolve(Client::class);
try {
$response = $client->get($photo);
$image = $response->getBody();
} catch (RequestException) {
$response = Http::throw()->get($photo);
$image = $response->body();
} catch (ConnectionException|RequestException) {
// we are opening and reading the default image so that
$default = public_path().'/assets/profile-images/default-image';
$handle = fopen($default, 'rb');

View file

@ -5,14 +5,15 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Bookmark;
use GuzzleHttp\Client;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use JsonException;
class SaveScreenshot implements ShouldQueue
{
@ -27,77 +28,68 @@ class SaveScreenshot implements ShouldQueue
/**
* Execute the job.
*
*
* @throws JsonException
*/
public function handle(): void
{
// A normal Guzzle client
$client = resolve(Client::class);
// A Guzzle client with a custom Middleware to retry the CloudConvert API requests
$retryClient = resolve('RetryGuzzle');
$cloudConvert = Http::baseUrl('https://api.cloudconvert.com/v2')
->withToken(config('services.cloudconvert.token'))
->throw();
// First request that CloudConvert takes a screenshot of the URL
$takeScreenshotJobResponse = $client->request('POST', 'https://api.cloudconvert.com/v2/capture-website', [
'headers' => [
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
],
'json' => [
$takeScreenshotJobResponse = $cloudConvert->post('/capture-website', [
'url' => $this->bookmark->url,
'output_format' => 'png',
'screen_width' => 1440,
'screen_height' => 900,
'wait_until' => 'networkidle0',
'wait_time' => 100,
],
]);
$taskId = json_decode($takeScreenshotJobResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
$taskId = $takeScreenshotJobResponse->json('data.id');
// Now wait till the status job is finished
$screenshotJobStatusResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/'.$taskId, [
'headers' => [
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
],
'query' => [
'include' => 'payload',
],
]);
$screenshotJobStatusResponse = $this->pollUntilFinished($cloudConvert, $taskId);
$finishedCaptureId = json_decode($screenshotJobStatusResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
$finishedCaptureId = $screenshotJobStatusResponse->json('data.id');
// Now we can create a new job to request thst the screenshot is exported to a temporary URL we can download the screenshot from
$exportImageJob = $client->request('POST', 'https://api.cloudconvert.com/v2/export/url', [
'headers' => [
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
],
'json' => [
$exportImageJob = $cloudConvert->post('/export/url', [
'input' => $finishedCaptureId,
'archive_multiple_files' => false,
],
]);
$exportImageJobId = json_decode($exportImageJob->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->id;
$exportImageJobId = $exportImageJob->json('data.id');
// Again, wait till the status of this export job is finished
$finalImageUrlResponse = $retryClient->request('GET', 'https://api.cloudconvert.com/v2/tasks/'.$exportImageJobId, [
'headers' => [
'Authorization' => 'Bearer '.config('services.cloudconvert.token'),
],
'query' => [
'include' => 'payload',
],
]);
$finalImageUrlResponse = $this->pollUntilFinished($cloudConvert, $exportImageJobId);
// Now we can download the screenshot and save it to the storage
$finalImageUrl = json_decode($finalImageUrlResponse->getBody()->getContents(), false, 512, JSON_THROW_ON_ERROR)->data->result->files[0]->url;
$finalImageUrl = $finalImageUrlResponse->json('data.result.files.0.url');
$finalImageUrlContent = $client->request('GET', $finalImageUrl);
$finalImageUrlContent = Http::throw()->get($finalImageUrl);
Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->getBody()->getContents());
Storage::disk('public')->put('/assets/img/bookmarks/'.$taskId.'.png', $finalImageUrlContent->body());
$this->bookmark->screenshot = $taskId;
$this->bookmark->save();
}
/**
* Poll a CloudConvert task until it reports a "finished" status.
*/
private function pollUntilFinished(PendingRequest $client, string $taskId): Response
{
$attempts = 0;
do {
$response = $client->get('/tasks/'.$taskId, ['include' => 'payload']);
$finished = $response->json('data.status') === 'finished';
if (! $finished) {
$attempts++;
usleep(1_000_000); // 1 second, matches CloudConvert's own polling guidance
}
} while (! $finished && $attempts < 5);
return $response;
}
}

View file

@ -5,8 +5,6 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Header;
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\Psr7\Utils;
@ -14,6 +12,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Mf2\Parser;
@ -32,8 +31,6 @@ class SendWebMentions implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(): void
{
@ -43,12 +40,9 @@ class SendWebMentions implements ShouldQueue
foreach ($urls as $url) {
$endpoint = $this->discoverWebmentionEndpoint($url);
if ($endpoint !== null) {
$guzzle = resolve(Client::class);
$guzzle->post($endpoint, [
'form_params' => [
Http::asForm()->post($endpoint, [
'source' => $this->note->uri,
'target' => $url,
],
]);
}
}
@ -56,8 +50,6 @@ class SendWebMentions implements ShouldQueue
/**
* Discover if a URL has a webmention endpoint.
*
* @throws GuzzleException
*/
public function discoverWebmentionEndpoint(string $url): ?string
{
@ -71,10 +63,9 @@ class SendWebMentions implements ShouldQueue
$endpoint = null;
$guzzle = resolve(Client::class);
$response = $guzzle->get($url);
$response = Http::get($url);
// check HTTP Headers for webmention endpoint
$links = Header::parse($response->getHeader('Link'));
$links = Header::parse($response->header('Link'));
foreach ($links as $link) {
if (array_key_exists('rel', $link) && mb_stristr($link['rel'], 'webmention')) {
return $this->resolveUri(trim($link[0], '<>'), $url);
@ -82,7 +73,7 @@ class SendWebMentions implements ShouldQueue
}
// failed to find a header so parse HTML
$html = (string) $response->getBody();
$html = $response->body();
if ($html === '') {
return null;

View file

@ -5,13 +5,12 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SyndicateNoteToBluesky implements ShouldQueue
{
@ -31,29 +30,18 @@ class SyndicateNoteToBluesky implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(Client $guzzle): void
public function handle(): void
{
$response = $guzzle->request(
'POST',
'https://brid.gy/publish/webmention',
[
'headers' => [
'Accept' => 'application/json',
],
'form_params' => [
// no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
$response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
'source' => $this->note->uri,
'target' => 'https://brid.gy/publish/bluesky',
],
'http_errors' => false,
]
);
]);
$body = json_decode((string) $response->getBody(), true);
$body = $response->json();
if ($response->getStatusCode() === 201) {
if ($response->status() === 201) {
$this->note->bluesky_url = $body['url'];
$this->note->save();
@ -61,7 +49,7 @@ class SyndicateNoteToBluesky implements ShouldQueue
}
throw new \RuntimeException(
'Bridgy publish to Bluesky failed: '.($body['error'] ?? (string) $response->getBody())
'Bridgy publish to Bluesky failed: '.($body['error'] ?? $response->body())
);
}
}

View file

@ -5,13 +5,12 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Models\Note;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SyndicateNoteToMastodon implements ShouldQueue
{
@ -31,29 +30,18 @@ class SyndicateNoteToMastodon implements ShouldQueue
/**
* Execute the job.
*
* @throws GuzzleException
*/
public function handle(Client $guzzle): void
public function handle(): void
{
$response = $guzzle->request(
'POST',
'https://brid.gy/publish/webmention',
[
'headers' => [
'Accept' => 'application/json',
],
'form_params' => [
// no ->throw()/->retry() here — Bridgy would duplicate-publish on retry, see $tries above
$response = Http::acceptJson()->asForm()->post('https://brid.gy/publish/webmention', [
'source' => $this->note->uri,
'target' => 'https://brid.gy/publish/mastodon',
],
'http_errors' => false,
]
);
]);
$body = json_decode((string) $response->getBody(), true);
$body = $response->json();
if ($response->getStatusCode() === 201) {
if ($response->status() === 201) {
$this->note->mastodon_url = $body['url'];
$this->note->save();
@ -61,7 +49,7 @@ class SyndicateNoteToMastodon implements ShouldQueue
}
throw new \RuntimeException(
'Bridgy publish to Mastodon failed: '.($body['error'] ?? (string) $response->getBody())
'Bridgy publish to Mastodon failed: '.($body['error'] ?? $response->body())
);
}
}

View file

@ -7,7 +7,6 @@ namespace App\Models;
use App\CommonMark\Generators\MentionGenerator;
use App\CommonMark\Renderers\MentionRenderer;
use App\Observers\NoteObserver;
use GuzzleHttp\Client;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
@ -20,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Jonnybarnes\IndieWeb\Numbers;
use Laravel\Scout\Searchable;
use League\CommonMark\Environment\Environment;
@ -363,18 +363,15 @@ class Note extends Model
$latLng = $latitude.','.$longitude;
return Cache::get($latLng, function () use ($latLng, $latitude, $longitude) {
$guzzle = resolve(Client::class);
$response = $guzzle->request('GET', 'https://nominatim.openstreetmap.org/reverse', [
'query' => [
$response = Http::withHeaders(['User-Agent' => 'jonnybarnes.uk, email jonny@jonnybarnes.uk'])
->get('https://nominatim.openstreetmap.org/reverse', [
'format' => 'json',
'lat' => $latitude,
'lon' => $longitude,
'zoom' => 18,
'addressdetails' => 1,
],
'headers' => ['User-Agent' => 'jonnybarnes.uk via Guzzle, email jonny@jonnybarnes.uk'],
]);
$json = json_decode((string) $response->getBody());
$json = $response->object();
if (isset($json->address->suburb)) {
$locality = $json->address->suburb;
if (isset($json->address->city)) {

View file

@ -2,10 +2,6 @@
namespace App\Providers;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@ -82,38 +78,6 @@ class AppServiceProvider extends ServiceProvider
);
});
// Configure Guzzle
$this->app->bind('RetryGuzzle', function () {
$handlerStack = HandlerStack::create();
$handlerStack->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;
}
// Finally for CloudConvert, retry if status is not final
return json_decode($response, false, 512, JSON_THROW_ON_ERROR)->data->status !== 'finished';
},
function () {
// Retry after 1 second
return 1000;
}
));
return new Client(['handler' => $handlerStack]);
});
// Turn on Eloquent strict mode when developing
Model::shouldBeStrict(! $this->app->isProduction());
}

View file

@ -8,10 +8,8 @@ use App\Exceptions\InternetArchiveException;
use App\Jobs\ProcessBookmark;
use App\Models\Bookmark;
use App\Models\Tag;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class BookmarkService
@ -55,21 +53,19 @@ class BookmarkService
* Given a URL, attempt to save it to the Internet Archive.
*
* @throws InternetArchiveException
* @throws GuzzleException
*/
public function getArchiveLink(string $url): string
{
$client = resolve(Client::class);
try {
$response = $client->request('GET', 'https://web.archive.org/save/'.$url);
} catch (ClientException $e) {
$response = Http::get('https://web.archive.org/save/'.$url);
if ($response->clientError()) {
// throw an exception to be caught
throw new InternetArchiveException;
}
if ($response->hasHeader('Content-Location')) {
if (Str::startsWith(Arr::get($response->getHeader('Content-Location'), 0), '/web')) {
return $response->getHeader('Content-Location')[0];
}
$contentLocation = $response->header('Content-Location');
if ($contentLocation !== '' && Str::startsWith($contentLocation, '/web')) {
return $contentLocation;
}
// throw an exception to be caught

View file

@ -6,12 +6,9 @@ namespace Tests\Feature\Admin;
use App\Models\Contact;
use App\Models\User;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -141,14 +138,12 @@ class ContactsTest extends TestCase
<img class="u-photo" alt="" src="http://tantek.com/tantek.png">
</div>
HTML;
$file = fopen(__DIR__.'/../../aaron.png', 'rb');
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'text/html'], $html),
new Response(200, ['Content-Type' => 'image/png'], $file),
$file = file_get_contents(__DIR__.'/../../aaron.png');
Http::fake([
'*' => Http::sequence()
->push($html, 200, ['Content-Type' => 'text/html'])
->push($file, 200, ['Content-Type' => 'image/png']),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$user = User::factory()->make();
$contact = Contact::factory()->create([
'homepage' => 'https://tantek.com',
@ -165,12 +160,9 @@ class ContactsTest extends TestCase
#[Test]
public function getting_remote_avatar_fails_gracefully_with_remote_not_found(): 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);
$user = User::factory()->make();
$contact = Contact::factory()->create();
@ -187,13 +179,11 @@ class ContactsTest extends TestCase
<img class="u-photo" src="http://tantek.com/tantek.png">
</div>
HTML;
$mock = new MockHandler([
new Response(200, ['Content-Type' => 'text/html'], $html),
new Response(404),
Http::fake([
'*' => Http::sequence()
->push($html, 200, ['Content-Type' => 'text/html'])
->push('', 404),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->instance(Client::class, $client);
$user = User::factory()->make();
$contact = Contact::factory()->create();

View file

@ -5,14 +5,11 @@ declare(strict_types=1);
namespace Tests\Feature;
use App\Models\User;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@ -269,12 +266,9 @@ class IndieAuthTest extends TestCase
</html>
HTML;
$mockHandler = new MockHandler([
new Response(200, [], $appPageHtml),
Http::fake([
'*' => Http::response($appPageHtml, 200),
]);
$handlerStack = HandlerStack::create($mockHandler);
$mockGuzzleClient = new Client(['handler' => $handlerStack]);
$this->app->instance(Client::class, $mockGuzzleClient);
$user = User::factory()->make();
$url = url()->query('/auth', [
@ -313,12 +307,9 @@ class IndieAuthTest extends TestCase
</html>
HTML;
$mockHandler = new MockHandler([
new Response(200, [], $appPageHtml),
Http::fake([
'*' => Http::response($appPageHtml, 200),
]);
$handlerStack = HandlerStack::create($mockHandler);
$mockGuzzleClient = new Client(['handler' => $handlerStack]);
$this->app->instance(Client::class, $mockGuzzleClient);
$user = User::factory()->make();
$url = url()->query('/auth', [

View file

@ -6,11 +6,8 @@ namespace Tests\Feature;
use App\Jobs\ProcessLike;
use App\Models\Like;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Jonnybarnes\WebmentionsParser\Authorship;
use PHPUnit\Framework\Attributes\Test;
@ -98,18 +95,12 @@ class LikesTest extends TestCase
</html>
END;
$mock = new MockHandler([
new Response(200, [], $content),
new Response(200, [], $content),
Http::fake([
'*' => Http::response($content, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->bind(Client::class, function () use ($client) {
return $client;
});
$authorship = new Authorship;
$job->handle($client, $authorship);
$job->handle($authorship);
$this->assertEquals('Fred Bloggs', Like::find($id)->author_name);
}
@ -141,18 +132,12 @@ class LikesTest extends TestCase
</html>
END;
$mock = new MockHandler([
new Response(200, [], $content),
new Response(200, [], $content),
Http::fake([
'*' => Http::response($content, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->bind(Client::class, function () use ($client) {
return $client;
});
$authorship = new Authorship;
$job->handle($client, $authorship);
$job->handle($authorship);
$this->assertEquals('Fred Bloggs', Like::find($id)->author_name);
}
@ -177,18 +162,12 @@ class LikesTest extends TestCase
</html>
END;
$mock = new MockHandler([
new Response(200, [], $content),
new Response(200, [], $content),
Http::fake([
'*' => Http::response($content, 200),
]);
$handler = HandlerStack::create($mock);
$client = new Client(['handler' => $handler]);
$this->app->bind(Client::class, function () use ($client) {
return $client;
});
$authorship = new Authorship;
$job->handle($client, $authorship);
$job->handle($authorship);
$this->assertNull(Like::find($id)->author_name);
}

View file

@ -3,11 +3,19 @@
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Http;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
}
public function removeDirIfEmpty(string $dir): void
{
// scandir() will always return `.` and `..` so even an “empty”

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,

Some files were not shown because too many files have changed in this diff Show more