From 61b104f88783fc806b37fdede9064f4e16383664 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 12:08:53 -0300 Subject: [PATCH 1/8] Show TikTok post metrics by resolving publish_id to the public video id. --- app/Services/Post/PostMetricsFetcher.php | 2 + app/Services/Social/TikTokAnalytics.php | 118 +++++++++++- tests/Feature/PostControllerTest.php | 45 +++++ .../Services/Social/TikTokAnalyticsTest.php | 179 ++++++++++++++++++ 4 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Services/Social/TikTokAnalyticsTest.php diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index b68e3305b..6e3a84bef 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -16,6 +16,7 @@ use App\Services\Social\PinterestAnalytics; use App\Services\Social\Telegram\TelegramAnalytics; use App\Services\Social\ThreadsAnalytics; +use App\Services\Social\TikTokAnalytics; use App\Services\Social\XAnalytics; use App\Services\Social\YouTubeAnalytics; use Illuminate\Support\Collection; @@ -74,6 +75,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform), Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::TikTok => app(TikTokAnalytics::class)->fetchPostMetrics($postPlatform), default => ['unsupported' => true, 'reason' => 'platform_not_supported'], }); } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 7cc7d5e0f..f1c4472dd 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; @@ -14,6 +15,18 @@ class TikTokAnalytics { use HasSocialHttpClient; + private const string VIDEO_METRIC_FIELDS = 'id,like_count,comment_count,share_count,view_count'; + + /** + * @var array + */ + private const array POST_METRICS = [ + 'view_count' => 'analytics.metrics.views', + 'like_count' => 'analytics.metrics.likes', + 'comment_count' => 'analytics.metrics.comments', + 'share_count' => 'analytics.metrics.shares', + ]; + private string $baseUrl; private string $accessToken; @@ -33,6 +46,109 @@ public function getMetrics(SocialAccount $account): array }); } + /** + * TikTok has no media insights edge. Per-post numbers live on + * `POST /v2/video/query/` and require the video's `item_id`, not the + * Content Posting `publish_id`. A stored `v_pub_*` / `p_pub_*` is resolved + * via status fetch: after moderation, `publicaly_available_post_id` is the + * id `video/query` accepts. Private posts never get one. + * + * @return array|array{unsupported: true, reason: string} + */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + + if (! $account || ! $postPlatform->platform_post_id) { + return ['unsupported' => true, 'reason' => 'missing_post_id']; + } + + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + + $this->accessToken = $account->access_token; + + $videoId = $this->videoIdFor($postPlatform); + + if ($videoId === null) { + return ['unsupported' => true, 'reason' => 'missing_post_id']; + } + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/video/query/?fields=".self::VIDEO_METRIC_FIELDS, [ + 'filters' => ['video_ids' => [$videoId]], + ]); + + if ($response->failed()) { + Log::warning('TikTok post metrics fetch failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return ['unsupported' => true, 'reason' => 'api_error']; + } + + $video = collect(data_get($response->json(), 'data.videos', [])) + ->first(fn (mixed $item): bool => (string) data_get($item, 'id') === $videoId); + + if (! is_array($video)) { + return ['unsupported' => true, 'reason' => 'api_error']; + } + + return collect(self::POST_METRICS) + ->map(fn (string $label, string $field): array => [ + 'label' => __($label), + 'value' => (int) data_get($video, $field, 0), + ]) + ->values() + ->all(); + } + + private function videoIdFor(PostPlatform $postPlatform): ?string + { + $stored = (string) $postPlatform->platform_post_id; + + if (ctype_digit($stored)) { + return $stored; + } + + return $this->resolveVideoIdFromPublish($postPlatform, $stored); + } + + private function resolveVideoIdFromPublish(PostPlatform $postPlatform, string $publishId): ?string + { + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/post/publish/status/fetch/", [ + 'publish_id' => $publishId, + ]); + + if ($response->failed()) { + Log::warning('TikTok publish status fetch for metrics failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + $videoId = data_get($response->json(), 'data.publicaly_available_post_id.0'); + $videoId = is_scalar($videoId) ? (string) $videoId : ''; + + if ($videoId === '' || ! ctype_digit($videoId)) { + return null; + } + + $username = $postPlatform->socialAccount?->username; + + $postPlatform->update([ + 'platform_post_id' => $videoId, + 'platform_url' => filled($username) + ? "https://www.tiktok.com/@{$username}/video/{$videoId}" + : $postPlatform->platform_url, + ]); + + return $videoId; + } + private function fetchMetricsFromApi(SocialAccount $account): array { if ($account->needsProactiveTokenRefresh()) { @@ -114,7 +230,7 @@ private function fetchVideoMetrics(): array $videoIds = array_map(fn ($v) => $v['id'], $videos); $queryResponse = $this->getHttpClient() - ->post("{$this->baseUrl}/video/query/?fields=id,like_count,comment_count,share_count,view_count", [ + ->post("{$this->baseUrl}/video/query/?fields=".self::VIDEO_METRIC_FIELDS, [ 'filters' => ['video_ids' => $videoIds], ]); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 3929baad2..66c05ddb6 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -1127,6 +1127,51 @@ $response->assertJsonFragment(['label' => 'Likes', 'value' => 42]); }); +test('platform metrics dispatches TikTok analytics for TikTok platform', function () { + $tiktokAccount = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'tiktoker', + 'token_expires_at' => now()->addDays(1), + ]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $pp = PostPlatform::factory()->tiktok()->create([ + 'post_id' => $post->id, + 'social_account_id' => $tiktokAccount->id, + 'platform' => Platform::TikTok, + 'status' => Status::Published, + 'platform_post_id' => '7685359243088103444', + ]); + + $api = config('trypost.platforms.tiktok.api'); + + Http::fake([ + $api.'/video/query/*' => Http::response([ + 'data' => [ + 'videos' => [[ + 'id' => '7685359243088103444', + 'view_count' => 220, + 'like_count' => 11, + 'comment_count' => 2, + 'share_count' => 1, + ]], + ], + 'error' => ['code' => 'ok'], + ]), + ]); + + $response = $this->actingAs($this->user) + ->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id])); + + $response->assertOk(); + $response->assertJsonFragment(['label' => 'Views', 'value' => 220]); + $response->assertJsonFragment(['label' => 'Likes', 'value' => 11]); +}); + test('show page renders for non-editable posts', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Services/Social/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php new file mode 100644 index 000000000..e51b80c20 --- /dev/null +++ b/tests/Feature/Services/Social/TikTokAnalyticsTest.php @@ -0,0 +1,179 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $this->account = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'tiktoker', + 'token_expires_at' => now()->addDays(1), + ]); + $this->api = config('trypost.platforms.tiktok.api'); +}); + +/** + * @return array + */ +function tiktokVideoQueryResponse(string $videoId, array $counts = []): array +{ + return [ + 'data' => [ + 'videos' => [[ + 'id' => $videoId, + 'view_count' => $counts['view_count'] ?? 0, + 'like_count' => $counts['like_count'] ?? 0, + 'comment_count' => $counts['comment_count'] ?? 0, + 'share_count' => $counts['share_count'] ?? 0, + ]], + ], + 'error' => ['code' => 'ok'], + ]; +} + +function tiktokPostPlatform(?string $platformPostId = '7685359243088103444'): PostPlatform +{ + return PostPlatform::factory()->tiktok()->create([ + 'post_id' => test()->post->id, + 'social_account_id' => test()->account->id, + 'platform' => Platform::TikTok, + 'platform_post_id' => $platformPostId, + 'platform_url' => 'https://www.tiktok.com/@tiktoker', + ]); +} + +test('tiktok analytics reads post metrics from video query', function () { + $videoId = '7685359243088103444'; + + Http::fake([ + $this->api.'/video/query/*' => Http::response(tiktokVideoQueryResponse($videoId, [ + 'view_count' => 1200, + 'like_count' => 45, + 'comment_count' => 8, + 'share_count' => 3, + ])), + ]); + + $metrics = (new TikTokAnalytics)->fetchPostMetrics(tiktokPostPlatform($videoId)); + + expect($metrics)->toBe([ + ['label' => __('analytics.metrics.views'), 'value' => 1200], + ['label' => __('analytics.metrics.likes'), 'value' => 45], + ['label' => __('analytics.metrics.comments'), 'value' => 8], + ['label' => __('analytics.metrics.shares'), 'value' => 3], + ]); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->api}/video/query/") + && data_get($request->data(), 'filters.video_ids') === [$videoId]); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/post/publish/status/fetch/')); +}); + +test('tiktok analytics resolves a publish id then persists the public video id', function () { + $publishId = 'v_pub_url~v2-1.7685359243088103444'; + $videoId = '7685359243088103444'; + $postPlatform = tiktokPostPlatform($publishId); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => [$videoId], + ], + 'error' => ['code' => 'ok'], + ]), + $this->api.'/video/query/*' => Http::response(tiktokVideoQueryResponse($videoId, [ + 'view_count' => 90, + 'like_count' => 4, + 'comment_count' => 1, + 'share_count' => 0, + ])), + ]); + + $metrics = (new TikTokAnalytics)->fetchPostMetrics($postPlatform); + + expect($metrics)->toBe([ + ['label' => __('analytics.metrics.views'), 'value' => 90], + ['label' => __('analytics.metrics.likes'), 'value' => 4], + ['label' => __('analytics.metrics.comments'), 'value' => 1], + ['label' => __('analytics.metrics.shares'), 'value' => 0], + ]); + + $postPlatform->refresh(); + + expect($postPlatform->platform_post_id)->toBe($videoId) + ->and($postPlatform->platform_url)->toBe('https://www.tiktok.com/@tiktoker/video/7685359243088103444'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/post/publish/status/fetch/') + && $request['publish_id'] === $publishId); + Http::assertSent(fn ($request) => str_starts_with($request->url(), "{$this->api}/video/query/") + && data_get($request->data(), 'filters.video_ids') === [$videoId]); +}); + +test('tiktok analytics waits when publish status has no public post id yet', function () { + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => [], + ], + 'error' => ['code' => 'ok'], + ]), + ]); + + $metrics = (new TikTokAnalytics)->fetchPostMetrics( + tiktokPostPlatform('v_pub_url~v2-1.still-in-review') + ); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'missing_post_id']); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/query/')); +}); + +test('tiktok analytics reports a missing platform post id as unsupported', function () { + Http::fake(); + + $metrics = (new TikTokAnalytics)->fetchPostMetrics(tiktokPostPlatform(null)); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'missing_post_id']); + + Http::assertNothingSent(); +}); + +test('tiktok analytics reports a query rejection as unsupported', function () { + Http::fake([ + $this->api.'/video/query/*' => Http::response([ + 'error' => ['code' => 'access_token_invalid', 'message' => 'The access token is invalid or not found in the request.'], + ], 401), + ]); + + $metrics = (new TikTokAnalytics)->fetchPostMetrics(tiktokPostPlatform()); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'api_error']); +}); + +test('tiktok analytics reports an empty video query as unsupported', function () { + Http::fake([ + $this->api.'/video/query/*' => Http::response([ + 'data' => ['videos' => []], + 'error' => ['code' => 'ok'], + ]), + ]); + + $metrics = (new TikTokAnalytics)->fetchPostMetrics(tiktokPostPlatform()); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'api_error']); +}); From c45ebe9d207679961cdbf3258f032cf229cf1fba Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 12:16:16 -0300 Subject: [PATCH 2/8] Persist the TikTok video URL when publish status never returns a post id. --- app/Services/Post/PostMetricsFetcher.php | 37 +++-- app/Services/Social/TikTokAnalytics.php | 134 +++++++++++++++++- app/Services/Social/TikTokPublisher.php | 4 + .../Services/Social/TikTokAnalyticsTest.php | 57 ++++++++ .../Services/Social/TikTokPublisherTest.php | 41 ++++++ 5 files changed, 256 insertions(+), 17 deletions(-) diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index 6e3a84bef..5cb519d7d 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -44,14 +44,18 @@ public function forPost(Post $post): Collection return $post->postPlatforms ->where('enabled', true) ->values() - ->map(fn (PostPlatform $pp) => [ - 'post_platform_id' => $pp->id, - 'platform' => $pp->platform->value, - 'status' => $pp->status->value, - 'platform_post_id' => $pp->platform_post_id, - 'platform_url' => $pp->platform_url, - 'metrics' => $this->forPlatform($pp), - ]); + ->map(function (PostPlatform $pp): array { + $metrics = $this->forPlatform($pp); + + return [ + 'post_platform_id' => $pp->id, + 'platform' => $pp->platform->value, + 'status' => $pp->status->value, + 'platform_post_id' => $pp->platform_post_id, + 'platform_url' => $pp->platform_url, + 'metrics' => $metrics, + ]; + }); } /** @@ -63,7 +67,14 @@ public function forPlatform(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'not_published']; } - return Cache::remember("post_metrics:{$postPlatform->id}", 300, fn () => match ($postPlatform->platform) { + $cacheKey = "post_metrics:{$postPlatform->id}"; + $cached = Cache::get($cacheKey); + + if (is_array($cached) && ! isset($cached['unsupported'])) { + return $cached; + } + + $metrics = match ($postPlatform->platform) { Platform::X => app(XAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), @@ -77,6 +88,12 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), Platform::TikTok => app(TikTokAnalytics::class)->fetchPostMetrics($postPlatform), default => ['unsupported' => true, 'reason' => 'platform_not_supported'], - }); + }; + + if (! isset($metrics['unsupported'])) { + Cache::put($cacheKey, $metrics, 300); + } + + return $metrics; } } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index f1c4472dd..0b22addb2 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -4,12 +4,15 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use Throwable; class TikTokAnalytics { @@ -17,6 +20,12 @@ class TikTokAnalytics private const string VIDEO_METRIC_FIELDS = 'id,like_count,comment_count,share_count,view_count'; + private const string VIDEO_LIST_FIELDS = 'id,title,create_time,share_url,like_count,comment_count,share_count,view_count'; + + private const int VIDEO_LIST_PAGE_SIZE = 20; + + private const int VIDEO_LIST_MAX_PAGES = 5; + /** * @var array */ @@ -63,11 +72,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - if ($account->needsProactiveTokenRefresh()) { - app(ConnectionVerifier::class)->refreshToken($account); - } - - $this->accessToken = $account->access_token; + $this->prepareAccessToken($account); $videoId = $this->videoIdFor($postPlatform); @@ -104,6 +109,25 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array ->all(); } + /** + * Public posts often stay on a Content Posting `publish_id` because TikTok + * omits `publicaly_available_post_id` even after PUBLISH_COMPLETE. The video + * still shows up on `video/list` with the caption we sent — match that and + * persist the real item id so the show-page link stops pointing at the profile. + */ + public function findVideoIdByCaption(PostPlatform $postPlatform): ?string + { + $account = $postPlatform->socialAccount; + + if (! $account) { + return null; + } + + $this->prepareAccessToken($account); + + return $this->matchVideoFromRecentList($postPlatform); + } + private function videoIdFor(PostPlatform $postPlatform): ?string { $stored = (string) $postPlatform->platform_post_id; @@ -112,7 +136,8 @@ private function videoIdFor(PostPlatform $postPlatform): ?string return $stored; } - return $this->resolveVideoIdFromPublish($postPlatform, $stored); + return $this->resolveVideoIdFromPublish($postPlatform, $stored) + ?? $this->persistResolvedVideo($postPlatform, $this->matchVideoFromRecentList($postPlatform)); } private function resolveVideoIdFromPublish(PostPlatform $postPlatform, string $publishId): ?string @@ -127,13 +152,77 @@ private function resolveVideoIdFromPublish(PostPlatform $postPlatform, string $p 'body' => $this->redactResponseBody($response->body()), ]); - return null; + return $this->persistResolvedVideo($postPlatform, $this->matchVideoFromRecentList($postPlatform)); } $videoId = data_get($response->json(), 'data.publicaly_available_post_id.0'); $videoId = is_scalar($videoId) ? (string) $videoId : ''; if ($videoId === '' || ! ctype_digit($videoId)) { + return $this->persistResolvedVideo($postPlatform, $this->matchVideoFromRecentList($postPlatform)); + } + + return $this->persistResolvedVideo($postPlatform, $videoId); + } + + private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string + { + $postPlatform->loadMissing('post'); + + $caption = $this->normalizeCaption( + (string) ($postPlatform->post?->content ?? '') + ); + + if ($caption === '') { + return null; + } + + $cursor = null; + + for ($page = 0; $page < self::VIDEO_LIST_MAX_PAGES; $page++) { + $payload = ['max_count' => self::VIDEO_LIST_PAGE_SIZE]; + + if (is_int($cursor) || (is_string($cursor) && $cursor !== '')) { + $payload['cursor'] = $cursor; + } + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/video/list/?fields=".self::VIDEO_LIST_FIELDS, $payload); + + if ($response->failed()) { + Log::warning('TikTok video list match failed', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + foreach (data_get($response->json(), 'data.videos', []) as $video) { + if (! is_array($video)) { + continue; + } + + $videoId = is_scalar(data_get($video, 'id')) ? (string) data_get($video, 'id') : ''; + $title = $this->normalizeCaption((string) data_get($video, 'title', '')); + + if ($videoId !== '' && ctype_digit($videoId) && $this->captionsMatch($caption, $title)) { + return $videoId; + } + } + + if (! data_get($response->json(), 'data.has_more')) { + return null; + } + + $cursor = data_get($response->json(), 'data.cursor'); + } + + return null; + } + + private function persistResolvedVideo(PostPlatform $postPlatform, ?string $videoId): ?string + { + if ($videoId === null || $videoId === '' || ! ctype_digit($videoId)) { return null; } @@ -149,6 +238,37 @@ private function resolveVideoIdFromPublish(PostPlatform $postPlatform, string $p return $videoId; } + private function captionsMatch(string $posted, string $title): bool + { + return $posted === $title + || str_starts_with($posted, $title) + || str_starts_with($title, $posted); + } + + private function normalizeCaption(string $text): string + { + return (string) Str::of(app(ContentSanitizer::class)->displayText($text, Platform::TikTok)) + ->squish() + ->lower(); + } + + private function prepareAccessToken(SocialAccount $account): void + { + if ($account->needsProactiveTokenRefresh()) { + try { + app(ConnectionVerifier::class)->refreshToken($account); + $account->refresh(); + } catch (Throwable $e) { + Log::warning('TikTok token refresh before post metrics failed', [ + 'account_id' => $account->id, + 'error' => $e->getMessage(), + ]); + } + } + + $this->accessToken = $account->access_token; + } + private function fetchMetricsFromApi(SocialAccount $account): array { if ($account->needsProactiveTokenRefresh()) { diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 73973997a..dd92a90b3 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -480,6 +480,10 @@ private function completePublish(PostPlatform $postPlatform, string $publishId): $postId = data_get($statusData, 'publicaly_available_post_id.0'); $postId = is_string($postId) && $postId !== '' ? $postId : null; + if ($postId === null && data_get($postPlatform->meta ?? [], 'privacy_level') !== 'SELF_ONLY') { + $postId = app(TikTokAnalytics::class)->findVideoIdByCaption($postPlatform); + } + return [ 'id' => $postId ?? $publishId, 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), diff --git a/tests/Feature/Services/Social/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php index e51b80c20..1bcd677de 100644 --- a/tests/Feature/Services/Social/TikTokAnalyticsTest.php +++ b/tests/Feature/Services/Social/TikTokAnalyticsTest.php @@ -132,6 +132,10 @@ function tiktokPostPlatform(?string $platformPostId = '7685359243088103444'): Po ], 'error' => ['code' => 'ok'], ]), + $this->api.'/video/list/*' => Http::response([ + 'data' => ['videos' => [], 'has_more' => false], + 'error' => ['code' => 'ok'], + ]), ]); $metrics = (new TikTokAnalytics)->fetchPostMetrics( @@ -143,6 +147,59 @@ function tiktokPostPlatform(?string $platformPostId = '7685359243088103444'): Po Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/query/')); }); +test('tiktok analytics matches a publish id to the public video by caption', function () { + $this->post->update([ + 'content' => 'Eu bato nessa tecla há 7 anos: construam produtos globais.', + ]); + + $videoId = '7682891910226234644'; + $postPlatform = tiktokPostPlatform('v_pub_url~v2-1.7682889326782842900'); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => [], + ], + 'error' => ['code' => 'ok'], + ]), + $this->api.'/video/list/*' => Http::response([ + 'data' => [ + 'videos' => [[ + 'id' => $videoId, + 'title' => 'Eu bato nessa tecla há 7 anos: construam produtos globais.', + 'view_count' => 661, + 'like_count' => 13, + 'comment_count' => 2, + 'share_count' => 1, + ]], + 'has_more' => false, + ], + 'error' => ['code' => 'ok'], + ]), + $this->api.'/video/query/*' => Http::response(tiktokVideoQueryResponse($videoId, [ + 'view_count' => 661, + 'like_count' => 13, + 'comment_count' => 2, + 'share_count' => 1, + ])), + ]); + + $metrics = (new TikTokAnalytics)->fetchPostMetrics($postPlatform); + + expect($metrics)->toBe([ + ['label' => __('analytics.metrics.views'), 'value' => 661], + ['label' => __('analytics.metrics.likes'), 'value' => 13], + ['label' => __('analytics.metrics.comments'), 'value' => 2], + ['label' => __('analytics.metrics.shares'), 'value' => 1], + ]); + + $postPlatform->refresh(); + + expect($postPlatform->platform_post_id)->toBe($videoId) + ->and($postPlatform->platform_url)->toBe("https://www.tiktok.com/@tiktoker/video/{$videoId}"); +}); + test('tiktok analytics reports a missing platform post id as unsupported', function () { Http::fake(); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 310ac30b7..981155be6 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -88,6 +88,47 @@ }); }); +test('tiktok publisher persists the public video url when status omits the post id', function () { + $this->post->update([ + 'content' => 'Construam produtos globais e faturem em dólar.', + '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', + ]], + ]); + $this->postPlatform->update(['meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']]); + + Http::fake([ + $this->api.'/post/publish/video/init/' => Http::response([ + 'data' => ['publish_id' => 'v_pub_url~v2-1.missing-id'], + ], 200), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => [], + ], + ], 200), + $this->api.'/video/list/*' => Http::response([ + 'data' => [ + 'videos' => [[ + 'id' => '7682891910226234644', + 'title' => 'Construam produtos globais e faturem em dólar.', + ]], + 'has_more' => false, + ], + 'error' => ['code' => 'ok'], + ]), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('7682891910226234644') + ->and($result['url'])->toBe('https://www.tiktok.com/@tiktoker/video/7682891910226234644'); +}); + test('tiktok publisher does not report success before processing completes', function () { $this->post->update([ 'media' => [[ From 093be50ab36a5963fd79071cbc9801d766df07aa Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 12:30:09 -0300 Subject: [PATCH 3/8] Centralize TikTok privacy levels in a PrivacyLevel enum across web, API, MCP and the editor. --- app/Enums/SocialAccount/Platform.php | 8 +- app/Enums/TikTok/PrivacyLevel.php | 54 ++++++++++ app/Mcp/Tools/Post/CreatePostTool.php | 2 +- app/Mcp/Tools/Post/UpdatePostTool.php | 2 +- app/Services/Social/TikTokCreatorInfo.php | 3 +- app/Services/Social/TikTokPublisher.php | 35 ++++-- app/Support/PostPlatformMetaRules.php | 35 +++--- database/factories/PostPlatformFactory.php | 3 +- .../posts/editor/PostEditorTabs.vue | 3 +- .../components/posts/editor/ScheduleTab.vue | 3 +- .../posts/editor/TikTokSettings.vue | 31 +++--- resources/js/composables/usePostCompliance.ts | 9 +- resources/js/pages/posts/Edit.vue | 3 +- resources/js/types/channel.ts | 3 +- resources/js/types/tiktok-privacy.ts | 20 ++++ tests/Browser/RepurposeAccountHealthTest.php | 5 +- tests/Browser/RepurposeTest.php | 3 +- tests/Feature/Api/PostApiPlatformMetaTest.php | 43 +++++++- tests/Feature/Api/RepurposeApiTest.php | 5 +- .../Jobs/PublishToSocialPlatformTest.php | 3 +- .../Feature/Mcp/PostPlatformMetaToolTest.php | 62 +++++++++++ tests/Feature/Mcp/RepurposeToolTest.php | 5 +- tests/Feature/Repurpose/AccountHealthTest.php | 7 +- tests/Feature/Repurpose/ActionsTest.php | 3 +- tests/Feature/Repurpose/ProcessItemTest.php | 7 +- .../Feature/Repurpose/RepurposeModelTest.php | 3 +- tests/Feature/Repurpose/WebTest.php | 3 +- .../Services/Social/TikTokCreatorInfoTest.php | 36 ++++++- .../Services/Social/TikTokPublisherTest.php | 100 +++++++++++++----- tests/Feature/Services/WebhookServiceTest.php | 5 +- tests/Feature/UpdatePostRequestTest.php | 40 ++++++- tests/Unit/Enums/PlatformTest.php | 5 + tests/Unit/Enums/TikTok/PrivacyLevelTest.php | 54 ++++++++++ tests/Unit/PostPlatformMetaRulesTest.php | 24 +++++ 34 files changed, 519 insertions(+), 108 deletions(-) create mode 100644 app/Enums/TikTok/PrivacyLevel.php create mode 100644 resources/js/types/tiktok-privacy.ts create mode 100644 tests/Unit/Enums/TikTok/PrivacyLevelTest.php diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index ee53b0541..211bca1b3 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -5,6 +5,7 @@ namespace App\Enums\SocialAccount; use App\Enums\Media\Type as MediaType; +use App\Enums\TikTok\PrivacyLevel; enum Platform: string { @@ -480,12 +481,7 @@ public function publishConfig(): array { return match ($this) { self::TikTok => [ - 'privacyLevelOptions' => [ - 'PUBLIC_TO_EVERYONE', - 'MUTUAL_FOLLOW_FRIENDS', - 'FOLLOWER_OF_CREATOR', - 'SELF_ONLY', - ], + 'privacyLevelOptions' => PrivacyLevel::values(), 'musicUsageConfirmationUrl' => 'https://www.tiktok.com/legal/page/global/music-usage-confirmation/en', 'brandedContentPolicyUrl' => 'https://www.tiktok.com/legal/page/global/bc-policy/en', ], diff --git a/app/Enums/TikTok/PrivacyLevel.php b/app/Enums/TikTok/PrivacyLevel.php new file mode 100644 index 000000000..d58f19513 --- /dev/null +++ b/app/Enums/TikTok/PrivacyLevel.php @@ -0,0 +1,54 @@ + + */ + public static function values(): array + { + return array_map(fn (self $level) => $level->value, self::cases()); + } + + /** + * Keep only values the Content Posting API accepts, in the given order. + * Unknown creator_info options are dropped so they never reach the editor + * or a publish payload. + * + * @param iterable $options + * @return list + */ + public static function knownValues(iterable $options): array + { + $values = []; + + foreach ($options as $option) { + $level = self::tryFrom((string) $option); + + if ($level instanceof self) { + $values[] = $level->value; + } + } + + return $values; + } + + public function allowsBrandedContent(): bool + { + return $this !== self::SelfOnly; + } +} diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index a90e40305..0de4bf31a 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -79,7 +79,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index c48885578..a8b975a43 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -119,7 +119,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'), 'content_type' => $p->string()->description('New content_type for this platform.'), - 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'), ])) ->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'), ]; diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 71ce1a4d6..f9af39877 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\TikTok\PrivacyLevel; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; @@ -83,7 +84,7 @@ private function fetchFresh(SocialAccount $account): array 'creator_nickname' => data_get($data, 'creator_nickname'), 'creator_username' => data_get($data, 'creator_username'), 'creator_avatar_url' => data_get($data, 'creator_avatar_url'), - 'privacy_level_options' => data_get($data, 'privacy_level_options', []), + 'privacy_level_options' => PrivacyLevel::knownValues(data_get($data, 'privacy_level_options', [])), 'comment_disabled' => (bool) data_get($data, 'comment_disabled', false), 'duet_disabled' => (bool) data_get($data, 'duet_disabled', false), 'stitch_disabled' => (bool) data_get($data, 'stitch_disabled', false), diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index dd92a90b3..5f7f66554 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -6,6 +6,7 @@ use App\Dto\MediaItem; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\TikTok\PublishStatus; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; @@ -98,23 +99,35 @@ private function getHttpClient(): PendingRequest } /** - * Resolve the user-selected privacy_level from meta, throwing when missing. - * TikTok UX Guideline Point 2b forbids any default — the user must pick - * explicitly. The FormRequest validates this upstream; this is the safety - * net for queue/job paths that bypass the request layer. + * Resolve the user-selected privacy_level from meta, throwing when missing + * or unknown. TikTok UX Guideline Point 2b forbids any default — the user + * must pick explicitly. The FormRequest validates this upstream; this is + * the safety net for queue/job paths that bypass the request layer. */ - private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): string + private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): PrivacyLevel { - $privacyLevel = data_get($postPlatform->meta ?? [], 'privacy_level'); + $privacyLevel = $this->privacyLevel($postPlatform); - if (blank($privacyLevel)) { + if ($privacyLevel === null) { throw new TikTokPublishException( userMessage: 'TikTok privacy level is required. Please open the post and pick a visibility option.', category: ErrorCategory::ContentPolicy, ); } - return (string) $privacyLevel; + if (! $privacyLevel->allowsBrandedContent() && data_get($postPlatform->meta ?? [], 'brand_content_toggle')) { + throw new TikTokPublishException( + userMessage: trans('posts.form.tiktok.privacy.private_disabled_branded'), + category: ErrorCategory::ContentPolicy, + ); + } + + return $privacyLevel; + } + + private function privacyLevel(PostPlatform $postPlatform): ?PrivacyLevel + { + return PrivacyLevel::tryFrom((string) data_get($postPlatform->meta ?? [], 'privacy_level')); } /** @@ -130,7 +143,7 @@ private function buildVideoPostInfo(PostPlatform $postPlatform, ?string $content $postInfo = [ 'title' => $content ?? '', - 'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform), + 'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform)->value, 'disable_duet' => ! data_get($meta, 'allow_duet', false), 'disable_comment' => ! data_get($meta, 'allow_comments', false), 'disable_stitch' => ! data_get($meta, 'allow_stitch', false), @@ -165,7 +178,7 @@ private function buildPhotoPostInfo(PostPlatform $postPlatform, ?string $content $postInfo = [ 'description' => $content ?? '', - 'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform), + 'privacy_level' => $this->resolveRequiredPrivacyLevel($postPlatform)->value, 'disable_comment' => ! data_get($meta, 'allow_comments', false), ]; @@ -480,7 +493,7 @@ private function completePublish(PostPlatform $postPlatform, string $publishId): $postId = data_get($statusData, 'publicaly_available_post_id.0'); $postId = is_string($postId) && $postId !== '' ? $postId : null; - if ($postId === null && data_get($postPlatform->meta ?? [], 'privacy_level') !== 'SELF_ONLY') { + if ($postId === null && $this->privacyLevel($postPlatform) !== PrivacyLevel::SelfOnly) { $postId = app(TikTokAnalytics::class)->findVideoIdByCaption($postPlatform); } diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index cec61ee93..76c639dba 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -6,6 +6,7 @@ use App\Enums\PostPlatform\AspectRatio; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -19,18 +20,6 @@ */ class PostPlatformMetaRules { - /** - * TikTok content visibility options. - * - * @var array - */ - public const TIKTOK_PRIVACY_LEVELS = [ - 'PUBLIC_TO_EVERYONE', - 'MUTUAL_FOLLOW_FRIENDS', - 'FOLLOWER_OF_CREATOR', - 'SELF_ONLY', - ]; - /** * Validation rules for `platforms.*.meta` and all its per-platform sub-keys. * Spread into a FormRequest/MCP tool rule set as the complete meta contract. @@ -49,7 +38,7 @@ public static function rules(): array 'platforms.*.meta.document_title' => ['sometimes', 'nullable', 'string', 'max:300'], // TikTok - 'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(self::TIKTOK_PRIVACY_LEVELS)], + 'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::enum(PrivacyLevel::class)], 'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'], 'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'], 'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'], @@ -163,10 +152,28 @@ public static function assertStoredPostPublishable(Post $post): void public static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array { return match (true) { - $platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')], + $platform === Platform::TikTok => self::tiktokPrivacyViolation($meta), $platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')], $platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')], default => null, }; } + + /** + * @return array{0: string, 1: string}|null + */ + private static function tiktokPrivacyViolation(mixed $meta): ?array + { + $privacyLevel = PrivacyLevel::tryFrom((string) data_get($meta, 'privacy_level')); + + if ($privacyLevel === null) { + return ['privacy_level', trans('posts.form.tiktok.privacy_required')]; + } + + if (! $privacyLevel->allowsBrandedContent() && data_get($meta, 'brand_content_toggle')) { + return ['privacy_level', trans('posts.form.tiktok.privacy.private_disabled_branded')]; + } + + return null; + } } diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index cb39e970f..fce0da4ed 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -7,6 +7,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\PostPlatform\Status; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -111,7 +112,7 @@ public function tiktok(): static return $this->state(fn (array $attributes) => [ 'platform' => Platform::TikTok, 'content_type' => ContentType::TikTokVideo, - 'meta' => ['privacy_level' => 'SELF_ONLY'], + 'meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value], ]); } diff --git a/resources/js/components/posts/editor/PostEditorTabs.vue b/resources/js/components/posts/editor/PostEditorTabs.vue index 471245178..9dcefb699 100644 --- a/resources/js/components/posts/editor/PostEditorTabs.vue +++ b/resources/js/components/posts/editor/PostEditorTabs.vue @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import type { PlatformIssue } from '@/composables/usePostCompliance'; import type { PinterestBoardsPayload } from '@/types'; import type { MediaItem } from '@/types/media'; +import type { TikTokPrivacyLevelValue } from '@/types/tiktok-privacy'; interface SocialAccount { id: string; @@ -45,7 +46,7 @@ interface TikTokCreatorInfo { creator_nickname: string | null; creator_username: string | null; creator_avatar_url: string | null; - privacy_level_options: string[]; + privacy_level_options: TikTokPrivacyLevelValue[]; comment_disabled: boolean; duet_disabled: boolean; stitch_disabled: boolean; diff --git a/resources/js/components/posts/editor/ScheduleTab.vue b/resources/js/components/posts/editor/ScheduleTab.vue index 8ebf737e6..4239fc1af 100644 --- a/resources/js/components/posts/editor/ScheduleTab.vue +++ b/resources/js/components/posts/editor/ScheduleTab.vue @@ -12,6 +12,7 @@ import { isVideo } from '@/lib/mediaType'; import type { PinterestBoard, PinterestBoardsPayload } from '@/types'; import type { Channel } from '@/types/channel'; import type { MediaItem } from '@/types/media'; +import type { TikTokPrivacyLevelValue } from '@/types/tiktok-privacy'; import { PostPlatformStatus } from '@/types/post'; interface SocialAccount { @@ -61,7 +62,7 @@ interface TikTokCreatorInfo { creator_nickname: string | null; creator_username: string | null; creator_avatar_url: string | null; - privacy_level_options: string[]; + privacy_level_options: TikTokPrivacyLevelValue[]; comment_disabled: boolean; duet_disabled: boolean; stitch_disabled: boolean; diff --git a/resources/js/components/posts/editor/TikTokSettings.vue b/resources/js/components/posts/editor/TikTokSettings.vue index 8bd06e751..d4c1aeef4 100644 --- a/resources/js/components/posts/editor/TikTokSettings.vue +++ b/resources/js/components/posts/editor/TikTokSettings.vue @@ -18,6 +18,12 @@ import { import { usePageErrors } from '@/composables/usePageErrors'; import { getPlatformLogo } from '@/composables/usePlatformLogo'; import { ContentType } from '@/types/content-type'; +import { + isTikTokPrivacyLevel, + TikTokPrivacyLevel, + tiktokPrivacyLabelKey, + type TikTokPrivacyLevelValue, +} from '@/types/tiktok-privacy'; interface SocialAccount { id: string; @@ -32,7 +38,7 @@ interface CreatorInfo { creator_nickname: string | null; creator_username: string | null; creator_avatar_url: string | null; - privacy_level_options: string[]; + privacy_level_options: TikTokPrivacyLevelValue[]; comment_disabled: boolean; duet_disabled: boolean; stitch_disabled: boolean; @@ -77,7 +83,7 @@ const open = ref(false); const errors = usePageErrors(); const privacyError = computed(() => { - if (props.meta?.privacy_level) { + if (isTikTokPrivacyLevel(props.meta?.privacy_level)) { return undefined; } @@ -142,17 +148,19 @@ const brandContentToggle = computed({ // Prefer the creator_info API response; fall back to the static list from the Platform enum. const allPrivacyOptions = computed(() => { - const fromApi = props.creatorInfo?.privacy_level_options ?? []; - return fromApi.length > 0 ? fromApi : props.publishConfig?.privacyLevelOptions ?? []; + const fromApi = (props.creatorInfo?.privacy_level_options ?? []).filter(isTikTokPrivacyLevel); + const fallback = (props.publishConfig?.privacyLevelOptions ?? []).filter(isTikTokPrivacyLevel); + + return fromApi.length > 0 ? fromApi : fallback; }); -// Render every option creator_info returns. SELF_ONLY is shown but disabled when +// Render every option creator_info returns. SelfOnly is shown but disabled when // Branded Content is checked (TikTok UX Guideline Point 3b — must show interaction, // not hide it). const privacyOptions = computed(() => allPrivacyOptions.value); const isSelfOnlyDisabled = (option: string): boolean => - option === 'SELF_ONLY' && brandContentToggle.value; + option === TikTokPrivacyLevel.SelfOnly && brandContentToggle.value; const commentDisabled = computed(() => Boolean(props.creatorInfo?.comment_disabled)); const duetDisabled = computed(() => Boolean(props.creatorInfo?.duet_disabled)); @@ -171,13 +179,6 @@ const exceedsMaxDuration = computed(() => { return props.videoDurationSec > maxDurationSec.value; }); -const privacyLabelKey: Record = { - PUBLIC_TO_EVERYONE: 'posts.form.tiktok.privacy.public', - MUTUAL_FOLLOW_FRIENDS: 'posts.form.tiktok.privacy.friends', - FOLLOWER_OF_CREATOR: 'posts.form.tiktok.privacy.followers', - SELF_ONLY: 'posts.form.tiktok.privacy.private', -}; - const hasAnyBrandToggle = computed(() => brandOrganicToggle.value || brandContentToggle.value); // Label required by TikTok: "Paid partnership" when branded content (with or without organic), @@ -190,7 +191,7 @@ const promotionalTitleKey = computed(() => // Surface a toast so the user understands why the field reset (TikTok UX Guideline Point 3b // requires informing the user when an auto-switch happens). watch(brandContentToggle, (value) => { - if (value && privacyLevel.value === 'SELF_ONLY') { + if (value && privacyLevel.value === TikTokPrivacyLevel.SelfOnly) { privacyLevel.value = ''; toast.warning(trans('posts.form.tiktok.branded_cleared_private')); } @@ -281,7 +282,7 @@ watch( :disabled="isSelfOnlyDisabled(option)" :title="isSelfOnlyDisabled(option) ? $t('posts.form.tiktok.privacy.private_disabled_branded') : undefined" > - {{ $t(privacyLabelKey[option] ?? option) }} + {{ $t(isTikTokPrivacyLevel(option) ? tiktokPrivacyLabelKey[option] : option) }} diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index 010c08b41..b5877a4a9 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -9,6 +9,7 @@ import { mediaLimitsDocsUrl } from '@/lib/docs'; import { ContentType } from '@/types/content-type'; import type { MediaItem } from '@/types/media'; import { Platform } from '@/types/platform'; +import { isTikTokPrivacyLevel, TikTokPrivacyLevel } from '@/types/tiktok-privacy'; export interface CompliancePostPlatform { id: string; @@ -55,15 +56,19 @@ const PLATFORM_META_RULES: Record = { const disclosureIncomplete = Boolean(meta.disclose) && !meta.brand_organic_toggle && !meta.brand_content_toggle; - const privacyLevelMissing = !meta.privacy_level; + const privacyLevelMissing = !isTikTokPrivacyLevel(meta.privacy_level); + const brandedPrivate = meta.privacy_level === TikTokPrivacyLevel.SelfOnly + && Boolean(meta.brand_content_toggle); let tooltipKey: string | null = null; if (disclosureIncomplete) { tooltipKey = 'posts.form.tiktok.compliance_incomplete'; + } else if (brandedPrivate) { + tooltipKey = 'posts.form.tiktok.privacy.private_disabled_branded'; } else if (privacyLevelMissing) { tooltipKey = 'posts.form.tiktok.privacy_required'; } return { - valid: !disclosureIncomplete && !privacyLevelMissing, + valid: !disclosureIncomplete && !privacyLevelMissing && !brandedPrivate, tooltipKey, }; }, diff --git a/resources/js/pages/posts/Edit.vue b/resources/js/pages/posts/Edit.vue index c9b0218bc..bb0e9c9ff 100644 --- a/resources/js/pages/posts/Edit.vue +++ b/resources/js/pages/posts/Edit.vue @@ -27,6 +27,7 @@ import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts import type { PinterestBoardsPayload } from '@/types'; import type { MediaItem } from '@/types/media'; import { PostStatus } from '@/types/post'; +import type { TikTokPrivacyLevelValue } from '@/types/tiktok-privacy'; interface SocialAccount { id: string; @@ -75,7 +76,7 @@ interface TikTokCreatorInfo { creator_nickname: string | null; creator_username: string | null; creator_avatar_url: string | null; - privacy_level_options: string[]; + privacy_level_options: TikTokPrivacyLevelValue[]; comment_disabled: boolean; duet_disabled: boolean; stitch_disabled: boolean; diff --git a/resources/js/types/channel.ts b/resources/js/types/channel.ts index d9aafcbbc..93d310cb5 100644 --- a/resources/js/types/channel.ts +++ b/resources/js/types/channel.ts @@ -1,4 +1,5 @@ import type { PinterestBoard } from '@/types'; +import type { TikTokPrivacyLevelValue } from '@/types/tiktok-privacy'; export interface ChannelAccount { id: string; @@ -15,7 +16,7 @@ export interface ChannelTikTokCreatorInfo { creator_nickname: string | null; creator_username: string | null; creator_avatar_url: string | null; - privacy_level_options: string[]; + privacy_level_options: TikTokPrivacyLevelValue[]; comment_disabled: boolean; duet_disabled: boolean; stitch_disabled: boolean; diff --git a/resources/js/types/tiktok-privacy.ts b/resources/js/types/tiktok-privacy.ts new file mode 100644 index 000000000..33c6a5663 --- /dev/null +++ b/resources/js/types/tiktok-privacy.ts @@ -0,0 +1,20 @@ +export const TikTokPrivacyLevel = { + PublicToEveryone: 'PUBLIC_TO_EVERYONE', + MutualFollowFriends: 'MUTUAL_FOLLOW_FRIENDS', + FollowerOfCreator: 'FOLLOWER_OF_CREATOR', + SelfOnly: 'SELF_ONLY', +} as const; + +export type TikTokPrivacyLevelValue = (typeof TikTokPrivacyLevel)[keyof typeof TikTokPrivacyLevel]; + +export const TIKTOK_PRIVACY_LEVELS: TikTokPrivacyLevelValue[] = Object.values(TikTokPrivacyLevel); + +export const isTikTokPrivacyLevel = (value: unknown): value is TikTokPrivacyLevelValue => + typeof value === 'string' && (TIKTOK_PRIVACY_LEVELS as string[]).includes(value); + +export const tiktokPrivacyLabelKey: Record = { + [TikTokPrivacyLevel.PublicToEveryone]: 'posts.form.tiktok.privacy.public', + [TikTokPrivacyLevel.MutualFollowFriends]: 'posts.form.tiktok.privacy.friends', + [TikTokPrivacyLevel.FollowerOfCreator]: 'posts.form.tiktok.privacy.followers', + [TikTokPrivacyLevel.SelfOnly]: 'posts.form.tiktok.privacy.private', +}; diff --git a/tests/Browser/RepurposeAccountHealthTest.php b/tests/Browser/RepurposeAccountHealthTest.php index 3e2fee22c..91482f7a8 100644 --- a/tests/Browser/RepurposeAccountHealthTest.php +++ b/tests/Browser/RepurposeAccountHealthTest.php @@ -5,6 +5,7 @@ use App\Enums\Repurpose\PauseReason; use App\Enums\Repurpose\Status; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\UserWorkspace\Role; use App\Models\Repurpose; use App\Models\SocialAccount; @@ -44,7 +45,7 @@ function waitForRepurposeHealthTestId(mixed $page, string $testId): void 'destinations' => [[ 'social_account_id' => $destination->id, 'content_type' => 'tiktok_video', - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]], ]); @@ -81,7 +82,7 @@ function waitForRepurposeHealthTestId(mixed $page, string $testId): void 'destinations' => [[ 'social_account_id' => $paused->id, 'content_type' => 'tiktok_video', - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]], ]); diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php index c4a0c4521..a527ff482 100644 --- a/tests/Browser/RepurposeTest.php +++ b/tests/Browser/RepurposeTest.php @@ -8,6 +8,7 @@ use App\Enums\Repurpose\SourceFormat; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status as AccountStatus; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\UserWorkspace\Role; use App\Models\Repurpose; use App\Models\RepurposeItem; @@ -253,7 +254,7 @@ function repurposeOwnerWithAccounts(): array 'destinations' => [[ 'social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]], ]); diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index 7966ab7ac..adf780920 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -5,6 +5,7 @@ use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Jobs\PublishPost; use App\Models\Post; use App\Models\PostPlatform; @@ -297,17 +298,33 @@ 'platforms' => [ ['social_account_id' => $instagram->id, 'content_type' => ContentType::InstagramFeed->value, 'meta' => ['aspect_ratio' => '4:5']], ['social_account_id' => $pinterest->id, 'content_type' => ContentType::PinterestPin->value, 'meta' => ['board_id' => 'board-99']], - ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'SELF_ONLY', 'allow_comments' => true]], + ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value, 'allow_comments' => true]], ], ]) ->assertCreated(); expect(PostPlatform::where('social_account_id', $instagram->id)->sole()->meta['aspect_ratio'])->toBe('4:5') ->and(PostPlatform::where('social_account_id', $pinterest->id)->sole()->meta['board_id'])->toBe('board-99') - ->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['privacy_level'])->toBe('SELF_ONLY') + ->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['privacy_level'])->toBe(PrivacyLevel::SelfOnly->value) ->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['allow_comments'])->toBeTrue(); }); +it('rejects an unknown TikTok privacy level on store', function () { + $tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]); + + $this->withHeaders($this->headers) + ->postJson(route('api.posts.store'), [ + 'content' => 'Unknown privacy', + 'platforms' => [[ + 'social_account_id' => $tiktok->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'EVERYONE'], + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['platforms.0.meta.privacy_level']); +}); + it('allows saving a Discord draft without a channel', function () { $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); $platform = PostPlatform::factory()->discord()->create([ @@ -352,6 +369,28 @@ ]); }); +it('rejects publishing TikTok as self only branded content', function () { + $tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $tiktokPlatform = PostPlatform::factory()->tiktok()->create([ + 'post_id' => $post->id, 'social_account_id' => $tiktok->id, 'enabled' => true, 'meta' => [], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [[ + 'id' => $tiktokPlatform->id, + 'meta' => [ + 'privacy_level' => PrivacyLevel::SelfOnly->value, + 'brand_content_toggle' => true, + ], + ]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['platforms.0.meta.privacy_level']); +}); + it('rejects publishing a Discord post without a channel', function () { $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); $platform = PostPlatform::factory()->discord()->create([ diff --git a/tests/Feature/Api/RepurposeApiTest.php b/tests/Feature/Api/RepurposeApiTest.php index 10d05433d..1967c10ba 100644 --- a/tests/Feature/Api/RepurposeApiTest.php +++ b/tests/Feature/Api/RepurposeApiTest.php @@ -11,6 +11,7 @@ use App\Enums\Repurpose\Status; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status as AccountStatus; +use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; use App\Models\PostPlatform; use App\Models\Repurpose; @@ -35,7 +36,7 @@ function tiktokDestinationPayload(SocialAccount $account): array return [ 'social_account_id' => $account->id, 'content_type' => ContentType::TikTokVideo->value, - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]; } @@ -87,7 +88,7 @@ function tiktokDestinationPayload(SocialAccount $account): array $this->withHeaders(apiHeaders($this->token)) ->getJson(route('api.repurposes.show', $id)) ->assertOk() - ->assertJsonPath('destinations.0.meta.privacy_level', 'PUBLIC_TO_EVERYONE'); + ->assertJsonPath('destinations.0.meta.privacy_level', PrivacyLevel::PublicToEveryone->value); }); test('a draft accepts a destination that is still missing its required meta', function () { diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index c6ef6d3f8..8beea12e4 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -8,6 +8,7 @@ use App\Enums\PostPlatform\Status as PlatformStatus; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status as AccountStatus; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\UserWorkspace\Role; use App\Events\PostPlatformStatusUpdated; use App\Exceptions\PlatformUnavailableException; @@ -1167,7 +1168,7 @@ 'social_account_id' => $account->id, 'status' => PlatformStatus::Pending, 'enabled' => true, - 'meta' => ['privacy_level' => 'SELF_ONLY'], + 'meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value], ]); $mockOptimizer = Mockery::mock(MediaOptimizer::class); diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index 85fd6e244..28b96d4bf 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -5,6 +5,7 @@ use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\UserWorkspace\Role; use App\Jobs\PublishPost; use App\Mcp\Servers\TryPostServer; @@ -177,6 +178,67 @@ 'pinterest' => ['pinterest', 'board_id', 'posts.form.pinterest.board_required'], ]); +test('create post rejects an unknown TikTok privacy level', function () { + $tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]); + + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'content' => 'Unknown privacy', + 'platforms' => [[ + 'social_account_id' => $tiktok->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'EVERYONE'], + ]], + ]); + + $response->assertHasErrors(); +}); + +test('publish post rejects stored TikTok self only branded content', function () { + $tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->tiktok()->create([ + 'post_id' => $post->id, + 'social_account_id' => $tiktok->id, + 'enabled' => true, + 'meta' => [ + 'privacy_level' => PrivacyLevel::SelfOnly->value, + 'brand_content_toggle' => true, + ], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__('posts.form.tiktok.privacy.private_disabled_branded')]); +}); + +test('publish post rejects a stored unknown TikTok privacy level', function () { + $tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->tiktok()->create([ + 'post_id' => $post->id, + 'social_account_id' => $tiktok->id, + 'enabled' => true, + 'meta' => ['privacy_level' => 'EVERYONE'], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__('posts.form.tiktok.privacy_required')]); +}); + test('attach media from upload accepts a PDF for a LinkedIn post', function () { $linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]); diff --git a/tests/Feature/Mcp/RepurposeToolTest.php b/tests/Feature/Mcp/RepurposeToolTest.php index d39a09353..cae7ff47c 100644 --- a/tests/Feature/Mcp/RepurposeToolTest.php +++ b/tests/Feature/Mcp/RepurposeToolTest.php @@ -11,6 +11,7 @@ use App\Enums\Repurpose\Status; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status as AccountStatus; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\UserWorkspace\Role; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Repurpose\ActivateRepurposeTool; @@ -49,7 +50,7 @@ function tiktokDestinationForMcp(SocialAccount $account): array return [ 'social_account_id' => $account->id, 'content_type' => ContentType::TikTokVideo->value, - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]; } @@ -80,7 +81,7 @@ function tiktokDestinationForMcp(SocialAccount $account): array TryPostServer::actingAs($this->user) ->tool(GetRepurposeTool::class, ['repurpose_id' => $repurpose->id]) ->assertOk() - ->assertSee('PUBLIC_TO_EVERYONE'); + ->assertSee(PrivacyLevel::PublicToEveryone->value); }); test('the list tool returns the workspace repurposes and nobody else\'s', function () { diff --git a/tests/Feature/Repurpose/AccountHealthTest.php b/tests/Feature/Repurpose/AccountHealthTest.php index 48f393ea4..6475236da 100644 --- a/tests/Feature/Repurpose/AccountHealthTest.php +++ b/tests/Feature/Repurpose/AccountHealthTest.php @@ -13,6 +13,7 @@ use App\Enums\Repurpose\Status; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status as AccountStatus; +use App\Enums\TikTok\PrivacyLevel; use App\Jobs\Repurpose\ProcessRepurposeItem; use App\Models\Repurpose; use App\Models\RepurposeItem; @@ -47,7 +48,7 @@ function healthDestination(Workspace $workspace): array return [ 'social_account_id' => $account->id, 'content_type' => ContentType::TikTokVideo->value, - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]; } @@ -806,7 +807,7 @@ function healthDestination(Workspace $workspace): array 'destinations' => [[ 'social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]], ]); @@ -820,5 +821,5 @@ function healthDestination(Workspace $workspace): array ]) ->assertSessionHasErrors('destinations.0.meta.privacy_level'); - expect(data_get($repurpose->fresh()->destinations, '0.meta.privacy_level'))->toBe('PUBLIC_TO_EVERYONE'); + expect(data_get($repurpose->fresh()->destinations, '0.meta.privacy_level'))->toBe(PrivacyLevel::PublicToEveryone->value); }); diff --git a/tests/Feature/Repurpose/ActionsTest.php b/tests/Feature/Repurpose/ActionsTest.php index 51d498053..1607092f2 100644 --- a/tests/Feature/Repurpose/ActionsTest.php +++ b/tests/Feature/Repurpose/ActionsTest.php @@ -14,6 +14,7 @@ use App\Enums\Repurpose\SourceFormat; use App\Enums\Repurpose\Status; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; use App\Models\Repurpose; use App\Models\RepurposeItem; @@ -38,7 +39,7 @@ function tiktokDestination(Workspace $workspace): array return [ 'social_account_id' => $account->id, 'content_type' => ContentType::TikTokVideo->value, - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]; } diff --git a/tests/Feature/Repurpose/ProcessItemTest.php b/tests/Feature/Repurpose/ProcessItemTest.php index 3b3f90ef7..2dfc8d78b 100644 --- a/tests/Feature/Repurpose/ProcessItemTest.php +++ b/tests/Feature/Repurpose/ProcessItemTest.php @@ -11,6 +11,7 @@ use App\Enums\Repurpose\PublishMode; use App\Enums\Repurpose\Status as RepurposeStatus; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Events\PostStatusChanged; use App\Exceptions\Repurpose\SourceDownloadException; use App\Jobs\PublishPost; @@ -46,7 +47,7 @@ function repurposeWithTwoDestinations(): RepurposeItem 'workspace_id' => $workspace->id, 'source_social_account_id' => $source->id, 'destinations' => [ - ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']], + ['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value]], ['social_account_id' => $youtube->id, 'content_type' => ContentType::YouTubeShort->value, 'meta' => []], ], ]); @@ -127,7 +128,7 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void ->where('platform', Platform::TikTok) ->sole(); - expect($tiktokPlatform->meta)->toEqual(['privacy_level' => 'PUBLIC_TO_EVERYONE']); + expect($tiktokPlatform->meta)->toEqual(['privacy_level' => PrivacyLevel::PublicToEveryone->value]); }); test('a caption over a destination limit is shortened for that post only', function () { @@ -403,7 +404,7 @@ function processItem(RepurposeItem $item, string $caption = 'My caption'): void 'workspace_id' => $workspace->id, 'source_social_account_id' => $source->id, 'destinations' => [ - ['social_account_id' => $off->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']], + ['social_account_id' => $off->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value]], ], ]); diff --git a/tests/Feature/Repurpose/RepurposeModelTest.php b/tests/Feature/Repurpose/RepurposeModelTest.php index d2260e14e..e6e8f40c1 100644 --- a/tests/Feature/Repurpose/RepurposeModelTest.php +++ b/tests/Feature/Repurpose/RepurposeModelTest.php @@ -7,6 +7,7 @@ use App\Enums\Repurpose\SourceFormat; use App\Enums\Repurpose\Status; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Models\Repurpose; use App\Models\RepurposeItem; use App\Models\SocialAccount; @@ -27,7 +28,7 @@ test('destinations round-trip as an array', function () { $destinations = [ - ['social_account_id' => (string) Str::uuid(), 'content_type' => 'tiktok_video', 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']], + ['social_account_id' => (string) Str::uuid(), 'content_type' => 'tiktok_video', 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value]], ]; $repurpose = Repurpose::factory()->create(['destinations' => $destinations]); diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php index 9f6e490b4..b41dc45c3 100644 --- a/tests/Feature/Repurpose/WebTest.php +++ b/tests/Feature/Repurpose/WebTest.php @@ -8,6 +8,7 @@ use App\Enums\Repurpose\SourceFormat; use App\Enums\Repurpose\Status; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\UserWorkspace\Role; use App\Models\Post; use App\Models\PostPlatform; @@ -38,7 +39,7 @@ function destinationPayload(SocialAccount $account): array return [ 'social_account_id' => $account->id, 'content_type' => ContentType::TikTokVideo->value, - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]; } diff --git a/tests/Feature/Services/Social/TikTokCreatorInfoTest.php b/tests/Feature/Services/Social/TikTokCreatorInfoTest.php index d25edfb7b..73b247643 100644 --- a/tests/Feature/Services/Social/TikTokCreatorInfoTest.php +++ b/tests/Feature/Services/Social/TikTokCreatorInfoTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\TikTok\PrivacyLevel; use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; @@ -28,7 +29,11 @@ 'creator_nickname' => 'Paulo', 'creator_username' => 'paulocastellano', 'creator_avatar_url' => 'https://cdn.tiktok.com/avatar.jpg', - 'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY'], + 'privacy_level_options' => [ + PrivacyLevel::PublicToEveryone->value, + PrivacyLevel::MutualFollowFriends->value, + PrivacyLevel::SelfOnly->value, + ], 'comment_disabled' => false, 'duet_disabled' => true, 'stitch_disabled' => true, @@ -42,7 +47,11 @@ expect($info['creator_nickname'])->toBe('Paulo') ->and($info['creator_username'])->toBe('paulocastellano') ->and($info['creator_avatar_url'])->toBe('https://cdn.tiktok.com/avatar.jpg') - ->and($info['privacy_level_options'])->toBe(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY']) + ->and($info['privacy_level_options'])->toBe([ + PrivacyLevel::PublicToEveryone->value, + PrivacyLevel::MutualFollowFriends->value, + PrivacyLevel::SelfOnly->value, + ]) ->and($info['comment_disabled'])->toBeFalse() ->and($info['duet_disabled'])->toBeTrue() ->and($info['stitch_disabled'])->toBeTrue() @@ -87,7 +96,7 @@ ], 200), $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ - 'privacy_level_options' => ['PUBLIC_TO_EVERYONE'], + 'privacy_level_options' => [PrivacyLevel::PublicToEveryone->value], ], ], 200), ]); @@ -97,3 +106,24 @@ Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token/')); expect($this->account->fresh()->access_token)->toBe('new-token'); }); + +test('it drops unknown privacy options returned by creator info', function () { + Http::fake([ + $this->api.'/post/publish/creator_info/query/' => Http::response([ + 'data' => [ + 'privacy_level_options' => [ + PrivacyLevel::PublicToEveryone->value, + 'EVERYONE', + PrivacyLevel::SelfOnly->value, + ], + ], + ], 200), + ]); + + $info = $this->service->fetch($this->account); + + expect($info['privacy_level_options'])->toBe([ + PrivacyLevel::PublicToEveryone->value, + PrivacyLevel::SelfOnly->value, + ]); +}); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 981155be6..1c41f8989 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -4,6 +4,7 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\TikTokPublishException; use App\Exceptions\TokenExpiredException; @@ -99,7 +100,7 @@ 'original_filename' => 'test-video.mp4', ]], ]); - $this->postPlatform->update(['meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value]]); Http::fake([ $this->api.'/post/publish/video/init/' => Http::response([ @@ -190,7 +191,7 @@ }); test('tiktok publisher checkpoints a photo publish_id before polling status', function () { - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [[ 'id' => 'test-media-image', @@ -218,7 +219,7 @@ test('tiktok publisher checkpoints photo derivatives with the publish_id before polling', function () { Storage::fake(); - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [[ 'id' => 'oversized', @@ -264,7 +265,7 @@ test('tiktok publisher keeps photo derivatives when status fetch reports an expired token', function () { Storage::fake(); - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [[ 'id' => 'oversized', @@ -333,7 +334,7 @@ test('tiktok publisher prunes photo derivatives when TikTok confirms the publish failed', function () { Storage::fake(); - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [[ 'id' => 'oversized', @@ -721,7 +722,7 @@ test('tiktok publisher publishes with user-selected privacy level even when creator info query fails', function () { // User has explicitly selected SELF_ONLY in meta. creator_info failure must not block publishing. - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [ @@ -763,7 +764,7 @@ } $body = json_decode($request->body(), true); - return data_get($body, 'post_info.privacy_level') === 'SELF_ONLY'; + return data_get($body, 'post_info.privacy_level') === PrivacyLevel::SelfOnly->value; }); }); @@ -799,7 +800,7 @@ test('tiktok publisher sends meta settings in video publish request', function () { $this->postPlatform->update([ 'meta' => [ - 'privacy_level' => 'PUBLIC_TO_EVERYONE', + 'privacy_level' => PrivacyLevel::PublicToEveryone->value, 'allow_comments' => true, 'allow_duet' => false, 'allow_stitch' => true, @@ -824,7 +825,7 @@ Http::fake([ $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ - 'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'SELF_ONLY'], + 'privacy_level_options' => [PrivacyLevel::PublicToEveryone->value, PrivacyLevel::SelfOnly->value], ], ], 200), $this->api.'/post/publish/video/init/' => Http::response([ @@ -844,7 +845,7 @@ $body = json_decode($request->body(), true); $postInfo = data_get($body, 'post_info'); - return $postInfo['privacy_level'] === 'PUBLIC_TO_EVERYONE' + return $postInfo['privacy_level'] === PrivacyLevel::PublicToEveryone->value && $postInfo['disable_comment'] === false && $postInfo['disable_duet'] === true && $postInfo['disable_stitch'] === false @@ -857,7 +858,7 @@ test('tiktok publisher sends auto_add_music for photo posts', function () { $this->postPlatform->update([ 'meta' => [ - 'privacy_level' => 'SELF_ONLY', + 'privacy_level' => PrivacyLevel::SelfOnly->value, 'allow_comments' => true, 'auto_add_music' => true, ], @@ -878,7 +879,7 @@ Http::fake([ $this->api.'/post/publish/creator_info/query/' => Http::response([ - 'data' => ['privacy_level_options' => ['SELF_ONLY']], + 'data' => ['privacy_level_options' => [PrivacyLevel::SelfOnly->value]], ], 200), $this->api.'/post/publish/content/init/' => Http::response([ 'data' => ['publish_id' => 'pub_music_123'], @@ -909,7 +910,7 @@ test('tiktok publisher does not send auto_add_music for video posts', function () { $this->postPlatform->update([ 'meta' => [ - 'privacy_level' => 'SELF_ONLY', + 'privacy_level' => PrivacyLevel::SelfOnly->value, 'auto_add_music' => true, ], ]); @@ -928,7 +929,7 @@ Http::fake([ $this->api.'/post/publish/creator_info/query/' => Http::response([ - 'data' => ['privacy_level_options' => ['SELF_ONLY']], + 'data' => ['privacy_level_options' => [PrivacyLevel::SelfOnly->value]], ], 200), $this->api.'/post/publish/video/init/' => Http::response([ 'data' => ['publish_id' => 'pub_vid_123'], @@ -953,7 +954,7 @@ test('tiktok publisher uses default settings when only privacy_level is set', function () { // Only privacy_level is set (required); all other meta keys absent — exercise default toggles. - $this->postPlatform->update(['meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value]]); $this->post->update([ 'media' => [ @@ -970,7 +971,7 @@ Http::fake([ $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ - 'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'], + 'privacy_level_options' => [PrivacyLevel::PublicToEveryone->value, PrivacyLevel::FollowerOfCreator->value, PrivacyLevel::SelfOnly->value], ], ], 200), $this->api.'/post/publish/video/init/' => Http::response([ @@ -992,7 +993,7 @@ // privacy_level passes through; all interaction toggles default to OFF to match // the UI checkbox state (TikTok UX guideline: none should be checked by default). - return $postInfo['privacy_level'] === 'PUBLIC_TO_EVERYONE' + return $postInfo['privacy_level'] === PrivacyLevel::PublicToEveryone->value && $postInfo['disable_comment'] === true && $postInfo['disable_duet'] === true && $postInfo['disable_stitch'] === true @@ -1014,12 +1015,12 @@ ], 'content' => 'My video caption', ]); - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); Http::fake([ $this->api.'/post/publish/creator_info/query/' => Http::response([ 'data' => [ - 'privacy_level_options' => ['SELF_ONLY'], + 'privacy_level_options' => [PrivacyLevel::SelfOnly->value], ], ], 200), $this->api.'/post/publish/video/init/' => Http::response([ @@ -1064,7 +1065,7 @@ 'data' => [ 'creator_nickname' => 'test', 'creator_username' => 'test', - 'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'SELF_ONLY'], + 'privacy_level_options' => [PrivacyLevel::PublicToEveryone->value, PrivacyLevel::SelfOnly->value], 'comment_disabled' => false, 'duet_disabled' => false, 'stitch_disabled' => false, @@ -1077,11 +1078,56 @@ ->toThrow(TikTokPublishException::class); }); +test('tiktok publisher throws when meta.privacy_level is not a known option', 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', + ]], + ]); + $this->postPlatform->update(['meta' => ['privacy_level' => 'EVERYONE']]); + + Http::fake(); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TikTokPublishException::class); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/post/publish/video/init/')); +}); + +test('tiktok publisher throws when self only is combined with branded content', 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', + ]], + ]); + $this->postPlatform->update([ + 'meta' => [ + 'privacy_level' => PrivacyLevel::SelfOnly->value, + 'brand_content_toggle' => true, + ], + ]); + + Http::fake(); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TikTokPublishException::class); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/post/publish/video/init/')); +}); + 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->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [ [ @@ -1135,7 +1181,7 @@ test('tiktok publisher passes a compliant photo through without hosting a copy', function () { Storage::fake(); - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [ [ @@ -1178,7 +1224,7 @@ 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->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [ [ @@ -1230,7 +1276,7 @@ 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->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [ [ @@ -1265,7 +1311,7 @@ Storage::fake(); // TikTok carousels can carry many images; here one is oversized, one is compliant. - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [ [ @@ -1334,7 +1380,7 @@ test('tiktok publisher prunes the hosted derivative even when publishing fails', function () { Storage::fake(); - $this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]); + $this->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [ [ @@ -1376,7 +1422,7 @@ 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->postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); $this->post->update([ 'media' => [ [ diff --git a/tests/Feature/Services/WebhookServiceTest.php b/tests/Feature/Services/WebhookServiceTest.php index 150ca88d4..75b92bee7 100644 --- a/tests/Feature/Services/WebhookServiceTest.php +++ b/tests/Feature/Services/WebhookServiceTest.php @@ -6,6 +6,7 @@ use App\Enums\Post\CreatedVia; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\Webhook\EventType as WebhookEvent; use App\Jobs\DispatchWebhook; use App\Models\Post; @@ -419,7 +420,7 @@ $platform = PostPlatform::factory()->failed()->recycle($post, $account)->create([ 'platform' => Platform::TikTok, 'content_type' => ContentType::TikTokVideo, - 'meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE'], + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], 'error_context' => ['retry_count' => 2], ]); @@ -429,7 +430,7 @@ ->and(data_get($payload, 'platforms.0.status'))->toBe('failed') ->and(data_get($payload, 'platforms.0.error_message'))->toBe('Failed to publish') ->and(data_get($payload, 'platforms.0.error_context'))->toEqual(['retry_count' => 2]) - ->and(data_get($payload, 'platforms.0.meta.privacy_level'))->toBe('PUBLIC_TO_EVERYONE'); + ->and(data_get($payload, 'platforms.0.meta.privacy_level'))->toBe(PrivacyLevel::PublicToEveryone->value); }); test('postPayload accepts integer media ids from generated attachments', function () { diff --git a/tests/Feature/UpdatePostRequestTest.php b/tests/Feature/UpdatePostRequestTest.php index 113af6dde..413f20f7d 100644 --- a/tests/Feature/UpdatePostRequestTest.php +++ b/tests/Feature/UpdatePostRequestTest.php @@ -5,6 +5,7 @@ use App\Enums\Post\Status; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Enums\UserWorkspace\Role; use App\Models\Post; use App\Models\PostPlatform; @@ -72,7 +73,7 @@ [ 'id' => $this->postPlatform->id, 'content_type' => ContentType::TikTokVideo->value, - 'meta' => ['privacy_level' => 'SELF_ONLY'], + 'meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value], ], ], ]); @@ -481,6 +482,43 @@ $response->assertSessionDoesntHaveErrors(['platforms.0.meta.board_id']); }); +test('publishing a tiktok post with an unknown privacy_level is rejected', function () { + $response = $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Publishing->value, + 'media' => $this->mediaPayload, + 'platforms' => [ + [ + 'id' => $this->postPlatform->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => ['privacy_level' => 'EVERYONE'], + ], + ], + ]); + + $response->assertSessionHasErrors('platforms.0.meta.privacy_level'); +}); + +test('publishing a tiktok post as self only branded content is rejected', function () { + $response = $this->actingAs($this->user) + ->put(route('app.posts.update', $this->post), [ + 'status' => Status::Publishing->value, + 'media' => $this->mediaPayload, + 'platforms' => [ + [ + 'id' => $this->postPlatform->id, + 'content_type' => ContentType::TikTokVideo->value, + 'meta' => [ + 'privacy_level' => PrivacyLevel::SelfOnly->value, + 'brand_content_toggle' => true, + ], + ], + ], + ]); + + $response->assertSessionHasErrors(['platforms.0.meta.privacy_level' => trans('posts.form.tiktok.privacy.private_disabled_branded')]); +}); + test('saving a tiktok post as draft without privacy_level skips the privacy_level rule', function () { $response = $this->actingAs($this->user) ->put(route('app.posts.update', $this->post), [ diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 5b9fc8f70..a88cee809 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -4,6 +4,7 @@ use App\Enums\Media\Type as MediaType; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; test('platform has correct labels', function () { expect(Platform::LinkedIn->label())->toBe('LinkedIn'); @@ -363,3 +364,7 @@ Platform::InstagramFacebook->value, ]); }); + +test('tiktok publish config privacy options come from the privacy level enum', function () { + expect(Platform::TikTok->publishConfig()['privacyLevelOptions'])->toBe(PrivacyLevel::values()); +}); diff --git a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php new file mode 100644 index 000000000..05da94246 --- /dev/null +++ b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php @@ -0,0 +1,54 @@ +toBe([ + PrivacyLevel::PublicToEveryone->value, + PrivacyLevel::MutualFollowFriends->value, + PrivacyLevel::FollowerOfCreator->value, + PrivacyLevel::SelfOnly->value, + ])->and(PrivacyLevel::values())->toBe([ + 'PUBLIC_TO_EVERYONE', + 'MUTUAL_FOLLOW_FRIENDS', + 'FOLLOWER_OF_CREATOR', + 'SELF_ONLY', + ]); +}); + +test('known values keep recognized options in order and drop unknowns', function () { + expect(PrivacyLevel::knownValues([ + PrivacyLevel::PublicToEveryone->value, + 'EVERYONE', + PrivacyLevel::SelfOnly->value, + '', + PrivacyLevel::FollowerOfCreator->value, + ]))->toBe([ + PrivacyLevel::PublicToEveryone->value, + PrivacyLevel::SelfOnly->value, + PrivacyLevel::FollowerOfCreator->value, + ]); +}); + +test('only self only forbids branded content', function (PrivacyLevel $level, bool $allowed) { + expect($level->allowsBrandedContent())->toBe($allowed); +})->with([ + 'public' => [PrivacyLevel::PublicToEveryone, true], + 'friends' => [PrivacyLevel::MutualFollowFriends, true], + 'followers' => [PrivacyLevel::FollowerOfCreator, true], + 'private' => [PrivacyLevel::SelfOnly, false], +]); + +test('the typescript privacy level const matches the php enum', function () { + $source = file_get_contents(resource_path('js/types/tiktok-privacy.ts')); + + preg_match('/export const TikTokPrivacyLevel = \{([^}]+)\}/s', $source, $block); + + expect($block[1] ?? null)->not->toBeNull(); + + preg_match_all("/'([A-Z_]+)'/", $block[1], $matches); + + expect($matches[1])->toBe(PrivacyLevel::values()); +}); diff --git a/tests/Unit/PostPlatformMetaRulesTest.php b/tests/Unit/PostPlatformMetaRulesTest.php index f3af6ec52..da28add62 100644 --- a/tests/Unit/PostPlatformMetaRulesTest.php +++ b/tests/Unit/PostPlatformMetaRulesTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Support\PostPlatformMetaRules; test('custom meta messages only cover pinterest title and link', function () { @@ -31,3 +33,25 @@ 'platforms.*.meta.link', ]); }); + +test('tiktok required meta treats missing and unknown privacy as unpublished', function (?array $meta) { + expect(PostPlatformMetaRules::requiredMetaViolation(Platform::TikTok, $meta)) + ->toBe(['privacy_level', trans('posts.form.tiktok.privacy_required')]); +})->with([ + 'missing' => [[]], + 'blank' => [['privacy_level' => '']], + 'unknown' => [['privacy_level' => 'EVERYONE']], +]); + +test('tiktok required meta accepts every privacy level enum value', function (PrivacyLevel $level) { + expect(PostPlatformMetaRules::requiredMetaViolation(Platform::TikTok, [ + 'privacy_level' => $level->value, + ]))->toBeNull(); +})->with(PrivacyLevel::cases()); + +test('tiktok required meta rejects self only branded content', function () { + expect(PostPlatformMetaRules::requiredMetaViolation(Platform::TikTok, [ + 'privacy_level' => PrivacyLevel::SelfOnly->value, + 'brand_content_toggle' => true, + ]))->toBe(['privacy_level', trans('posts.form.tiktok.privacy.private_disabled_branded')]); +}); From d35b98f8289141556e6ad3100b12a9c84291ec68 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 12:36:43 -0300 Subject: [PATCH 4/8] Fix TikTok caption matching edge cases and dedupe the privacy publish rules. --- app/Enums/TikTok/PrivacyLevel.php | 21 +---- app/Services/Post/PostMetricsFetcher.php | 17 +--- app/Services/Social/TikTokAnalytics.php | 92 ++++++++++--------- app/Services/Social/TikTokPublisher.php | 31 ++----- app/Support/PostPlatformMetaRules.php | 2 +- .../posts/editor/TikTokSettings.vue | 13 +-- .../Services/Social/TikTokAnalyticsTest.php | 51 +++++++++- .../Services/Social/TikTokPublisherTest.php | 3 + tests/Unit/Enums/TikTok/PrivacyLevelTest.php | 14 --- 9 files changed, 123 insertions(+), 121 deletions(-) diff --git a/app/Enums/TikTok/PrivacyLevel.php b/app/Enums/TikTok/PrivacyLevel.php index d58f19513..5bda42ac1 100644 --- a/app/Enums/TikTok/PrivacyLevel.php +++ b/app/Enums/TikTok/PrivacyLevel.php @@ -34,21 +34,10 @@ public static function values(): array */ public static function knownValues(iterable $options): array { - $values = []; - - foreach ($options as $option) { - $level = self::tryFrom((string) $option); - - if ($level instanceof self) { - $values[] = $level->value; - } - } - - return $values; - } - - public function allowsBrandedContent(): bool - { - return $this !== self::SelfOnly; + return collect($options) + ->map(fn (mixed $option): ?string => self::tryFrom((string) $option)?->value) + ->filter() + ->values() + ->all(); } } diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index 5cb519d7d..85bb3ba3c 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -67,14 +67,7 @@ public function forPlatform(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'not_published']; } - $cacheKey = "post_metrics:{$postPlatform->id}"; - $cached = Cache::get($cacheKey); - - if (is_array($cached) && ! isset($cached['unsupported'])) { - return $cached; - } - - $metrics = match ($postPlatform->platform) { + return Cache::remember("post_metrics:{$postPlatform->id}", 300, fn () => match ($postPlatform->platform) { Platform::X => app(XAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), @@ -88,12 +81,6 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), Platform::TikTok => app(TikTokAnalytics::class)->fetchPostMetrics($postPlatform), default => ['unsupported' => true, 'reason' => 'platform_not_supported'], - }; - - if (! isset($metrics['unsupported'])) { - Cache::put($cacheKey, $metrics, 300); - } - - return $metrics; + }); } } diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 0b22addb2..e6c0578aa 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -5,6 +5,7 @@ namespace App\Services\Social; use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -112,8 +113,9 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array /** * Public posts often stay on a Content Posting `publish_id` because TikTok * omits `publicaly_available_post_id` even after PUBLISH_COMPLETE. The video - * still shows up on `video/list` with the caption we sent — match that and - * persist the real item id so the show-page link stops pointing at the profile. + * still shows up on `video/list` with the caption we sent — match that so + * the show-page link stops pointing at the profile. SELF_ONLY posts never + * appear on the list, so they are not looked up. */ public function findVideoIdByCaption(PostPlatform $postPlatform): ?string { @@ -136,11 +138,25 @@ private function videoIdFor(PostPlatform $postPlatform): ?string return $stored; } - return $this->resolveVideoIdFromPublish($postPlatform, $stored) - ?? $this->persistResolvedVideo($postPlatform, $this->matchVideoFromRecentList($postPlatform)); + $videoId = $this->publicVideoIdFromStatus($stored) ?? $this->matchVideoFromRecentList($postPlatform); + + if ($videoId === null) { + return null; + } + + $username = $postPlatform->socialAccount?->username; + + $postPlatform->update([ + 'platform_post_id' => $videoId, + 'platform_url' => filled($username) + ? "https://www.tiktok.com/@{$username}/video/{$videoId}" + : $postPlatform->platform_url, + ]); + + return $videoId; } - private function resolveVideoIdFromPublish(PostPlatform $postPlatform, string $publishId): ?string + private function publicVideoIdFromStatus(string $publishId): ?string { $response = $this->getHttpClient() ->post("{$this->baseUrl}/post/publish/status/fetch/", [ @@ -152,26 +168,21 @@ private function resolveVideoIdFromPublish(PostPlatform $postPlatform, string $p 'body' => $this->redactResponseBody($response->body()), ]); - return $this->persistResolvedVideo($postPlatform, $this->matchVideoFromRecentList($postPlatform)); - } - - $videoId = data_get($response->json(), 'data.publicaly_available_post_id.0'); - $videoId = is_scalar($videoId) ? (string) $videoId : ''; - - if ($videoId === '' || ! ctype_digit($videoId)) { - return $this->persistResolvedVideo($postPlatform, $this->matchVideoFromRecentList($postPlatform)); + return null; } - return $this->persistResolvedVideo($postPlatform, $videoId); + return $this->digitsOrNull($response->json('data.publicaly_available_post_id.0')); } private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string { + if (PrivacyLevel::tryFrom((string) data_get($postPlatform->meta, 'privacy_level')) === PrivacyLevel::SelfOnly) { + return null; + } + $postPlatform->loadMissing('post'); - $caption = $this->normalizeCaption( - (string) ($postPlatform->post?->content ?? '') - ); + $caption = $this->normalizeCaption((string) $postPlatform->post?->content); if ($caption === '') { return null; @@ -182,7 +193,7 @@ private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string for ($page = 0; $page < self::VIDEO_LIST_MAX_PAGES; $page++) { $payload = ['max_count' => self::VIDEO_LIST_PAGE_SIZE]; - if (is_int($cursor) || (is_string($cursor) && $cursor !== '')) { + if (filled($cursor)) { $payload['cursor'] = $cursor; } @@ -197,52 +208,43 @@ private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string return null; } - foreach (data_get($response->json(), 'data.videos', []) as $video) { - if (! is_array($video)) { - continue; - } + $data = $response->json('data', []); - $videoId = is_scalar(data_get($video, 'id')) ? (string) data_get($video, 'id') : ''; + foreach (data_get($data, 'videos', []) as $video) { + $videoId = $this->digitsOrNull(data_get($video, 'id')); $title = $this->normalizeCaption((string) data_get($video, 'title', '')); - if ($videoId !== '' && ctype_digit($videoId) && $this->captionsMatch($caption, $title)) { + if ($videoId !== null && $this->captionsMatch($caption, $title)) { return $videoId; } } - if (! data_get($response->json(), 'data.has_more')) { + if (! data_get($data, 'has_more')) { return null; } - $cursor = data_get($response->json(), 'data.cursor'); + $cursor = data_get($data, 'cursor'); } return null; } - private function persistResolvedVideo(PostPlatform $postPlatform, ?string $videoId): ?string + /** + * `video/list` titles may be a truncated form of the caption we posted, so a + * prefix match in either direction counts. An empty title never matches: + * `str_starts_with($x, '')` is true and would claim any untitled video. + */ + private function captionsMatch(string $posted, string $title): bool { - if ($videoId === null || $videoId === '' || ! ctype_digit($videoId)) { - return null; - } - - $username = $postPlatform->socialAccount?->username; - - $postPlatform->update([ - 'platform_post_id' => $videoId, - 'platform_url' => filled($username) - ? "https://www.tiktok.com/@{$username}/video/{$videoId}" - : $postPlatform->platform_url, - ]); - - return $videoId; + return $title !== '' + && (str_starts_with($posted, $title) || str_starts_with($title, $posted)); } - private function captionsMatch(string $posted, string $title): bool + private function digitsOrNull(mixed $value): ?string { - return $posted === $title - || str_starts_with($posted, $title) - || str_starts_with($title, $posted); + $value = is_scalar($value) ? (string) $value : ''; + + return ctype_digit($value) ? $value : null; } private function normalizeCaption(string $text): string diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 5f7f66554..46e1c486b 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -15,6 +15,7 @@ use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; +use App\Support\PostPlatformMetaRules; use App\Support\Social\PublishCheckpoint; use App\Support\Social\TikTokPhotoDerivativeCleaner; use Illuminate\Http\Client\PendingRequest; @@ -99,35 +100,23 @@ private function getHttpClient(): PendingRequest } /** - * Resolve the user-selected privacy_level from meta, throwing when missing - * or unknown. TikTok UX Guideline Point 2b forbids any default — the user - * must pick explicitly. The FormRequest validates this upstream; this is - * the safety net for queue/job paths that bypass the request layer. + * Resolve the user-selected privacy_level from meta. TikTok UX Guideline + * Point 2b forbids any default — the user must pick explicitly. The + * FormRequest enforces the same rules upstream; this is the safety net for + * queue/job paths that bypass the request layer. */ private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): PrivacyLevel { - $privacyLevel = $this->privacyLevel($postPlatform); + $violation = PostPlatformMetaRules::requiredMetaViolation(Platform::TikTok, $postPlatform->meta); - if ($privacyLevel === null) { + if ($violation !== null) { throw new TikTokPublishException( - userMessage: 'TikTok privacy level is required. Please open the post and pick a visibility option.', + userMessage: $violation[1], category: ErrorCategory::ContentPolicy, ); } - if (! $privacyLevel->allowsBrandedContent() && data_get($postPlatform->meta ?? [], 'brand_content_toggle')) { - throw new TikTokPublishException( - userMessage: trans('posts.form.tiktok.privacy.private_disabled_branded'), - category: ErrorCategory::ContentPolicy, - ); - } - - return $privacyLevel; - } - - private function privacyLevel(PostPlatform $postPlatform): ?PrivacyLevel - { - return PrivacyLevel::tryFrom((string) data_get($postPlatform->meta ?? [], 'privacy_level')); + return PrivacyLevel::from((string) data_get($postPlatform->meta, 'privacy_level')); } /** @@ -493,7 +482,7 @@ private function completePublish(PostPlatform $postPlatform, string $publishId): $postId = data_get($statusData, 'publicaly_available_post_id.0'); $postId = is_string($postId) && $postId !== '' ? $postId : null; - if ($postId === null && $this->privacyLevel($postPlatform) !== PrivacyLevel::SelfOnly) { + if ($postId === null) { $postId = app(TikTokAnalytics::class)->findVideoIdByCaption($postPlatform); } diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index 76c639dba..6eb0e75ab 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -170,7 +170,7 @@ private static function tiktokPrivacyViolation(mixed $meta): ?array return ['privacy_level', trans('posts.form.tiktok.privacy_required')]; } - if (! $privacyLevel->allowsBrandedContent() && data_get($meta, 'brand_content_toggle')) { + if ($privacyLevel === PrivacyLevel::SelfOnly && data_get($meta, 'brand_content_toggle')) { return ['privacy_level', trans('posts.form.tiktok.privacy.private_disabled_branded')]; } diff --git a/resources/js/components/posts/editor/TikTokSettings.vue b/resources/js/components/posts/editor/TikTokSettings.vue index d4c1aeef4..9a8367b99 100644 --- a/resources/js/components/posts/editor/TikTokSettings.vue +++ b/resources/js/components/posts/editor/TikTokSettings.vue @@ -147,19 +147,16 @@ const brandContentToggle = computed({ }); // Prefer the creator_info API response; fall back to the static list from the Platform enum. -const allPrivacyOptions = computed(() => { +// Every option is rendered. SelfOnly is shown but disabled when Branded Content is +// checked (TikTok UX Guideline Point 3b — must show interaction, not hide it). +const privacyOptions = computed(() => { const fromApi = (props.creatorInfo?.privacy_level_options ?? []).filter(isTikTokPrivacyLevel); const fallback = (props.publishConfig?.privacyLevelOptions ?? []).filter(isTikTokPrivacyLevel); return fromApi.length > 0 ? fromApi : fallback; }); -// Render every option creator_info returns. SelfOnly is shown but disabled when -// Branded Content is checked (TikTok UX Guideline Point 3b — must show interaction, -// not hide it). -const privacyOptions = computed(() => allPrivacyOptions.value); - -const isSelfOnlyDisabled = (option: string): boolean => +const isSelfOnlyDisabled = (option: TikTokPrivacyLevelValue): boolean => option === TikTokPrivacyLevel.SelfOnly && brandContentToggle.value; const commentDisabled = computed(() => Boolean(props.creatorInfo?.comment_disabled)); @@ -282,7 +279,7 @@ watch( :disabled="isSelfOnlyDisabled(option)" :title="isSelfOnlyDisabled(option) ? $t('posts.form.tiktok.privacy.private_disabled_branded') : undefined" > - {{ $t(isTikTokPrivacyLevel(option) ? tiktokPrivacyLabelKey[option] : option) }} + {{ $t(tiktokPrivacyLabelKey[option]) }} diff --git a/tests/Feature/Services/Social/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php index 1bcd677de..94f1cd1ab 100644 --- a/tests/Feature/Services/Social/TikTokAnalyticsTest.php +++ b/tests/Feature/Services/Social/TikTokAnalyticsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\SocialAccount\Platform; +use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -53,6 +54,7 @@ function tiktokPostPlatform(?string $platformPostId = '7685359243088103444'): Po 'platform' => Platform::TikTok, 'platform_post_id' => $platformPostId, 'platform_url' => 'https://www.tiktok.com/@tiktoker', + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], ]); } @@ -123,7 +125,9 @@ function tiktokPostPlatform(?string $platformPostId = '7685359243088103444'): Po && data_get($request->data(), 'filters.video_ids') === [$videoId]); }); -test('tiktok analytics waits when publish status has no public post id yet', function () { +test('tiktok analytics reports missing_post_id when neither status nor the video list resolve the publish id', function () { + $this->post->update(['content' => 'Still in review']); + Http::fake([ $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ @@ -144,9 +148,54 @@ function tiktokPostPlatform(?string $platformPostId = '7685359243088103444'): Po expect($metrics)->toBe(['unsupported' => true, 'reason' => 'missing_post_id']); + Http::assertSentCount(2); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/query/')); }); +test('tiktok analytics never matches an untitled video from the list', function () { + $this->post->update(['content' => 'A caption that no listed video carries']); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => []], + 'error' => ['code' => 'ok'], + ]), + $this->api.'/video/list/*' => Http::response([ + 'data' => [ + 'videos' => [['id' => '7000000000000000001', 'title' => '']], + 'has_more' => false, + ], + 'error' => ['code' => 'ok'], + ]), + ]); + + $postPlatform = tiktokPostPlatform('v_pub_url~v2-1.untitled'); + + expect((new TikTokAnalytics)->fetchPostMetrics($postPlatform)) + ->toBe(['unsupported' => true, 'reason' => 'missing_post_id']) + ->and($postPlatform->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.untitled'); +}); + +test('tiktok analytics does not scan the video list for a self only post', function () { + $this->post->update(['content' => 'Private caption']); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => []], + 'error' => ['code' => 'ok'], + ]), + ]); + + $postPlatform = tiktokPostPlatform('v_pub_url~v2-1.private'); + $postPlatform->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); + + expect((new TikTokAnalytics)->fetchPostMetrics($postPlatform)) + ->toBe(['unsupported' => true, 'reason' => 'missing_post_id']); + + Http::assertSentCount(1); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/list/')); +}); + test('tiktok analytics matches a publish id to the public video by caption', function () { $this->post->update([ 'content' => 'Eu bato nessa tecla há 7 anos: construam produtos globais.', diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 1c41f8989..ea8214efd 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -766,6 +766,9 @@ return data_get($body, 'post_info.privacy_level') === PrivacyLevel::SelfOnly->value; }); + + // A private post never shows up on video/list, so no caption lookup is attempted. + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/list/')); }); test('tiktok publisher throws exception when publish fails', function () { diff --git a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php index 05da94246..57fa53205 100644 --- a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php +++ b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php @@ -6,11 +6,6 @@ test('tiktok privacy level matches the content posting api values', function () { expect(PrivacyLevel::values())->toBe([ - PrivacyLevel::PublicToEveryone->value, - PrivacyLevel::MutualFollowFriends->value, - PrivacyLevel::FollowerOfCreator->value, - PrivacyLevel::SelfOnly->value, - ])->and(PrivacyLevel::values())->toBe([ 'PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', @@ -32,15 +27,6 @@ ]); }); -test('only self only forbids branded content', function (PrivacyLevel $level, bool $allowed) { - expect($level->allowsBrandedContent())->toBe($allowed); -})->with([ - 'public' => [PrivacyLevel::PublicToEveryone, true], - 'friends' => [PrivacyLevel::MutualFollowFriends, true], - 'followers' => [PrivacyLevel::FollowerOfCreator, true], - 'private' => [PrivacyLevel::SelfOnly, false], -]); - test('the typescript privacy level const matches the php enum', function () { $source = file_get_contents(resource_path('js/types/tiktok-privacy.ts')); From 507c0312ddb3c7922ff7c924c162d788cc0050bb Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 12:42:26 -0300 Subject: [PATCH 5/8] Stop the TikTok caption scan at videos older than the publish so a same-caption repost is never claimed. --- app/Services/Social/TikTokAnalytics.php | 25 ++++++-- .../Services/Social/TikTokAnalyticsTest.php | 60 +++++++++++++++++-- .../Services/Social/TikTokPublisherTest.php | 1 + 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index e6c0578aa..bca4cb6fb 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -21,12 +21,19 @@ class TikTokAnalytics private const string VIDEO_METRIC_FIELDS = 'id,like_count,comment_count,share_count,view_count'; - private const string VIDEO_LIST_FIELDS = 'id,title,create_time,share_url,like_count,comment_count,share_count,view_count'; + private const string VIDEO_LIST_FIELDS = 'id,title,create_time'; private const int VIDEO_LIST_PAGE_SIZE = 20; private const int VIDEO_LIST_MAX_PAGES = 5; + /** + * `published_at` is stamped after TikTok finishes processing, which can trail + * the video's `create_time` by up to the status-poll window (~1 h). A day of + * slack keeps our own video inside the scan on slow publishes. + */ + private const int PUBLISH_CLOCK_SLACK_SECONDS = 86400; + /** * @var array */ @@ -174,6 +181,11 @@ private function publicVideoIdFromStatus(string $publishId): ?string return $this->digitsOrNull($response->json('data.publicaly_available_post_id.0')); } + /** + * `video/list` is sorted by `create_time` desc, so scanning stops at the + * first video older than the publish — anything past it cannot be ours, and + * an older repost with the same caption must never be claimed. + */ private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string { if (PrivacyLevel::tryFrom((string) data_get($postPlatform->meta, 'privacy_level')) === PrivacyLevel::SelfOnly) { @@ -188,6 +200,7 @@ private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string return null; } + $notBefore = ($postPlatform->published_at ?? now())->getTimestamp() - self::PUBLISH_CLOCK_SLACK_SECONDS; $cursor = null; for ($page = 0; $page < self::VIDEO_LIST_MAX_PAGES; $page++) { @@ -211,6 +224,10 @@ private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string $data = $response->json('data', []); foreach (data_get($data, 'videos', []) as $video) { + if ((int) data_get($video, 'create_time', 0) < $notBefore) { + return null; + } + $videoId = $this->digitsOrNull(data_get($video, 'id')); $title = $this->normalizeCaption((string) data_get($video, 'title', '')); @@ -219,11 +236,11 @@ private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string } } - if (! data_get($data, 'has_more')) { + $cursor = data_get($data, 'cursor'); + + if (! data_get($data, 'has_more') || blank($cursor)) { return null; } - - $cursor = data_get($data, 'cursor'); } return null; diff --git a/tests/Feature/Services/Social/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php index 94f1cd1ab..3fe9fd5f8 100644 --- a/tests/Feature/Services/Social/TikTokAnalyticsTest.php +++ b/tests/Feature/Services/Social/TikTokAnalyticsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\PostPlatform\Status as PostPlatformStatus; use App\Enums\SocialAccount\Platform; use App\Enums\TikTok\PrivacyLevel; use App\Models\Post; @@ -9,6 +10,7 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Services\Post\PostMetricsFetcher; use App\Services\Social\TikTokAnalytics; use Illuminate\Support\Facades\Http; @@ -217,10 +219,7 @@ function tiktokPostPlatform(?string $platformPostId = '7685359243088103444'): Po 'videos' => [[ 'id' => $videoId, 'title' => 'Eu bato nessa tecla há 7 anos: construam produtos globais.', - 'view_count' => 661, - 'like_count' => 13, - 'comment_count' => 2, - 'share_count' => 1, + 'create_time' => now()->getTimestamp(), ]], 'has_more' => false, ], @@ -249,6 +248,59 @@ function tiktokPostPlatform(?string $platformPostId = '7685359243088103444'): Po ->and($postPlatform->platform_url)->toBe("https://www.tiktok.com/@tiktoker/video/{$videoId}"); }); +test('tiktok analytics stops scanning at videos older than the publish instead of claiming a same-caption repost', function () { + $this->post->update(['content' => 'Same caption, posted twice']); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => []], + 'error' => ['code' => 'ok'], + ]), + $this->api.'/video/list/*' => Http::response([ + 'data' => [ + 'videos' => [[ + 'id' => '7000000000000000002', + 'title' => 'Same caption, posted twice', + 'create_time' => now()->subDays(3)->getTimestamp(), + ]], + 'has_more' => true, + 'cursor' => now()->subDays(3)->getTimestampMs(), + ], + 'error' => ['code' => 'ok'], + ]), + ]); + + $postPlatform = tiktokPostPlatform('v_pub_url~v2-1.repost'); + $postPlatform->update(['published_at' => now()]); + + expect((new TikTokAnalytics)->fetchPostMetrics($postPlatform)) + ->toBe(['unsupported' => true, 'reason' => 'missing_post_id']) + ->and($postPlatform->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.repost'); + + Http::assertSentCount(2); +}); + +test('tiktok analytics forPost returns the backfilled video url alongside the metrics', function () { + $videoId = '7685359243088103444'; + $postPlatform = tiktokPostPlatform('v_pub_url~v2-1.backfill'); + $postPlatform->update(['status' => PostPlatformStatus::Published]); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => [$videoId]], + 'error' => ['code' => 'ok'], + ]), + $this->api.'/video/query/*' => Http::response(tiktokVideoQueryResponse($videoId, ['view_count' => 5])), + ]); + + $platforms = app(PostMetricsFetcher::class)->forPost($this->post->fresh()); + + expect($platforms->first())->toMatchArray([ + 'platform_post_id' => $videoId, + 'platform_url' => "https://www.tiktok.com/@tiktoker/video/{$videoId}", + ])->and($platforms->first()['metrics'][0])->toBe(['label' => __('analytics.metrics.views'), 'value' => 5]); +}); + test('tiktok analytics reports a missing platform post id as unsupported', function () { Http::fake(); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index ea8214efd..0d44cb1cd 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -117,6 +117,7 @@ 'videos' => [[ 'id' => '7682891910226234644', 'title' => 'Construam produtos globais e faturem em dólar.', + 'create_time' => now()->getTimestamp(), ]], 'has_more' => false, ], From 463f02e9f54637eddc2b1a3fefbc61ed7926fa1e Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 12:50:13 -0300 Subject: [PATCH 6/8] Drop PrivacyLevel::knownValues and filter creator_info options against values() instead. --- app/Enums/TikTok/PrivacyLevel.php | 19 +------------------ app/Services/Social/TikTokCreatorInfo.php | 5 ++++- tests/Unit/Enums/TikTok/PrivacyLevelTest.php | 14 -------------- 3 files changed, 5 insertions(+), 33 deletions(-) diff --git a/app/Enums/TikTok/PrivacyLevel.php b/app/Enums/TikTok/PrivacyLevel.php index 5bda42ac1..6539fd3c9 100644 --- a/app/Enums/TikTok/PrivacyLevel.php +++ b/app/Enums/TikTok/PrivacyLevel.php @@ -21,23 +21,6 @@ enum PrivacyLevel: string */ public static function values(): array { - return array_map(fn (self $level) => $level->value, self::cases()); - } - - /** - * Keep only values the Content Posting API accepts, in the given order. - * Unknown creator_info options are dropped so they never reach the editor - * or a publish payload. - * - * @param iterable $options - * @return list - */ - public static function knownValues(iterable $options): array - { - return collect($options) - ->map(fn (mixed $option): ?string => self::tryFrom((string) $option)?->value) - ->filter() - ->values() - ->all(); + return array_column(self::cases(), 'value'); } } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index f9af39877..1727abbc6 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -84,7 +84,10 @@ private function fetchFresh(SocialAccount $account): array 'creator_nickname' => data_get($data, 'creator_nickname'), 'creator_username' => data_get($data, 'creator_username'), 'creator_avatar_url' => data_get($data, 'creator_avatar_url'), - 'privacy_level_options' => PrivacyLevel::knownValues(data_get($data, 'privacy_level_options', [])), + 'privacy_level_options' => array_values(array_intersect( + (array) data_get($data, 'privacy_level_options', []), + PrivacyLevel::values(), + )), 'comment_disabled' => (bool) data_get($data, 'comment_disabled', false), 'duet_disabled' => (bool) data_get($data, 'duet_disabled', false), 'stitch_disabled' => (bool) data_get($data, 'stitch_disabled', false), diff --git a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php index 57fa53205..aec6ba895 100644 --- a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php +++ b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php @@ -13,20 +13,6 @@ ]); }); -test('known values keep recognized options in order and drop unknowns', function () { - expect(PrivacyLevel::knownValues([ - PrivacyLevel::PublicToEveryone->value, - 'EVERYONE', - PrivacyLevel::SelfOnly->value, - '', - PrivacyLevel::FollowerOfCreator->value, - ]))->toBe([ - PrivacyLevel::PublicToEveryone->value, - PrivacyLevel::SelfOnly->value, - PrivacyLevel::FollowerOfCreator->value, - ]); -}); - test('the typescript privacy level const matches the php enum', function () { $source = file_get_contents(resource_path('js/types/tiktok-privacy.ts')); From 074c9ee99f5d2b12224e122a8cde52f1949853ec Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 12:54:25 -0300 Subject: [PATCH 7/8] Assert TikTok TS privacy values with toContain instead of parsing the file with regex. --- tests/Unit/Enums/TikTok/PrivacyLevelTest.php | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php index aec6ba895..aa228b54a 100644 --- a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php +++ b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php @@ -13,14 +13,6 @@ ]); }); -test('the typescript privacy level const matches the php enum', function () { - $source = file_get_contents(resource_path('js/types/tiktok-privacy.ts')); - - preg_match('/export const TikTokPrivacyLevel = \{([^}]+)\}/s', $source, $block); - - expect($block[1] ?? null)->not->toBeNull(); - - preg_match_all("/'([A-Z_]+)'/", $block[1], $matches); - - expect($matches[1])->toBe(PrivacyLevel::values()); -}); +test('the typescript privacy level const contains every php value', function (string $value) { + expect(file_get_contents(resource_path('js/types/tiktok-privacy.ts')))->toContain("'{$value}'"); +})->with(PrivacyLevel::values()); From c00725be4346aecd04fe975e8447954f00c90070 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 18 Sep 2026 12:58:38 -0300 Subject: [PATCH 8/8] Drop the TypeScript privacy-level parity test. --- tests/Unit/Enums/TikTok/PrivacyLevelTest.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php index aa228b54a..c24e42c2a 100644 --- a/tests/Unit/Enums/TikTok/PrivacyLevelTest.php +++ b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php @@ -12,7 +12,3 @@ 'SELF_ONLY', ]); }); - -test('the typescript privacy level const contains every php value', function (string $value) { - expect(file_get_contents(resource_path('js/types/tiktok-privacy.ts')))->toContain("'{$value}'"); -})->with(PrivacyLevel::values());