diff --git a/tests/Unit/Services/Social/BlueskyPublisherTest.php b/tests/Unit/Services/Social/BlueskyPublisherTest.php index e8de111c..3605f41b 100644 --- a/tests/Unit/Services/Social/BlueskyPublisherTest.php +++ b/tests/Unit/Services/Social/BlueskyPublisherTest.php @@ -11,6 +11,7 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\MediaOptimizer; use App\Services\Social\BlueskyPublisher; use Illuminate\Support\Facades\Http; @@ -188,6 +189,100 @@ ->toThrow(TokenExpiredException::class); }); +test('bluesky publisher optimizes images before upload', function () { + $tempFile = tempnam(sys_get_temp_dir(), 'bsky_test_'); + file_put_contents($tempFile, str_repeat('x', 1024)); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + + $this->mock(MediaOptimizer::class) + ->shouldReceive('optimizeImage') + ->once() + ->with(Mockery::any(), Platform::Bluesky) + ->andReturn($tempFile); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'uploadBlob')) { + return Http::response([ + 'blob' => [ + '$type' => 'blob', + 'ref' => ['$link' => 'bafkreiabc123'], + 'mimeType' => 'image/jpeg', + 'size' => 1024, + ], + ], 200); + } + + if (str_contains($url, 'createRecord')) { + return Http::response([ + 'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3abc123xyz', + 'cid' => 'bafyreiabc123', + ], 200); + } + + // Media download fallback (covers relative storage URLs in test env) + return Http::response(str_repeat('x', 1024), 200); + }); + + $this->publisher->publish($this->postPlatform); + + @unlink($tempFile); +}); + +test('bluesky publisher handles media download failure gracefully', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'createRecord')) { + return Http::response([ + 'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3textonly', + 'cid' => 'bafyreiabc123', + ], 200); + } + + // CDN download returns 404 — blob upload is skipped + return Http::response('Not Found', 404); + }); + + // When media download fails, uploadBlob returns null and post publishes as text-only + $result = $this->publisher->publish($this->postPlatform); + + expect($result)->toHaveKey('id'); + expect($result['id'])->toBe('3textonly'); + + // The createRecord request should NOT contain an embed (no images uploaded) + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'createRecord')) { + return false; + } + $record = $request['record']; + + return ! isset($record['embed']); + }); +}); + test('bluesky publisher limits images to 4', function () { // Create 6 media items through the PostPlatform's media() relation for ($i = 0; $i < 6; $i++) { diff --git a/tests/Unit/Services/Social/FacebookPublisherTest.php b/tests/Unit/Services/Social/FacebookPublisherTest.php index 0ac0291e..aaaa4340 100644 --- a/tests/Unit/Services/Social/FacebookPublisherTest.php +++ b/tests/Unit/Services/Social/FacebookPublisherTest.php @@ -339,6 +339,36 @@ ->toThrow(Exception::class, 'Facebook text posts require content'); }); +test('facebook publisher cleans up temp files after reel upload', function () { + $this->postPlatform->update(['content_type' => ContentType::FacebookReel]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/reel.mp4', + 'original_filename' => 'reel.mp4', + 'mime_type' => 'video/mp4', + 'size' => 5120000, + 'order' => 0, + ]); + + Http::fake([ + '*/page_123/video_reels' => Http::sequence() + ->push(['video_id' => 'reel_video_cleanup_123'], 200) + ->push(['id' => 'reel_cleanup_456', 'success' => true], 200), + '*/reel_video_cleanup_123' => Http::response(['success' => true], 200), + '*' => Http::response('', 200), + ]); + + $this->publisher->publish($this->postPlatform); + + // Assert no leftover fb_reel_ temp files exist + $tempDir = sys_get_temp_dir(); + $leftoverFiles = glob("{$tempDir}/fb_reel_*") ?: []; + + expect($leftoverFiles)->toBeEmpty(); +}); + test('facebook publisher can publish single image with null content', function () { $this->postPlatform->update(['content' => null]); diff --git a/tests/Unit/Services/Social/InstagramPublisherTest.php b/tests/Unit/Services/Social/InstagramPublisherTest.php index 4cf28e09..16d076ad 100644 --- a/tests/Unit/Services/Social/InstagramPublisherTest.php +++ b/tests/Unit/Services/Social/InstagramPublisherTest.php @@ -611,6 +611,45 @@ expect($result['id'])->toBe('media-empty-content'); }); +test('instagram publisher routes feed video to reel', function () { + // InstagramFeed content type with a single video should route to publishReel (REELS media_type) + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/feed-video.mp4', + 'original_filename' => 'feed-video.mp4', + 'mime_type' => 'video/mp4', + 'size' => 2048000, + 'order' => 0, + 'meta' => ['width' => 1080, 'height' => 1920, 'duration' => 15], + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'reel-container-999', + ], 200), + 'https://graph.instagram.com/v24.0/reel-container-999*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'feed-reel-123', + ], 200), + 'https://graph.instagram.com/v24.0/feed-reel-123*' => Http::response([ + 'permalink' => 'https://www.instagram.com/reel/FEEDVID/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('feed-reel-123'); + + // Assert media_type=REELS was sent in the container creation request + Http::assertSent(function ($request) { + return str_contains($request->url(), '/ig_123456789/media') + && str_contains($request->body(), 'REELS'); + }); +}); + test('instagram publisher handles publish failure', function () { $this->postPlatform->media()->create([ 'collection' => 'default', diff --git a/tests/Unit/Services/Social/LinkedInPublisherTest.php b/tests/Unit/Services/Social/LinkedInPublisherTest.php index c41a7965..91ef452f 100644 --- a/tests/Unit/Services/Social/LinkedInPublisherTest.php +++ b/tests/Unit/Services/Social/LinkedInPublisherTest.php @@ -165,3 +165,190 @@ expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(Exception::class, 'Unsupported LinkedIn content type'); }); + +test('linkedin publisher can publish post with image', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 512000, + 'order' => 0, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + + $uploadUrl = 'https://www.linkedin.com/dms/upload/v2/pic/0/C5622AQFake'; + + Http::fake(function ($request) use ($uploadUrl) { + $url = $request->url(); + + if (str_contains($url, '/rest/images')) { + return Http::response([ + 'value' => [ + 'uploadUrl' => $uploadUrl, + 'image' => 'urn:li:image:C5622AQFakeImageUrn', + ], + ], 200); + } + + if ($url === $uploadUrl) { + return Http::response(null, 201); + } + + if (str_contains($url, '/rest/posts')) { + return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:9876543210']); + } + + // Media download fallback + return Http::response('fake-image-content', 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('urn:li:share:9876543210'); + expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:9876543210'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/images')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts') + && isset($request['content']['media']['id']) + ); +}); + +test('linkedin publisher can publish carousel with multiple images', function () { + $this->postPlatform->update(['content_type' => ContentType::LinkedInCarousel]); + + for ($i = 1; $i <= 3; $i++) { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => "media/2026-01/carousel-{$i}.jpg", + 'original_filename' => "carousel-{$i}.jpg", + 'mime_type' => 'image/jpeg', + 'size' => 256000, + 'order' => $i - 1, + 'meta' => ['width' => 1200, 'height' => 628], + ]); + } + + $uploadUrls = [ + 'https://www.linkedin.com/dms/upload/v2/pic/carousel/1', + 'https://www.linkedin.com/dms/upload/v2/pic/carousel/2', + 'https://www.linkedin.com/dms/upload/v2/pic/carousel/3', + ]; + + $imageUrns = [ + 'urn:li:image:CarouselImageUrn1', + 'urn:li:image:CarouselImageUrn2', + 'urn:li:image:CarouselImageUrn3', + ]; + + $initCallCount = 0; + + Http::fake(function ($request) use ($uploadUrls, $imageUrns, &$initCallCount) { + $url = $request->url(); + + if (str_contains($url, '/rest/images')) { + $idx = $initCallCount % 3; + $initCallCount++; + + return Http::response([ + 'value' => [ + 'uploadUrl' => $uploadUrls[$idx], + 'image' => $imageUrns[$idx], + ], + ], 200); + } + + // Image PUT upload + foreach ($uploadUrls as $uploadUrl) { + if ($url === $uploadUrl) { + return Http::response(null, 201); + } + } + + if (str_contains($url, '/rest/posts')) { + return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:carousel999']); + } + + // Media download fallback + return Http::response('fake-image-content', 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('urn:li:share:carousel999'); + expect($result['url'])->toContain('linkedin.com/feed/update/urn:li:share:carousel999'); + + // Assert the post payload contains multiImage.images with 3 items + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/rest/posts')) { + return false; + } + $images = data_get($request->data(), 'content.multiImage.images'); + + return is_array($images) && count($images) === 3; + }); +}); + +test('linkedin publisher can publish post with video', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/test-video.mp4', + 'original_filename' => 'test-video.mp4', + 'mime_type' => 'video/mp4', + 'size' => 2 * 1024 * 1024, + 'order' => 0, + 'meta' => ['duration' => 30], + ]); + + $chunkUploadUrl = 'https://www.linkedin.com/dms/upload/v2/chunk/video/1'; + + Http::fake(function ($request) use ($chunkUploadUrl) { + $url = $request->url(); + + if (str_contains($url, 'initializeUpload') && str_contains($url, '/rest/videos')) { + return Http::response([ + 'value' => [ + 'video' => 'urn:li:video:FakeVideoUrn', + 'uploadToken' => 'upload-token-abc', + 'uploadInstructions' => [ + [ + 'uploadUrl' => $chunkUploadUrl, + 'firstByte' => 0, + 'lastByte' => 1023, + ], + ], + ], + ], 200); + } + + if ($url === $chunkUploadUrl) { + return Http::response(null, 200, ['etag' => '"etag-abc123"']); + } + + if (str_contains($url, 'finalizeUpload') && str_contains($url, '/rest/videos')) { + return Http::response(null, 200); + } + + if (str_contains($url, '/rest/videos/')) { + return Http::response(['status' => 'AVAILABLE'], 200); + } + + if (str_contains($url, '/rest/posts')) { + return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:1111111111']); + } + + // Media download fallback + return Http::response(str_repeat('x', 1024), 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('urn:li:share:1111111111'); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'initializeUpload')); + Http::assertSent(fn ($request) => str_contains($request->url(), 'finalizeUpload')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts')); +}); diff --git a/tests/Unit/Services/Social/MastodonPublisherTest.php b/tests/Unit/Services/Social/MastodonPublisherTest.php index c50c18f6..0e866aef 100644 --- a/tests/Unit/Services/Social/MastodonPublisherTest.php +++ b/tests/Unit/Services/Social/MastodonPublisherTest.php @@ -11,6 +11,7 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Media\MediaOptimizer; use App\Services\Social\MastodonPublisher; use Illuminate\Support\Facades\Http; @@ -239,6 +240,128 @@ }); }); +test('mastodon publisher optimizes images before upload', function () { + // Create a temp file with a valid JPEG so mime_content_type() detects image/jpeg + $optimizedFile = tempnam(sys_get_temp_dir(), 'masto_opt_'); + file_put_contents($optimizedFile, str_repeat('x', 1024)); + + // Minimal 1x1 JPEG bytes so mime_content_type() returns image/jpeg for the downloaded file + $minimalJpeg = "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + ."\xFF\xDB\x00\x43\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\x09\x09\x08\x0A\x0C" + ."\x14\x0D\x0C\x0B\x0B\x0C\x19\x12\x13\x0F\x14\x1D\x1A\x1F\x1E\x1D\x1A\x1C\x1C\x20" + ."\xFF\xC0\x00\x0B\x08\x00\x01\x00\x01\x01\x01\x11\x00\xFF\xC4\x00\x1F\x00\x00\x01" + ."\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05" + ."\xFF\xDA\x00\x08\x01\x01\x00\x00\x3F\x00\xFB\xD3\xFF\xD9"; + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + + $this->mock(MediaOptimizer::class) + ->shouldReceive('optimizeImage') + ->once() + ->with(Mockery::any(), Platform::Mastodon) + ->andReturn($optimizedFile); + + Http::fake(function ($request) use ($minimalJpeg) { + $url = $request->url(); + + if (str_contains($url, '/api/v1/media')) { + return Http::response([ + 'id' => 'media-optimized-123', + 'type' => 'image', + 'url' => 'https://mastodon.social/media/image.jpg', + ], 200); + } + + if (str_contains($url, '/api/v1/statuses')) { + return Http::response([ + 'id' => '109876543210', + 'url' => 'https://mastodon.social/@testuser/109876543210', + ], 200); + } + + // Media download: return valid JPEG so mime_content_type() detects image/jpeg + return Http::response($minimalJpeg, 200, ['Content-Type' => 'image/jpeg']); + }); + + $this->publisher->publish($this->postPlatform); + + @unlink($optimizedFile); +}); + +test('mastodon publisher publishes text-only when media upload fails', function () { + // Minimal valid JPEG header so mime_content_type() detects image/jpeg + $minimalJpeg = "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00" + ."\xFF\xDB\x00\x43\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\x09\x09\x08\x0A\x0C" + ."\x14\x0D\x0C\x0B\x0B\x0C\x19\x12\x13\x0F\x14\x1D\x1A\x1F\x1E\x1D\x1A\x1C\x1C\x20" + ."\xFF\xC0\x00\x0B\x08\x00\x01\x00\x01\x01\x01\x11\x00\xFF\xC4\x00\x1F\x00\x00\x01" + ."\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05" + ."\xFF\xDA\x00\x08\x01\x01\x00\x00\x3F\x00\xFB\xD3\xFF\xD9"; + + $optimizedFile = tempnam(sys_get_temp_dir(), 'masto_fail_opt_'); + file_put_contents($optimizedFile, str_repeat('x', 512)); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/failing-upload.jpg', + 'original_filename' => 'failing-upload.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + 'meta' => ['width' => 800, 'height' => 600], + ]); + + $this->mock(MediaOptimizer::class) + ->shouldReceive('optimizeImage') + ->once() + ->with(Mockery::any(), Platform::Mastodon) + ->andReturn($optimizedFile); + + Http::fake(function ($request) use ($minimalJpeg) { + $url = $request->url(); + + // Media upload returns 500 — uploadMedia returns null + if (str_contains($url, '/api/v1/media')) { + return Http::response(['error' => 'Internal server error'], 500); + } + + if (str_contains($url, '/api/v1/statuses')) { + return Http::response([ + 'id' => '999111222333', + 'url' => 'https://mastodon.social/@testuser/999111222333', + ], 200); + } + + // Media download — return valid JPEG bytes + return Http::response($minimalJpeg, 200, ['Content-Type' => 'image/jpeg']); + }); + + $result = $this->publisher->publish($this->postPlatform); + + // Post still succeeds as text-only + expect($result['id'])->toBe('999111222333'); + + // The statuses request should NOT include media_ids + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/api/v1/statuses')) { + return false; + } + + return ! isset($request->data()['media_ids']); + }); + + @unlink($optimizedFile); +}); + test('mastodon publisher defaults to mastodon.social if no instance in meta', function () { $this->socialAccount->update(['meta' => []]); diff --git a/tests/Unit/Services/Social/PinterestPublisherTest.php b/tests/Unit/Services/Social/PinterestPublisherTest.php index bbec012e..2031ea5a 100644 --- a/tests/Unit/Services/Social/PinterestPublisherTest.php +++ b/tests/Unit/Services/Social/PinterestPublisherTest.php @@ -338,6 +338,68 @@ expect($boards[0]['id'])->toBe('board_1'); }); +test('pinterest publisher can publish video pin', function () { + $this->postPlatform->update(['content_type' => ContentType::PinterestVideoPin]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/video.mp4', + 'original_filename' => 'video.mp4', + 'mime_type' => 'video/mp4', + 'size' => 5120000, + 'order' => 0, + ]); + + $s3UploadUrl = 'https://pinterest-media-upload.s3.amazonaws.com/upload'; + + Http::fake(function ($request) use ($s3UploadUrl) { + $url = $request->url(); + + // Step 1: Register media + if (str_contains($url, '/v5/media') && $request->method() === 'POST') { + return Http::response([ + 'media_id' => 'media_video_789', + 'upload_url' => $s3UploadUrl, + 'upload_parameters' => [ + 'key' => 'uploads/video.mp4', + 'AWSAccessKeyId' => 'FAKE_KEY', + ], + ], 201); + } + + // Step 2: S3 upload + if ($url === $s3UploadUrl) { + return Http::response('', 204); + } + + // Step 3: Media status check + if (str_contains($url, '/v5/media/media_video_789')) { + return Http::response(['status' => 'succeeded'], 200); + } + + // Step 4: Create pin + if (str_contains($url, '/v5/pins')) { + return Http::response(['id' => 'video_pin_999'], 200); + } + + // Video download + return Http::response('fake-video-content', 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result)->toHaveKey('id'); + expect($result)->toHaveKey('url'); + expect($result['id'])->toBe('video_pin_999'); + expect($result['url'])->toBe('https://pinterest.com/pin/video_pin_999'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v5/pins') + && data_get($request->data(), 'media_source.source_type') === 'video_id'; + }); +}); + test('pinterest publisher throws exception for unsupported content type', function () { $this->postPlatform->update(['content_type' => ContentType::InstagramFeed]); diff --git a/tests/Unit/Services/Social/ThreadsPublisherTest.php b/tests/Unit/Services/Social/ThreadsPublisherTest.php index 4ebd7c26..028c7f22 100644 --- a/tests/Unit/Services/Social/ThreadsPublisherTest.php +++ b/tests/Unit/Services/Social/ThreadsPublisherTest.php @@ -335,6 +335,34 @@ expect($result['id'])->toBe('media-123'); }); +test('threads publisher throws exception when all carousel items fail', function () { + for ($i = 0; $i < 3; $i++) { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => "media/2026-01/fail-image-{$i}.jpg", + 'original_filename' => "fail-{$i}.jpg", + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => $i, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + } + + Http::fake([ + 'https://graph.threads.net/v1.0/123456789/threads' => Http::response([ + 'error' => [ + 'message' => 'Upload failed', + 'type' => 'OAuthException', + 'code' => 100, + ], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(Exception::class, 'Failed to create any carousel items'); +}); + test('threads publisher can publish video with null content', function () { $this->postPlatform->update(['content' => null]); diff --git a/tests/Unit/Services/Social/TikTokPublisherTest.php b/tests/Unit/Services/Social/TikTokPublisherTest.php index 311cee7e..bb931ab9 100644 --- a/tests/Unit/Services/Social/TikTokPublisherTest.php +++ b/tests/Unit/Services/Social/TikTokPublisherTest.php @@ -286,6 +286,49 @@ expect($result['url'])->toBeNull(); }); +test('tiktok publisher falls back to self only when creator info fails', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/test-video.mp4', + 'original_filename' => 'test-video.mp4', + 'mime_type' => 'video/mp4', + 'size' => 1024000, + 'order' => 0, + ]); + + Http::fake([ + // creator_info/query returns 500 — publisher should fall back to SELF_ONLY + 'https://open.tiktokapis.com/v2/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([ + 'data' => ['publish_id' => 'pub_fallback_123'], + ], 200), + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publish_id' => 'pub_fallback_123', + ], + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result)->toHaveKey('id'); + expect($result['id'])->toBe('pub_fallback_123'); + + // Assert SELF_ONLY was used in the video init payload + Http::assertSent(function ($request) { + if (! str_contains($request->url(), '/post/publish/video/init/')) { + return false; + } + $body = json_decode($request->body(), true); + + return data_get($body, 'post_info.privacy_level') === 'SELF_ONLY'; + }); +}); + test('tiktok publisher throws exception when publish fails', function () { $this->postPlatform->media()->create([ 'collection' => 'default', diff --git a/tests/Unit/Services/Social/XPublisherTest.php b/tests/Unit/Services/Social/XPublisherTest.php index 43946f27..732e451c 100644 --- a/tests/Unit/Services/Social/XPublisherTest.php +++ b/tests/Unit/Services/Social/XPublisherTest.php @@ -181,3 +181,109 @@ expect(fn () => $this->publisher->publish($this->postPlatform)) ->toThrow(TokenExpiredException::class, 'No refresh token available for X account'); }); + +test('x publisher handles gif upload with processing', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/animated.gif', + 'original_filename' => 'animated.gif', + 'mime_type' => 'image/gif', + 'size' => 1024 * 1024, // 1MB — triggers chunked upload (isGif === true) + 'order' => 0, + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'gif_media_555']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response(null, 204); + } + + if (str_contains($url, '/finalize')) { + // Return processing_info so waitForProcessing is triggered + return Http::response([ + 'data' => ['id' => 'gif_media_555'], + 'processing_info' => ['state' => 'pending', 'check_after_secs' => 1], + ], 200); + } + + if (str_contains($url, '/2/media/gif_media_555')) { + return Http::response(['processing_info' => ['state' => 'succeeded']], 200); + } + + if (str_contains($url, '/2/tweets')) { + return Http::response(['data' => ['id' => '9999888877776666', 'text' => 'Hello from X!']], 200); + } + + // GIF download + return Http::response('fake-gif-content', 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('9999888877776666'); + + // GIF uses chunked upload (not simple upload) + Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/upload/initialize')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/append')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/finalize')); + // waitForProcessing was called + Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/gif_media_555')); +}); + +test('x publisher uploads video via chunked upload', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/test-video.mp4', + 'original_filename' => 'test-video.mp4', + 'mime_type' => 'video/mp4', + 'size' => 2 * 1024 * 1024, + 'order' => 0, + 'meta' => ['duration' => 30], + ]); + + Http::fake(function ($request) { + $url = $request->url(); + + // Order matters: more specific patterns first + if (str_contains($url, '/2/media/upload/initialize')) { + return Http::response(['data' => ['id' => 'media_id_999']], 200); + } + + if (str_contains($url, '/append')) { + return Http::response(null, 204); + } + + if (str_contains($url, '/finalize')) { + return Http::response(['data' => ['id' => 'media_id_999']], 200); + } + + if (str_contains($url, '/2/media/')) { + // STATUS check: GET /2/media/{id} + return Http::response(['processing_info' => ['state' => 'succeeded']], 200); + } + + if (str_contains($url, '/2/tweets')) { + return Http::response(['data' => ['id' => '9876543210987654321', 'text' => 'Hello from X!']], 200); + } + + // Media download (any URL including relative paths in test env) + return Http::response('fake-video-content', 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('9876543210987654321'); + expect($result['url'])->toContain('x.com/testuser/status/9876543210987654321'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/2/media/upload/initialize')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/append')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/finalize')); + Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets')); +});