Unify crop storage folder and make IG/FB crop tests symmetric

- CropsImageForAspectRatio: drop the per-publisher path-prefix argument; crops
  from both platforms go to a single `social-crops/` directory (the folder has
  no functional meaning — the platform fetches by URL and nothing reads it back;
  UUID filenames prevent collisions).
- Close the IG/FB test asymmetry surfaced in review: Instagram single-image crop
  now runs across 1:1/4:5/16:9 (dataset) and has a crop-download-failure test;
  Facebook gains an explicit 'original' bypass test. Both platforms now cover the
  same matrix (crop per ratio + uploaded-URL is the crop, original/no-meta
  bypass, multi-image, download-failure exception).

Follow-up tracked in #83 (orphaned crops are never pruned from storage).
This commit is contained in:
Paulo Castellano 2026-06-05 18:30:17 -03:00
parent e25b320ad2
commit 1b096f9ede
5 changed files with 64 additions and 18 deletions

View file

@ -12,12 +12,14 @@
trait CropsImageForAspectRatio
{
private const CROP_DIRECTORY = 'social-crops';
/**
* Crop the image to the user-selected aspect ratio and return a public URL
* the platform can fetch. Returns the original URL untouched when no ratio
* is set or 'original' is selected.
*/
protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRatio, string $pathPrefix): string
protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRatio): string
{
if (! $aspectRatio || $aspectRatio === 'original') {
return $imageUrl;
@ -36,7 +38,7 @@ protected function cropImageForAspectRatio(string $imageUrl, ?string $aspectRati
$cropped = app(MediaOptimizer::class)->cropToAspectRatio($tempInput, $ratio);
$path = "{$pathPrefix}/".Str::uuid()->toString().'.jpg';
$path = self::CROP_DIRECTORY.'/'.Str::uuid()->toString().'.jpg';
Storage::put($path, file_get_contents($cropped));
@unlink($cropped);

View file

@ -117,7 +117,7 @@ private function publishTextPost(string $pageId, string $accessToken, string $co
private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array
{
$payload = [
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio, 'facebook-crops'),
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio),
'access_token' => $accessToken,
];
@ -155,7 +155,7 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
}
$uploadResponse = $this->facebookHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio, 'facebook-crops'),
'url' => $this->cropImageForAspectRatio($media->url, $aspectRatio),
'published' => 'false',
'access_token' => $accessToken,
]);

View file

@ -79,7 +79,7 @@ private function publishFeed(string $instagramId, string $accessToken, ?string $
private function publishSingleImage(string $instagramId, string $accessToken, ?string $content, $media, ?string $aspectRatio): array
{
$imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio, 'instagram-crops');
$imageUrl = $this->cropImageForAspectRatio($media->url, $aspectRatio);
// Step 1: Create container
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", [
@ -205,7 +205,7 @@ private function publishCarousel(string $instagramId, string $accessToken, ?stri
$params['video_url'] = $media->url;
$params['media_type'] = 'VIDEO';
} else {
$params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio, 'instagram-crops');
$params['image_url'] = $this->cropImageForAspectRatio($media->url, $aspectRatio);
}
$containerResponse = $this->socialHttp()->post("{$this->baseUrl}/{$instagramId}/media", $params);

View file

@ -665,7 +665,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string
$this->publisher->publish($this->postPlatform);
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/'));
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/'));
expect($crops)->toHaveCount(1);
$manager = new ImageManager(Driver::class);
@ -676,7 +676,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string
@unlink($tempFile);
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos')
&& str_contains($request['url'], 'facebook-crops/')
&& str_contains($request['url'], 'social-crops/')
&& ! str_contains($request['url'], 'example.com'));
})->with([
'1:1' => ['1:1', 1.0],
@ -706,7 +706,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string
$this->publisher->publish($this->postPlatform);
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/'));
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/'));
expect($crops)->toHaveCount(2);
$manager = new ImageManager(Driver::class);
@ -719,7 +719,7 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string
}
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos')
&& str_contains($request['url'] ?? '', 'facebook-crops/'));
&& str_contains($request['url'] ?? '', 'social-crops/'));
});
test('facebook image post throws when the source image cannot be downloaded for cropping', function () {
@ -755,7 +755,29 @@ function facebookJpegBytes(int $width = 1200, int $height = 800): string
$this->publisher->publish($this->postPlatform);
expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'facebook-crops/')))->toBeEmpty();
expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')))->toBeEmpty();
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos')
&& $request['url'] === 'https://example.com/media/a.jpg');
});
test('facebook single image post with original aspect ratio uploads the original image (no crop)', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => 'original']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo_1', 'post_id' => 'post_1'], 200),
]);
$this->publisher->publish($this->postPlatform);
expect(collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/')))->toBeEmpty();
Http::assertSent(fn ($request) => str_contains($request->url(), '/page_123/photos')
&& $request['url'] === 'https://example.com/media/a.jpg');

View file

@ -4,6 +4,7 @@
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
use App\Models\PostPlatform;
@ -726,10 +727,10 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
->toThrow(Exception::class);
});
test('feed image is cropped to chosen aspect ratio before publishing', function () {
test('feed image is cropped to chosen aspect ratio before publishing', function (string $aspectRatio, float $expected) {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->postPlatform->update(['meta' => ['aspect_ratio' => $aspectRatio]]);
$this->post->update([
'media' => [
@ -753,15 +754,14 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
$this->publisher->publish($this->postPlatform);
$cropped = collect(Storage::allFiles())->first(fn (string $path) => str_starts_with($path, 'instagram-crops/'));
$cropped = collect(Storage::allFiles())->first(fn (string $path) => str_starts_with($path, 'social-crops/'));
expect($cropped)->not->toBeNull();
$manager = new ImageManager(Driver::class);
$tempFile = tempnam(sys_get_temp_dir(), 'verify_');
file_put_contents($tempFile, Storage::get($cropped));
$image = $manager->decodePath($tempFile);
$ratio = $image->width() / $image->height();
expect(abs($ratio - 0.8))->toBeLessThan(0.01);
expect(abs($image->width() / $image->height() - $expected))->toBeLessThan(0.01);
@unlink($tempFile);
Http::assertSent(function ($request) {
@ -770,9 +770,31 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
}
$imageUrl = $request['image_url'] ?? '';
return str_contains($imageUrl, 'instagram-crops/')
return str_contains($imageUrl, 'social-crops/')
&& ! str_contains($imageUrl, 'example.com/media/test.jpg');
});
})->with([
'1:1' => ['1:1', 1.0],
'4:5' => ['4:5', 4 / 5],
'16:9' => ['16:9', 16 / 9],
]);
test('feed image throws when the source image cannot be downloaded for cropping', function () {
Storage::fake();
$this->postPlatform->update(['meta' => ['aspect_ratio' => '4:5']]);
$this->post->update([
'media' => [
['id' => 'm1', 'path' => 'media/a.jpg', 'url' => 'https://example.com/media/a.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'a.jpg'],
],
]);
Http::fake([
'https://example.com/media/a.jpg' => Http::response('', 404),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(InstagramPublishException::class, 'Failed to download image for cropping');
});
test('feed image with original aspect ratio bypasses crop', function () {
@ -875,7 +897,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string
$this->publisher->publish($this->postPlatform);
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'instagram-crops/'));
$crops = collect(Storage::allFiles())->filter(fn (string $path) => str_starts_with($path, 'social-crops/'));
expect($crops)->toHaveCount(2);
$manager = new ImageManager(Driver::class);