2026-08-13 20:58:49 +02:00
|
|
|
<?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'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 21:06:02 +02:00
|
|
|
public function test_it_returns_null_when_the_source_exceeds_the_pixel_cap(): void
|
|
|
|
|
{
|
|
|
|
|
// A small file can still decode to a huge bitmap; this is the guard that protects the worker.
|
|
|
|
|
$this->fake($this->jpeg(9000, 6000), ['files' => [['file' => 'abc123.jpg']]]);
|
|
|
|
|
|
|
|
|
|
$this->assertNull($this->uploader()->upload('https://cdn.example.com/huge-dimensions.jpg', 'token'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 20:58:49 +02:00
|
|
|
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 '';
|
|
|
|
|
}
|
|
|
|
|
}
|