diff --git a/.ai/rules/index.md b/.ai/rules/index.md index cfa50321f..ae7fa9465 100644 --- a/.ai/rules/index.md +++ b/.ai/rules/index.md @@ -22,4 +22,5 @@ Before planning or editing, find the row whose globs match the file's path and r | app/Enums/SocialAccount/Platform.php | .ai/rules/social-account.md | | app/Services/Social/GoogleBusinessPublisher.php, app/Support/Social/GoogleBusinessDerivativeCleaner.php, app/Actions/Post/DeletePost.php, app/Actions/Post/UpdatePost.php, app/Actions/Workspace/PurgeWorkspace.php, app/Http/Controllers/Auth/SocialController.php, app/Support/Social/ThreadProgress.php, app/Support/ThreadReplies.php, app/Services/Social/Concerns/PublishesThreads.php, app/Services/Social/FacebookPublisher.php, app/Services/Social/InstagramPublisher.php | .ai/rules/social.md | | app/Support/PostPlatformMetaRules.php | .ai/rules/support.md | +| app/Services/Social/TikTok*.php, app/Actions/Post/AssignTikTokVideoId.php, app/Jobs/ResolveTikTokVideoId.php, app/Console/Commands/ResolveTikTokVideoIds.php | .ai/rules/tiktok.md | | app/Mcp/Tools/** | .ai/rules/tools.md | diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md new file mode 100644 index 000000000..1d6fd2aaa --- /dev/null +++ b/.ai/rules/tiktok.md @@ -0,0 +1,12 @@ +--- +paths: + - 'app/Services/Social/TikTok*.php' + - app/Actions/Post/AssignTikTokVideoId.php + - app/Jobs/ResolveTikTokVideoId.php + - app/Console/Commands/ResolveTikTokVideoIds.php +--- + +# TikTok + +## TikTok video ids come only from the status fetch +Never infer a TikTok post's video id from video/list (caption prefix, create_time window): it handed each post of a same-caption series the previous post's video (17 posts, October 2026). TikTok reports `publicaly_available_post_id` only after moderation (minutes to hours, seen weeks later); until then the post keeps its publish_id and the profile url. `PublishToSocialPlatform` dispatches `ResolveTikTokVideoId` (through `TikTokPublisher::publicVideoId()`) a minute after the publish; `social:resolve-tiktok-video-ids` (every 15 minutes) asks again for slower reviews, for 30 days. The resolver and the metrics job write the id through `AssignTikTokVideoId`, which also moves the analytics publication. The only other writer is `ImportExternalPosts::claimedBySentPost` (exact text, single candidate in the match window, deferred when ambiguous); for TikTok it only claims for a post without a numeric video id (still on its publish_id, or released by the repair), never one whose numeric id TikTok already reported. Only posts public to everyone are asked: TikTok never reports an id for followers, friends or private posts. A video another TryPost post holds is refused (`LogicException` from `reconcileRemoteId`); the resolver logs it once a day instead of failing, and `tiktok:repair-video-ids` is the fix for those. diff --git a/app/Actions/Post/AssignTikTokVideoId.php b/app/Actions/Post/AssignTikTokVideoId.php new file mode 100644 index 000000000..6987f0419 --- /dev/null +++ b/app/Actions/Post/AssignTikTokVideoId.php @@ -0,0 +1,61 @@ +lockForUpdate()->find($post->id); + + if (blank($locked)) { + return; + } + + $account = $locked->socialAccount; + $previousUrl = $locked->platform_url; + $videoUrl = filled($account) ? TikTokPublisher::postUrl($account, $videoId) : null; + $publication = $locked->analyticsPublication()->first() + ?? (filled($account) ? $this->syncPublication->handle($locked) : null); + + if (filled($publication)) { + $this->publications->reconcileRemoteId($publication, $videoId); + + if (filled($videoUrl) && (blank($publication->permalink) || $publication->permalink === $previousUrl)) { + $publication->update(['permalink' => $videoUrl]); + } + } + + $locked->writePublication([ + 'platform_post_id' => $videoId, + 'platform_url' => $videoUrl ?? $previousUrl, + ]); + }, self::DEADLOCK_ATTEMPTS); + } +} diff --git a/app/Actions/Post/ImportExternalPosts.php b/app/Actions/Post/ImportExternalPosts.php index ccccf9102..0341309b7 100644 --- a/app/Actions/Post/ImportExternalPosts.php +++ b/app/Actions/Post/ImportExternalPosts.php @@ -184,7 +184,8 @@ private static function isTryPostThreadSegment(SocialAccount $account, Analytics * from this channel with the same text near the same time is that * publication: it takes over the remote id instead of a duplicate import. * Multiple candidates, captionless Instagram posts and Instagram publishes - * still in progress wait without claiming a remote id. + * still in progress wait without claiming a remote id. A TikTok post whose + * numeric video id TikTok already reported is never a candidate. */ private static function claimedBySentPost(SocialAccount $account, AnalyticsPublication $publication): bool { @@ -255,6 +256,7 @@ private static function matchingTryPostTargets(SocialAccount $account, Analytics ->whereColumn('analytics_publications.remote_id', 'posts.platform_post_id'))) ->with('analyticsPublication') ->get() + ->reject(fn (Post $target): bool => $account->platform === Platform::TikTok && ctype_digit((string) $target->platform_post_id)) ->filter(fn (Post $target): bool => self::sameText( $target->content_type->isCaptionless() ? '' : (string) $target->content, $text, diff --git a/app/Console/Commands/ResolveTikTokVideoIds.php b/app/Console/Commands/ResolveTikTokVideoIds.php new file mode 100644 index 000000000..6422a82d1 --- /dev/null +++ b/app/Console/Commands/ResolveTikTokVideoIds.php @@ -0,0 +1,66 @@ +publishedToTikTok() + ->where('meta->privacy_level', PrivacyLevel::PublicToEveryone->value) + ->whereHas('socialAccount', fn (Builder $query): Builder => $query->connected()) + ->where('published_at', '>=', now()->subDays(self::RESOLVE_WITHIN_DAYS)) + ->select(['id', 'platform', 'origin', 'publish_status', 'platform_post_id', 'meta', 'published_at', 'last_reconciled_at']) + ->lazyById() + ->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId() && $this->isDue($post)) + ->each(fn (Post $post) => ResolveTikTokVideoId::dispatch($post)); + + return self::SUCCESS; + } + + /** + * The publish job already asks a minute after the publish, so this sweep + * (every fifteen minutes) only catches slower reviews: on every run + * through a post's first hour, hourly through its first day and daily + * after that. + */ + private function isDue(Post $post): bool + { + if (blank($post->last_reconciled_at)) { + return true; + } + + $recheckAfterMinutes = match (true) { + $post->published_at->greaterThan(now()->subHour()) => self::RECHECK_NEW_POST_AFTER_MINUTES, + $post->published_at->greaterThan(now()->subDay()) => self::RECHECK_FIRST_DAY_AFTER_MINUTES, + default => self::RECHECK_OLDER_POST_AFTER_MINUTES, + }; + + return $post->last_reconciled_at->lessThanOrEqualTo(now()->subMinutes($recheckAfterMinutes)); + } +} diff --git a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php new file mode 100644 index 000000000..01d51b401 --- /dev/null +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -0,0 +1,352 @@ +option('dry-run'); + + DB::beginTransaction(); + + try { + $repairs = $this->repairs(); + $deletedWithRepairs = $this->applyRepairs($repairs); + $settled = $this->settleSharedVideos(); + } catch (Throwable $exception) { + DB::rollBack(); + + throw $exception; + } + + $dryRun ? DB::rollBack() : DB::commit(); + + $this->table( + ['Post', 'Published at', 'Held video', 'Own video'], + $repairs->map(fn (array $repair): array => [ + data_get($repair, 'post.id'), + data_get($repair, 'post.published_at')->toDateTimeString(), + data_get($repair, 'held.remote_id'), + data_get($repair, 'own.remote_id'), + ])->all(), + ); + + $this->table( + ['Video', 'Kept by', 'Got their own video back', 'Released posts', 'Deleted imported copies'], + $settled->map(fn (array $video): array => [ + data_get($video, 'video.remote_id'), + data_get($video, 'owner.id'), + data_get($video, 'returned')->pluck('id')->implode(', ') ?: '-', + data_get($video, 'released')->pluck('id')->implode(', ') ?: '-', + data_get($video, 'copies'), + ])->all(), + ); + + $awaiting = Post::query() + ->publishedToTikTok() + ->whereHas('socialAccount', fn (Builder $query): Builder => $query->connected()) + ->lazyById() + ->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId()) + ->collect(); + + $returned = $repairs->count() + $settled->sum(fn (array $video): int => data_get($video, 'returned')->count()); + $released = $settled->sum(fn (array $video): int => data_get($video, 'released')->count()); + $copies = $deletedWithRepairs + $settled->sum(fn (array $video): int => data_get($video, 'copies')); + + if ($dryRun) { + $this->info("{$returned} post(s) would get their own video back; {$released} post(s) would lose a video another post owns; {$copies} imported copy(ies) would be deleted; {$awaiting->count()} post(s) would ask TikTok for their video id."); + + return self::SUCCESS; + } + + $awaiting->each(fn (Post $post) => ResolveTikTokVideoId::dispatch($post)); + + $this->info("{$returned} post(s) got their own video back; {$released} post(s) lost a video another post owns; {$copies} imported copy(ies) deleted; {$awaiting->count()} post(s) are asking TikTok for their video id."); + + return self::SUCCESS; + } + + /** + * A post holds the wrong video when that video was created right before + * another post of the channel was published, and one video was created + * right before the post itself. + * + * @return Collection + */ + private function repairs(): Collection + { + $repairs = AnalyticsPublication::query() + ->where('network', Platform::TikTok->network()) + ->whereHas('post', fn (Builder $query): Builder => $query->publishedToTikTok()->whereNotNull('published_at')->has('socialAccount')) + ->with('post.socialAccount') + ->lazyById() + ->filter(fn (AnalyticsPublication $held): bool => ctype_digit($held->remote_id) + && $held->provider_published_at->lessThan($held->post->published_at->subMinutes(self::LATE_VIDEO_MINUTES)) + && $this->publishedRightAfter($held)) + ->map(fn (AnalyticsPublication $held): array => [ + 'post' => $held->post, + 'held' => $held, + 'own' => $this->videoCreatedRightBefore($held->post, $held), + ]) + ->filter(fn (array $repair): bool => filled(data_get($repair, 'own'))) + ->values() + ->collect(); + + do { + $before = $repairs->count(); + $repairs = $this->withFreeOwnVideo($repairs); + } while ($repairs->count() !== $before); + + return $repairs; + } + + /** + * @param Collection $repairs + * @return int imported copies deleted + */ + private function applyRepairs(Collection $repairs): int + { + $repairs->each(fn (array $repair) => data_get($repair, 'held')->update([ + 'post_id' => null, + 'origin' => PublicationOrigin::External, + ])); + + return $repairs->filter(fn (array $repair): bool => $this->giveBack(data_get($repair, 'post'), data_get($repair, 'own')->fresh()))->count(); + } + + /** + * Keeps a repair only when its own video is claimed by no other repair and + * is free once the repairs run: unlinked, imported, or held by a post that + * is itself repaired. Dropping one repair can strand another, so the caller + * repeats this until nothing changes. + * + * @param Collection $repairs + * @return Collection + */ + private function withFreeOwnVideo(Collection $repairs): Collection + { + $repairedPostIds = $repairs->map(fn (array $repair): string => data_get($repair, 'post.id')); + $claims = $repairs->countBy(fn (array $repair): string => data_get($repair, 'own.id')); + + return $repairs + ->filter(fn (array $repair): bool => $claims->get(data_get($repair, 'own.id')) === 1 + && (blank(data_get($repair, 'own.post_id')) + || $repairedPostIds->contains(data_get($repair, 'own.post_id')) + || Post::query()->imported()->whereKey(data_get($repair, 'own.post_id'))->exists())) + ->values(); + } + + private function publishedRightAfter(AnalyticsPublication $held): bool + { + $createdAt = $held->provider_published_at; + + return Post::query() + ->createdInTryPost() + ->where('social_account_id', $held->post->social_account_id) + ->whereKeyNot($held->post_id) + ->whereBetween('published_at', [$createdAt, $createdAt->addMinutes(self::CREATE_TO_PUBLISH_MINUTES)]) + ->exists(); + } + + /** + * The one video of the channel created in the two minutes before the post + * was published, or null when there is none or more than one. + */ + private function videoCreatedRightBefore(Post $post, AnalyticsPublication $sameChannel): ?AnalyticsPublication + { + $publishedAt = $post->published_at; + + $candidates = AnalyticsPublication::query() + ->where('workspace_id', $sameChannel->workspace_id) + ->where('social_account_key', $sameChannel->social_account_key) + ->where('network', $sameChannel->network) + ->whereBetween('provider_published_at', [$publishedAt->subMinutes(self::CREATE_TO_PUBLISH_MINUTES), $publishedAt]) + ->limit(2) + ->get(); + + return $candidates->count() === 1 && ctype_digit($candidates->sole()->remote_id) ? $candidates->sole() : null; + } + + /** + * @return bool whether an imported copy of the video was deleted + */ + private function giveBack(Post $post, AnalyticsPublication $own): bool + { + $imported = filled($own->post_id) ? Post::query()->imported()->whereKey($own->post_id)->first() : null; + + if (filled($imported)) { + DeleteOwnedMedia::forPosts([$imported->id]); + Post::withoutEvents(fn (): ?bool => $imported->delete()); + } + + $own->update(['post_id' => $post->id, 'origin' => PublicationOrigin::TryPost]); + + $post->writePublication([ + 'platform_post_id' => $own->remote_id, + 'platform_url' => $own->permalink + ?? TikTokPublisher::postUrl($post->socialAccount, $own->remote_id) + ?? $post->platform_url, + ]); + + return filled($imported); + } + + /** + * Every video id more than one post of a channel holds. The TryPost post + * published right after the video was created, with no other video in that + * window, keeps it. Each other TryPost post gets its own video back when + * exactly one free video was created right before it, and otherwise loses + * the id and points at the profile again; imported copies are deleted. A + * video without exactly one such owner is left alone. + * + * @return Collection, released: Collection, copies: int}> + */ + private function settleSharedVideos(): Collection + { + return $this->sharedVideoPosts() + ->select(['id', 'social_account_id', 'platform_post_id']) + ->lazyById() + ->filter(fn (Post $post): bool => ctype_digit((string) $post->platform_post_id)) + ->groupBy(fn (Post $post): string => "{$post->social_account_id}:{$post->platform_post_id}") + ->filter(fn (Collection $posts): bool => $posts->count() > 1) + ->map(fn (Collection $posts): ?array => $this->settle(data_get($posts->first(), 'social_account_id'), data_get($posts->first(), 'platform_post_id'))) + ->filter() + ->values() + ->collect(); + } + + /** + * Published TryPost posts and imported posts on TikTok channels that still + * exist, carrying a video id. + * + * @return Builder + */ + private function sharedVideoPosts(): Builder + { + return Post::query() + ->where('platform', Platform::TikTok) + ->where(fn (Builder $query): Builder => $query->imported() + ->orWhere(fn (Builder $published): Builder => $published->createdInTryPost()->publicationPublished())) + ->has('socialAccount') + ->whereNotNull('platform_post_id'); + } + + /** + * Reads the posts holding the video again, since settling an earlier video + * can hand one of them its own video back. + * + * @return array{video: AnalyticsPublication, owner: Post, returned: Collection, released: Collection, copies: int}|null + */ + private function settle(string $socialAccountId, string $videoId): ?array + { + $posts = $this->sharedVideoPosts() + ->where('social_account_id', $socialAccountId) + ->where('platform_post_id', $videoId) + ->with('socialAccount') + ->get(); + + if ($posts->count() < 2) { + return null; + } + + $sample = $posts->first(); + $videos = AnalyticsPublication::query() + ->where('workspace_id', $sample->workspace_id) + ->where('social_account_id', $sample->social_account_id) + ->where('network', Platform::TikTok->network()) + ->where('remote_id', $sample->platform_post_id) + ->limit(2) + ->get(); + + if ($videos->count() !== 1) { + return null; + } + + $video = $videos->sole(); + $createdAt = $video->provider_published_at; + $owners = $posts->filter(fn (Post $post): bool => $post->origin === Origin::TryPost + && filled($post->published_at) + && $post->published_at->betweenIncluded($createdAt, $createdAt->addMinutes(self::CREATE_TO_PUBLISH_MINUTES))); + + if ($owners->count() !== 1) { + return null; + } + + $owner = $owners->sole(); + $ownerVideo = $this->videoCreatedRightBefore($owner, $video); + + if (blank($ownerVideo) || ! $ownerVideo->is($video)) { + return null; + } + + if ($video->post_id !== $owner->id && AnalyticsPublication::query()->where('post_id', $owner->id)->exists()) { + return null; + } + + $others = $posts->reject(fn (Post $post): bool => $post->is($owner))->values(); + $copies = $others->filter(fn (Post $post): bool => $post->origin === Origin::Network)->values(); + + $video->update(['post_id' => $owner->id, 'origin' => PublicationOrigin::TryPost]); + + if ($copies->isNotEmpty()) { + DeleteOwnedMedia::forPosts($copies->pluck('id')->all()); + Post::query()->whereKey($copies->pluck('id')->all())->delete(); + } + + $returned = new Collection; + $released = new Collection; + $deletedWithReturns = 0; + + $others->filter(fn (Post $post): bool => $post->origin === Origin::TryPost)->each(function (Post $post) use ($video, $returned, $released, &$deletedWithReturns): void { + $own = filled($post->published_at) ? $this->videoCreatedRightBefore($post, $video) : null; + + if (filled($own) && ! $own->is($video) && (blank($own->post_id) || Post::query()->imported()->whereKey($own->post_id)->exists())) { + $deletedWithReturns += (int) $this->giveBack($post, $own); + $returned->push($post); + + return; + } + + $post->writePublication([ + 'platform_post_id' => null, + 'platform_url' => TikTokPublisher::postUrl($post->socialAccount) ?? $post->platform_url, + ]); + $released->push($post); + }); + + return [ + 'video' => $video, + 'owner' => $owner, + 'returned' => $returned, + 'released' => $released, + 'copies' => $copies->count() + $deletedWithReturns, + ]; + } +} diff --git a/app/Jobs/Analytics/CollectPublicationMetrics.php b/app/Jobs/Analytics/CollectPublicationMetrics.php index b44e85611..78c07bb2e 100644 --- a/app/Jobs/Analytics/CollectPublicationMetrics.php +++ b/app/Jobs/Analytics/CollectPublicationMetrics.php @@ -4,8 +4,8 @@ namespace App\Jobs\Analytics; -use App\Actions\Analytics\UpsertAnalyticsPublication; use App\Actions\Analytics\WritePublicationDailySnapshot; +use App\Actions\Post\AssignTikTokVideoId; use App\Enums\Analytics\PublicationAvailability; use App\Enums\Analytics\PublicationContentType; use App\Enums\SocialAccount\Platform; @@ -82,7 +82,7 @@ public function retryUntil(): CarbonImmutable public function handle( PublicationMetricsCollectorFactory $collectors, - UpsertAnalyticsPublication $publications, + AssignTikTokVideoId $assignVideoId, WritePublicationDailySnapshot $writer, AnalyticsJobLog $log, ): void { @@ -113,9 +113,10 @@ public function handle( $collector = $collectors->for($publication->platform); if ($collector instanceof TikTokPublicationMetricsCollector - && $publication->post_id - && ! ctype_digit($publication->remote_id)) { - $publications->reconcileRemoteId($publication, $collector->publicVideoId($publication)); + && ! ctype_digit($publication->remote_id) + && filled($publication->post)) { + $assignVideoId->handle($publication->post, $collector->publicVideoId($publication)); + $publication->refresh()->setRelation('socialAccount', $channel); } $observation = $collector->collect($publication, $date); diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 7692c9f55..90110b27b 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -215,6 +215,12 @@ private function recordPublishResult(array $result): void LocalPostState::Recurring, null => $this->post->markPublicationPublished($platformPostId, $platformUrl), }; + + if ($this->post->awaitsTikTokVideoId()) { + ResolveTikTokVideoId::dispatch($this->post) + ->delay(now()->addSeconds(ResolveTikTokVideoId::FIRST_CHECK_AFTER_SECONDS)) + ->afterCommit(); + } } private function refreshAccountToken(): void diff --git a/app/Jobs/ResolveTikTokVideoId.php b/app/Jobs/ResolveTikTokVideoId.php new file mode 100644 index 000000000..b6d095528 --- /dev/null +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -0,0 +1,77 @@ +onQueue($post->platform->queue()); + } + + public function uniqueId(): string + { + return $this->post->id; + } + + public function handle(TikTokPublisher $publisher, AssignTikTokVideoId $assignVideoId): void + { + $this->post->refresh(); + + if (! $this->post->awaitsTikTokVideoId() || ! $this->post->socialAccount()->connected()->exists()) { + return; + } + + $videoId = $publisher->publicVideoId($this->post); + $this->post->writePublication(['last_reconciled_at' => now()]); + + if (blank($videoId)) { + return; + } + + try { + $assignVideoId->handle($this->post, $videoId); + } catch (UniqueConstraintViolationException) { + return; + } catch (LogicException) { + if (Cache::add("tiktok-video-id:held-by-another-post:{$this->post->id}", true, now()->addDay())) { + Log::warning('TikTok reported a video another TryPost post holds; not assigned.', [ + 'post_id' => $this->post->id, + 'video_id' => $videoId, + ]); + } + } + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index eed0b88cc..bee8c9e20 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -16,6 +16,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\Observers\PostObserver; use App\Support\Media\MediaCopyBatch; use Carbon\CarbonInterface; @@ -338,6 +339,16 @@ public function scopeOnChannels(Builder $query, ?array $channelIds): Builder return $query->when($channelIds !== null, fn (Builder $filtered): Builder => $filtered->whereIn('posts.social_account_id', $channelIds)); } + /** + * Posts TryPost published to TikTok. + */ + public function scopePublishedToTikTok(Builder $query): Builder + { + return $query->createdInTryPost() + ->where('posts.platform', Platform::TikTok) + ->publicationPublished(); + } + /** * Posts the network confirmed as published. */ @@ -407,6 +418,21 @@ public function hasChannel(): bool return filled($this->social_account_id); } + /** + * A TikTok post TryPost published to everyone that still carries the + * Content Posting `publish_id`: TikTok reports the video id only after + * moderation, and only for a post public to everyone. + */ + public function awaitsTikTokVideoId(): bool + { + return $this->platform === Platform::TikTok + && $this->origin === Origin::TryPost + && $this->publish_status === PublishStatus::Published + && filled($this->platform_post_id) + && ! ctype_digit($this->platform_post_id) + && PrivacyLevel::tryFrom((string) data_get($this->meta, 'privacy_level')) === PrivacyLevel::PublicToEveryone; + } + /** * Display name, falling back to the snapshot when the account was deleted. */ diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php deleted file mode 100644 index 9ead0bdaa..000000000 --- a/app/Services/Social/TikTokAnalytics.php +++ /dev/null @@ -1,406 +0,0 @@ - - */ - 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; - - public function __construct() - { - $this->baseUrl = config('trypost.platforms.tiktok.api'); - } - - public function getMetrics(SocialAccount $account): array - { - $cacheKey = "analytics:tiktok:{$account->id}"; - $cacheTtl = app()->isProduction() ? 3600 : 1; - - return Cache::remember($cacheKey, $cacheTtl, function () use ($account) { - return $this->fetchMetricsFromApi($account); - }); - } - - /** - * 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(Post $post): array - { - $account = $post->socialAccount; - - if (! $account || ! $post->platform_post_id) { - return ['unsupported' => true, 'reason' => 'missing_post_id']; - } - - $this->prepareAccessToken($account); - - $videoId = $this->videoIdFor($post); - - 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(Post $post): ?string - { - $account = $post->socialAccount; - - if (! $account) { - return null; - } - - $this->prepareAccessToken($account); - - return $this->matchVideoFromRecentList($post); - } - - private function videoIdFor(Post $post): ?string - { - $stored = (string) $post->platform_post_id; - - if (ctype_digit($stored)) { - return $stored; - } - - $videoId = $this->publicVideoIdFromStatus($stored) ?? $this->matchVideoFromRecentList($post); - - if ($videoId === null) { - return null; - } - - $username = $post->socialAccount?->username; - - $post->writePublication([ - 'platform_post_id' => $videoId, - 'platform_url' => filled($username) - ? "https://www.tiktok.com/@{$username}/video/{$videoId}" - : $post->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. Allow one - * day because our `published_at` can lag TikTok's `create_time` during review. - */ - private function matchVideoFromRecentList(Post $post): ?string - { - if (PrivacyLevel::tryFrom((string) data_get($post->meta, 'privacy_level')) === PrivacyLevel::SelfOnly) { - return null; - } - - $caption = $this->normalizeCaption((string) $post->content); - - if ($caption === '') { - return null; - } - - $notBefore = ($post->published_at ?? now())->copy()->utc()->subDay()->getTimestamp(); - $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()) { - app(ConnectionVerifier::class)->refreshToken($account); - } - - $this->accessToken = $account->access_token; - - $metrics = []; - - $userStats = $this->fetchUserStats(); - $metrics = array_merge($metrics, $userStats); - - $videoMetrics = $this->fetchVideoMetrics(); - $metrics = array_merge($metrics, $videoMetrics); - - return $metrics; - } - - private function fetchUserStats(): array - { - $response = $this->getHttpClient() - ->get("{$this->baseUrl}/user/info/", [ - 'fields' => 'follower_count,following_count,likes_count,video_count', - ]); - - if ($response->failed()) { - Log::warning('TikTok user stats fetch failed', [ - 'body' => $this->redactResponseBody($response->body()), - ]); - - return []; - } - - $user = data_get($response->json(), 'data.user', []); - - $metrics = []; - - if (($value = data_get($user, 'follower_count')) !== null) { - $metrics[] = ['label' => __('analytics.metrics.followers'), 'value' => $value]; - } - - if (($value = data_get($user, 'following_count')) !== null) { - $metrics[] = ['label' => __('analytics.metrics.following'), 'value' => $value]; - } - - if (($value = data_get($user, 'likes_count')) !== null) { - $metrics[] = ['label' => __('analytics.metrics.total_likes'), 'value' => $value]; - } - - if (($value = data_get($user, 'video_count')) !== null) { - $metrics[] = ['label' => __('analytics.metrics.videos'), 'value' => $value]; - } - - return $metrics; - } - - private function fetchVideoMetrics(): array - { - $videoListResponse = $this->getHttpClient() - ->post("{$this->baseUrl}/video/list/?fields=id", [ - 'max_count' => 20, - ]); - - if ($videoListResponse->failed()) { - Log::warning('TikTok video list fetch failed', [ - 'body' => $this->redactResponseBody($videoListResponse->body()), - ]); - - return []; - } - - $videos = data_get($videoListResponse->json(), 'data.videos', []); - - if (empty($videos)) { - return []; - } - - $videoIds = array_map(fn ($v) => $v['id'], $videos); - - $queryResponse = $this->getHttpClient() - ->post("{$this->baseUrl}/video/query/?fields=".self::VIDEO_METRIC_FIELDS, [ - 'filters' => ['video_ids' => $videoIds], - ]); - - if ($queryResponse->failed()) { - Log::warning('TikTok video query failed', [ - 'body' => $this->redactResponseBody($queryResponse->body()), - ]); - - return []; - } - - $videoDetails = data_get($queryResponse->json(), 'data.videos', []); - - if (empty($videoDetails)) { - return []; - } - - $totalViews = 0; - $totalLikes = 0; - $totalComments = 0; - $totalShares = 0; - - foreach ($videoDetails as $video) { - $totalViews += data_get($video, 'view_count', 0); - $totalLikes += data_get($video, 'like_count', 0); - $totalComments += data_get($video, 'comment_count', 0); - $totalShares += data_get($video, 'share_count', 0); - } - - return [ - ['label' => __('analytics.metrics.views'), 'value' => $totalViews], - ['label' => __('analytics.metrics.recent_likes'), 'value' => $totalLikes], - ['label' => __('analytics.metrics.recent_comments'), 'value' => $totalComments], - ['label' => __('analytics.metrics.recent_shares'), 'value' => $totalShares], - ]; - } - - private function getHttpClient(): PendingRequest - { - return $this->socialHttp()->asJson()->withToken($this->accessToken); - } -} diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 324c221cc..c6e499d8d 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -11,6 +11,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\TikTokPublishException; +use App\Exceptions\TokenExpiredException; use App\Models\Post; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; @@ -18,6 +19,7 @@ use App\Support\PostPlatformMetaRules; use App\Support\Social\PublishCheckpoint; use App\Support\Social\TikTokPhotoDerivativeCleaner; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -94,6 +96,40 @@ public function publish(Post $post): array ); } + /** + * The public video id TikTok reports for a published post's `publish_id`. + * TikTok sends it only once the post clears moderation, which can take hours. + */ + public function publicVideoId(Post $post): ?string + { + $account = $post->socialAccount; + + if (blank($account) || blank($post->platform_post_id)) { + return null; + } + + try { + if ($account->needsProactiveTokenRefresh()) { + app(ConnectionVerifier::class)->refreshToken($account); + } + } catch (TokenExpiredException|PlatformUnavailableException) { + return null; + } + + $this->accessToken = $account->access_token; + + try { + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/post/publish/status/fetch/", [ + 'publish_id' => $post->platform_post_id, + ]); + } catch (ConnectionException) { + return null; + } + + return $response->successful() ? $this->videoIdFrom((array) $response->json('data', [])) : null; + } + private function getHttpClient(): PendingRequest { return $this->socialHttp()->asJson()->withToken($this->accessToken); @@ -483,19 +519,25 @@ private function completePublishWithCleanup(Post $post, string $publishId, array private function completePublish(Post $post, string $publishId): array { $statusData = $this->waitForPublishStatus($publishId); - $postId = (string) data_get($statusData, 'publicaly_available_post_id.0'); - - if (blank($postId)) { - $postId = app(TikTokAnalytics::class)->findVideoIdByCaption($post); - } + $postId = $this->videoIdFrom($statusData); return [ 'id' => $postId ?? $publishId, - 'url' => $this->buildTikTokUrl($post->socialAccount, $postId), + 'url' => self::postUrl($post->socialAccount, $postId), ]; } - private function buildTikTokUrl(SocialAccount $account, ?string $postId = null): ?string + /** + * @param array $statusData + */ + private function videoIdFrom(array $statusData): ?string + { + $videoId = (string) data_get($statusData, 'publicaly_available_post_id.0'); + + return ctype_digit($videoId) ? $videoId : null; + } + + public static function postUrl(SocialAccount $account, ?string $postId = null): ?string { $username = $account->username; diff --git a/routes/console.php b/routes/console.php index daa9a7039..d594bb09a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -15,6 +15,7 @@ use App\Console\Commands\RecoverStuckPosts; use App\Console\Commands\RefreshExpiringTokens; use App\Console\Commands\Repurpose\PollRepurposes; +use App\Console\Commands\ResolveTikTokVideoIds; use App\Console\Commands\RssFeed\PollRssFeeds; use App\Enums\SocialAccount\Platform; use App\Jobs\Analytics\FinalizeAccountDailySnapshots; @@ -27,6 +28,7 @@ Schedule::command(RefreshExpiringTokens::class)->everyFifteenMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(ReconcileGoogleBusinessPosts::class)->everyFiveMinutes()->withoutOverlapping()->onOneServer(); +Schedule::command(ResolveTikTokVideoIds::class)->everyFifteenMinutes()->withoutOverlapping()->onOneServer(); Schedule::command(PruneWebhookLogs::class)->daily()->withoutOverlapping()->onOneServer(); Schedule::command(PruneExpiredPostHistoryCommand::class)->daily()->withoutOverlapping()->onOneServer(); Schedule::command(PruneTemporaryUploadsCommand::class)->hourly()->withoutOverlapping()->onOneServer(); diff --git a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php new file mode 100644 index 000000000..24017cce7 --- /dev/null +++ b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php @@ -0,0 +1,132 @@ +forAccount($account, ContentType::TikTokVideo)->published()->create([ + 'platform_post_id' => $platformPostId, + 'platform_url' => 'https://www.tiktok.com/@tiktoker', + ]); +} + +test('a post without an analytics publication gets the video id, its url and a publication', function () { + $post = tiktokPostAwaitingVideo(SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker'])); + + app(AssignTikTokVideoId::class)->handle($post, '7694860629638940686'); + + expect($post->fresh()) + ->platform_post_id->toBe('7694860629638940686') + ->platform_url->toBe('https://www.tiktok.com/@tiktoker/video/7694860629638940686') + ->and(AnalyticsPublication::query()->where('post_id', $post->id)->sole()) + ->remote_id->toBe('7694860629638940686') + ->origin->toBe(PublicationOrigin::TryPost) + ->permalink->toBe('https://www.tiktok.com/@tiktoker/video/7694860629638940686'); +}); + +test('a post deleted before its video id is assigned is left as it is', function () { + $post = tiktokPostAwaitingVideo(SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker'])); + Post::query()->whereKey($post->id)->delete(); + + app(AssignTikTokVideoId::class)->handle($post, '7694860629638940686'); + + expect(AnalyticsPublication::query()->exists())->toBeFalse(); +}); + +test('a post without an analytics publication takes its video back from the copy the importer made', function () { + $account = SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker']); + $post = tiktokPostAwaitingVideo($account); + $video = app(UpsertAnalyticsPublication::class)->external($account, new DiscoveredPublication( + providerPostId: '7694860629638940686', + publishedAt: now()->subDays(2)->toImmutable(), + contentType: PublicationContentType::Video, + permalink: 'https://www.tiktok.com/@tiktoker/video/7694860629638940686?share=1', + )); + ImportExternalPosts::execute($account); + $imported = Post::query()->imported()->sole(); + + app(AssignTikTokVideoId::class)->handle($post, '7694860629638940686'); + + expect(Post::query()->whereKey($imported->id)->exists())->toBeFalse() + ->and(AnalyticsPublication::query()->sole()) + ->post_id->toBe($post->id) + ->remote_id->toBe('7694860629638940686') + ->permalink->toBe('https://www.tiktok.com/@tiktoker/video/7694860629638940686?share=1') + ->and(AnalyticsPublication::query()->whereKey($video->id)->exists())->toBeFalse(); +}); + +test('a post without an analytics publication is refused a video another TryPost post holds', function () { + $account = SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker']); + $post = tiktokPostAwaitingVideo($account); + app(SyncTryPostPublication::class)->handle(tiktokPostAwaitingVideo($account, '7694860629638940686')); + + expect(fn () => app(AssignTikTokVideoId::class)->handle($post, '7694860629638940686')) + ->toThrow(LogicException::class); + + expect($post->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.pending') + ->and(AnalyticsPublication::query()->where('post_id', $post->id)->exists())->toBeFalse(); +}); + +test('a channel without a username keeps the post url it had', function () { + $post = tiktokPostAwaitingVideo(SocialAccount::factory()->tiktok()->create(['username' => null])); + + app(AssignTikTokVideoId::class)->handle($post, '7694860629638940686'); + + expect($post->fresh()) + ->platform_post_id->toBe('7694860629638940686') + ->platform_url->toBe('https://www.tiktok.com/@tiktoker'); +}); + +test('a video held by another TryPost post is refused and nothing is written', function () { + $account = SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker']); + $post = tiktokPostAwaitingVideo($account); + $publication = app(SyncTryPostPublication::class)->handle($post); + app(SyncTryPostPublication::class)->handle(tiktokPostAwaitingVideo($account, '7694860629638940686')); + + expect(fn () => app(AssignTikTokVideoId::class)->handle($post, '7694860629638940686')) + ->toThrow(LogicException::class); + + expect($post->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.pending') + ->and($publication->fresh()->remote_id)->toBe('v_pub_url~v2-1.pending'); +}); + +test('a post whose channel is gone keeps its url and still moves its publication', function () { + $account = SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker']); + $post = tiktokPostAwaitingVideo($account); + $publication = app(SyncTryPostPublication::class)->handle($post); + $post->forceFill(['social_account_id' => null])->save(); + + app(AssignTikTokVideoId::class)->handle($post->fresh(), '7694860629638940686'); + + expect($post->fresh()) + ->platform_post_id->toBe('7694860629638940686') + ->platform_url->toBe('https://www.tiktok.com/@tiktoker') + ->and($publication->fresh()->remote_id)->toBe('7694860629638940686'); +}); + +test('a publication without a link gets the video link', function () { + $post = tiktokPostAwaitingVideo(SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker'])); + $publication = app(SyncTryPostPublication::class)->handle($post); + $publication->update(['permalink' => null]); + + app(AssignTikTokVideoId::class)->handle($post, '7694860629638940686'); + + expect($publication->fresh()->permalink)->toBe('https://www.tiktok.com/@tiktoker/video/7694860629638940686'); +}); diff --git a/tests/Feature/Analytics/PersistedPostMetricsReadTest.php b/tests/Feature/Analytics/PersistedPostMetricsReadTest.php index d10dca7f8..41b577916 100644 --- a/tests/Feature/Analytics/PersistedPostMetricsReadTest.php +++ b/tests/Feature/Analytics/PersistedPostMetricsReadTest.php @@ -221,3 +221,38 @@ expect(app(ReadPublicationAnalytics::class)->latestFor($post)['metrics'])->not->toHaveKey('engagement_rate'); }); + +test('tiktok post metrics return the saved video url and observation without a provider call', function () { + Queue::fake([BootstrapAccountAnalytics::class, CollectAccountDailySnapshot::class]); + $videoId = '7685359243088103444'; + $account = SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker']); + $post = Post::factory()->forAccount($account)->published()->create([ + 'platform_post_id' => $videoId, + 'platform_url' => "https://www.tiktok.com/@tiktoker/video/{$videoId}", + ]); + $publication = AnalyticsPublication::factory()->create([ + 'workspace_id' => $account->workspace_id, + 'social_account_id' => $account->id, + 'social_account_key' => $account->id, + 'post_id' => $post->id, + 'network' => Platform::TikTok->network(), + 'platform' => Platform::TikTok, + 'remote_id' => $videoId, + 'content_type' => PublicationContentType::Video, + ]); + AnalyticsPublicationDailySnapshot::factory()->create([ + 'publication_id' => $publication->id, + 'views_count' => 5, + 'metrics' => ['views' => ['value' => 5, 'unit' => 'count', 'availability' => 'available']], + ]); + Http::fake(); + + $analytics = app(ReadPublicationAnalytics::class)->forPost($post->fresh()); + + expect($analytics)->toMatchArray([ + 'platform_post_id' => $videoId, + 'platform_url' => "https://www.tiktok.com/@tiktoker/video/{$videoId}", + ])->and($analytics['metrics']['metrics']['views']['value'])->toBe(5); + + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Analytics/PublicationMetricsJobsTest.php b/tests/Feature/Analytics/PublicationMetricsJobsTest.php index 9b5529d46..5b94c7a3d 100644 --- a/tests/Feature/Analytics/PublicationMetricsJobsTest.php +++ b/tests/Feature/Analytics/PublicationMetricsJobsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\Analytics\QueuePublicationMetricsForPage; +use App\Actions\Analytics\SyncTryPostPublication; use App\Dto\Analytics\DiscoveredPublication; use App\Dto\Analytics\PublicationPage; use App\Enums\Analytics\PublicationAvailability; @@ -101,10 +102,59 @@ function discoveredFor(AnalyticsPublication $publication): DiscoveredPublication expect($publication->fresh()->remote_id)->toBe('123456789') ->and($post->fresh()->platform_post_id)->toBe('123456789') + ->and($post->fresh()->platform_url)->toBe("https://www.tiktok.com/@{$account->username}/video/123456789") ->and($publication->dailySnapshots()->first()->views_count)->toBe(12); Http::assertSentCount(2); }); +test('TikTok metric job collects a publication without a post without assigning it the video id', function () { + $date = CarbonImmutable::parse('2026-09-23 12:00:00', 'UTC'); + CarbonImmutable::setTestNow($date); + $account = SocialAccount::factory()->create(['platform' => Platform::TikTok]); + $publication = AnalyticsPublication::factory()->create([ + 'workspace_id' => $account->workspace_id, + 'social_account_id' => $account->id, + 'social_account_key' => $account->id, + 'network' => Platform::TikTok->network(), + 'platform' => Platform::TikTok, + 'platform_user_id' => $account->platform_user_id, + 'remote_id' => 'v_pub_abc', + 'origin' => PublicationOrigin::External, + 'provider_published_at' => $date->subDay(), + ]); + Http::fake(['*' => Http::sequence() + ->push(['data' => ['publicaly_available_post_id' => ['123456789']]]) + ->push(['error' => ['code' => 'ok'], 'data' => ['videos' => [[ + 'id' => '123456789', 'view_count' => 12, 'like_count' => 1, + ]]]])]); + + app()->call([(new CollectPublicationMetrics($publication->id, $date->toDateString())), 'handle']); + + expect($publication->fresh()->remote_id)->toBe('v_pub_abc') + ->and($publication->dailySnapshots()->first()->views_count)->toBe(12); +}); + +test('TikTok metric job refuses a video id another TryPost post holds', function () { + $date = CarbonImmutable::parse('2026-09-23 12:00:00', 'UTC'); + CarbonImmutable::setTestNow($date); + $account = SocialAccount::factory()->create(['platform' => Platform::TikTok]); + $post = Post::factory()->forAccount($account, ContentType::TikTokVideo)->published()->create([ + 'platform_post_id' => 'v_pub_abc', + ]); + $holder = Post::factory()->forAccount($account, ContentType::TikTokVideo)->published()->create([ + 'platform_post_id' => '123456789', + ]); + $publication = app(SyncTryPostPublication::class)->handle($post); + app(SyncTryPostPublication::class)->handle($holder); + $publication->update(['provider_published_at' => $date->subDay()]); + Http::fake(['*' => Http::response(['data' => ['publicaly_available_post_id' => ['123456789']]])]); + + expect(fn () => app()->call([(new CollectPublicationMetrics($publication->id, $date->toDateString())), 'handle'])) + ->toThrow(LogicException::class); + + expect($post->fresh()->platform_post_id)->toBe('v_pub_abc'); +}); + test('regular collection respects the X and non-X refresh windows', function (Platform $platform, int $age, bool $eligible) { $date = CarbonImmutable::parse('2026-09-23 12:00:00', 'UTC'); CarbonImmutable::setTestNow($date); diff --git a/tests/Feature/Commands/RetryFailedPostTest.php b/tests/Feature/Commands/RetryFailedPostTest.php index f8a4848a8..2787daa65 100644 --- a/tests/Feature/Commands/RetryFailedPostTest.php +++ b/tests/Feature/Commands/RetryFailedPostTest.php @@ -304,7 +304,7 @@ $api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ 'status' => 'PUBLISH_COMPLETE', - 'publicaly_available_post_id' => ['video_123'], + 'publicaly_available_post_id' => ['7000000000000000123'], ], ]), ]); @@ -312,7 +312,7 @@ (new PublishToSocialPlatform($failedTikTok->fresh()))->handle(); expect($failedTikTok->fresh()->publish_status)->toBe(PlatformStatus::Published) - ->and($failedTikTok->fresh()->platform_post_id)->toBe('video_123'); + ->and($failedTikTok->fresh()->platform_post_id)->toBe('7000000000000000123'); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/')); }); @@ -491,7 +491,7 @@ $api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ 'status' => 'PUBLISH_COMPLETE', - 'publicaly_available_post_id' => ['video_456'], + 'publicaly_available_post_id' => ['7000000000000000456'], ], ]), ]); @@ -499,7 +499,7 @@ (new PublishToSocialPlatform($failedTikTok->fresh()))->handle(); expect($failedTikTok->fresh()->publish_status)->toBe(PlatformStatus::Published) - ->and($failedTikTok->fresh()->platform_post_id)->toBe('video_456'); + ->and($failedTikTok->fresh()->platform_post_id)->toBe('7000000000000000456'); Http::assertSent(fn ($request) => str_contains($request->url(), '/init/')); }); diff --git a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php new file mode 100644 index 000000000..1e08189c1 --- /dev/null +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -0,0 +1,427 @@ +account = SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker']); +}); + +function repairTikTokPost(string $platformPostId, string $publishedAt): Post +{ + return Post::factory()->forAccount(test()->account, ContentType::TikTokVideo)->published()->create([ + 'platform_post_id' => $platformPostId, + 'platform_url' => 'https://www.tiktok.com/@tiktoker', + 'published_at' => $publishedAt, + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], + ]); +} + +function repairTikTokVideo(string $remoteId, string $createdAt, ?Post $post = null): AnalyticsPublication +{ + $account = test()->account; + + return AnalyticsPublication::factory()->create([ + 'workspace_id' => $account->workspace_id, + 'social_account_id' => $account->id, + 'social_account_key' => $account->id, + 'network' => Platform::TikTok->network(), + 'platform' => Platform::TikTok, + 'platform_user_id' => $account->platform_user_id, + 'content_type' => PublicationContentType::Video, + 'remote_id' => $remoteId, + 'permalink' => "https://www.tiktok.com/@tiktoker/video/{$remoteId}", + 'provider_published_at' => $createdAt, + 'post_id' => $post?->id, + 'origin' => blank($post) || $post->origin === Origin::Network ? PublicationOrigin::External : PublicationOrigin::TryPost, + ]); +} + +/** + * Post A was published before TikTok reported its video, and post B, a day + * later, took A's video by caption while its own video was imported apart. + * + * @return array{first: Post, second: Post, firstVideo: AnalyticsPublication, secondVideo: AnalyticsPublication, imported: Post, provisional: AnalyticsPublication} + */ +function claimedTikTokVideoChain(): array +{ + $first = repairTikTokPost('v_pub_url~v2-1.first', '2026-10-08 10:00:30'); + $provisional = repairTikTokVideo('v_pub_url~v2-1.first', '2026-10-08 10:00:30', $first); + $second = repairTikTokPost('7000000000000000001', '2026-10-09 10:00:30'); + $firstVideo = repairTikTokVideo('7000000000000000001', '2026-10-08 10:00:00', $second); + $imported = Post::factory()->forAccount(test()->account, ContentType::TikTokVideo)->imported()->create([ + 'platform_post_id' => '7000000000000000002', + ]); + $secondVideo = repairTikTokVideo('7000000000000000002', '2026-10-09 10:00:00', $imported); + + return compact('first', 'second', 'firstVideo', 'secondVideo', 'imported', 'provisional'); +} + +test('a post gets its own video back and the video it took is freed for the post that published it', function () { + $chain = claimedTikTokVideoChain(); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($chain['second']->fresh()) + ->platform_post_id->toBe('7000000000000000002') + ->platform_url->toBe('https://www.tiktok.com/@tiktoker/video/7000000000000000002') + ->and($chain['secondVideo']->fresh()) + ->post_id->toBe($chain['second']->id) + ->origin->toBe(PublicationOrigin::TryPost) + ->and($chain['firstVideo']->fresh()) + ->post_id->toBeNull() + ->origin->toBe(PublicationOrigin::External) + ->and(Post::query()->whereKey($chain['imported']->id)->exists())->toBeFalse() + ->and($chain['first']->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.first'); + + Queue::assertPushed(ResolveTikTokVideoId::class, fn (ResolveTikTokVideoId $job): bool => $job->post->is($chain['first'])); +}); + +test('a second run finds nothing left to repair', function () { + $chain = claimedTikTokVideoChain(); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + $this->artisan('tiktok:repair-video-ids', ['--dry-run' => true]) + ->expectsOutputToContain('0 post(s) would get their own video back') + ->assertSuccessful(); + + expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000002'); +}); + +test('the copy the importer made of a post own video goes with its media', function () { + $chain = claimedTikTokVideoChain(); + $media = Media::factory()->ownedByPost($chain['imported'])->create(); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect(Media::query()->whereKey($media->id)->exists())->toBeFalse(); +}); + +test('a post gets a video url built from its channel when the video has no permalink', function (?string $username, string $expectedUrl) { + $chain = claimedTikTokVideoChain(); + $chain['secondVideo']->update(['permalink' => null]); + $this->account->update(['username' => $username]); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($chain['second']->fresh()->platform_url)->toBe($expectedUrl); +})->with([ + 'with a username' => ['tiktoker', 'https://www.tiktok.com/@tiktoker/video/7000000000000000002'], + 'without a username' => [null, 'https://www.tiktok.com/@tiktoker'], +]); + +test('posts on a channel that is not connected are not sent to TikTok', function () { + $chain = claimedTikTokVideoChain(); + $this->account->update(['status' => Status::Disconnected]); + + $this->artisan('tiktok:repair-video-ids') + ->expectsOutputToContain('0 post(s) are asking TikTok for their video id.') + ->assertSuccessful(); + + expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000002'); + Queue::assertNotPushed(ResolveTikTokVideoId::class); +}); + +test('posts whose channel is gone are left alone', function () { + $chain = claimedTikTokVideoChain(); + $chain['secondVideo']->update(['permalink' => null]); + Post::query()->whereKey([$chain['first']->id, $chain['second']->id])->update(['social_account_id' => null]); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000001') + ->and($chain['firstVideo']->fresh()->post_id)->toBe($chain['second']->id); +}); + +test('a post whose publish only finished long after TikTok created its video keeps that video', function () { + $slow = repairTikTokPost('7000000000000000003', '2026-10-09 14:40:00'); + $video = repairTikTokVideo('7000000000000000003', '2026-10-09 14:00:00', $slow); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($slow->fresh()->platform_post_id)->toBe('7000000000000000003') + ->and($video->fresh()->post_id)->toBe($slow->id); +}); + +test('a dry run lists the repairs without writing them', function () { + $chain = claimedTikTokVideoChain(); + + $this->artisan('tiktok:repair-video-ids', ['--dry-run' => true]) + ->expectsOutputToContain('1 post(s) would get their own video back; 0 post(s) would lose a video another post owns; 1 imported copy(ies) would be deleted; 1 post(s) would ask TikTok for their video id.') + ->assertSuccessful(); + + expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000001') + ->and($chain['firstVideo']->fresh()->post_id)->toBe($chain['second']->id) + ->and(Post::query()->whereKey($chain['imported']->id)->exists())->toBeTrue(); + + Queue::assertNotPushed(ResolveTikTokVideoId::class); +}); + +test('a run of posts that each took the previous post video all get their own back', function () { + $first = repairTikTokPost('v_pub_url~v2-1.first', '2026-10-07 10:00:30'); + repairTikTokVideo('v_pub_url~v2-1.first', '2026-10-07 10:00:30', $first); + $second = repairTikTokPost('7000000000000000011', '2026-10-08 10:00:30'); + $firstVideo = repairTikTokVideo('7000000000000000011', '2026-10-07 10:00:00', $second); + $third = repairTikTokPost('7000000000000000012', '2026-10-09 10:00:30'); + $secondVideo = repairTikTokVideo('7000000000000000012', '2026-10-08 10:00:00', $third); + $thirdVideo = repairTikTokVideo('7000000000000000013', '2026-10-09 10:00:00'); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($second->fresh()->platform_post_id)->toBe('7000000000000000012') + ->and($third->fresh()->platform_post_id)->toBe('7000000000000000013') + ->and($secondVideo->fresh()->post_id)->toBe($second->id) + ->and($thirdVideo->fresh()->post_id)->toBe($third->id) + ->and($firstVideo->fresh()->post_id)->toBeNull(); + + Queue::assertPushed(ResolveTikTokVideoId::class, fn (ResolveTikTokVideoId $job): bool => $job->post->is($first)); +}); + +test('a post is left alone when the post holding its own video cannot be repaired', function () { + $first = repairTikTokPost('v_pub_url~v2-1.first', '2026-10-07 10:00:30'); + repairTikTokVideo('v_pub_url~v2-1.first', '2026-10-07 10:00:30', $first); + $second = repairTikTokPost('7000000000000000021', '2026-10-08 10:00:30'); + repairTikTokVideo('7000000000000000021', '2026-10-07 10:00:00', $second); + $third = repairTikTokPost('7000000000000000022', '2026-10-09 10:00:30'); + repairTikTokVideo('7000000000000000022', '2026-10-08 10:00:00', $third); + $correct = repairTikTokPost('7000000000000000023', '2026-10-09 10:00:35'); + repairTikTokVideo('7000000000000000023', '2026-10-09 10:00:00', $correct); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($second->fresh()->platform_post_id)->toBe('7000000000000000021') + ->and($third->fresh()->platform_post_id)->toBe('7000000000000000022') + ->and($correct->fresh()->platform_post_id)->toBe('7000000000000000023'); +}); + +test('posts are left alone when their own video is unknown, ambiguous or claimed by another post', function (string $case) { + $previous = repairTikTokPost('v_pub_url~v2-1.previous', '2026-10-07 10:00:30'); + repairTikTokVideo('v_pub_url~v2-1.previous', '2026-10-07 10:00:30', $previous); + $post = repairTikTokPost('7000000000000000031', '2026-10-08 10:00:30'); + $held = repairTikTokVideo('7000000000000000031', '2026-10-07 10:00:00', $post); + $video = repairTikTokVideo('7000000000000000032', $case === 'unknown' ? '2026-10-08 09:50:00' : '2026-10-08 10:00:20'); + + if ($case === 'ambiguous') { + repairTikTokVideo('7000000000000000033', '2026-10-08 09:59:40'); + } elseif ($case === 'claimed') { + $other = repairTikTokPost('v_pub_url~v2-1.other', '2026-10-07 12:00:30'); + repairTikTokVideo('v_pub_url~v2-1.other', '2026-10-07 12:00:30', $other); + $sibling = repairTikTokPost('7000000000000000034', '2026-10-08 10:01:00'); + repairTikTokVideo('7000000000000000034', '2026-10-07 12:00:00', $sibling); + } + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($post->fresh()->platform_post_id)->toBe('7000000000000000031') + ->and($held->fresh()->post_id)->toBe($post->id) + ->and($video->fresh()->post_id)->toBeNull(); +})->with([ + 'no video in its window' => ['unknown'], + 'two videos in its window' => ['ambiguous'], + 'another post claims the same video' => ['claimed'], +]); + +/** + * Three posts of one channel hold video 7000000000000000041, created at + * 10:00:00 on 2026-10-05: the one published 30 seconds later, one published + * an hour before the video existed, and an imported copy. + * + * @return array{owner: Post, earlier: Post, copy: Post, video: AnalyticsPublication} + */ +function sharedTikTokVideo(bool $publicationOnEarlierPost = false): array +{ + $owner = repairTikTokPost('7000000000000000041', '2026-10-05 10:00:30'); + $earlier = repairTikTokPost('7000000000000000041', '2026-10-05 09:00:30'); + $copy = Post::factory()->forAccount(test()->account, ContentType::TikTokVideo)->imported()->create([ + 'platform_post_id' => '7000000000000000041', + 'published_at' => '2026-10-05 10:00:00', + ]); + $video = repairTikTokVideo('7000000000000000041', '2026-10-05 10:00:00', $publicationOnEarlierPost ? $earlier : $owner); + + return compact('owner', 'earlier', 'copy', 'video'); +} + +test('a video several posts hold stays with the post published right after it was created', function (bool $publicationOnEarlierPost) { + $shared = sharedTikTokVideo($publicationOnEarlierPost); + $media = Media::factory()->ownedByPost($shared['copy'])->create(); + + $this->artisan('tiktok:repair-video-ids') + ->expectsOutputToContain('0 post(s) got their own video back; 1 post(s) lost a video another post owns; 1 imported copy(ies) deleted;') + ->assertSuccessful(); + + expect($shared['owner']->fresh()->platform_post_id)->toBe('7000000000000000041') + ->and($shared['video']->fresh()) + ->post_id->toBe($shared['owner']->id) + ->origin->toBe(PublicationOrigin::TryPost) + ->and($shared['earlier']->fresh()) + ->platform_post_id->toBeNull() + ->platform_url->toBe('https://www.tiktok.com/@tiktoker') + ->and(Post::query()->whereKey($shared['copy']->id)->exists())->toBeFalse() + ->and(Media::query()->whereKey($media->id)->exists())->toBeFalse(); +})->with([ + 'publication on the owner' => [false], + 'publication on a post that does not own it' => [true], +]); + +test('a video several posts hold is left alone without exactly one post published right after it', function (string $case) { + $shared = sharedTikTokVideo(); + + match ($case) { + 'two posts right after' => repairTikTokPost('7000000000000000041', '2026-10-05 10:01:00'), + 'none right after' => $shared['owner']->forceFill(['published_at' => '2026-10-05 10:30:00'])->save(), + 'owner holds another publication' => $shared['video']->update(['post_id' => $shared['earlier']->id]) + && repairTikTokVideo('v_pub_url~v2-1.owner', '2026-10-05 08:00:00', $shared['owner']), + }; + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($shared['earlier']->fresh()->platform_post_id)->toBe('7000000000000000041') + ->and(Post::query()->whereKey($shared['copy']->id)->exists())->toBeTrue(); +})->with([ + 'two posts right after' => ['two posts right after'], + 'none right after' => ['none right after'], + 'owner holds another publication' => ['owner holds another publication'], +]); + +test('a dry run rolls both repairs back and only queues media file deletions for after a commit that never comes', function () { + $chain = claimedTikTokVideoChain(); + $shared = sharedTikTokVideo(); + $media = Media::factory()->ownedByPost($shared['copy'])->create(); + + $this->artisan('tiktok:repair-video-ids', ['--dry-run' => true]) + ->expectsOutputToContain('1 post(s) would get their own video back; 1 post(s) would lose a video another post owns; 2 imported copy(ies) would be deleted;') + ->assertSuccessful(); + + expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000001') + ->and($shared['earlier']->fresh()->platform_post_id)->toBe('7000000000000000041') + ->and(Post::query()->whereKey([$chain['imported']->id, $shared['copy']->id])->count())->toBe(2) + ->and(Media::query()->whereKey($media->id)->exists())->toBeTrue(); + Queue::assertPushed(DeleteMediaFiles::class, fn (DeleteMediaFiles $job): bool => $job->afterCommit === true); +}); + +test('an imported copy left behind when a post gets its own video back is deleted in the same run', function () { + $chain = claimedTikTokVideoChain(); + $orphan = Post::factory()->forAccount($this->account, ContentType::TikTokVideo)->imported()->create([ + 'platform_post_id' => '7000000000000000002', + 'published_at' => '2026-10-09 10:00:00', + ]); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000002') + ->and(Post::query()->whereKey($orphan->id)->exists())->toBeFalse(); +}); + +test('a native video is never deleted when the post holding its id had another video created in its window', function () { + $native = Post::factory()->forAccount($this->account, ContentType::TikTokVideo)->imported()->create([ + 'platform_post_id' => '7000000000000000051', + 'published_at' => '2026-10-05 10:00:00', + ]); + $nativeVideo = repairTikTokVideo('7000000000000000051', '2026-10-05 10:00:00', $native); + $post = repairTikTokPost('7000000000000000051', '2026-10-05 10:01:00'); + repairTikTokVideo('7000000000000000052', '2026-10-05 10:00:40'); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect(Post::query()->whereKey($native->id)->exists())->toBeTrue() + ->and($nativeVideo->fresh()->post_id)->toBe($native->id) + ->and($post->fresh()->platform_post_id)->toBe('7000000000000000051'); +}); + +test('a post that loses a video another post owns gets its own video back when there is one', function () { + $shared = sharedTikTokVideo(); + $ownCopy = Post::factory()->forAccount($this->account, ContentType::TikTokVideo)->imported()->create([ + 'platform_post_id' => '7000000000000000042', + 'published_at' => '2026-10-05 09:00:00', + ]); + $ownVideo = repairTikTokVideo('7000000000000000042', '2026-10-05 09:00:00', $ownCopy); + + $this->artisan('tiktok:repair-video-ids') + ->expectsOutputToContain('1 post(s) got their own video back; 0 post(s) lost a video another post owns; 2 imported copy(ies) deleted;') + ->assertSuccessful(); + + expect($shared['earlier']->fresh()->platform_post_id)->toBe('7000000000000000042') + ->and($ownVideo->fresh()->post_id)->toBe($shared['earlier']->id) + ->and(Post::query()->whereKey($ownCopy->id)->exists())->toBeFalse() + ->and($shared['owner']->fresh()->platform_post_id)->toBe('7000000000000000041'); +}); + +test('a shared video is left alone when the posts are not published, have no publication or sit on other channels', function (string $case) { + $posts = match ($case) { + 'a failed post' => [ + repairTikTokPost('7000000000000000061', '2026-10-05 10:00:30'), + tap(repairTikTokPost('7000000000000000061', '2026-10-05 09:00:30'), fn (Post $post) => $post->forceFill(['publish_status' => PublishStatus::Failed])->save()), + ], + 'no publication' => [ + repairTikTokPost('7000000000000000061', '2026-10-05 10:00:30'), + repairTikTokPost('7000000000000000061', '2026-10-05 09:00:30'), + ], + 'another channel' => [ + repairTikTokPost('7000000000000000061', '2026-10-05 10:00:30'), + Post::factory()->forAccount(SocialAccount::factory()->tiktok()->create(), ContentType::TikTokVideo)->published()->create([ + 'platform_post_id' => '7000000000000000061', + 'published_at' => '2026-10-05 09:00:30', + ]), + ], + }; + + if ($case !== 'no publication') { + repairTikTokVideo('7000000000000000061', '2026-10-05 10:00:00', $posts[0]); + } + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect(Post::query()->whereKey(array_map(fn (Post $post): string => $post->id, $posts))->pluck('platform_post_id')->unique()->all()) + ->toBe(['7000000000000000061']); +})->with([ + 'a failed post' => ['a failed post'], + 'no publication' => ['no publication'], + 'another channel' => ['another channel'], +]); + +test('a failure rolls every repair back', function () { + $chain = claimedTikTokVideoChain(); + Post::updating(fn () => throw new RuntimeException('Write failed')); + + expect(fn () => Artisan::call('tiktok:repair-video-ids'))->toThrow(RuntimeException::class, 'Write failed'); + + expect($chain['firstVideo']->fresh()->post_id)->toBe($chain['second']->id) + ->and(Post::query()->whereKey($chain['imported']->id)->exists())->toBeTrue(); +}); + +test('a video handed back while settling an earlier shared video is settled with its current holders', function () { + $shared = sharedTikTokVideo(); + $ownCopy = Post::factory()->forAccount($this->account, ContentType::TikTokVideo)->imported()->create([ + 'platform_post_id' => '7000000000000000043', + 'published_at' => '2026-10-05 09:00:00', + ]); + $ownVideo = repairTikTokVideo('7000000000000000043', '2026-10-05 09:00:00', $ownCopy); + $later = repairTikTokPost('7000000000000000043', '2026-10-05 12:01:00'); + + $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); + + expect($shared['earlier']->fresh()->platform_post_id)->toBe('7000000000000000043') + ->and($ownVideo->fresh()->post_id)->toBe($shared['earlier']->id) + ->and($later->fresh()->platform_post_id)->toBeNull() + ->and(Post::query()->where('platform_post_id', '7000000000000000043')->count())->toBe(1); +}); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index e91b49a81..a6c14bb2f 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -22,6 +22,7 @@ use App\Exceptions\Social\YouTubePublishException; use App\Exceptions\TokenExpiredException; use App\Jobs\PublishToSocialPlatform; +use App\Jobs\ResolveTikTokVideoId; use App\Jobs\SendNotification; use App\Mail\AccountDisconnected; use App\Mail\PostPublished; @@ -37,6 +38,7 @@ use App\Services\Social\LinkedInPagePublisher; use App\Services\Social\LinkedInPublisher; use App\Services\Social\PinterestPublisher; +use App\Services\Social\TikTokPublisher; use App\Support\Social\GoogleBusinessDerivativeCleaner; use Carbon\Carbon; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -1448,7 +1450,7 @@ function linkedInRejection(int $status): LinkedInPublishException ->push([ 'data' => [ 'status' => 'PUBLISH_COMPLETE', - 'publicaly_available_post_id' => ['video_123'], + 'publicaly_available_post_id' => ['7000000000000000123'], ], ]), '*' => Http::response('fake-image-content', 200), @@ -1459,7 +1461,7 @@ function linkedInRejection(int $status): LinkedInPublishException $platform->refresh(); expect($platform->publish_status)->toBe(PlatformStatus::Published) - ->and($platform->platform_post_id)->toBe('video_123') + ->and($platform->platform_post_id)->toBe('7000000000000000123') ->and($platform->error_context)->toBeNull() ->and(Storage::allFiles('social-tiktok-photos'))->toBeEmpty() ->and(Http::recorded(fn ($request) => str_contains($request->url(), '/post/publish/content/init/'))) @@ -2347,3 +2349,66 @@ function linkedInRejection(int $status): LinkedInPublishException 'error' => 'An unexpected error occurred while publishing. Please try again.', ])); }); + +test('a tiktok post published before TikTok reports its video id asks again a minute later', function (string $platformPostId, PrivacyLevel $privacy, bool $asks) { + Event::fake(); + Mail::fake(); + Queue::fake([ResolveTikTokVideoId::class]); + $this->freezeTime(); + + $account = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'tiktoker', + 'token_expires_at' => now()->addDay(), + ]); + $this->post->update([ + 'media' => [[ + 'id' => 'photo', + 'path' => 'media/2026-01/photo.jpg', + 'url' => 'https://example.com/media/2026-01/photo.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'photo.jpg', + 'meta' => ['width' => 1080, 'height' => 1080], + ]], + ]); + $post = publishJobRetarget($this->post, $account, [ + 'content_type' => ContentType::TikTokPhoto, + 'publish_status' => PlatformStatus::Pending, + 'meta' => ['privacy_level' => $privacy->value], + ]); + + $publisher = Mockery::mock(TikTokPublisher::class); + $publisher->shouldReceive('publish')->once()->andReturn(['id' => $platformPostId, 'url' => 'https://www.tiktok.com/@tiktoker']); + $this->app->instance(TikTokPublisher::class, $publisher); + + (new PublishToSocialPlatform($post))->handle(); + + expect($post->fresh()->publish_status)->toBe(PlatformStatus::Published); + + $asks + ? Queue::assertPushedOn(Platform::TikTok->queue(), ResolveTikTokVideoId::class, fn (ResolveTikTokVideoId $job): bool => $job->post->is($post) + && $job->delay->equalTo(now()->addSeconds(ResolveTikTokVideoId::FIRST_CHECK_AFTER_SECONDS))) + : Queue::assertNotPushed(ResolveTikTokVideoId::class); +})->with([ + 'no video id yet' => ['p_pub_url~v2.pending', PrivacyLevel::PublicToEveryone, true], + 'video id already reported' => ['7694860629638940686', PrivacyLevel::PublicToEveryone, false], + 'private post' => ['p_pub_url~v2.private', PrivacyLevel::SelfOnly, false], + 'followers only post' => ['p_pub_url~v2.followers', PrivacyLevel::FollowerOfCreator, false], +]); + +test('a post on another network never asks TikTok for a video id', function () { + Event::fake(); + Queue::fake([ResolveTikTokVideoId::class]); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->once()->andReturn([ + 'id' => 'urn:li:share:7000000000000000001', + 'url' => 'https://linkedin.com/post/1', + ]); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->post))->handle(); + + expect($this->post->fresh()->publish_status)->toBe(PlatformStatus::Published); + Queue::assertNotPushed(ResolveTikTokVideoId::class); +}); diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php new file mode 100644 index 000000000..2b3b77642 --- /dev/null +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -0,0 +1,260 @@ +account = SocialAccount::factory()->tiktok()->create([ + 'username' => 'tiktoker', + 'token_expires_at' => now()->addDay(), + ]); + $this->statusUrl = config('trypost.platforms.tiktok.api').'/post/publish/status/fetch/'; +}); + +/** + * @param array $attributes + */ +function awaitingTikTokPost(array $attributes = []): Post +{ + return Post::factory()->forAccount(test()->account, ContentType::TikTokVideo)->published()->create([ + 'platform_post_id' => 'v_pub_url~v2-1.pending', + 'platform_url' => 'https://www.tiktok.com/@tiktoker', + 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], + ...$attributes, + ]); +} + +function resolveTikTokVideoId(Post $post): void +{ + app()->call([new ResolveTikTokVideoId($post), 'handle']); +} + +test('the video id TikTok reports replaces the publish id on the post and its analytics publication', function () { + $post = awaitingTikTokPost(); + $publication = app(SyncTryPostPublication::class)->handle($post); + + Http::fake([$this->statusUrl => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['7694860629638940686']], + 'error' => ['code' => 'ok'], + ])]); + + resolveTikTokVideoId($post); + + expect($post->fresh()) + ->platform_post_id->toBe('7694860629638940686') + ->platform_url->toBe('https://www.tiktok.com/@tiktoker/video/7694860629638940686') + ->and($publication->fresh()->remote_id)->toBe('7694860629638940686'); + + Http::assertSent(fn ($request) => $request['publish_id'] === 'v_pub_url~v2-1.pending'); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/list/')); +}); + +test('a post TikTok has not reported yet keeps its publish id and records the check', function () { + $post = awaitingTikTokPost(); + + Http::fake([$this->statusUrl => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => []], + 'error' => ['code' => 'ok'], + ])]); + + resolveTikTokVideoId($post); + + expect($post->fresh()) + ->platform_post_id->toBe('v_pub_url~v2-1.pending') + ->platform_url->toBe('https://www.tiktok.com/@tiktoker') + ->last_reconciled_at->not->toBeNull(); + + Http::assertSentCount(1); +}); + +test('a video held by another post is logged once a day, records the check and leaves the post as it is', function () { + Log::spy(); + $post = awaitingTikTokPost(['published_at' => now()->subMinutes(30)]); + app(SyncTryPostPublication::class)->handle($post); + app(SyncTryPostPublication::class)->handle(awaitingTikTokPost(['platform_post_id' => '7694860629638940686'])); + + Http::fake([$this->statusUrl => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['7694860629638940686']], + 'error' => ['code' => 'ok'], + ])]); + + resolveTikTokVideoId($post); + resolveTikTokVideoId($post); + + Log::shouldHaveReceived('warning') + ->with('TikTok reported a video another TryPost post holds; not assigned.', Mockery::type('array')) + ->once(); + + expect($post->fresh()) + ->platform_post_id->toBe('v_pub_url~v2-1.pending') + ->last_reconciled_at->not->toBeNull(); + + $this->artisan(ResolveTikTokVideoIds::class)->assertSuccessful(); + + Queue::assertNotPushed(ResolveTikTokVideoId::class); +}); + +test('posts that cannot get a video id are never sent to TikTok', function (array $attributes) { + Http::fake(); + + resolveTikTokVideoId(awaitingTikTokPost($attributes)); + + Http::assertNothingSent(); +})->with([ + 'self only' => [['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]], + 'followers only' => [['meta' => ['privacy_level' => PrivacyLevel::FollowerOfCreator->value]]], + 'friends only' => [['meta' => ['privacy_level' => PrivacyLevel::MutualFollowFriends->value]]], + 'already resolved' => [['platform_post_id' => '7694860629638940686']], + 'imported' => [['origin' => Origin::Network]], +]); + +test('a post on a channel that is not connected is never sent to TikTok', function (Status $status) { + $post = awaitingTikTokPost(); + $this->account->update(['status' => $status]); + Http::fake(); + + resolveTikTokVideoId($post); + + Http::assertNothingSent(); +})->with([Status::Disconnected, Status::TokenExpired]); + +test('a video discovered at the same moment leaves the post for the next check', function () { + $post = awaitingTikTokPost(); + $assignVideoId = Mockery::mock(AssignTikTokVideoId::class); + $assignVideoId->shouldReceive('handle')->once()->andThrow(new UniqueConstraintViolationException('pgsql', 'insert', [], new PDOException('duplicate key value'))); + $this->app->instance(AssignTikTokVideoId::class, $assignVideoId); + + Http::fake([$this->statusUrl => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['7694860629638940686']], + 'error' => ['code' => 'ok'], + ])]); + + resolveTikTokVideoId($post); + + expect($post->fresh()) + ->platform_post_id->toBe('v_pub_url~v2-1.pending') + ->last_reconciled_at->not->toBeNull(); +}); + +test('a post deleted before its check runs drops the job without failing it', function () { + $post = awaitingTikTokPost(); + $job = new ResolveTikTokVideoId($post); + $post->delete(); + Http::fake(); + + $queue = new SyncQueue; + $queue->setContainer(app()); + $queue->push($job); + + Http::assertNothingSent(); +}); + +test('the sweep asks again for a new post on every run, then hourly, then daily, for a month', function (int $publishedMinutesAgo, ?int $checkedMinutesAgo, bool $dispatched) { + $post = awaitingTikTokPost(['published_at' => now()->subMinutes($publishedMinutesAgo)]); + $post->forceFill([ + 'last_reconciled_at' => transform($checkedMinutesAgo, fn (int $minutes): CarbonImmutable => now()->subMinutes($minutes)), + ])->save(); + + $this->artisan(ResolveTikTokVideoIds::class)->assertSuccessful(); + + $dispatched + ? Queue::assertPushed(ResolveTikTokVideoId::class, fn (ResolveTikTokVideoId $job): bool => $job->post->is($post)) + : Queue::assertNotPushed(ResolveTikTokVideoId::class); +})->with([ + 'never checked' => [2, null, true], + 'new, checked 9 minutes ago' => [30, 9, false], + 'new, checked 10 minutes ago' => [30, 10, true], + 'first day, checked 30 minutes ago' => [120, 30, false], + 'first day, checked an hour ago' => [120, 60, true], + 'older, checked 2 hours ago' => [3 * 1440, 120, false], + 'older, checked a day ago' => [3 * 1440, 1440, true], + 'past the month' => [31 * 1440, null, false], +]); + +test('the sweep skips posts that already have their video id or never get one', function (array $attributes) { + awaitingTikTokPost($attributes); + + $this->artisan(ResolveTikTokVideoIds::class)->assertSuccessful(); + + Queue::assertNotPushed(ResolveTikTokVideoId::class); +})->with([ + 'resolved' => [['platform_post_id' => '7694860629638940686']], + 'self only' => [['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]], + 'followers only' => [['meta' => ['privacy_level' => PrivacyLevel::FollowerOfCreator->value]]], + 'imported' => [['origin' => Origin::Network]], + 'publish failed' => [['publish_status' => PublishStatus::Failed]], + 'publish retrying' => [['publish_status' => PublishStatus::Retrying]], +]); + +test('a post is checked once while its check is already queued', function () { + $post = awaitingTikTokPost(); + + ResolveTikTokVideoId::dispatch($post)->delay(now()->addSeconds(ResolveTikTokVideoId::FIRST_CHECK_AFTER_SECONDS)); + $this->artisan(ResolveTikTokVideoIds::class)->assertSuccessful(); + + Queue::assertPushed(ResolveTikTokVideoId::class, 1); +}); + +test('the sweep queues the check on the TikTok queue', function () { + awaitingTikTokPost(); + + $this->artisan(ResolveTikTokVideoIds::class)->assertSuccessful(); + + Queue::assertPushedOn(Platform::TikTok->queue(), ResolveTikTokVideoId::class); +}); + +test('the sweep skips posts whose channel is not connected', function (Status $status) { + awaitingTikTokPost(); + $this->account->update(['status' => $status]); + + $this->artisan(ResolveTikTokVideoIds::class)->assertSuccessful(); + + Queue::assertNotPushed(ResolveTikTokVideoId::class); +})->with([Status::Disconnected, Status::TokenExpired]); + +test('the sweep runs every fifteen minutes on one server', function () { + $event = collect(app(Schedule::class)->events()) + ->sole(fn (Event $event): bool => str_contains((string) $event->command, 'social:resolve-tiktok-video-ids')); + + foreach (['12:00' => true, '12:15' => true, '12:45' => true, '12:01' => false, '12:14' => false] as $time => $due) { + $this->travelTo(CarbonImmutable::parse("2026-10-10 {$time}:00", 'UTC')); + + expect($event->isDue(app()))->toBe($due, "at {$time}"); + } + + expect($event->onOneServer)->toBeTrue() + ->and($event->withoutOverlapping)->toBeTrue(); +}); + +test('a sweep with no post waiting for its video id sends nothing to TikTok', function () { + awaitingTikTokPost(['platform_post_id' => '7694860629638940686']); + Http::fake(); + + $this->artisan(ResolveTikTokVideoIds::class)->assertSuccessful(); + + Queue::assertNotPushed(ResolveTikTokVideoId::class); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Posts/ImportExternalPostsTest.php b/tests/Feature/Posts/ImportExternalPostsTest.php index 82ade593a..015e42a50 100644 --- a/tests/Feature/Posts/ImportExternalPostsTest.php +++ b/tests/Feature/Posts/ImportExternalPostsTest.php @@ -435,6 +435,28 @@ function sentByTryPost(SocialAccount $account, string $platformPostId, string $c ->and($tryPost->fresh()->platform_post_id)->toBe('media-0'); }); +test('a tiktok post whose video id TikTok already reported is never moved to a native video with the same caption', function () { + $account = SocialAccount::factory()->tiktok()->create(); + $tryPost = sentByTryPost($account, '7300000000000000001', 'Same caption', now()->subHour()->toImmutable()); + externalPublication($account, [ + 'remote_id' => '7300000000000000001', + 'post_id' => $tryPost->id, + 'origin' => PublicationOrigin::TryPost, + 'provider_synced_at' => null, + ]); + externalPublication($account, [ + 'remote_id' => '7300000000000000002', + 'excerpt' => 'Same caption', + 'content_type' => PublicationContentType::Video, + 'provider_published_at' => now()->subHour(), + ]); + + ImportExternalPosts::execute($account); + + expect($tryPost->fresh()->platform_post_id)->toBe('7300000000000000001') + ->and(Post::query()->imported()->sole()->platform_post_id)->toBe('7300000000000000002'); +}); + test('two trypost posts matching the same publication link neither and import nothing this run', function () { Log::spy(); $account = SocialAccount::factory()->instagram()->create(); diff --git a/tests/Feature/Services/Social/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php deleted file mode 100644 index 0f312c2da..000000000 --- a/tests/Feature/Services/Social/TikTokAnalyticsTest.php +++ /dev/null @@ -1,367 +0,0 @@ -user = User::factory()->create(); - $this->workspace = Workspace::factory()->create(['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->post = Post::factory()->forAccount($this->account)->create([ - 'user_id' => $this->user->id, - ]); - $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 tiktokPost(?string $platformPostId = '7685359243088103444'): Post -{ - return tap(test()->post->forceFill([ - 'platform_post_id' => $platformPostId, - 'platform_url' => 'https://www.tiktok.com/@tiktoker', - 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], - ]))->save(); -} - -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(tiktokPost($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'; - $post = tiktokPost($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($post); - - 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], - ]); - - $post->refresh(); - - expect($post->platform_post_id)->toBe($videoId) - ->and($post->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( - tiktokPost('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'], - ]), - ]); - - $post = tiktokPost('v_pub_url~v2-1.untitled'); - - expect((new TikTokAnalytics)->fetchPostMetrics($post)) - ->toBe(['unsupported' => true, 'reason' => 'missing_post_id']) - ->and($post->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'], - ]), - ]); - - $post = tiktokPost('v_pub_url~v2-1.private'); - $post->update(['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]); - - expect((new TikTokAnalytics)->fetchPostMetrics($post)) - ->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'; - $post = tiktokPost('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($post); - - 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], - ]); - - $post->refresh(); - - expect($post->platform_post_id)->toBe($videoId) - ->and($post->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'], - ]), - ]); - - $post = tiktokPost('v_pub_url~v2-1.repost'); - $post->update(['published_at' => now()]); - - expect((new TikTokAnalytics)->fetchPostMetrics($post)) - ->toBe(['unsupported' => true, 'reason' => 'missing_post_id']) - ->and($post->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.repost'); - - Http::assertSentCount(2); -}); - -test('tiktok video matching keeps the one-day cutoff inclusive without changing the published timestamp', function (int $secondsBeforeCutoff, ?string $expectedVideoId) { - $this->post->update(['content' => 'Boundary caption']); - $publishedAt = CarbonImmutable::parse('2026-09-23 12:00:00', 'UTC'); - $videoId = '7000000000000000003'; - $post = tiktokPost('v_pub_url~v2-1.boundary'); - $post->update(['published_at' => $publishedAt]); - - Http::fake([ - $this->api.'/video/list/*' => Http::response([ - 'data' => [ - 'videos' => [[ - 'id' => $videoId, - 'title' => 'Boundary caption', - 'create_time' => $publishedAt->subDay()->subSeconds($secondsBeforeCutoff)->getTimestamp(), - ]], - 'has_more' => false, - ], - 'error' => ['code' => 'ok'], - ]), - ]); - - expect((new TikTokAnalytics)->findVideoIdByCaption($post))->toBe($expectedVideoId) - ->and($post->published_at->toDateTimeString())->toBe('2026-09-23 12:00:00'); -})->with([ - 'at cutoff' => [0, '7000000000000000003'], - 'before cutoff' => [1, null], -]); - -test('tiktok post metrics facade returns the saved video url and metrics without provider reads', function () { - $videoId = '7685359243088103444'; - $post = tiktokPost($videoId); - $post->forceFill([ - 'publish_status' => PublishStatus::Published, - 'platform_url' => "https://www.tiktok.com/@tiktoker/video/{$videoId}", - ])->save(); - $publication = AnalyticsPublication::query()->where('post_id', $post->id)->firstOrFail(); - AnalyticsPublicationDailySnapshot::factory()->create([ - 'publication_id' => $publication->id, - 'views_count' => 5, - 'metrics' => ['views' => ['value' => 5, 'unit' => 'count', 'availability' => 'available']], - ]); - - Http::fake(); - - $analytics = app(ReadPublicationAnalytics::class)->forPost($this->post->fresh()); - - expect($analytics)->toMatchArray([ - 'platform_post_id' => $videoId, - 'platform_url' => "https://www.tiktok.com/@tiktoker/video/{$videoId}", - ])->and($analytics['metrics']['metrics']['views']['value'])->toBe(5); - - Http::assertNothingSent(); -}); - -test('tiktok analytics reports a missing platform post id as unsupported', function () { - Http::fake(); - - $metrics = (new TikTokAnalytics)->fetchPostMetrics(tiktokPost(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(tiktokPost()); - - 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(tiktokPost()); - - expect($metrics)->toBe(['unsupported' => true, 'reason' => 'api_error']); -}); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 89532be70..9b7bb80ba 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -14,6 +14,7 @@ use App\Models\Workspace; use App\Services\Media\MediaOptimizer; use App\Services\Social\TikTokPublisher; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; @@ -104,7 +105,7 @@ 'string id' => '7694308097568836885', ]); -test('tiktok publisher persists the public video url when status omits the post id', function (array $statusData) { +test('tiktok publisher keeps the publish id and the profile url while TikTok has not reported the video id', function (array $statusData) { $this->post->update([ 'content' => 'Construam produtos globais e faturem em dólar.', 'media' => [[ @@ -127,23 +128,14 @@ ...$statusData, ], ], 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->post); - expect($result['id'])->toBe('7682891910226234644') - ->and($result['url'])->toBe('https://www.tiktok.com/@tiktoker/video/7682891910226234644'); + expect($result['id'])->toBe('v_pub_url~v2-1.missing-id') + ->and($result['url'])->toBe('https://www.tiktok.com/@tiktoker'); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/list/')); })->with([ 'empty list' => [['publicaly_available_post_id' => []]], 'missing field' => [[]], @@ -321,7 +313,7 @@ ->push([ 'data' => [ 'status' => 'PUBLISH_COMPLETE', - 'publicaly_available_post_id' => ['video_123'], + 'publicaly_available_post_id' => ['7000000000000000123'], ], ]), '*' => Http::response('fake-image-content', 200), @@ -343,7 +335,7 @@ $result = $this->publisher->publish($this->post->fresh()); - expect($result['id'])->toBe('video_123'); + expect($result['id'])->toBe('7000000000000000123'); foreach ($paths as $path) { Storage::assertMissing($path); @@ -405,7 +397,7 @@ $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ 'status' => 'PUBLISH_COMPLETE', - 'publicaly_available_post_id' => ['video_123'], + 'publicaly_available_post_id' => ['7000000000000000123'], ], ]), ]); @@ -413,8 +405,8 @@ $result = $this->publisher->publish($this->post->fresh()); expect($result)->toBe([ - 'id' => 'video_123', - 'url' => 'https://www.tiktok.com/@tiktoker/video/video_123', + 'id' => '7000000000000000123', + 'url' => 'https://www.tiktok.com/@tiktoker/video/7000000000000000123', ]); Http::assertSentCount(1); @@ -472,7 +464,7 @@ Http::fake([ $this->api.'/post/publish/status/fetch/' => Http::sequence() ->push(['data' => ['status' => 'PROCESSING_DOWNLOAD']]) - ->push(['data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['video_123']]]), + ->push(['data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['7000000000000000123']]]), ]); expect(fn () => $this->publisher->publish($this->post->fresh())) @@ -484,7 +476,7 @@ $result = $this->publisher->publish($this->post->fresh()); - expect($result['id'])->toBe('video_123'); + expect($result['id'])->toBe('7000000000000000123'); Storage::assertMissing($derivativePath); Http::assertNotSent(fn ($request) => str_contains($request->url(), '/init/')); }); @@ -791,7 +783,6 @@ 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/')); }); @@ -861,10 +852,6 @@ $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), - $this->api.'/video/list/*' => Http::response([ - 'data' => ['videos' => [], 'has_more' => false], - 'error' => ['code' => 'ok'], - ]), ]); $this->publisher->publish($this->post); @@ -1011,10 +998,6 @@ $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => ['status' => 'PUBLISH_COMPLETE'], ], 200), - $this->api.'/video/list/*' => Http::response([ - 'data' => ['videos' => [], 'has_more' => false], - 'error' => ['code' => 'ok'], - ]), ]); $this->publisher->publish($this->post); @@ -1553,3 +1536,84 @@ 'offset of 1.5 s' => [1500], 'no offset' => [null], ]); + +test('tiktok publisher reads the public video id TikTok reports for a publish id', function (array $statusResponse, int $httpStatus, ?string $expectedVideoId) { + $this->post->forceFill(['platform_post_id' => 'v_pub_url~v2-1.pending'])->save(); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => Http::response($statusResponse, $httpStatus), + ]); + + expect($this->publisher->publicVideoId($this->post))->toBe($expectedVideoId); + + Http::assertSent(fn ($request) => $request['publish_id'] === 'v_pub_url~v2-1.pending'); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/list/')); +})->with([ + 'reported as a string' => [['data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['7694860629638940686']]], 200, '7694860629638940686'], + 'reported as an integer' => [['data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => [7694860629638940686]]], 200, '7694860629638940686'], + 'still in moderation' => [['data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => []]], 200, null], + 'status fetch rejected' => [['error' => ['code' => 'access_token_invalid']], 401, null], + 'status fetch unavailable' => [['error' => ['code' => 'internal_error']], 503, null], + 'not a video id' => [['data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['v_pub_url~v2-1.pending']]], 200, null], +]); + +test('tiktok publisher does not read the video id when TikTok cannot be reached', function () { + $this->post->forceFill(['platform_post_id' => 'v_pub_url~v2-1.pending'])->save(); + + Http::fake([ + $this->api.'/post/publish/status/fetch/' => fn () => throw new ConnectionException('Connection timed out'), + ]); + + expect($this->publisher->publicVideoId($this->post))->toBeNull(); +}); + +test('tiktok publisher does not read the video id when the token refresh fails', function (array $refreshResponse, int $httpStatus) { + $this->socialAccount->update(['token_expires_at' => now()->subHour()]); + $this->post->forceFill(['platform_post_id' => 'v_pub_url~v2-1.pending'])->save(); + + Http::fake([ + $this->api.'/oauth/token/' => Http::response($refreshResponse, $httpStatus), + ]); + + expect($this->publisher->publicVideoId($this->post->fresh()))->toBeNull(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/post/publish/status/fetch/')); +})->with([ + 'refresh rejected' => [['error' => 'invalid_grant'], 400], + 'TikTok unavailable' => [['error' => 'server_error'], 503], +]); + +test('tiktok publisher does not ask for a video id without a channel or a publish id', function (bool $withChannel) { + $post = $withChannel + ? tap($this->post, fn (Post $post) => $post->forceFill(['platform_post_id' => null])->save()) + : Post::factory()->create(['platform_post_id' => 'v_pub_url~v2-1.pending']); + Http::fake(); + + expect($this->publisher->publicVideoId($post))->toBeNull(); + + Http::assertNothingSent(); +})->with([ + 'no publish id' => [true], + 'no channel' => [false], +]); + +test('tiktok publisher reads the video id with the token it just refreshed', function () { + $this->socialAccount->update(['token_expires_at' => now()->subHour(), 'access_token' => 'old-token']); + $this->post->forceFill(['platform_post_id' => 'v_pub_url~v2-1.pending'])->save(); + + Http::fake([ + $this->api.'/oauth/token/' => Http::response([ + 'access_token' => 'new-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 86400, + ]), + $this->api.'/post/publish/status/fetch/' => Http::response([ + 'data' => ['status' => 'PUBLISH_COMPLETE', 'publicaly_available_post_id' => ['7694860629638940686']], + ]), + ]); + + expect($this->publisher->publicVideoId($this->post->fresh()))->toBe('7694860629638940686'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/post/publish/status/fetch/') + && $request->hasHeader('Authorization', 'Bearer new-token')); +});