Release v1.4.0 #146
4 changed files with 429 additions and 21 deletions
|
|
@ -7,6 +7,7 @@
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
use App\Services\Auth\LemmyAuthService;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
||||||
class LemmyPublisher
|
class LemmyPublisher
|
||||||
|
|
@ -15,10 +16,13 @@ class LemmyPublisher
|
||||||
|
|
||||||
private PlatformAccount $account;
|
private PlatformAccount $account;
|
||||||
|
|
||||||
|
private ThumbnailUploader $thumbnailUploader;
|
||||||
|
|
||||||
public function __construct(PlatformAccount $account)
|
public function __construct(PlatformAccount $account)
|
||||||
{
|
{
|
||||||
$this->api = new LemmyApiService($account->instance_url);
|
$this->api = new LemmyApiService($account->instance_url);
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
|
$this->thumbnailUploader = new ThumbnailUploader($account->instance_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -54,13 +58,25 @@ private function createPost(string $token, array $extractedData, PlatformChannel
|
||||||
{
|
{
|
||||||
$languageId = $extractedData['language_id'] ?? null;
|
$languageId = $extractedData['language_id'] ?? null;
|
||||||
|
|
||||||
|
$thumbnail = $extractedData['thumbnail'] ?? null;
|
||||||
|
$thumbnail = is_string($thumbnail) && $thumbnail !== '' ? $thumbnail : null;
|
||||||
|
|
||||||
|
$hosted = $this->thumbnailUploader->upload($thumbnail, $token);
|
||||||
|
|
||||||
|
if ($thumbnail !== null && $hosted === null) {
|
||||||
|
resolve(LogSaver::class)->warning('Thumbnail upload failed; publishing without one', $channel, [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'source' => $thumbnail,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->api->createPost(
|
return $this->api->createPost(
|
||||||
$token,
|
$token,
|
||||||
$extractedData['title'] ?? 'Untitled',
|
$extractedData['title'] ?? 'Untitled',
|
||||||
$extractedData['description'] ?? '',
|
$extractedData['description'] ?? '',
|
||||||
$channel->channel_id,
|
$channel->channel_id,
|
||||||
$article->url,
|
$article->url,
|
||||||
$extractedData['thumbnail'] ?? null,
|
$hosted,
|
||||||
$languageId
|
$languageId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
app/Modules/Lemmy/Services/ThumbnailUploader.php
Normal file
125
app/Modules/Lemmy/Services/ThumbnailUploader.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Lemmy\Services;
|
||||||
|
|
||||||
|
use App\Modules\Lemmy\LemmyRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ThumbnailUploader
|
||||||
|
{
|
||||||
|
private const MAX_WIDTH = 600;
|
||||||
|
|
||||||
|
// A 4000x2256 JPEG decodes to ~27MB in GD; the worker runs with memory_limit=128M.
|
||||||
|
private const MAX_SOURCE_BYTES = 10_485_760;
|
||||||
|
|
||||||
|
private const MAX_SOURCE_PIXELS = 50_000_000;
|
||||||
|
|
||||||
|
private const JPEG_QUALITY = 82;
|
||||||
|
|
||||||
|
public function __construct(private string $instance) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an instance-hosted URL for a downscaled copy, or null if anything fails.
|
||||||
|
*/
|
||||||
|
public function upload(?string $sourceUrl, string $token): ?string
|
||||||
|
{
|
||||||
|
if ($sourceUrl === null || $sourceUrl === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$source = $this->download($sourceUrl);
|
||||||
|
|
||||||
|
if ($source === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resized = $this->resize($source);
|
||||||
|
|
||||||
|
if ($resized === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->store($resized, $token);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function download(string $url): ?string
|
||||||
|
{
|
||||||
|
$response = Http::timeout(30)->get($url);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $response->body();
|
||||||
|
|
||||||
|
return strlen($body) > self::MAX_SOURCE_BYTES ? null : $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resize(string $source): ?string
|
||||||
|
{
|
||||||
|
$info = @getimagesizefromstring($source);
|
||||||
|
|
||||||
|
if ($info === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$width, $height] = $info;
|
||||||
|
|
||||||
|
if ($width < 1 || $height < 1 || $width * $height > self::MAX_SOURCE_PIXELS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($width <= self::MAX_WIDTH) {
|
||||||
|
return $source;
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = @imagecreatefromstring($source);
|
||||||
|
|
||||||
|
if ($image === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetHeight = (int) max(1, round($height * (self::MAX_WIDTH / $width)));
|
||||||
|
$resized = imagescale($image, self::MAX_WIDTH, $targetHeight);
|
||||||
|
imagedestroy($image);
|
||||||
|
|
||||||
|
if ($resized === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
imagejpeg($resized, null, self::JPEG_QUALITY);
|
||||||
|
} finally {
|
||||||
|
$bytes = (string) ob_get_clean();
|
||||||
|
imagedestroy($resized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bytes === '' ? null : $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function store(string $bytes, string $token): ?string
|
||||||
|
{
|
||||||
|
$response = (new LemmyRequest($this->instance, $token))
|
||||||
|
->postMultipart('pictrs/image', 'images[]', $bytes, 'thumbnail.jpg');
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $response->json('files.0.file');
|
||||||
|
|
||||||
|
if (! is_string($file) || $file === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// $instance is a full scheme-qualified URL — platform_accounts.instance_url is validated as a URL.
|
||||||
|
return sprintf('%s/pictrs/image/%s', rtrim($this->instance, '/'), $file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,9 @@
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||||
|
use App\Modules\Lemmy\Services\ThumbnailUploader;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
use App\Services\Auth\LemmyAuthService;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
|
@ -25,6 +27,29 @@ protected function tearDown(): void
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps in a mocked API and an uploader that echoes its input back, so existing
|
||||||
|
* expectations can keep asserting on the thumbnail they passed in.
|
||||||
|
*/
|
||||||
|
private function injectMocks(LemmyPublisher $publisher, LemmyApiService $api, ?ThumbnailUploader $uploader = null): void
|
||||||
|
{
|
||||||
|
if (! $uploader instanceof ThumbnailUploader) {
|
||||||
|
$passthrough = Mockery::mock(ThumbnailUploader::class);
|
||||||
|
$passthrough->shouldReceive('upload')->andReturnUsing(fn (?string $url): ?string => $url);
|
||||||
|
$uploader = $passthrough;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($publisher);
|
||||||
|
|
||||||
|
$apiProperty = $reflection->getProperty('api');
|
||||||
|
$apiProperty->setAccessible(true);
|
||||||
|
$apiProperty->setValue($publisher, $api);
|
||||||
|
|
||||||
|
$uploaderProperty = $reflection->getProperty('thumbnailUploader');
|
||||||
|
$uploaderProperty->setAccessible(true);
|
||||||
|
$uploaderProperty->setValue($publisher, $uploader);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_constructor_initializes_api_service(): void
|
public function test_constructor_initializes_api_service(): void
|
||||||
{
|
{
|
||||||
$account = PlatformAccount::factory()->make([
|
$account = PlatformAccount::factory()->make([
|
||||||
|
|
@ -92,10 +117,7 @@ public function test_publish_to_channel_with_all_data(): void
|
||||||
// Create publisher and inject mocked API using reflection
|
// Create publisher and inject mocked API using reflection
|
||||||
$publisher = new LemmyPublisher($account);
|
$publisher = new LemmyPublisher($account);
|
||||||
|
|
||||||
$reflection = new \ReflectionClass($publisher);
|
$this->injectMocks($publisher, $apiMock);
|
||||||
$apiProperty = $reflection->getProperty('api');
|
|
||||||
$apiProperty->setAccessible(true);
|
|
||||||
$apiProperty->setValue($publisher, $apiMock);
|
|
||||||
|
|
||||||
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
||||||
|
|
||||||
|
|
@ -145,10 +167,7 @@ public function test_publish_to_channel_with_minimal_data(): void
|
||||||
// Create publisher and inject mocked API using reflection
|
// Create publisher and inject mocked API using reflection
|
||||||
$publisher = new LemmyPublisher($account);
|
$publisher = new LemmyPublisher($account);
|
||||||
|
|
||||||
$reflection = new \ReflectionClass($publisher);
|
$this->injectMocks($publisher, $apiMock);
|
||||||
$apiProperty = $reflection->getProperty('api');
|
|
||||||
$apiProperty->setAccessible(true);
|
|
||||||
$apiProperty->setValue($publisher, $apiMock);
|
|
||||||
|
|
||||||
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
||||||
|
|
||||||
|
|
@ -201,10 +220,7 @@ public function test_publish_to_channel_without_thumbnail(): void
|
||||||
// Create publisher and inject mocked API using reflection
|
// Create publisher and inject mocked API using reflection
|
||||||
$publisher = new LemmyPublisher($account);
|
$publisher = new LemmyPublisher($account);
|
||||||
|
|
||||||
$reflection = new \ReflectionClass($publisher);
|
$this->injectMocks($publisher, $apiMock);
|
||||||
$apiProperty = $reflection->getProperty('api');
|
|
||||||
$apiProperty->setAccessible(true);
|
|
||||||
$apiProperty->setValue($publisher, $apiMock);
|
|
||||||
|
|
||||||
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
||||||
|
|
||||||
|
|
@ -273,10 +289,7 @@ public function test_publish_to_channel_throws_api_exception(): void
|
||||||
// Create publisher and inject mocked API using reflection
|
// Create publisher and inject mocked API using reflection
|
||||||
$publisher = new LemmyPublisher($account);
|
$publisher = new LemmyPublisher($account);
|
||||||
|
|
||||||
$reflection = new \ReflectionClass($publisher);
|
$this->injectMocks($publisher, $apiMock);
|
||||||
$apiProperty = $reflection->getProperty('api');
|
|
||||||
$apiProperty->setAccessible(true);
|
|
||||||
$apiProperty->setValue($publisher, $apiMock);
|
|
||||||
|
|
||||||
$this->expectException(Exception::class);
|
$this->expectException(Exception::class);
|
||||||
$this->expectExceptionMessage('API Error');
|
$this->expectExceptionMessage('API Error');
|
||||||
|
|
@ -327,13 +340,99 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create
|
||||||
// Create publisher and inject mocked API using reflection
|
// Create publisher and inject mocked API using reflection
|
||||||
$publisher = new LemmyPublisher($account);
|
$publisher = new LemmyPublisher($account);
|
||||||
|
|
||||||
$reflection = new \ReflectionClass($publisher);
|
$this->injectMocks($publisher, $apiMock);
|
||||||
$apiProperty = $reflection->getProperty('api');
|
|
||||||
$apiProperty->setAccessible(true);
|
|
||||||
$apiProperty->setValue($publisher, $apiMock);
|
|
||||||
|
|
||||||
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
||||||
|
|
||||||
$this->assertEquals(['success' => true], $result);
|
$this->assertEquals(['success' => true], $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_passes_the_uploaded_thumbnail_url_to_create_post(): void
|
||||||
|
{
|
||||||
|
$account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']);
|
||||||
|
$article = Article::factory()->make(['url' => 'https://example.com/article']);
|
||||||
|
$channel = PlatformChannel::factory()->make(['channel_id' => 7]);
|
||||||
|
|
||||||
|
$authMock = Mockery::mock(LemmyAuthService::class);
|
||||||
|
$authMock->shouldReceive('getToken')->andReturn('tok');
|
||||||
|
$this->app->instance(LemmyAuthService::class, $authMock);
|
||||||
|
|
||||||
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
|
$apiMock->shouldReceive('createPost')
|
||||||
|
->once()
|
||||||
|
->with('tok', 'T', '', 7, 'https://example.com/article', 'https://lemmy.world/pictrs/image/x.jpg', null)
|
||||||
|
->andReturn(['ok' => true]);
|
||||||
|
|
||||||
|
$uploader = Mockery::mock(ThumbnailUploader::class);
|
||||||
|
$uploader->shouldReceive('upload')
|
||||||
|
->once()
|
||||||
|
->with('https://cdn.example.com/big.jpg', 'tok')
|
||||||
|
->andReturn('https://lemmy.world/pictrs/image/x.jpg');
|
||||||
|
|
||||||
|
$publisher = new LemmyPublisher($account);
|
||||||
|
$this->injectMocks($publisher, $apiMock, $uploader);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
['ok' => true],
|
||||||
|
$publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/big.jpg'], $channel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_logs_a_warning_when_the_thumbnail_upload_fails(): void
|
||||||
|
{
|
||||||
|
$account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']);
|
||||||
|
$article = Article::factory()->create();
|
||||||
|
$channel = PlatformChannel::factory()->create();
|
||||||
|
|
||||||
|
$authMock = Mockery::mock(LemmyAuthService::class);
|
||||||
|
$authMock->shouldReceive('getToken')->andReturn('tok');
|
||||||
|
$this->app->instance(LemmyAuthService::class, $authMock);
|
||||||
|
|
||||||
|
$logMock = Mockery::mock(LogSaver::class);
|
||||||
|
$logMock->shouldReceive('warning')
|
||||||
|
->once()
|
||||||
|
->withArgs(fn (string $message, ?PlatformChannel $c, array $context): bool => str_contains($message, 'Thumbnail upload failed')
|
||||||
|
&& $context['source'] === 'https://cdn.example.com/big.jpg');
|
||||||
|
$this->app->instance(LogSaver::class, $logMock);
|
||||||
|
|
||||||
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
|
$apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]);
|
||||||
|
|
||||||
|
$uploader = Mockery::mock(ThumbnailUploader::class);
|
||||||
|
$uploader->shouldReceive('upload')->once()->andReturn(null);
|
||||||
|
|
||||||
|
$publisher = new LemmyPublisher($account);
|
||||||
|
$this->injectMocks($publisher, $apiMock, $uploader);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
['ok' => true],
|
||||||
|
$publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/big.jpg'], $channel)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_does_not_log_when_there_was_no_thumbnail_to_upload(): void
|
||||||
|
{
|
||||||
|
$account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']);
|
||||||
|
$article = Article::factory()->make(['url' => 'https://example.com/article']);
|
||||||
|
$channel = PlatformChannel::factory()->make(['channel_id' => 7]);
|
||||||
|
|
||||||
|
$authMock = Mockery::mock(LemmyAuthService::class);
|
||||||
|
$authMock->shouldReceive('getToken')->andReturn('tok');
|
||||||
|
$this->app->instance(LemmyAuthService::class, $authMock);
|
||||||
|
|
||||||
|
$logMock = Mockery::mock(LogSaver::class);
|
||||||
|
$logMock->shouldNotReceive('warning');
|
||||||
|
$this->app->instance(LogSaver::class, $logMock);
|
||||||
|
|
||||||
|
$apiMock = Mockery::mock(LemmyApiService::class);
|
||||||
|
$apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]);
|
||||||
|
|
||||||
|
$uploader = Mockery::mock(ThumbnailUploader::class);
|
||||||
|
$uploader->shouldReceive('upload')->once()->with(null, 'tok')->andReturn(null);
|
||||||
|
|
||||||
|
$publisher = new LemmyPublisher($account);
|
||||||
|
$this->injectMocks($publisher, $apiMock, $uploader);
|
||||||
|
|
||||||
|
$this->assertSame(['ok' => true], $publisher->publishToChannel($article, ['title' => 'T'], $channel));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
168
tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php
Normal file
168
tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Modules\Lemmy\Services;
|
||||||
|
|
||||||
|
use App\Modules\Lemmy\Services\ThumbnailUploader;
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ThumbnailUploaderTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int<1, max> $width
|
||||||
|
* @param int<1, max> $height
|
||||||
|
*/
|
||||||
|
private function jpeg(int $width, int $height): string
|
||||||
|
{
|
||||||
|
$image = imagecreatetruecolor($width, $height);
|
||||||
|
$colour = imagecolorallocate($image, 120, 90, 60);
|
||||||
|
imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, $colour === false ? 0 : $colour);
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
imagejpeg($image, null, 90);
|
||||||
|
$bytes = (string) ob_get_clean();
|
||||||
|
imagedestroy($image);
|
||||||
|
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uploader(): ThumbnailUploader
|
||||||
|
{
|
||||||
|
return new ThumbnailUploader('https://lemmy.world');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fake(string $sourceBody, mixed $uploadResponse, int $uploadStatus = 200): void
|
||||||
|
{
|
||||||
|
Http::fake([
|
||||||
|
'https://cdn.example.com/*' => Http::response($sourceBody, 200, ['Content-Type' => 'image/jpeg']),
|
||||||
|
'https://lemmy.world/pictrs/image' => Http::response($uploadResponse, $uploadStatus),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_an_instance_hosted_url(): void
|
||||||
|
{
|
||||||
|
$this->fake($this->jpeg(1200, 800), ['files' => [['file' => 'abc123.jpg', 'delete_token' => 'tok']]]);
|
||||||
|
|
||||||
|
$url = $this->uploader()->upload('https://cdn.example.com/big.jpg', 'token');
|
||||||
|
|
||||||
|
$this->assertSame('https://lemmy.world/pictrs/image/abc123.jpg', $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_downscales_a_wide_image_before_upload(): void
|
||||||
|
{
|
||||||
|
$source = $this->jpeg(2000, 1000);
|
||||||
|
$this->fake($source, ['files' => [['file' => 'abc123.jpg']]]);
|
||||||
|
|
||||||
|
$this->uploader()->upload('https://cdn.example.com/big.jpg', 'token');
|
||||||
|
|
||||||
|
Http::assertSent(function (Request $request) use ($source): bool {
|
||||||
|
if ($request->url() !== 'https://lemmy.world/pictrs/image') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploaded = $this->multipartFileContents($request);
|
||||||
|
$size = getimagesizefromstring($uploaded);
|
||||||
|
|
||||||
|
return $size !== false
|
||||||
|
&& $size[0] === 600
|
||||||
|
&& $size[1] === 300
|
||||||
|
&& $uploaded !== $source
|
||||||
|
&& strlen($uploaded) < strlen($source);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_uploads_a_small_image_untouched(): void
|
||||||
|
{
|
||||||
|
$source = $this->jpeg(320, 240);
|
||||||
|
$this->fake($source, ['files' => [['file' => 'abc123.jpg']]]);
|
||||||
|
|
||||||
|
$this->uploader()->upload('https://cdn.example.com/small.jpg', 'token');
|
||||||
|
|
||||||
|
Http::assertSent(function (Request $request) use ($source): bool {
|
||||||
|
return $request->url() !== 'https://lemmy.world/pictrs/image'
|
||||||
|
|| $this->multipartFileContents($request) === $source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_sends_the_token_as_a_bearer_header(): void
|
||||||
|
{
|
||||||
|
$this->fake($this->jpeg(800, 600), ['files' => [['file' => 'abc123.jpg']]]);
|
||||||
|
|
||||||
|
$this->uploader()->upload('https://cdn.example.com/big.jpg', 'secret-token');
|
||||||
|
|
||||||
|
Http::assertSent(fn (Request $request): bool => $request->url() !== 'https://lemmy.world/pictrs/image'
|
||||||
|
|| $request->header('Authorization')[0] === 'Bearer secret-token');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_for_a_null_source(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$this->assertNull($this->uploader()->upload(null, 'token'));
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_for_an_empty_source(): void
|
||||||
|
{
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$this->assertNull($this->uploader()->upload('', 'token'));
|
||||||
|
|
||||||
|
Http::assertNothingSent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_when_the_download_fails(): void
|
||||||
|
{
|
||||||
|
Http::fake(['https://cdn.example.com/*' => Http::response('', 404)]);
|
||||||
|
|
||||||
|
$this->assertNull($this->uploader()->upload('https://cdn.example.com/gone.jpg', 'token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_when_the_source_is_not_an_image(): void
|
||||||
|
{
|
||||||
|
$this->fake('<html>not an image</html>', ['files' => [['file' => 'abc123.jpg']]]);
|
||||||
|
|
||||||
|
$this->assertNull($this->uploader()->upload('https://cdn.example.com/page.html', 'token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_when_the_upload_fails(): void
|
||||||
|
{
|
||||||
|
$this->fake($this->jpeg(1200, 800), ['error' => 'nope'], 500);
|
||||||
|
|
||||||
|
$this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_when_the_upload_response_has_no_file(): void
|
||||||
|
{
|
||||||
|
$this->fake($this->jpeg(1200, 800), ['files' => []]);
|
||||||
|
|
||||||
|
$this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_when_the_source_exceeds_the_size_cap(): void
|
||||||
|
{
|
||||||
|
$this->fake(str_repeat('x', 10_485_761), ['files' => [['file' => 'abc123.jpg']]]);
|
||||||
|
|
||||||
|
$this->assertNull($this->uploader()->upload('https://cdn.example.com/huge.jpg', 'token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_null_when_the_download_throws(): void
|
||||||
|
{
|
||||||
|
Http::fake(fn () => throw new \RuntimeException('connection refused'));
|
||||||
|
|
||||||
|
$this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function multipartFileContents(Request $request): string
|
||||||
|
{
|
||||||
|
foreach ($request->data() as $part) {
|
||||||
|
if (($part['name'] ?? null) === 'images[]') {
|
||||||
|
return (string) $part['contents'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue