diff --git a/app/DataTransferObjects/MediaItem.php b/app/DataTransferObjects/MediaItem.php index eae009487..2e0abedee 100644 --- a/app/DataTransferObjects/MediaItem.php +++ b/app/DataTransferObjects/MediaItem.php @@ -10,6 +10,7 @@ class MediaItem { /** + * @param array|null $meta * @param array|null $source_meta */ public function __construct( @@ -20,6 +21,7 @@ public function __construct( public readonly ?string $original_filename = null, public readonly ?Source $source = null, public readonly ?array $source_meta = null, + public readonly ?array $meta = null, ) {} public function isVideo(): bool @@ -37,6 +39,26 @@ public function isDocument(): bool return Type::classify($this->mime_type, $this->path) === Type::Document; } + /** + * Stored pixel width from upload-time metadata, when known. + */ + public function width(): ?int + { + $width = data_get($this->meta, 'width'); + + return is_numeric($width) ? (int) $width : null; + } + + /** + * Stored pixel height from upload-time metadata, when known. + */ + public function height(): ?int + { + $height = data_get($this->meta, 'height'); + + return is_numeric($height) ? (int) $height : null; + } + /** * @param array $data */ @@ -63,6 +85,7 @@ public static function fromArray(array $data): self $source = is_string($sourceValue) ? Source::tryFrom($sourceValue) : null; $sourceMeta = data_get($data, 'source_meta'); + $meta = data_get($data, 'meta'); return new self( id: data_get($data, 'id', ''), @@ -72,6 +95,7 @@ public static function fromArray(array $data): self original_filename: data_get($data, 'original_filename'), source: $source, source_meta: is_array($sourceMeta) ? $sourceMeta : null, + meta: is_array($meta) ? $meta : null, ); } } diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 65de20e3f..d2a7b7a73 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -95,6 +95,18 @@ public function optimizeImage(string $filePath, Platform $platform): string return $tempFile; } + /** + * The maximum image width (px) enforced for a platform. Pull-from-URL + * publishers (e.g. TikTok) use this to decide whether a source image needs + * a resized, spec-compliant derivative before the platform fetches it. + */ + public function maxWidthForPlatform(Platform $platform): ?int + { + $maxWidth = data_get($this->getImageConfig($platform), 'max_width'); + + return is_int($maxWidth) ? $maxWidth : null; + } + /** * Center-crop an image to the given aspect ratio (width / height). * Returns path to a temp file (caller must clean up). diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 55d08c845..13bf69917 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -4,20 +4,28 @@ namespace App\Services\Social; +use App\DataTransferObjects\MediaItem; use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\TikTokPublishException; use App\Models\PostPlatform; use App\Models\SocialAccount; +use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Throwable; class TikTokPublisher { use HasSocialHttpClient; + private const PHOTO_DERIVATIVE_DIRECTORY = 'social-tiktok-photos'; + private string $baseUrl; private string $accessToken; @@ -198,66 +206,184 @@ private function publishVideo(PostPlatform $postPlatform, $media, ?string $conte private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?string $content): array { - $photoUrls = $mediaCollection - ->filter(fn ($m) => $m->isImage()) - ->map(fn ($m) => $m->url) - ->values() - ->toArray(); + $images = $mediaCollection->filter(fn ($m) => $m->isImage())->values(); - if (empty($photoUrls)) { + if ($images->isEmpty()) { throw new TikTokPublishException( userMessage: 'No valid images found for TikTok photo post', category: ErrorCategory::MediaFormat, ); } - $postInfo = $this->buildPhotoPostInfo($postPlatform, $content); + $derivatives = []; - // Auto add music is only for photos. - $meta = $postPlatform->meta ?? []; - if (data_get($meta, 'auto_add_music', false)) { - $postInfo['auto_add_music'] = true; + try { + $photoUrls = []; + + foreach ($images as $image) { + [$url, $derivativePath] = $this->resolvePhotoUrl($image); + $photoUrls[] = $url; + + if ($derivativePath !== null) { + $derivatives[] = $derivativePath; + } + } + + $postInfo = $this->buildPhotoPostInfo($postPlatform, $content); + + // Auto add music is only for photos. + $meta = $postPlatform->meta ?? []; + if (data_get($meta, 'auto_add_music', false)) { + $postInfo['auto_add_music'] = true; + } + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/post/publish/content/init/", [ + 'post_info' => $postInfo, + 'source_info' => [ + 'source' => 'PULL_FROM_URL', + 'photo_cover_index' => 0, + 'photo_images' => $photoUrls, + ], + 'post_mode' => 'DIRECT_POST', + 'media_type' => 'PHOTO', + ]); + + if ($response->failed()) { + Log::error('TikTok photo publish failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + $data = $response->json(); + + $publishId = data_get($data, 'data.publish_id'); + + if (! $publishId) { + throw new TikTokPublishException( + userMessage: 'TikTok did not return a publish_id', + category: ErrorCategory::ServerError, + ); + } + + // Wait for processing and get final status + $statusData = $this->waitForPublishStatus($publishId); + $postId = data_get($statusData, 'publicaly_available_post_id.0'); + + return [ + 'id' => $postId ?? $publishId, + 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), + ]; + } finally { + $this->pruneDerivatives($derivatives); } + } - $response = $this->getHttpClient() - ->post("{$this->baseUrl}/post/publish/content/init/", [ - 'post_info' => $postInfo, - 'source_info' => [ - 'source' => 'PULL_FROM_URL', - 'photo_cover_index' => 0, - 'photo_images' => $photoUrls, - ], - 'post_mode' => 'DIRECT_POST', - 'media_type' => 'PHOTO', - ]); + /** + * Resolve the URL TikTok will PULL_FROM_URL for a single photo. TikTok rejects + * images wider than 1080px with picture_size_check_failed, and because the + * platform fetches the bytes from us we cannot optimize them in-flight like + * the upload-based publishers do. So an oversized image is rendered to a + * spec-compliant JPEG derivative hosted on our public disk and that URL is + * handed to TikTok instead. Images already within spec pass through untouched. + * + * @return array{0: string, 1: string|null} the URL to publish, and the + * storage path of any derivative + * created (null when passed through) + */ + private function resolvePhotoUrl(MediaItem $image): array + { + $maxWidth = app(MediaOptimizer::class)->maxWidthForPlatform(Platform::TikTok); + $width = $image->width(); - if ($response->failed()) { - Log::error('TikTok photo publish failed', [ - 'status' => $response->status(), - 'body' => $this->redactResponseBody($response->body()), - ]); - $this->handleApiError($response); + if ($maxWidth !== null && $width !== null && $width <= $maxWidth) { + return [$image->url, null]; } - $data = $response->json(); + return $this->renderCompliantPhoto($image); + } - $publishId = data_get($data, 'data.publish_id'); + /** + * Download the image, resize it to TikTok's spec, and host the copy on the + * public disk so TikTok can pull it. + * + * @return array{0: string, 1: string} the derivative's public URL and its + * storage path (for later cleanup) + */ + private function renderCompliantPhoto(MediaItem $image): array + { + $tempInput = tempnam(sys_get_temp_dir(), 'tiktok_photo_'); + + try { + $download = Http::sink($tempInput)->timeout(120)->get($image->url); + + if ($download->failed()) { + throw new TikTokPublishException( + userMessage: 'Failed to download image for TikTok resizing', + category: ErrorCategory::ServerError, + ); + } + + return $this->hostResizedPhoto($tempInput); + } finally { + @unlink($tempInput); + } + } + + /** + * Resize the downloaded file to TikTok's spec and host the copy on the public + * disk. Decoder/storage failures are surfaced as a categorized publish + * exception instead of leaking as an uncategorized error. + * + * @return array{0: string, 1: string} the derivative's public URL and storage path + */ + private function hostResizedPhoto(string $tempInput): array + { + try { + $optimized = app(MediaOptimizer::class)->optimizeImage($tempInput, Platform::TikTok); + + try { + $path = self::PHOTO_DERIVATIVE_DIRECTORY.'/'.Str::uuid()->toString().'.jpg'; + Storage::put($path, file_get_contents($optimized)); + } finally { + @unlink($optimized); + } + + return [Storage::url($path), $path]; + } catch (Throwable $e) { + Log::error('TikTok photo resize/host failed', [ + 'exception' => $e->getMessage(), + ]); - if (! $publishId) { throw new TikTokPublishException( - userMessage: 'TikTok did not return a publish_id', + userMessage: 'Failed to prepare image for TikTok.', category: ErrorCategory::ServerError, ); } + } - // Wait for processing and get final status - $statusData = $this->waitForPublishStatus($publishId); - $postId = data_get($statusData, 'publicaly_available_post_id.0'); + /** + * Remove hosted photo derivatives, swallowing storage errors so cleanup can + * never mask the publish result. + * + * @param list $paths + */ + private function pruneDerivatives(array $paths): void + { + if ($paths === []) { + return; + } - return [ - 'id' => $postId ?? $publishId, - 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), - ]; + try { + Storage::delete($paths); + } catch (Throwable $e) { + Log::warning('Failed to prune TikTok photo derivatives', [ + 'paths' => $paths, + 'exception' => $e->getMessage(), + ]); + } } private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): array diff --git a/tests/Feature/Services/Social/TikTokCreatorInfoTest.php b/tests/Feature/Services/Social/TikTokCreatorInfoTest.php index 15ad23a9a..f12bed08c 100644 --- a/tests/Feature/Services/Social/TikTokCreatorInfoTest.php +++ b/tests/Feature/Services/Social/TikTokCreatorInfoTest.php @@ -17,11 +17,13 @@ ]); $this->service = new TikTokCreatorInfo; + + $this->api = config('trypost.platforms.tiktok.api'); }); test('it returns full creator payload from api response', function () { Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ 'creator_nickname' => 'Paulo', 'creator_username' => 'paulocastellano', @@ -49,7 +51,7 @@ test('it returns an empty payload when the api fails', function () { Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response(['error' => 'unauthorized'], 401), + $this->api.'/post/publish/creator_info/query/' => Http::response(['error' => 'unauthorized'], 401), ]); $info = $this->service->fetch($this->account); @@ -66,12 +68,12 @@ $this->account->update(['token_expires_at' => now()->subMinute()]); Http::fake([ - 'https://open.tiktokapis.com/v2/oauth/token/' => Http::response([ + $this->api.'/oauth/token/' => Http::response([ 'access_token' => 'new-token', 'refresh_token' => 'new-refresh', 'expires_in' => 3600, ], 200), - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ 'privacy_level_options' => ['PUBLIC_TO_EVERYONE'], ], diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 96016fa04..d05ca5ed3 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -11,8 +11,10 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\MediaOptimizer; use App\Services\Social\TikTokPublisher; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; beforeEach(function () { $this->user = User::factory()->create(); @@ -39,6 +41,8 @@ ]); $this->publisher = new TikTokPublisher; + + $this->api = config('trypost.platforms.tiktok.api'); }); test('tiktok publisher throws exception when no media', function () { @@ -60,10 +64,10 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ 'status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_123', @@ -92,15 +96,16 @@ 'url' => 'https://example.com/media/2026-01/image1.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'image1.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], ], ], ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([ + $this->api.'/post/publish/content/init/' => Http::response([ 'data' => ['publish_id' => 'pub_photo_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ 'status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_photo_123', @@ -138,7 +143,7 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'error' => [ 'code' => 'invalid_request', 'message' => 'Invalid request', @@ -164,7 +169,7 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'error' => [ 'code' => 'access_token_invalid', 'message' => 'Access token is invalid', @@ -192,15 +197,15 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/oauth/token/' => Http::response([ + $this->api.'/oauth/token/' => Http::response([ 'access_token' => 'new-access-token', 'refresh_token' => 'new-refresh-token', 'expires_in' => 86400, ], 200), - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), ]); @@ -253,7 +258,7 @@ ]); Http::fake([ - '*/oauth/token/' => Http::response([ + $this->api.'/oauth/token/' => Http::response([ 'error' => ['code' => 'invalid_grant', 'message' => 'Refresh token expired'], ], 400), ]); @@ -293,10 +298,10 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), ]); @@ -322,10 +327,10 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), ]); @@ -353,13 +358,13 @@ Http::fake([ // creator_info/query returns 500 — should not affect publishing since user picked privacy_level. - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'error' => ['code' => 'internal_error', 'message' => 'Internal server error'], ], 500), - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_fallback_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ 'status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_fallback_123', @@ -397,10 +402,10 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ 'status' => 'FAILED', 'fail_reason' => 'video_rejected', @@ -438,15 +443,15 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ 'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'SELF_ONLY'], ], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_meta_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), ]); @@ -487,18 +492,19 @@ 'url' => 'https://example.com/media/2026-01/photo.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'photo.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], ], ], ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => ['privacy_level_options' => ['SELF_ONLY']], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([ + $this->api.'/post/publish/content/init/' => Http::response([ 'data' => ['publish_id' => 'pub_music_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), ]); @@ -542,13 +548,13 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => ['privacy_level_options' => ['SELF_ONLY']], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_vid_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), ]); @@ -583,15 +589,15 @@ ]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ 'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'], ], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_default_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), ]); @@ -632,15 +638,15 @@ $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); Http::fake([ - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ 'privacy_level_options' => ['SELF_ONLY'], ], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([ + $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_video_123'], ], 200), - 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_video_123'], ], 200), ]); @@ -675,7 +681,7 @@ Http::fake([ // creator_info returns a healthy response — fallback would have silently picked PUBLIC_TO_EVERYONE. - 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ 'creator_nickname' => 'test', 'creator_username' => 'test', @@ -691,3 +697,345 @@ expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(TikTokPublishException::class); }); + +test('tiktok publisher resizes an oversized photo and pulls a hosted compliant copy', function () { + Storage::fake(); + + // TikTok rejects images wider than 1080px; this one is 1254px wide. + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_resize_123'], + ], 200), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE'], + ], 200), + '*' => Http::response('fake-image-content', 200), + ]); + + $this->publisher->publish($this->postPlatform); + + // TikTok must be handed the hosted derivative, never the oversized original. + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + $photoUrl = data_get(json_decode($request->body(), true), 'source_info.photo_images.0'); + + return str_contains($photoUrl, 'social-tiktok-photos/') + && ! str_contains($photoUrl, 'example.com'); + }); + + // The derivative is pruned once TikTok has pulled it. + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher passes a compliant photo through without hosting a copy', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-compliant', + 'path' => 'media/2026-01/ok.jpg', + 'url' => 'https://example.com/media/2026-01/ok.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'ok.jpg', + 'meta' => ['width' => 1080, 'height' => 1920], + ], + ], + ]); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_passthrough_123'], + ], 200), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE'], + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + // The original URL is published unchanged and nothing is downloaded or hosted. + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + + return data_get(json_decode($request->body(), true), 'source_info.photo_images.0') + === 'https://example.com/media/2026-01/ok.jpg'; + }); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'example.com')); + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher resizes a photo when its dimensions are unknown', function () { + Storage::fake(); + + // No width/height metadata: fall back to the safe path and host a compliant copy. + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-unknown', + 'path' => 'media/2026-01/unknown.jpg', + 'url' => 'https://example.com/media/2026-01/unknown.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'unknown.jpg', + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_unknown_123'], + ], 200), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE'], + ], 200), + '*' => Http::response('fake-image-content', 200), + ]); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + + return str_contains( + (string) data_get(json_decode($request->body(), true), 'source_info.photo_images.0'), + 'social-tiktok-photos/' + ); + }); + + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher fails clearly when an oversized photo cannot be downloaded for resizing', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ], + ], + ]); + + // optimizeImage is intentionally not stubbed: it must never be reached when the + // download fails, and the strict mock would throw if it were called. + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + '*' => Http::response('not found', 500), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TikTokPublishException::class, 'Failed to download image for TikTok resizing'); + + // Nothing should be left hosted when resizing never completes. + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher resizes only the oversized photos in a mixed carousel', function () { + Storage::fake(); + + // TikTok carousels can carry many images; here one is oversized, one is compliant. + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ], + [ + 'id' => 'compliant', + 'path' => 'media/2026-01/small.jpg', + 'url' => 'https://example.com/media/2026-01/small.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'small.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + // optimizeImage must run exactly once — only for the oversized image. + $mockOptimizer->shouldReceive('optimizeImage')->once()->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_mixed_123'], + ], 200), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE'], + ], 200), + '*' => Http::response('fake-image-content', 200), + ]); + + $this->publisher->publish($this->postPlatform); + + // Order is preserved: oversized -> hosted derivative, compliant -> original URL untouched. + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + $images = data_get(json_decode($request->body(), true), 'source_info.photo_images'); + + return is_array($images) + && count($images) === 2 + && str_contains($images[0], 'social-tiktok-photos/') + && ! str_contains($images[0], 'example.com') + && $images[1] === 'https://example.com/media/2026-01/small.jpg'; + }); + + // The compliant image is never downloaded; only the oversized one is fetched for resizing. + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'small.jpg')); + + // The single derivative is pruned after publish. + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher prunes the hosted derivative even when publishing fails', function () { + Storage::fake(); + + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + // The derivative is hosted first, then TikTok rejects the publish: the finally + // must still remove it so a failed post never orphans a file on disk. + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response([ + 'error' => ['code' => 'internal_error', 'message' => 'boom'], + ], 500), + '*' => Http::response('fake-image-content', 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TikTokPublishException::class); + + expect(Storage::allFiles('social-tiktok-photos'))->toBeEmpty(); +}); + +test('tiktok publisher still reports success when derivative cleanup throws on the storage disk', function () { + // The production default disk (r2) is configured to throw on a failed delete. + // Cleanup must never turn an already-published post into a reported failure. + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->post->update([ + 'media' => [ + [ + 'id' => 'oversized', + 'path' => 'media/2026-01/big.jpg', + 'url' => 'https://example.com/media/2026-01/big.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'big.jpg', + 'meta' => ['width' => 1254, 'height' => 1254], + ], + ], + ]); + + $mockOptimizer = Mockery::mock(MediaOptimizer::class); + $mockOptimizer->shouldReceive('maxWidthForPlatform')->with(Platform::TikTok)->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->with(Mockery::type('string'), Platform::TikTok)->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Storage::shouldReceive('put')->andReturnTrue(); + Storage::shouldReceive('url')->andReturn('https://cdn.example.com/social-tiktok-photos/x.jpg'); + Storage::shouldReceive('delete')->andThrow(new RuntimeException('r2 delete failed')); + + Http::fake([ + $this->api.'/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_cleanup_throws_123'], + ], 200), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE'], + ], 200), + '*' => Http::response('fake-image-content', 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('pub_cleanup_throws_123'); +}); diff --git a/tests/Unit/DataTransferObjects/MediaItemTest.php b/tests/Unit/DataTransferObjects/MediaItemTest.php index e178a7028..e6953ad22 100644 --- a/tests/Unit/DataTransferObjects/MediaItemTest.php +++ b/tests/Unit/DataTransferObjects/MediaItemTest.php @@ -34,3 +34,43 @@ expect($item->isImage())->toBeTrue(); }); + +test('fromArray reads pixel dimensions from the meta block', function () { + $item = MediaItem::fromArray([ + 'path' => 'photo.jpg', + 'url' => 'https://x/photo.jpg', + 'meta' => ['width' => 1254, 'height' => 836], + ]); + + expect($item->width())->toBe(1254) + ->and($item->height())->toBe(836); +}); + +test('width and height are null when no meta is present', function () { + $item = MediaItem::fromArray(['path' => 'photo.jpg', 'url' => 'https://x/photo.jpg']); + + expect($item->width())->toBeNull() + ->and($item->height())->toBeNull(); +}); + +test('width and height ignore non-numeric meta values', function () { + $item = MediaItem::fromArray([ + 'path' => 'photo.jpg', + 'url' => 'https://x/photo.jpg', + 'meta' => ['width' => 'wide', 'height' => null], + ]); + + expect($item->width())->toBeNull() + ->and($item->height())->toBeNull(); +}); + +test('numeric string dimensions are coerced to integers', function () { + $item = MediaItem::fromArray([ + 'path' => 'photo.jpg', + 'url' => 'https://x/photo.jpg', + 'meta' => ['width' => '1080', 'height' => '1920'], + ]); + + expect($item->width())->toBe(1080) + ->and($item->height())->toBe(1920); +}); diff --git a/tests/Unit/Exceptions/Social/TikTokPublishExceptionTest.php b/tests/Unit/Exceptions/Social/TikTokPublishExceptionTest.php index b852e2188..c05d81356 100644 --- a/tests/Unit/Exceptions/Social/TikTokPublishExceptionTest.php +++ b/tests/Unit/Exceptions/Social/TikTokPublishExceptionTest.php @@ -16,7 +16,7 @@ ], ], 429); - $fakeResponse = Http::fake(['*' => $response])->post('https://open.tiktokapis.com/test'); + $fakeResponse = Http::fake(['*' => $response])->post(config('trypost.platforms.tiktok.api').'/test'); $exception = TikTokPublishException::fromApiResponse($fakeResponse); @@ -36,7 +36,7 @@ ], ], 401); - $fakeResponse = Http::fake(['*' => $response])->post('https://open.tiktokapis.com/test'); + $fakeResponse = Http::fake(['*' => $response])->post(config('trypost.platforms.tiktok.api').'/test'); TikTokPublishException::fromApiResponse($fakeResponse); })->throws(TokenExpiredException::class); @@ -50,7 +50,7 @@ ], ], 400); - $fakeResponse = Http::fake(['*' => $response])->post('https://open.tiktokapis.com/test'); + $fakeResponse = Http::fake(['*' => $response])->post(config('trypost.platforms.tiktok.api').'/test'); $exception = TikTokPublishException::fromApiResponse($fakeResponse); @@ -91,7 +91,7 @@ ], ], 400); - $fakeResponse = Http::fake(['*' => $response])->post('https://open.tiktokapis.com/test'); + $fakeResponse = Http::fake(['*' => $response])->post(config('trypost.platforms.tiktok.api').'/test'); $exception = TikTokPublishException::fromApiResponse($fakeResponse); diff --git a/tests/Unit/Services/Media/MediaOptimizerTest.php b/tests/Unit/Services/Media/MediaOptimizerTest.php index cbed2ed5f..54c7cae3b 100644 --- a/tests/Unit/Services/Media/MediaOptimizerTest.php +++ b/tests/Unit/Services/Media/MediaOptimizerTest.php @@ -111,6 +111,22 @@ function createTestImage(int $width, int $height, string $format = 'image/jpeg') expect($optimized->width())->toBeLessThanOrEqual(1000); }); +it('exposes the configured max width per platform', function () { + $optimizer = new MediaOptimizer; + + expect($optimizer->maxWidthForPlatform(Platform::TikTok))->toBe(1080) + ->and($optimizer->maxWidthForPlatform(Platform::Instagram))->toBe(1440) + ->and($optimizer->maxWidthForPlatform(Platform::Pinterest))->toBe(1000); +}); + +it('reports a max width for every platform', function () { + $optimizer = new MediaOptimizer; + + foreach (Platform::cases() as $platform) { + expect($optimizer->maxWidthForPlatform($platform))->toBeInt()->toBeGreaterThan(0); + } +}); + it('handles all platforms without error', function () use (&$tempFiles) { $source = createTestImage(1000, 800); $tempFiles[] = $source;