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..6539fd3c9 --- /dev/null +++ b/app/Enums/TikTok/PrivacyLevel.php @@ -0,0 +1,26 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} 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/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index b68e3305b..85bb3ba3c 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; @@ -43,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, + ]; + }); } /** @@ -74,6 +79,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..bca4cb6fb 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -4,16 +4,46 @@ 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; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; +use Throwable; class TikTokAnalytics { use HasSocialHttpClient; + 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'; + + 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 + */ + 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 +63,231 @@ 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']; + } + + $this->prepareAccessToken($account); + + $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(); + } + + /** + * 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 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 + { + $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; + + if (ctype_digit($stored)) { + return $stored; + } + + $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 publicVideoIdFromStatus(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; + } + + 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) { + return null; + } + + $postPlatform->loadMissing('post'); + + $caption = $this->normalizeCaption((string) $postPlatform->post?->content); + + if ($caption === '') { + 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++) { + $payload = ['max_count' => self::VIDEO_LIST_PAGE_SIZE]; + + if (filled($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; + } + + $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', '')); + + if ($videoId !== null && $this->captionsMatch($caption, $title)) { + return $videoId; + } + } + + $cursor = data_get($data, 'cursor'); + + if (! data_get($data, 'has_more') || blank($cursor)) { + return null; + } + } + + return null; + } + + /** + * `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 + { + return $title !== '' + && (str_starts_with($posted, $title) || str_starts_with($title, $posted)); + } + + private function digitsOrNull(mixed $value): ?string + { + $value = is_scalar($value) ? (string) $value : ''; + + return ctype_digit($value) ? $value : null; + } + + 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()) { @@ -114,7 +369,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/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 71ce1a4d6..1727abbc6 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,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' => 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/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 73973997a..46e1c486b 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; @@ -14,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; @@ -98,23 +100,23 @@ 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. 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): string + private function resolveRequiredPrivacyLevel(PostPlatform $postPlatform): PrivacyLevel { - $privacyLevel = data_get($postPlatform->meta ?? [], 'privacy_level'); + $violation = PostPlatformMetaRules::requiredMetaViolation(Platform::TikTok, $postPlatform->meta); - if (blank($privacyLevel)) { + 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, ); } - return (string) $privacyLevel; + return PrivacyLevel::from((string) data_get($postPlatform->meta, 'privacy_level')); } /** @@ -130,7 +132,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 +167,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,6 +482,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) { + $postId = app(TikTokAnalytics::class)->findVideoIdByCaption($postPlatform); + } + return [ 'id' => $postId ?? $publishId, 'url' => $this->buildTikTokUrl($postPlatform->socialAccount, $postId), diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index cec61ee93..6eb0e75ab 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 === PrivacyLevel::SelfOnly && 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..9a8367b99 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; } @@ -141,18 +147,17 @@ 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 ?? []; -}); +// 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); -// Render every option creator_info returns. SELF_ONLY 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); + return fromApi.length > 0 ? fromApi : fallback; +}); -const isSelfOnlyDisabled = (option: string): boolean => - option === 'SELF_ONLY' && brandContentToggle.value; +const isSelfOnlyDisabled = (option: TikTokPrivacyLevelValue): boolean => + option === TikTokPrivacyLevel.SelfOnly && brandContentToggle.value; const commentDisabled = computed(() => Boolean(props.creatorInfo?.comment_disabled)); const duetDisabled = computed(() => Boolean(props.creatorInfo?.duet_disabled)); @@ -171,13 +176,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 +188,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 +279,7 @@ watch( :disabled="isSelfOnlyDisabled(option)" :title="isSelfOnlyDisabled(option) ? $t('posts.form.tiktok.privacy.private_disabled_branded') : undefined" > - {{ $t(privacyLabelKey[option] ?? option) }} + {{ $t(tiktokPrivacyLabelKey[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/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/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/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php new file mode 100644 index 000000000..3fe9fd5f8 --- /dev/null +++ b/tests/Feature/Services/Social/TikTokAnalyticsTest.php @@ -0,0 +1,337 @@ +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', + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], + ]); +} + +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 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' => [ + 'status' => 'PUBLISH_COMPLETE', + 'publicaly_available_post_id' => [], + ], + 'error' => ['code' => 'ok'], + ]), + $this->api.'/video/list/*' => Http::response([ + 'data' => ['videos' => [], 'has_more' => false], + '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::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.', + ]); + + $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.', + 'create_time' => now()->getTimestamp(), + ]], + '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 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(); + + $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']); +}); 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 310ac30b7..0d44cb1cd 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; @@ -88,6 +89,48 @@ }); }); +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' => PrivacyLevel::PublicToEveryone->value]]); + + 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.', + 'create_time' => now()->getTimestamp(), + ]], + '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' => [[ @@ -149,7 +192,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', @@ -177,7 +220,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', @@ -223,7 +266,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', @@ -292,7 +335,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', @@ -680,7 +723,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' => [ @@ -722,8 +765,11 @@ } $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; }); + + // 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 () { @@ -758,7 +804,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, @@ -783,7 +829,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([ @@ -803,7 +849,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 @@ -816,7 +862,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, ], @@ -837,7 +883,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'], @@ -868,7 +914,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, ], ]); @@ -887,7 +933,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'], @@ -912,7 +958,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' => [ @@ -929,7 +975,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([ @@ -951,7 +997,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 @@ -973,12 +1019,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([ @@ -1023,7 +1069,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, @@ -1036,11 +1082,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' => [ [ @@ -1094,7 +1185,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' => [ [ @@ -1137,7 +1228,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' => [ [ @@ -1189,7 +1280,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' => [ [ @@ -1224,7 +1315,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' => [ [ @@ -1293,7 +1384,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' => [ [ @@ -1335,7 +1426,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..c24e42c2a --- /dev/null +++ b/tests/Unit/Enums/TikTok/PrivacyLevelTest.php @@ -0,0 +1,14 @@ +toBe([ + 'PUBLIC_TO_EVERYONE', + 'MUTUAL_FOLLOW_FRIENDS', + 'FOLLOWER_OF_CREATOR', + 'SELF_ONLY', + ]); +}); 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')]); +});