From c5fca040a937d16ba7307f54e80dc9db2d37bbc2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 26 Jun 2026 12:23:56 -0300 Subject: [PATCH 1/6] fix(tiktok): resize oversized photos before PULL_FROM_URL TikTok photo posts hand the platform our media URLs and let it pull the bytes, so they never went through MediaOptimizer like the upload-based publishers do. An image wider than 1080px reached TikTok untouched and was rejected with picture_size_check_failed ("Image dimensions exceed limits"). Photos within spec still pass through unchanged. Oversized ones (or images with unknown dimensions) are downloaded, resized to TikTok's 1080px spec, hosted on the public disk, and that URL is handed to TikTok instead. The derivative is pruned once TikTok has pulled it. Reuses the same hosted-derivative pattern Instagram/Threads already use for aspect-ratio cropping; the TikTok max_width config was previously dead code. --- app/DataTransferObjects/MediaItem.php | 24 +++ app/Services/Media/MediaOptimizer.php | 12 ++ app/Services/Social/TikTokPublisher.php | 171 +++++++++++++----- .../Services/Social/TikTokPublisherTest.php | 154 ++++++++++++++++ 4 files changed, 318 insertions(+), 43 deletions(-) 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..37bc6f068 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -4,20 +4,27 @@ 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; class TikTokPublisher { use HasSocialHttpClient; + private const PHOTO_DERIVATIVE_DIRECTORY = 'social-tiktok-photos'; + private string $baseUrl; private string $accessToken; @@ -198,66 +205,144 @@ 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 { + // TikTok pulls the images during the synchronous status poll above, + // so by the time we reach here the fetch is finished and the + // temporary derivatives can be safely removed. + if ($derivatives !== []) { + Storage::delete($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_'); - if (! $publishId) { - throw new TikTokPublishException( - userMessage: 'TikTok did not return a publish_id', - category: ErrorCategory::ServerError, - ); - } + try { + $download = Http::sink($tempInput)->timeout(120)->get($image->url); - // Wait for processing and get final status - $statusData = $this->waitForPublishStatus($publishId); - $postId = data_get($statusData, 'publicaly_available_post_id.0'); + if ($download->failed()) { + throw new TikTokPublishException( + userMessage: 'Failed to download image for TikTok resizing', + category: ErrorCategory::ServerError, + ); + } - return [ - 'id' => $postId ?? $publishId, - 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), - ]; + $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]; + } finally { + @unlink($tempInput); + } } private function waitForPublishStatus(string $publishId, int $maxAttempts = 20): array diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 96016fa04..16d448d60 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(); @@ -92,6 +94,7 @@ 'url' => 'https://example.com/media/2026-01/image1.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'image1.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], ], ], ]); @@ -487,6 +490,7 @@ 'url' => 'https://example.com/media/2026-01/photo.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'photo.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], ], ], ]); @@ -691,3 +695,153 @@ 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')->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + 'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_resize_123'], + ], 200), + 'https://open.tiktokapis.com/v2/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([ + 'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_passthrough_123'], + ], 200), + 'https://open.tiktokapis.com/v2/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')->andReturn(1080); + $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + $optimized = tempnam(sys_get_temp_dir(), 'tt_opt_'); + copy($tempFile, $optimized); + + return $optimized; + }); + app()->instance(MediaOptimizer::class, $mockOptimizer); + + Http::fake([ + 'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([ + 'data' => ['publish_id' => 'pub_unknown_123'], + ], 200), + 'https://open.tiktokapis.com/v2/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(); +}); From 13e4d88b712bd77333394e1af747c9cf5d9c71d9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 26 Jun 2026 12:31:46 -0300 Subject: [PATCH 2/6] test(tiktok): cover MediaItem dimensions and platform max-width helpers Direct unit tests for the new MediaItem::width()/height() accessors and MediaOptimizer::maxWidthForPlatform(), plus drop a redundant inline comment. --- app/Services/Social/TikTokPublisher.php | 3 -- .../DataTransferObjects/MediaItemTest.php | 40 +++++++++++++++++++ .../Services/Media/MediaOptimizerTest.php | 16 ++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 37bc6f068..09017ece2 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -276,9 +276,6 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), ]; } finally { - // TikTok pulls the images during the synchronous status poll above, - // so by the time we reach here the fetch is finished and the - // temporary derivatives can be safely removed. if ($derivatives !== []) { Storage::delete($derivatives); } 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/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; From 0829814d8b90a82ecc33607f81c84c901efea55c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 26 Jun 2026 12:42:39 -0300 Subject: [PATCH 3/6] test(tiktok): cover download-failure path and assert TikTok-specific optimization Adds a test for the resize download-failure branch, and tightens the resize mocks to assert optimization is requested for Platform::TikTok specifically. --- .../Services/Social/TikTokPublisherTest.php | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 16d448d60..067b3f05f 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -715,8 +715,8 @@ ]); $mockOptimizer = Mockery::mock(MediaOptimizer::class); - $mockOptimizer->shouldReceive('maxWidthForPlatform')->andReturn(1080); - $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + $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); @@ -811,8 +811,8 @@ ]); $mockOptimizer = Mockery::mock(MediaOptimizer::class); - $mockOptimizer->shouldReceive('maxWidthForPlatform')->andReturn(1080); - $mockOptimizer->shouldReceive('optimizeImage')->andReturnUsing(function (string $tempFile) { + $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); @@ -845,3 +845,37 @@ 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(); +}); From 3cf9af9caf567168e88cad549ad931c322f8779c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 26 Jun 2026 13:08:11 -0300 Subject: [PATCH 4/6] fix(tiktok): keep photo derivative cleanup from masking a successful publish The default disk (r2) is configured with throw => true, so Storage::delete re-throws on a failed delete. Running that inside the finally meant a delete failure could discard the publishPhotos return value and surface an already-live TikTok post as a failure (risking a duplicate re-publish). Move cleanup into a pruneDerivatives helper that swallows storage errors, and wrap the resize/host step so decoder/storage failures surface as a categorized TikTokPublishException instead of an uncategorized error. --- app/Services/Social/TikTokPublisher.php | 54 ++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 09017ece2..13bf69917 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -18,6 +18,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Throwable; class TikTokPublisher { @@ -276,9 +277,7 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), ]; } finally { - if ($derivatives !== []) { - Storage::delete($derivatives); - } + $this->pruneDerivatives($derivatives); } } @@ -327,6 +326,22 @@ private function renderCompliantPhoto(MediaItem $image): array ); } + 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 { @@ -337,8 +352,37 @@ private function renderCompliantPhoto(MediaItem $image): array } return [Storage::url($path), $path]; - } finally { - @unlink($tempInput); + } catch (Throwable $e) { + Log::error('TikTok photo resize/host failed', [ + 'exception' => $e->getMessage(), + ]); + + throw new TikTokPublishException( + userMessage: 'Failed to prepare image for TikTok.', + category: ErrorCategory::ServerError, + ); + } + } + + /** + * 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; + } + + try { + Storage::delete($paths); + } catch (Throwable $e) { + Log::warning('Failed to prune TikTok photo derivatives', [ + 'paths' => $paths, + 'exception' => $e->getMessage(), + ]); } } From 5df854092b268468f5ece92f46983ae32ad41ac0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 26 Jun 2026 13:08:18 -0300 Subject: [PATCH 5/6] test(tiktok): cover cleanup edge cases and fake against the config base URL - Multi-image carousel: only the oversized photo is resized, order preserved, the compliant one passes through untouched. - Cleanup runs even when publishing fails (no orphaned derivative). - A throwing Storage::delete does not turn a successful publish into a failure. - Replace hardcoded https://open.tiktokapis.com/v2 with config('trypost.platforms.tiktok.api') so the fakes can't drift from the configured (overridable) host. --- .../Services/Social/TikTokPublisherTest.php | 242 +++++++++++++++--- 1 file changed, 201 insertions(+), 41 deletions(-) diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 067b3f05f..d05ca5ed3 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -41,6 +41,8 @@ ]); $this->publisher = new TikTokPublisher; + + $this->api = config('trypost.platforms.tiktok.api'); }); test('tiktok publisher throws exception when no media', function () { @@ -62,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', @@ -100,10 +102,10 @@ ]); 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', @@ -141,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', @@ -167,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', @@ -195,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), ]); @@ -256,7 +258,7 @@ ]); Http::fake([ - '*/oauth/token/' => Http::response([ + $this->api.'/oauth/token/' => Http::response([ 'error' => ['code' => 'invalid_grant', 'message' => 'Refresh token expired'], ], 400), ]); @@ -296,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), ]); @@ -325,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), ]); @@ -356,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', @@ -400,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', @@ -441,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), ]); @@ -496,13 +498,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/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), ]); @@ -546,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), ]); @@ -587,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), ]); @@ -636,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), ]); @@ -679,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', @@ -725,10 +727,10 @@ app()->instance(MediaOptimizer::class, $mockOptimizer); 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_resize_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), '*' => Http::response('fake-image-content', 200), @@ -769,10 +771,10 @@ ]); 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_passthrough_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), ]); @@ -821,10 +823,10 @@ app()->instance(MediaOptimizer::class, $mockOptimizer); 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_unknown_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), '*' => Http::response('fake-image-content', 200), @@ -879,3 +881,161 @@ // 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'); +}); From ffa437761588a8f464b40018911d2cc05b55abfb Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 26 Jun 2026 13:13:15 -0300 Subject: [PATCH 6/6] test(tiktok): fake against the config base URL across all TikTok test files Convert TikTokCreatorInfoTest and TikTokPublishExceptionTest to fake against config('trypost.platforms.tiktok.api') instead of the hardcoded host, matching the TikTokPublisherTest cleanup. TikTokControllerTest is left as-is: its only TikTok URL is a Socialite authorize stub, which has no config counterpart. --- .../Feature/Services/Social/TikTokCreatorInfoTest.php | 10 ++++++---- .../Exceptions/Social/TikTokPublishExceptionTest.php | 8 ++++---- 2 files changed, 10 insertions(+), 8 deletions(-) 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/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);