diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index e2b90893..52209a8b 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -97,7 +97,14 @@ private function queryCreatorInfo(SocialAccount $account): array ]; } - private function buildPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array + /** + * Build the post_info payload for a VIDEO post. TikTok's video endpoint + * accepts the caption in the `title` field (capped at 2200 chars by the + * platform's maxContentLength). + * + * @return array + */ + private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array { $meta = $postPlatform->meta ?? []; @@ -127,13 +134,45 @@ private function buildPostInfo(PostPlatform $postPlatform, ?string $content, arr return $postInfo; } + /** + * Build the post_info payload for a PHOTO carousel. TikTok's photo endpoint + * accepts the caption in the `description` field (cap 4000 UTF-16 runes). + * The `title` field is a separate 90-char headline that we don't expose, + * so we omit it. Duet/Stitch and is_aigc do not apply to photo posts. + * + * @return array + */ + private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content, array $creatorInfo): array + { + $meta = $postPlatform->meta ?? []; + + $privacyLevel = data_get($meta, 'privacy_level') + ?: data_get($creatorInfo, 'privacy_level', 'SELF_ONLY'); + + $postInfo = [ + 'description' => $content ?? '', + 'privacy_level' => $privacyLevel, + 'disable_comment' => ! data_get($meta, 'allow_comments', true), + ]; + + if (data_get($meta, 'brand_content_toggle', false)) { + $postInfo['brand_content_toggle'] = true; + } + + if (data_get($meta, 'brand_organic_toggle', false)) { + $postInfo['brand_organic_toggle'] = true; + } + + return $postInfo; + } + private function publishVideo(PostPlatform $postPlatform, $media, ?string $content): array { $creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount); $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/video/init/", [ - 'post_info' => $this->buildPostInfo($postPlatform, $content, $creatorInfo), + 'post_info' => $this->buildVideoPostInfo($postPlatform, $content, $creatorInfo), 'source_info' => [ 'source' => 'PULL_FROM_URL', 'video_url' => $media->url, @@ -186,11 +225,9 @@ private function publishPhotos(PostPlatform $postPlatform, $mediaCollection, ?st $creatorInfo = $this->queryCreatorInfo($postPlatform->socialAccount); - $postInfo = $this->buildPostInfo($postPlatform, $content, $creatorInfo); - // Photos don't support duet/stitch/is_aigc - unset($postInfo['disable_duet'], $postInfo['disable_stitch'], $postInfo['is_aigc']); + $postInfo = $this->buildPhotoPostInfo($postPlatform, $content, $creatorInfo); - // Auto add music is only for photos + // Auto add music is only for photos. $meta = $postPlatform->meta ?? []; if (data_get($meta, 'auto_add_music', false)) { $postInfo['auto_add_music'] = true; diff --git a/tests/Unit/Services/Social/TikTokPublisherTest.php b/tests/Unit/Services/Social/TikTokPublisherTest.php index 0fb975ec..bf94f68b 100644 --- a/tests/Unit/Services/Social/TikTokPublisherTest.php +++ b/tests/Unit/Services/Social/TikTokPublisherTest.php @@ -114,7 +114,13 @@ expect($result['id'])->toBe('pub_photo_123'); Http::assertSent(function ($request) { - return str_contains($request->url(), '/post/publish/content/init/'); + if (! str_contains($request->url(), '/post/publish/content/init/')) { + return false; + } + $body = json_decode($request->body(), true); + + return data_get($body, 'post_info.description') === 'Check out this TikTok video!' + && ! isset($body['post_info']['title']); }); }); @@ -481,7 +487,9 @@ return $postInfo['auto_add_music'] === true && ! isset($postInfo['disable_duet']) && ! isset($postInfo['disable_stitch']) - && ! isset($postInfo['is_aigc']); + && ! isset($postInfo['is_aigc']) + && ! isset($postInfo['title']) + && isset($postInfo['description']); }); }); @@ -577,3 +585,45 @@ && ! isset($postInfo['brand_content_toggle']); }); }); + +test('tiktok publisher sends video caption in title field, never description', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-video', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test-video.mp4', + ], + ], + 'content' => 'My video caption', + ]); + $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + + Http::fake([ + 'https://open.tiktokapis.com/v2/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([ + 'data' => ['publish_id' => 'pub_video_123'], + ], 200), + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_video_123'], + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); + + 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.title') === 'My video caption' + && ! isset($body['post_info']['description']); + }); +});