From 5468508823faa505703d29e74d1188b09afc6ba3 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 10:33:29 -0300 Subject: [PATCH 01/18] Stop guessing TikTok video ids from captions When TikTok had not reported a post's public video id yet, the publisher matched the newest video whose caption started the same way. For channels whose captions all open alike, that handed each post the previous post's video, so 17 posts showed another video's metrics and link, and the real video was imported as a duplicate post. TikTok reports the id only after moderation, so a scheduled sweep now asks the status endpoint again (every run in the first hour, hourly through the first day, then daily for 30 days). Every write of the id goes through AssignTikTokVideoId, which also moves the analytics publication. tiktok:repair-video-ids gives the affected posts their own video back. --- .ai/rules/index.md | 1 + .ai/rules/tiktok.md | 12 ++ app/Actions/Post/AssignTikTokVideoId.php | 37 ++++ .../Commands/ResolveTikTokVideoIds.php | 56 ++++++ .../Scripts/RepairTikTokVideoIdsCommand.php | 155 ++++++++++++++++ .../Analytics/CollectPublicationMetrics.php | 11 +- app/Jobs/ResolveTikTokVideoId.php | 55 ++++++ app/Models/Post.php | 16 ++ app/Services/Social/TikTokAnalytics.php | 120 +------------ app/Services/Social/TikTokPublisher.php | 9 +- routes/console.php | 2 + .../RepairTikTokVideoIdsCommandTest.php | 117 ++++++++++++ .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 127 +++++++++++++ .../Services/Social/TikTokAnalyticsTest.php | 167 +----------------- .../Services/Social/TikTokPublisherTest.php | 28 +-- 15 files changed, 604 insertions(+), 309 deletions(-) create mode 100644 .ai/rules/tiktok.md create mode 100644 app/Actions/Post/AssignTikTokVideoId.php create mode 100644 app/Console/Commands/ResolveTikTokVideoIds.php create mode 100644 app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php create mode 100644 app/Jobs/ResolveTikTokVideoId.php create mode 100644 tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php create mode 100644 tests/Feature/Jobs/ResolveTikTokVideoIdTest.php 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..8eb9a6754 --- /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. `social:resolve-tiktok-video-ids` (every 5 min, `ResolveTikTokVideoId`) asks again for 30 days; every write of the id goes through `AssignTikTokVideoId`, which also moves the analytics publication. diff --git a/app/Actions/Post/AssignTikTokVideoId.php b/app/Actions/Post/AssignTikTokVideoId.php new file mode 100644 index 000000000..a1f4b3431 --- /dev/null +++ b/app/Actions/Post/AssignTikTokVideoId.php @@ -0,0 +1,37 @@ +analyticsPublication()->first(); + + if ($publication !== null) { + $this->publications->reconcileRemoteId($publication, $videoId); + } + + $post->writePublication([ + 'platform_post_id' => $videoId, + 'platform_url' => $post->socialAccount + ? TikTokPublisher::postUrl($post->socialAccount, $videoId) + : $post->platform_url, + ]); + }); + } +} diff --git a/app/Console/Commands/ResolveTikTokVideoIds.php b/app/Console/Commands/ResolveTikTokVideoIds.php new file mode 100644 index 000000000..f2ec89fbc --- /dev/null +++ b/app/Console/Commands/ResolveTikTokVideoIds.php @@ -0,0 +1,56 @@ +createdInTryPost() + ->where('platform', Platform::TikTok) + ->publicationPublished() + ->where('published_at', '>=', now()->subDays(self::RESOLVE_WITHIN_DAYS)) + ->lazyById() + ->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId() && $this->isDue($post)) + ->each(fn (Post $post) => ResolveTikTokVideoId::dispatch($post)); + + return self::SUCCESS; + } + + /** + * Moderation usually ends within a minute, so a new post is checked on + * every run, then hourly through its first day, then daily. + */ + private function isDue(Post $post): bool + { + if ($post->last_reconciled_at === null) { + return true; + } + + $recheckAfterMinutes = match (true) { + $post->published_at->greaterThan(now()->subHour()) => 5, + $post->published_at->greaterThan(now()->subDay()) => 60, + default => 1440, + }; + + 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..b111876cd --- /dev/null +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -0,0 +1,155 @@ +repairs(); + + $this->table( + ['Post', 'Published at', 'Held video', 'Own video'], + $repairs->map(fn (array $repair): array => [ + $repair['post']->id, + $repair['post']->published_at->toDateTimeString(), + $repair['held']->remote_id, + $repair['own']->remote_id, + ])->all(), + ); + + $awaiting = Post::query() + ->createdInTryPost() + ->where('platform', Platform::TikTok) + ->publicationPublished() + ->lazyById() + ->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId()) + ->collect(); + + if ($this->option('dry-run')) { + $this->info("{$repairs->count()} post(s) would get their own video back; {$awaiting->count()} post(s) would ask TikTok for their video id."); + + return self::SUCCESS; + } + + DB::transaction(function () use ($repairs): void { + $repairs->each(fn (array $repair) => $repair['held']->forceFill([ + 'post_id' => null, + 'origin' => PublicationOrigin::External, + ])->save()); + + $repairs->each(fn (array $repair) => $this->giveBack($repair['post'], $repair['own']->fresh())); + }); + + $awaiting->each(fn (Post $post) => ResolveTikTokVideoId::dispatch($post)); + + $this->info("{$repairs->count()} post(s) got their own video back; {$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->createdInTryPost()) + ->with('post') + ->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->ownVideo($held), + ]) + ->filter(fn (array $repair): bool => $repair['own'] !== null) + ->values() + ->collect(); + + $repairedPostIds = $repairs->map(fn (array $repair): string => $repair['post']->id); + + return $repairs + ->filter(fn (array $repair): bool => $repair['own']->post_id === null + || $repairedPostIds->contains($repair['own']->post_id) + || Post::query()->imported()->whereKey($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(); + } + + private function ownVideo(AnalyticsPublication $held): ?AnalyticsPublication + { + $publishedAt = $held->post->published_at; + + $candidates = AnalyticsPublication::query() + ->where('workspace_id', $held->workspace_id) + ->where('social_account_key', $held->social_account_key) + ->where('network', $held->network) + ->whereKeyNot($held->id) + ->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; + } + + private function giveBack(Post $post, AnalyticsPublication $own): void + { + $imported = $own->post_id === null ? null : Post::query()->imported()->whereKey($own->post_id)->first(); + + if ($imported !== null) { + DeleteOwnedMedia::forPosts([$imported->id]); + Post::withoutEvents(fn (): ?bool => $imported->delete()); + } + + $own->forceFill(['post_id' => $post->id, 'origin' => PublicationOrigin::TryPost])->save(); + + $post->writePublication([ + 'platform_post_id' => $own->remote_id, + 'platform_url' => $own->permalink + ?? ($post->socialAccount ? TikTokPublisher::postUrl($post->socialAccount, $own->remote_id) : $post->platform_url), + ]); + } +} diff --git a/app/Jobs/Analytics/CollectPublicationMetrics.php b/app/Jobs/Analytics/CollectPublicationMetrics.php index b44e85611..d3b020a0f 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) + && $publication->post) { + $assignVideoId->handle($publication->post, $collector->publicVideoId($publication)); + $publication->refresh()->setRelation('socialAccount', $channel); } $observation = $collector->collect($publication, $date); diff --git a/app/Jobs/ResolveTikTokVideoId.php b/app/Jobs/ResolveTikTokVideoId.php new file mode 100644 index 000000000..8fb3375d7 --- /dev/null +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -0,0 +1,55 @@ +onQueue($post->platform->queue()); + } + + public function uniqueId(): string + { + return $this->post->id; + } + + public function handle(TikTokAnalytics $tiktok, AssignTikTokVideoId $assignVideoId): void + { + $this->post->refresh(); + + if (! $this->post->awaitsTikTokVideoId()) { + return; + } + + $videoId = $tiktok->publicVideoId($this->post); + + if ($videoId === null) { + $this->post->writePublication(['last_reconciled_at' => now()]); + + return; + } + + $assignVideoId->handle($this->post, $videoId); + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index eed0b88cc..b41a1549d 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; @@ -407,6 +408,21 @@ public function hasChannel(): bool return filled($this->social_account_id); } + /** + * A public TikTok post TryPost published that still carries the Content + * Posting `publish_id`: TikTok reports the video id only after moderation, + * and never for a private post. + */ + 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::SelfOnly; + } + /** * 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 index 9ead0bdaa..93796b070 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -4,15 +4,13 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; -use App\Enums\TikTok\PrivacyLevel; +use App\Actions\Post\AssignTikTokVideoId; use App\Models\Post; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; use Throwable; class TikTokAnalytics @@ -21,12 +19,6 @@ class TikTokAnalytics private const string VIDEO_METRIC_FIELDS = 'id,like_count,comment_count,share_count,view_count'; - private const string VIDEO_LIST_FIELDS = 'id,title,create_time'; - - private const int VIDEO_LIST_PAGE_SIZE = 20; - - private const int VIDEO_LIST_MAX_PAGES = 5; - /** * @var array */ @@ -111,23 +103,20 @@ public function fetchPostMetrics(Post $post): array } /** - * Public posts often stay on a Content Posting `publish_id` because TikTok - * omits `publicaly_available_post_id` even after PUBLISH_COMPLETE. The video - * still shows up on `video/list` with the caption we sent — match that so - * the show-page link stops pointing at the profile. SELF_ONLY posts never - * appear on the list, so they are not looked up. + * The public video id TikTok reports for the post's `publish_id`. TikTok + * sends it only once the post clears moderation, which can take hours. */ - public function findVideoIdByCaption(Post $post): ?string + public function publicVideoId(Post $post): ?string { $account = $post->socialAccount; - if (! $account) { + if (! $account || blank($post->platform_post_id)) { return null; } $this->prepareAccessToken($account); - return $this->matchVideoFromRecentList($post); + return $this->publicVideoIdFromStatus((string) $post->platform_post_id); } private function videoIdFor(Post $post): ?string @@ -138,21 +127,12 @@ private function videoIdFor(Post $post): ?string return $stored; } - $videoId = $this->publicVideoIdFromStatus($stored) ?? $this->matchVideoFromRecentList($post); + $videoId = $this->publicVideoIdFromStatus($stored); - if ($videoId === null) { - return null; + if ($videoId !== null) { + app(AssignTikTokVideoId::class)->handle($post, $videoId); } - $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; } @@ -174,81 +154,6 @@ private function publicVideoIdFromStatus(string $publishId): ?string return $this->digitsOrNull($response->json('data.publicaly_available_post_id.0')); } - /** - * `video/list` is sorted by `create_time` desc, so scanning stops at the - * first video older than the publish — anything past it cannot be ours, and - * an older repost with the same caption must never be claimed. 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 : ''; @@ -256,13 +161,6 @@ private function digitsOrNull(mixed $value): ?string 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()) { diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 324c221cc..b991f7701 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -484,18 +484,15 @@ 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 = blank($postId) ? null : $postId; 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 + 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..3a2856ebc 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)->everyFiveMinutes()->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/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php new file mode 100644 index 000000000..ebfc6ff4d --- /dev/null +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -0,0 +1,117 @@ +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' => $post === null || $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 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; 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); +}); diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php new file mode 100644 index 000000000..345775ac6 --- /dev/null +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -0,0 +1,127 @@ +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('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]]], + 'already resolved' => [['platform_post_id' => '7694860629638940686']], + 'imported' => [['origin' => Origin::Network]], +]); + +test('the sweep asks 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' => $checkedMinutesAgo === null ? null : now()->subMinutes($checkedMinutesAgo), + ])->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 3 minutes ago' => [30, 3, false], + 'new, checked 5 minutes ago' => [30, 5, 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]]], +]); diff --git a/tests/Feature/Services/Social/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php index 0f312c2da..550f80c24 100644 --- a/tests/Feature/Services/Social/TikTokAnalyticsTest.php +++ b/tests/Feature/Services/Social/TikTokAnalyticsTest.php @@ -12,7 +12,6 @@ use App\Models\User; use App\Models\Workspace; use App\Services\Social\TikTokAnalytics; -use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Http; beforeEach(function () { @@ -124,9 +123,7 @@ function tiktokPost(?string $platformPostId = '7685359243088103444'): Post && 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']); - +test('tiktok analytics reports missing_post_id while TikTok has not reported the video id, without guessing one', function () { Http::fake([ $this->api.'/post/publish/status/fetch/' => Http::response([ 'data' => [ @@ -135,176 +132,18 @@ function tiktokPost(?string $platformPostId = '7685359243088103444'): Post ], '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'); + $post = tiktokPost('v_pub_url~v2-1.still-in-review'); 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']); + ->and($post->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.still-in-review'); 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); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 89532be70..822ceacef 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -104,7 +104,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 +127,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' => [[]], @@ -791,7 +782,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 +851,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 +997,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); From a91697b343fece56a3c288a123153ec9fc8255f8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 10:47:35 -0300 Subject: [PATCH 02/18] Harden the TikTok id repair and resolver after review - Repair: repeat the free-video filter until it settles, and drop videos claimed by more than one post, so no post is left pointing at a video that moved to another. - Resolver job drops itself when its post was deleted. - The sweep selects only the columns it reads. - A channel without a username keeps the post url it had. - Use blank()/filled()/transform() instead of strict null checks. - Rule names ImportExternalPosts::claimedBySentPost as the other writer. - Tests for the repair chain and its guards, AssignTikTokVideoId, a deleted post and the video url the metrics job now writes. --- .ai/rules/tiktok.md | 2 +- app/Actions/Post/AssignTikTokVideoId.php | 7 +-- .../Commands/ResolveTikTokVideoIds.php | 3 +- .../Scripts/RepairTikTokVideoIdsCommand.php | 36 ++++++++--- app/Jobs/ResolveTikTokVideoId.php | 4 +- app/Services/Social/TikTokAnalytics.php | 2 +- app/Services/Social/TikTokPublisher.php | 3 +- .../Actions/Post/AssignTikTokVideoIdTest.php | 55 ++++++++++++++++ .../Analytics/PublicationMetricsJobsTest.php | 1 + .../RepairTikTokVideoIdsCommandTest.php | 63 +++++++++++++++++++ .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 14 +++++ 11 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md index 8eb9a6754..f2d2a2820 100644 --- a/.ai/rules/tiktok.md +++ b/.ai/rules/tiktok.md @@ -9,4 +9,4 @@ paths: # 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. `social:resolve-tiktok-video-ids` (every 5 min, `ResolveTikTokVideoId`) asks again for 30 days; every write of the id goes through `AssignTikTokVideoId`, which also moves the analytics publication. +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. `social:resolve-tiktok-video-ids` (every 5 min, `ResolveTikTokVideoId`) asks again for 30 days. The resolver, the post page 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), which is safe to keep. diff --git a/app/Actions/Post/AssignTikTokVideoId.php b/app/Actions/Post/AssignTikTokVideoId.php index a1f4b3431..296d5aad7 100644 --- a/app/Actions/Post/AssignTikTokVideoId.php +++ b/app/Actions/Post/AssignTikTokVideoId.php @@ -22,15 +22,14 @@ public function handle(Post $post, string $videoId): void DB::transaction(function () use ($post, $videoId): void { $publication = $post->analyticsPublication()->first(); - if ($publication !== null) { + if (filled($publication)) { $this->publications->reconcileRemoteId($publication, $videoId); } $post->writePublication([ 'platform_post_id' => $videoId, - 'platform_url' => $post->socialAccount - ? TikTokPublisher::postUrl($post->socialAccount, $videoId) - : $post->platform_url, + 'platform_url' => ($post->socialAccount ? TikTokPublisher::postUrl($post->socialAccount, $videoId) : null) + ?? $post->platform_url, ]); }); } diff --git a/app/Console/Commands/ResolveTikTokVideoIds.php b/app/Console/Commands/ResolveTikTokVideoIds.php index f2ec89fbc..7339066f9 100644 --- a/app/Console/Commands/ResolveTikTokVideoIds.php +++ b/app/Console/Commands/ResolveTikTokVideoIds.php @@ -28,6 +28,7 @@ public function handle(): int ->where('platform', Platform::TikTok) ->publicationPublished() ->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)); @@ -41,7 +42,7 @@ public function handle(): int */ private function isDue(Post $post): bool { - if ($post->last_reconciled_at === null) { + if (blank($post->last_reconciled_at)) { return true; } diff --git a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php index b111876cd..8a3175a99 100644 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -94,16 +94,37 @@ private function repairs(): Collection 'held' => $held, 'own' => $this->ownVideo($held), ]) - ->filter(fn (array $repair): bool => $repair['own'] !== null) + ->filter(fn (array $repair): bool => filled($repair['own'])) ->values() ->collect(); + do { + $before = $repairs->count(); + $repairs = $this->withFreeOwnVideo($repairs); + } while ($repairs->count() !== $before); + + return $repairs; + } + + /** + * 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 => $repair['post']->id); + $claims = $repairs->countBy(fn (array $repair): string => $repair['own']->id); return $repairs - ->filter(fn (array $repair): bool => $repair['own']->post_id === null - || $repairedPostIds->contains($repair['own']->post_id) - || Post::query()->imported()->whereKey($repair['own']->post_id)->exists()) + ->filter(fn (array $repair): bool => $claims->get($repair['own']->id) === 1 + && (blank($repair['own']->post_id) + || $repairedPostIds->contains($repair['own']->post_id) + || Post::query()->imported()->whereKey($repair['own']->post_id)->exists())) ->values(); } @@ -137,9 +158,9 @@ private function ownVideo(AnalyticsPublication $held): ?AnalyticsPublication private function giveBack(Post $post, AnalyticsPublication $own): void { - $imported = $own->post_id === null ? null : Post::query()->imported()->whereKey($own->post_id)->first(); + $imported = filled($own->post_id) ? Post::query()->imported()->whereKey($own->post_id)->first() : null; - if ($imported !== null) { + if (filled($imported)) { DeleteOwnedMedia::forPosts([$imported->id]); Post::withoutEvents(fn (): ?bool => $imported->delete()); } @@ -149,7 +170,8 @@ private function giveBack(Post $post, AnalyticsPublication $own): void $post->writePublication([ 'platform_post_id' => $own->remote_id, 'platform_url' => $own->permalink - ?? ($post->socialAccount ? TikTokPublisher::postUrl($post->socialAccount, $own->remote_id) : $post->platform_url), + ?? ($post->socialAccount ? TikTokPublisher::postUrl($post->socialAccount, $own->remote_id) : null) + ?? $post->platform_url, ]); } } diff --git a/app/Jobs/ResolveTikTokVideoId.php b/app/Jobs/ResolveTikTokVideoId.php index 8fb3375d7..d363ca5d6 100644 --- a/app/Jobs/ResolveTikTokVideoId.php +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -19,6 +19,8 @@ class ResolveTikTokVideoId implements ShouldBeUnique, ShouldQueue { use Queueable; + public bool $deleteWhenMissingModels = true; + /** Must exceed HasSocialHttpClient's 120s HTTP timeout so a slow request cannot kill the worker. */ public int $timeout = 180; @@ -44,7 +46,7 @@ public function handle(TikTokAnalytics $tiktok, AssignTikTokVideoId $assignVideo $videoId = $tiktok->publicVideoId($this->post); - if ($videoId === null) { + if (blank($videoId)) { $this->post->writePublication(['last_reconciled_at' => now()]); return; diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 93796b070..4f1977487 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -129,7 +129,7 @@ private function videoIdFor(Post $post): ?string $videoId = $this->publicVideoIdFromStatus($stored); - if ($videoId !== null) { + if (filled($videoId)) { app(AssignTikTokVideoId::class)->handle($post, $videoId); } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index b991f7701..80c448331 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -483,8 +483,7 @@ 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'); - $postId = blank($postId) ? null : $postId; + $postId = transform(data_get($statusData, 'publicaly_available_post_id.0'), fn (mixed $id): string => (string) $id); return [ 'id' => $postId ?? $publishId, diff --git a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php new file mode 100644 index 000000000..d4cb9512a --- /dev/null +++ b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php @@ -0,0 +1,55 @@ +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 and its url', 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'); +}); + +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'); +}); diff --git a/tests/Feature/Analytics/PublicationMetricsJobsTest.php b/tests/Feature/Analytics/PublicationMetricsJobsTest.php index 9b5529d46..6c765e60f 100644 --- a/tests/Feature/Analytics/PublicationMetricsJobsTest.php +++ b/tests/Feature/Analytics/PublicationMetricsJobsTest.php @@ -101,6 +101,7 @@ 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); }); diff --git a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php index ebfc6ff4d..7c9ec2958 100644 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -115,3 +115,66 @@ function claimedTikTokVideoChain(): array 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 ambiguous or claimed by another post', function (bool $twoVideosInWindow) { + $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', '2026-10-08 10:00:20'); + + if ($twoVideosInWindow) { + repairTikTokVideo('7000000000000000033', '2026-10-08 09:59:40'); + } else { + $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([ + 'two videos in its window' => [true], + 'another post claims the same video' => [false], +]); diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index 345775ac6..c58583ace 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -11,6 +11,7 @@ use App\Models\Post; use App\Models\SocialAccount; use Carbon\CarbonImmutable; +use Illuminate\Queue\SyncQueue; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; @@ -93,6 +94,19 @@ function resolveTikTokVideoId(Post $post): void 'imported' => [['origin' => Origin::Network]], ]); +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 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([ From c7c0103a7b9c70b58e62a7dd3d0e649f3f5100cc Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 10:56:30 -0300 Subject: [PATCH 03/18] Move the TikTok video id lookup into the publisher and drop TikTokAnalytics TikTokAnalytics had no caller left since the analytics pipeline replaced it; only the new resolver used it, for a lookup that belongs to publishing. The status fetch now lives in TikTokPublisher::publicVideoId(), next to the publish polling, sharing one way to read the id from TikTok's response. - Post::scopePublishedToTikTok() for the query the sweep and the repair share. - The repair only reads posts that were published. - Named recheck intervals in the sweep. - Tests: publicVideoId, the sweep skipping unpublished posts, a second repair run, and the TikTok metrics read moved next to its siblings. --- .ai/rules/tiktok.md | 2 +- .../Commands/ResolveTikTokVideoIds.php | 19 +- .../Scripts/RepairTikTokVideoIdsCommand.php | 6 +- app/Jobs/ResolveTikTokVideoId.php | 6 +- app/Models/Post.php | 10 + app/Services/Social/TikTokAnalytics.php | 304 ------------------ app/Services/Social/TikTokPublisher.php | 41 ++- .../PersistedPostMetricsReadTest.php | 35 ++ .../RepairTikTokVideoIdsCommandTest.php | 13 +- .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 6 +- .../Services/Social/TikTokAnalyticsTest.php | 206 ------------ .../Services/Social/TikTokPublisherTest.php | 32 ++ 12 files changed, 151 insertions(+), 529 deletions(-) delete mode 100644 app/Services/Social/TikTokAnalytics.php delete mode 100644 tests/Feature/Services/Social/TikTokAnalyticsTest.php diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md index f2d2a2820..2f0942608 100644 --- a/.ai/rules/tiktok.md +++ b/.ai/rules/tiktok.md @@ -9,4 +9,4 @@ paths: # 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. `social:resolve-tiktok-video-ids` (every 5 min, `ResolveTikTokVideoId`) asks again for 30 days. The resolver, the post page 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), which is safe to keep. +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. `social:resolve-tiktok-video-ids` (every 5 min, `ResolveTikTokVideoId` through `TikTokPublisher::publicVideoId()`) asks again 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), which is safe to keep. diff --git a/app/Console/Commands/ResolveTikTokVideoIds.php b/app/Console/Commands/ResolveTikTokVideoIds.php index 7339066f9..6f8440671 100644 --- a/app/Console/Commands/ResolveTikTokVideoIds.php +++ b/app/Console/Commands/ResolveTikTokVideoIds.php @@ -4,7 +4,6 @@ namespace App\Console\Commands; -use App\Enums\SocialAccount\Platform; use App\Jobs\ResolveTikTokVideoId; use App\Models\Post; use Illuminate\Console\Command; @@ -19,14 +18,18 @@ class ResolveTikTokVideoIds extends Command * TikTok has reported a video id weeks after the publish, so the sweep keeps * asking for a month, less often as the post gets older. */ - public const int RESOLVE_WITHIN_DAYS = 30; + private const int RESOLVE_WITHIN_DAYS = 30; + + private const int RECHECK_NEW_POST_AFTER_MINUTES = 5; + + private const int RECHECK_FIRST_DAY_AFTER_MINUTES = 60; + + private const int RECHECK_OLDER_POST_AFTER_MINUTES = 1440; public function handle(): int { Post::query() - ->createdInTryPost() - ->where('platform', Platform::TikTok) - ->publicationPublished() + ->publishedToTikTok() ->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() @@ -47,9 +50,9 @@ private function isDue(Post $post): bool } $recheckAfterMinutes = match (true) { - $post->published_at->greaterThan(now()->subHour()) => 5, - $post->published_at->greaterThan(now()->subDay()) => 60, - default => 1440, + $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 index 8a3175a99..7e3624107 100644 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -43,9 +43,7 @@ public function handle(): int ); $awaiting = Post::query() - ->createdInTryPost() - ->where('platform', Platform::TikTok) - ->publicationPublished() + ->publishedToTikTok() ->lazyById() ->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId()) ->collect(); @@ -83,7 +81,7 @@ private function repairs(): Collection { $repairs = AnalyticsPublication::query() ->where('network', Platform::TikTok->network()) - ->whereHas('post', fn (Builder $query): Builder => $query->createdInTryPost()) + ->whereHas('post', fn (Builder $query): Builder => $query->publishedToTikTok()->whereNotNull('published_at')) ->with('post') ->lazyById() ->filter(fn (AnalyticsPublication $held): bool => ctype_digit($held->remote_id) diff --git a/app/Jobs/ResolveTikTokVideoId.php b/app/Jobs/ResolveTikTokVideoId.php index d363ca5d6..56049e57f 100644 --- a/app/Jobs/ResolveTikTokVideoId.php +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -6,7 +6,7 @@ use App\Actions\Post\AssignTikTokVideoId; use App\Models\Post; -use App\Services\Social\TikTokAnalytics; +use App\Services\Social\TikTokPublisher; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -36,7 +36,7 @@ public function uniqueId(): string return $this->post->id; } - public function handle(TikTokAnalytics $tiktok, AssignTikTokVideoId $assignVideoId): void + public function handle(TikTokPublisher $publisher, AssignTikTokVideoId $assignVideoId): void { $this->post->refresh(); @@ -44,7 +44,7 @@ public function handle(TikTokAnalytics $tiktok, AssignTikTokVideoId $assignVideo return; } - $videoId = $tiktok->publicVideoId($this->post); + $videoId = $publisher->publicVideoId($this->post); if (blank($videoId)) { $this->post->writePublication(['last_reconciled_at' => now()]); diff --git a/app/Models/Post.php b/app/Models/Post.php index b41a1549d..36a9278f7 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -342,6 +342,16 @@ public function scopeOnChannels(Builder $query, ?array $channelIds): Builder /** * Posts the network confirmed as published. */ + /** + * Posts TryPost published to TikTok. + */ + public function scopePublishedToTikTok(Builder $query): Builder + { + return $query->createdInTryPost() + ->where('posts.platform', Platform::TikTok) + ->publicationPublished(); + } + public function scopePublicationPublished(Builder $query): Builder { return $query->where('posts.publish_status', PublishStatus::Published); diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php deleted file mode 100644 index 4f1977487..000000000 --- a/app/Services/Social/TikTokAnalytics.php +++ /dev/null @@ -1,304 +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(); - } - - /** - * The public video id TikTok reports for the 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 (! $account || blank($post->platform_post_id)) { - return null; - } - - $this->prepareAccessToken($account); - - return $this->publicVideoIdFromStatus((string) $post->platform_post_id); - } - - private function videoIdFor(Post $post): ?string - { - $stored = (string) $post->platform_post_id; - - if (ctype_digit($stored)) { - return $stored; - } - - $videoId = $this->publicVideoIdFromStatus($stored); - - if (filled($videoId)) { - app(AssignTikTokVideoId::class)->handle($post, $videoId); - } - - 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')); - } - - private function digitsOrNull(mixed $value): ?string - { - $value = is_scalar($value) ? (string) $value : ''; - - return ctype_digit($value) ? $value : null; - } - - 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 80c448331..3a9cccf0c 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; @@ -94,6 +95,36 @@ 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; + + $response = $this->getHttpClient() + ->post("{$this->baseUrl}/post/publish/status/fetch/", [ + 'publish_id' => $post->platform_post_id, + ]); + + return $response->successful() ? $this->videoIdFrom((array) $response->json('data', [])) : null; + } + private function getHttpClient(): PendingRequest { return $this->socialHttp()->asJson()->withToken($this->accessToken); @@ -483,7 +514,7 @@ private function completePublishWithCleanup(Post $post, string $publishId, array private function completePublish(Post $post, string $publishId): array { $statusData = $this->waitForPublishStatus($publishId); - $postId = transform(data_get($statusData, 'publicaly_available_post_id.0'), fn (mixed $id): string => (string) $id); + $postId = $this->videoIdFrom($statusData); return [ 'id' => $postId ?? $publishId, @@ -491,6 +522,14 @@ private function completePublish(Post $post, string $publishId): array ]; } + /** + * @param array $statusData + */ + private function videoIdFrom(array $statusData): ?string + { + return transform(data_get($statusData, 'publicaly_available_post_id.0'), fn (mixed $id): string => (string) $id); + } + public static function postUrl(SocialAccount $account, ?string $postId = null): ?string { $username = $account->username; 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/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php index 7c9ec2958..7b244e2bb 100644 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -48,7 +48,7 @@ function repairTikTokVideo(string $remoteId, string $createdAt, ?Post $post = nu 'permalink' => "https://www.tiktok.com/@tiktoker/video/{$remoteId}", 'provider_published_at' => $createdAt, 'post_id' => $post?->id, - 'origin' => $post === null || $post->origin === Origin::Network ? PublicationOrigin::External : PublicationOrigin::TryPost, + 'origin' => blank($post) || $post->origin === Origin::Network ? PublicationOrigin::External : PublicationOrigin::TryPost, ]); } @@ -92,6 +92,17 @@ function claimedTikTokVideoChain(): array 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('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); diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index c58583ace..afc06e42b 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -5,6 +5,7 @@ use App\Actions\Analytics\SyncTryPostPublication; use App\Console\Commands\ResolveTikTokVideoIds; use App\Enums\Post\Origin; +use App\Enums\Post\PublishStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\TikTok\PrivacyLevel; use App\Jobs\ResolveTikTokVideoId; @@ -110,7 +111,7 @@ function resolveTikTokVideoId(Post $post): void test('the sweep asks 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' => $checkedMinutesAgo === null ? null : now()->subMinutes($checkedMinutesAgo), + 'last_reconciled_at' => transform($checkedMinutesAgo, fn (int $minutes): CarbonImmutable => now()->subMinutes($minutes)), ])->save(); $this->artisan(ResolveTikTokVideoIds::class)->assertSuccessful(); @@ -138,4 +139,7 @@ function resolveTikTokVideoId(Post $post): void })->with([ 'resolved' => [['platform_post_id' => '7694860629638940686']], 'self only' => [['meta' => ['privacy_level' => PrivacyLevel::SelfOnly->value]]], + 'imported' => [['origin' => Origin::Network]], + 'publish failed' => [['publish_status' => PublishStatus::Failed]], + 'publish retrying' => [['publish_status' => PublishStatus::Retrying]], ]); diff --git a/tests/Feature/Services/Social/TikTokAnalyticsTest.php b/tests/Feature/Services/Social/TikTokAnalyticsTest.php deleted file mode 100644 index 550f80c24..000000000 --- a/tests/Feature/Services/Social/TikTokAnalyticsTest.php +++ /dev/null @@ -1,206 +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 while TikTok has not reported the video id, without guessing one', function () { - 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.still-in-review'); - - expect((new TikTokAnalytics)->fetchPostMetrics($post)) - ->toBe(['unsupported' => true, 'reason' => 'missing_post_id']) - ->and($post->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.still-in-review'); - - Http::assertSentCount(1); - Http::assertNotSent(fn ($request) => str_contains($request->url(), '/video/list/')); -}); - -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 822ceacef..809c9a608 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -1535,3 +1535,35 @@ '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], +]); + +test('tiktok publisher does not read the video id when the token refresh is rejected', function () { + $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(['error' => 'invalid_grant'], 400), + ]); + + expect($this->publisher->publicVideoId($this->post->fresh()))->toBeNull(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/post/publish/status/fetch/')); +}); From c37d4b35ca7657c2bad230719d2f9a9e34a0cc54 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:09:28 -0300 Subject: [PATCH 04/18] Keep the TikTok resolver from failing in a loop - A network timeout while asking TikTok for the video id counts as not reported yet instead of failing the job. - The job records the check before assigning the id, so a video held by another post follows the recheck cadence instead of failing every run. - Only a numeric id counts as a video id, the same rule that decides a post still awaits one; fixtures now use numeric ids like TikTok's int64. - Put scopePublicationPublished's docblock back above it. --- app/Jobs/ResolveTikTokVideoId.php | 9 +++---- app/Models/Post.php | 6 ++--- app/Services/Social/TikTokPublisher.php | 17 ++++++++---- .../Feature/Commands/RetryFailedPostTest.php | 8 +++--- .../Jobs/PublishToSocialPlatformTest.php | 4 +-- .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 21 +++++++++++++++ .../Services/Social/TikTokPublisherTest.php | 26 ++++++++++++++----- 7 files changed, 64 insertions(+), 27 deletions(-) diff --git a/app/Jobs/ResolveTikTokVideoId.php b/app/Jobs/ResolveTikTokVideoId.php index 56049e57f..58ef1ee89 100644 --- a/app/Jobs/ResolveTikTokVideoId.php +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -45,13 +45,10 @@ public function handle(TikTokPublisher $publisher, AssignTikTokVideoId $assignVi } $videoId = $publisher->publicVideoId($this->post); + $this->post->writePublication(['last_reconciled_at' => now()]); - if (blank($videoId)) { - $this->post->writePublication(['last_reconciled_at' => now()]); - - return; + if (filled($videoId)) { + $assignVideoId->handle($this->post, $videoId); } - - $assignVideoId->handle($this->post, $videoId); } } diff --git a/app/Models/Post.php b/app/Models/Post.php index 36a9278f7..e5c55ca09 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -339,9 +339,6 @@ 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 the network confirmed as published. - */ /** * Posts TryPost published to TikTok. */ @@ -352,6 +349,9 @@ public function scopePublishedToTikTok(Builder $query): Builder ->publicationPublished(); } + /** + * Posts the network confirmed as published. + */ public function scopePublicationPublished(Builder $query): Builder { return $query->where('posts.publish_status', PublishStatus::Published); diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 3a9cccf0c..c6e499d8d 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -19,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; @@ -117,10 +118,14 @@ public function publicVideoId(Post $post): ?string $this->accessToken = $account->access_token; - $response = $this->getHttpClient() - ->post("{$this->baseUrl}/post/publish/status/fetch/", [ - 'publish_id' => $post->platform_post_id, - ]); + 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; } @@ -527,7 +532,9 @@ private function completePublish(Post $post, string $publishId): array */ private function videoIdFrom(array $statusData): ?string { - return transform(data_get($statusData, 'publicaly_available_post_id.0'), fn (mixed $id): string => (string) $id); + $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 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/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index e91b49a81..759b9e346 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -1448,7 +1448,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 +1459,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/'))) diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index afc06e42b..7cb3c5adb 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -83,6 +83,27 @@ function resolveTikTokVideoId(Post $post): void Http::assertSentCount(1); }); +test('a video held by another post records the check so the sweep waits before asking again', function () { + $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'], + ])]); + + expect(fn () => resolveTikTokVideoId($post))->toThrow(LogicException::class); + + 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(); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 809c9a608..f38f33bdc 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; @@ -312,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), @@ -334,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); @@ -396,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'], ], ]), ]); @@ -404,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); @@ -463,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())) @@ -475,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/')); }); @@ -1553,8 +1554,19 @@ '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 is rejected', function () { $this->socialAccount->update(['token_expires_at' => now()->subHour()]); $this->post->forceFill(['platform_post_id' => 'v_pub_url~v2-1.pending'])->save(); From db65564a33528e548cf7174e435261de73ba10bd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:17:39 -0300 Subject: [PATCH 05/18] Protect TikTok posts without an analytics publication and skip broken channels - AssignTikTokVideoId gives a post published before it had an analytics publication one first, so the move still drops the importer's copy of the video and refuses a video another post holds (30 of the 71 waiting posts on the prod clone have none). - The sweep and the job skip posts whose channel is not connected instead of calling TikTok with a dead token every day. - The repair reads its rows with data_get() and updates fillable columns with update(). --- app/Actions/Post/AssignTikTokVideoId.php | 14 ++++-- .../Commands/ResolveTikTokVideoIds.php | 2 + .../Scripts/RepairTikTokVideoIdsCommand.php | 30 ++++++------ app/Jobs/ResolveTikTokVideoId.php | 2 +- .../Actions/Post/AssignTikTokVideoIdTest.php | 46 ++++++++++++++++++- .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 20 ++++++++ 6 files changed, 93 insertions(+), 21 deletions(-) diff --git a/app/Actions/Post/AssignTikTokVideoId.php b/app/Actions/Post/AssignTikTokVideoId.php index 296d5aad7..dd990a7c6 100644 --- a/app/Actions/Post/AssignTikTokVideoId.php +++ b/app/Actions/Post/AssignTikTokVideoId.php @@ -4,6 +4,7 @@ namespace App\Actions\Post; +use App\Actions\Analytics\SyncTryPostPublication; use App\Actions\Analytics\UpsertAnalyticsPublication; use App\Models\Post; use App\Services\Social\TikTokPublisher; @@ -12,15 +13,22 @@ /** * Moves a TikTok post from its Content Posting `publish_id` to the public * video id TikTok reported for it, together with its analytics publication. + * A post published before it had one gets it first, so the move still drops + * an imported copy of the video and refuses a video another post holds. */ class AssignTikTokVideoId { - public function __construct(private readonly UpsertAnalyticsPublication $publications) {} + public function __construct( + private readonly SyncTryPostPublication $syncPublication, + private readonly UpsertAnalyticsPublication $publications, + ) {} public function handle(Post $post, string $videoId): void { DB::transaction(function () use ($post, $videoId): void { - $publication = $post->analyticsPublication()->first(); + $account = $post->socialAccount; + $publication = $post->analyticsPublication()->first() + ?? (filled($account) ? $this->syncPublication->handle($post) : null); if (filled($publication)) { $this->publications->reconcileRemoteId($publication, $videoId); @@ -28,7 +36,7 @@ public function handle(Post $post, string $videoId): void $post->writePublication([ 'platform_post_id' => $videoId, - 'platform_url' => ($post->socialAccount ? TikTokPublisher::postUrl($post->socialAccount, $videoId) : null) + 'platform_url' => (filled($account) ? TikTokPublisher::postUrl($account, $videoId) : null) ?? $post->platform_url, ]); }); diff --git a/app/Console/Commands/ResolveTikTokVideoIds.php b/app/Console/Commands/ResolveTikTokVideoIds.php index 6f8440671..2adfaae47 100644 --- a/app/Console/Commands/ResolveTikTokVideoIds.php +++ b/app/Console/Commands/ResolveTikTokVideoIds.php @@ -7,6 +7,7 @@ use App\Jobs\ResolveTikTokVideoId; use App\Models\Post; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; class ResolveTikTokVideoIds extends Command { @@ -30,6 +31,7 @@ public function handle(): int { Post::query() ->publishedToTikTok() + ->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() diff --git a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php index 7e3624107..b9daabeff 100644 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -35,10 +35,10 @@ public function handle(): int $this->table( ['Post', 'Published at', 'Held video', 'Own video'], $repairs->map(fn (array $repair): array => [ - $repair['post']->id, - $repair['post']->published_at->toDateTimeString(), - $repair['held']->remote_id, - $repair['own']->remote_id, + 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(), ); @@ -55,12 +55,12 @@ public function handle(): int } DB::transaction(function () use ($repairs): void { - $repairs->each(fn (array $repair) => $repair['held']->forceFill([ + $repairs->each(fn (array $repair) => data_get($repair, 'held')->update([ 'post_id' => null, 'origin' => PublicationOrigin::External, - ])->save()); + ])); - $repairs->each(fn (array $repair) => $this->giveBack($repair['post'], $repair['own']->fresh())); + $repairs->each(fn (array $repair) => $this->giveBack(data_get($repair, 'post'), data_get($repair, 'own')->fresh())); }); $awaiting->each(fn (Post $post) => ResolveTikTokVideoId::dispatch($post)); @@ -92,7 +92,7 @@ private function repairs(): Collection 'held' => $held, 'own' => $this->ownVideo($held), ]) - ->filter(fn (array $repair): bool => filled($repair['own'])) + ->filter(fn (array $repair): bool => filled(data_get($repair, 'own'))) ->values() ->collect(); @@ -115,14 +115,14 @@ private function repairs(): Collection */ private function withFreeOwnVideo(Collection $repairs): Collection { - $repairedPostIds = $repairs->map(fn (array $repair): string => $repair['post']->id); - $claims = $repairs->countBy(fn (array $repair): string => $repair['own']->id); + $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($repair['own']->id) === 1 - && (blank($repair['own']->post_id) - || $repairedPostIds->contains($repair['own']->post_id) - || Post::query()->imported()->whereKey($repair['own']->post_id)->exists())) + ->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(); } @@ -163,7 +163,7 @@ private function giveBack(Post $post, AnalyticsPublication $own): void Post::withoutEvents(fn (): ?bool => $imported->delete()); } - $own->forceFill(['post_id' => $post->id, 'origin' => PublicationOrigin::TryPost])->save(); + $own->update(['post_id' => $post->id, 'origin' => PublicationOrigin::TryPost]); $post->writePublication([ 'platform_post_id' => $own->remote_id, diff --git a/app/Jobs/ResolveTikTokVideoId.php b/app/Jobs/ResolveTikTokVideoId.php index 58ef1ee89..9f4c20c10 100644 --- a/app/Jobs/ResolveTikTokVideoId.php +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -40,7 +40,7 @@ public function handle(TikTokPublisher $publisher, AssignTikTokVideoId $assignVi { $this->post->refresh(); - if (! $this->post->awaitsTikTokVideoId()) { + if (! $this->post->awaitsTikTokVideoId() || ! $this->post->socialAccount()->connected()->exists()) { return; } diff --git a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php index d4cb9512a..6dc0e016d 100644 --- a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php +++ b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php @@ -3,8 +3,14 @@ declare(strict_types=1); use App\Actions\Analytics\SyncTryPostPublication; +use App\Actions\Analytics\UpsertAnalyticsPublication; use App\Actions\Post\AssignTikTokVideoId; +use App\Actions\Post\ImportExternalPosts; +use App\Dto\Analytics\DiscoveredPublication; +use App\Enums\Analytics\PublicationContentType; +use App\Enums\Analytics\PublicationOrigin; use App\Enums\PostPlatform\ContentType; +use App\Models\AnalyticsPublication; use App\Models\Post; use App\Models\SocialAccount; use Illuminate\Support\Facades\Queue; @@ -21,14 +27,50 @@ function tiktokPostAwaitingVideo(SocialAccount $account, string $platformPostId ]); } -test('a post without an analytics publication gets the video id and its url', function () { +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'); + ->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); +}); + +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', + )); + 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') + ->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 () { diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index 7cb3c5adb..05e41d0e2 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -7,6 +7,7 @@ use App\Enums\Post\Origin; use App\Enums\Post\PublishStatus; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Status; use App\Enums\TikTok\PrivacyLevel; use App\Jobs\ResolveTikTokVideoId; use App\Models\Post; @@ -116,6 +117,16 @@ function resolveTikTokVideoId(Post $post): void '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 post deleted before its check runs drops the job without failing it', function () { $post = awaitingTikTokPost(); $job = new ResolveTikTokVideoId($post); @@ -164,3 +175,12 @@ function resolveTikTokVideoId(Post $post): void 'publish failed' => [['publish_status' => PublishStatus::Failed]], 'publish retrying' => [['publish_status' => PublishStatus::Retrying]], ]); + +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]); From 606fed0de596ae99bb36b62eaacab48eac02d465 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:23:00 -0300 Subject: [PATCH 06/18] Load the channel with the posts the repair reads and cover its fallbacks - The repair eager-loads each post's channel; building a video url for a video without a permalink lazy-loaded it and failed under strict mode. - The repair only dispatches posts on connected channels, so its count matches what the job will do. - Tests: the url fallbacks, the imported copy's media, disconnected channels, the publisher's guards and the queue the check runs on. --- .../Scripts/RepairTikTokVideoIdsCommand.php | 3 +- .../RepairTikTokVideoIdsCommandTest.php | 36 +++++++++++++++++++ .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 9 +++++ .../Services/Social/TikTokPublisherTest.php | 14 ++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php index b9daabeff..7ce5d0ff5 100644 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -44,6 +44,7 @@ public function handle(): int $awaiting = Post::query() ->publishedToTikTok() + ->whereHas('socialAccount', fn (Builder $query): Builder => $query->connected()) ->lazyById() ->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId()) ->collect(); @@ -82,7 +83,7 @@ private function repairs(): Collection $repairs = AnalyticsPublication::query() ->where('network', Platform::TikTok->network()) ->whereHas('post', fn (Builder $query): Builder => $query->publishedToTikTok()->whereNotNull('published_at')) - ->with('post') + ->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)) diff --git a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php index 7b244e2bb..95c64247c 100644 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -7,9 +7,11 @@ use App\Enums\Post\Origin; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Enums\SocialAccount\Status; use App\Enums\TikTok\PrivacyLevel; use App\Jobs\ResolveTikTokVideoId; use App\Models\AnalyticsPublication; +use App\Models\Media; use App\Models\Post; use App\Models\SocialAccount; use Carbon\CarbonImmutable; @@ -103,6 +105,40 @@ function claimedTikTokVideoChain(): array 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('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); diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index 05e41d0e2..29dfb37da 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -7,6 +7,7 @@ use App\Enums\Post\Origin; use App\Enums\Post\PublishStatus; use App\Enums\PostPlatform\ContentType; +use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Enums\TikTok\PrivacyLevel; use App\Jobs\ResolveTikTokVideoId; @@ -176,6 +177,14 @@ function resolveTikTokVideoId(Post $post): void 'publish retrying' => [['publish_status' => PublishStatus::Retrying]], ]); +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]); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index f38f33bdc..4920267c6 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -1579,3 +1579,17 @@ Http::assertNotSent(fn ($request) => str_contains($request->url(), '/post/publish/status/fetch/')); }); + +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], +]); From 5c0c1705d70e4f4d9d7b9a03a66568d6e77d6650 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:23:45 -0300 Subject: [PATCH 07/18] Run the TikTok video id sweep every minute The sweep only queries the database; TikTok is called only for a post still on a publish_id whose recheck is due. Running every minute makes the first check land within a minute of the publish, while the 5-minute, hourly and daily rechecks keep the number of requests the same. --- .ai/rules/tiktok.md | 2 +- .../Commands/ResolveTikTokVideoIds.php | 6 ++++-- routes/console.php | 2 +- .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 21 +++++++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md index 2f0942608..75be4befb 100644 --- a/.ai/rules/tiktok.md +++ b/.ai/rules/tiktok.md @@ -9,4 +9,4 @@ paths: # 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. `social:resolve-tiktok-video-ids` (every 5 min, `ResolveTikTokVideoId` through `TikTokPublisher::publicVideoId()`) asks again 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), which is safe to keep. +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. `social:resolve-tiktok-video-ids` (every minute, only querying the database until a post is due; `ResolveTikTokVideoId` through `TikTokPublisher::publicVideoId()`) asks again 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), which is safe to keep. diff --git a/app/Console/Commands/ResolveTikTokVideoIds.php b/app/Console/Commands/ResolveTikTokVideoIds.php index 2adfaae47..f97a08318 100644 --- a/app/Console/Commands/ResolveTikTokVideoIds.php +++ b/app/Console/Commands/ResolveTikTokVideoIds.php @@ -42,8 +42,10 @@ public function handle(): int } /** - * Moderation usually ends within a minute, so a new post is checked on - * every run, then hourly through its first day, then daily. + * Runs every minute and only queries the database: a post is sent to + * TikTok on the first run after it publishes, as moderation usually ends + * within a minute, then every five minutes through its first hour, hourly + * through its first day and daily after that. */ private function isDue(Post $post): bool { diff --git a/routes/console.php b/routes/console.php index 3a2856ebc..c4934c3a8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -28,7 +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)->everyFiveMinutes()->withoutOverlapping()->onOneServer(); +Schedule::command(ResolveTikTokVideoIds::class)->everyMinute()->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/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index 29dfb37da..e6451727f 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -14,6 +14,8 @@ use App\Models\Post; use App\Models\SocialAccount; use Carbon\CarbonImmutable; +use Illuminate\Console\Scheduling\Event; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Queue\SyncQueue; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Queue; @@ -193,3 +195,22 @@ function resolveTikTokVideoId(Post $post): void Queue::assertNotPushed(ResolveTikTokVideoId::class); })->with([Status::Disconnected, Status::TokenExpired]); + +test('the sweep runs every minute 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')); + + expect($event->expression)->toBe('* * * * *') + ->and($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(); +}); From 38f92af3220da301747273b86bd800f86d736e6d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:28:28 -0300 Subject: [PATCH 08/18] Repair only posts that still have a channel and test the sweep by when it is due - The repair requires the post's channel, so a video url is always built from a live channel and channelless posts never match each other. - AssignTikTokVideoId is covered for a post whose channel is gone. - The schedule test checks the sweep is due at any minute instead of matching a cron string. --- .../Scripts/RepairTikTokVideoIdsCommand.php | 4 ++-- .../Actions/Post/AssignTikTokVideoIdTest.php | 14 ++++++++++++++ .../Console/RepairTikTokVideoIdsCommandTest.php | 11 +++++++++++ tests/Feature/Jobs/ResolveTikTokVideoIdTest.php | 9 +++++++-- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php index 7ce5d0ff5..cbe163f96 100644 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -82,7 +82,7 @@ private function repairs(): Collection { $repairs = AnalyticsPublication::query() ->where('network', Platform::TikTok->network()) - ->whereHas('post', fn (Builder $query): Builder => $query->publishedToTikTok()->whereNotNull('published_at')) + ->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) @@ -169,7 +169,7 @@ private function giveBack(Post $post, AnalyticsPublication $own): void $post->writePublication([ 'platform_post_id' => $own->remote_id, 'platform_url' => $own->permalink - ?? ($post->socialAccount ? TikTokPublisher::postUrl($post->socialAccount, $own->remote_id) : null) + ?? TikTokPublisher::postUrl($post->socialAccount, $own->remote_id) ?? $post->platform_url, ]); } diff --git a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php index 6dc0e016d..fa51f6d41 100644 --- a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php +++ b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php @@ -95,3 +95,17 @@ function tiktokPostAwaitingVideo(SocialAccount $account, string $platformPostId 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'); +}); diff --git a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php index 95c64247c..87d2daffe 100644 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -139,6 +139,17 @@ function claimedTikTokVideoChain(): array 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); diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index e6451727f..ce6c0830a 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -200,8 +200,13 @@ function resolveTikTokVideoId(Post $post): void $event = collect(app(Schedule::class)->events()) ->sole(fn (Event $event): bool => str_contains((string) $event->command, 'social:resolve-tiktok-video-ids')); - expect($event->expression)->toBe('* * * * *') - ->and($event->onOneServer)->toBeTrue() + foreach (['12:00', '12:01', '12:07', '23:59'] as $time) { + $this->travelTo(CarbonImmutable::parse("2026-10-10 {$time}:00", 'UTC')); + + expect($event->isDue(app()))->toBeTrue("due at {$time}"); + } + + expect($event->onOneServer)->toBeTrue() ->and($event->withoutOverlapping)->toBeTrue(); }); From 245b118b467b2607c5216ed2bac052c2f9408265 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:33:23 -0300 Subject: [PATCH 09/18] Ask TikTok for the video id a minute after the publish The publish job dispatches ResolveTikTokVideoId one minute after a public TikTok post is published without its video id: moderation usually ends within a minute, so this resolves most posts. The sweep drops to every 15 minutes and only catches slower reviews, rechecking on every run through the first hour, hourly through the first day and daily after that. --- .ai/rules/tiktok.md | 2 +- .../Commands/ResolveTikTokVideoIds.php | 10 ++-- app/Jobs/PublishToSocialPlatform.php | 6 +++ app/Jobs/ResolveTikTokVideoId.php | 6 +++ routes/console.php | 2 +- .../Jobs/PublishToSocialPlatformTest.php | 47 +++++++++++++++++++ .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 12 ++--- 7 files changed, 72 insertions(+), 13 deletions(-) diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md index 75be4befb..7bff09853 100644 --- a/.ai/rules/tiktok.md +++ b/.ai/rules/tiktok.md @@ -9,4 +9,4 @@ paths: # 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. `social:resolve-tiktok-video-ids` (every minute, only querying the database until a post is due; `ResolveTikTokVideoId` through `TikTokPublisher::publicVideoId()`) asks again 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), which is safe to keep. +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), which is safe to keep. diff --git a/app/Console/Commands/ResolveTikTokVideoIds.php b/app/Console/Commands/ResolveTikTokVideoIds.php index f97a08318..ec961c28c 100644 --- a/app/Console/Commands/ResolveTikTokVideoIds.php +++ b/app/Console/Commands/ResolveTikTokVideoIds.php @@ -21,7 +21,7 @@ class ResolveTikTokVideoIds extends Command */ private const int RESOLVE_WITHIN_DAYS = 30; - private const int RECHECK_NEW_POST_AFTER_MINUTES = 5; + private const int RECHECK_NEW_POST_AFTER_MINUTES = 10; private const int RECHECK_FIRST_DAY_AFTER_MINUTES = 60; @@ -42,10 +42,10 @@ public function handle(): int } /** - * Runs every minute and only queries the database: a post is sent to - * TikTok on the first run after it publishes, as moderation usually ends - * within a minute, then every five minutes through its first hour, hourly - * through its first day and daily after that. + * 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 { 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 index 9f4c20c10..5abc27b5f 100644 --- a/app/Jobs/ResolveTikTokVideoId.php +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -19,6 +19,12 @@ class ResolveTikTokVideoId implements ShouldBeUnique, ShouldQueue { use Queueable; + /** + * TikTok moderation usually ends within a minute of the publish, so the + * publish job asks once after that; the sweep covers slower reviews. + */ + public const int FIRST_CHECK_AFTER_SECONDS = 60; + public bool $deleteWhenMissingModels = true; /** Must exceed HasSocialHttpClient's 120s HTTP timeout so a slow request cannot kill the worker. */ diff --git a/routes/console.php b/routes/console.php index c4934c3a8..d594bb09a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -28,7 +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)->everyMinute()->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/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 759b9e346..0c506aab9 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; @@ -2347,3 +2349,48 @@ 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], +]); diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index ce6c0830a..b0dfd5514 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -143,7 +143,7 @@ function resolveTikTokVideoId(Post $post): void Http::assertNothingSent(); }); -test('the sweep asks for a new post on every run, then hourly, then daily, for a month', function (int $publishedMinutesAgo, ?int $checkedMinutesAgo, bool $dispatched) { +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)), @@ -156,8 +156,8 @@ function resolveTikTokVideoId(Post $post): void : Queue::assertNotPushed(ResolveTikTokVideoId::class); })->with([ 'never checked' => [2, null, true], - 'new, checked 3 minutes ago' => [30, 3, false], - 'new, checked 5 minutes ago' => [30, 5, 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], @@ -196,14 +196,14 @@ function resolveTikTokVideoId(Post $post): void Queue::assertNotPushed(ResolveTikTokVideoId::class); })->with([Status::Disconnected, Status::TokenExpired]); -test('the sweep runs every minute on one server', function () { +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', '12:01', '12:07', '23:59'] as $time) { + 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()))->toBeTrue("due at {$time}"); + expect($event->isDue(app()))->toBe($due, "at {$time}"); } expect($event->onOneServer)->toBeTrue() From f2262101d389d40e50613172132915fea8b91486 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:36:39 -0300 Subject: [PATCH 10/18] Test that only TikTok posts ask for a video id after publishing --- .../Jobs/PublishToSocialPlatformTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 0c506aab9..62a8fcdd8 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -2394,3 +2394,20 @@ function linkedInRejection(int $status): LinkedInPublishException 'video id already reported' => ['7694860629638940686', PrivacyLevel::PublicToEveryone, false], 'private post' => ['p_pub_url~v2.private', PrivacyLevel::SelfOnly, 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); +}); From 243f1a3790642237570ba196e26a4374271d722d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:41:01 -0300 Subject: [PATCH 11/18] Cover the publisher's token handling when reading a video id and an unknown own video in the repair --- .../RepairTikTokVideoIdsCommandTest.php | 13 ++++---- .../Services/Social/TikTokPublisherTest.php | 30 +++++++++++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php index 87d2daffe..213f484eb 100644 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -211,16 +211,16 @@ function claimedTikTokVideoChain(): array ->and($correct->fresh()->platform_post_id)->toBe('7000000000000000023'); }); -test('posts are left alone when their own video is ambiguous or claimed by another post', function (bool $twoVideosInWindow) { +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', '2026-10-08 10:00:20'); + $video = repairTikTokVideo('7000000000000000032', $case === 'unknown' ? '2026-10-08 09:50:00' : '2026-10-08 10:00:20'); - if ($twoVideosInWindow) { + if ($case === 'ambiguous') { repairTikTokVideo('7000000000000000033', '2026-10-08 09:59:40'); - } else { + } 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'); @@ -233,6 +233,7 @@ function claimedTikTokVideoChain(): array ->and($held->fresh()->post_id)->toBe($post->id) ->and($video->fresh()->post_id)->toBeNull(); })->with([ - 'two videos in its window' => [true], - 'another post claims the same video' => [false], + 'no video in its window' => ['unknown'], + 'two videos in its window' => ['ambiguous'], + 'another post claims the same video' => ['claimed'], ]); diff --git a/tests/Feature/Services/Social/TikTokPublisherTest.php b/tests/Feature/Services/Social/TikTokPublisherTest.php index 4920267c6..9b7bb80ba 100644 --- a/tests/Feature/Services/Social/TikTokPublisherTest.php +++ b/tests/Feature/Services/Social/TikTokPublisherTest.php @@ -1567,18 +1567,21 @@ expect($this->publisher->publicVideoId($this->post))->toBeNull(); }); -test('tiktok publisher does not read the video id when the token refresh is rejected', function () { +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(['error' => 'invalid_grant'], 400), + $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 @@ -1593,3 +1596,24 @@ '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')); +}); From af240828aeaaf2c93831502a51245da2301f7b3b Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 11:52:40 -0300 Subject: [PATCH 12/18] Ask only public TikTok posts, keep resolved ids from the importer, fix the publication link From an independent review of the PR: - Only posts public to everyone await a video id: TikTok never reports one for followers, friends or private posts, so asking for 30 days was waste. - AssignTikTokVideoId re-reads the post under a lock and stops if it was deleted while TikTok answered, instead of creating an orphan publication. - The analytics publication gets the video link too, not only the post, so insights, exports and the API stop pointing at the profile. - The importer never claims a native video for a TikTok post whose numeric id TikTok already reported; it only claims for posts still on a publish_id. - filled() instead of truthiness in the metrics job. - Tests for each, plus the metrics job with a publication without a post and with a video another post holds. --- .ai/rules/tiktok.md | 2 +- app/Actions/Post/AssignTikTokVideoId.php | 23 ++++++--- app/Actions/Post/ImportExternalPosts.php | 1 + .../Analytics/CollectPublicationMetrics.php | 2 +- app/Models/Post.php | 8 +-- .../Actions/Post/AssignTikTokVideoIdTest.php | 15 +++++- .../Analytics/PublicationMetricsJobsTest.php | 49 +++++++++++++++++++ .../Jobs/PublishToSocialPlatformTest.php | 1 + .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 3 ++ .../Feature/Posts/ImportExternalPostsTest.php | 22 +++++++++ 10 files changed, 112 insertions(+), 14 deletions(-) diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md index 7bff09853..65f843bcf 100644 --- a/.ai/rules/tiktok.md +++ b/.ai/rules/tiktok.md @@ -9,4 +9,4 @@ paths: # 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), which is safe to keep. +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 still on its publish_id, 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. diff --git a/app/Actions/Post/AssignTikTokVideoId.php b/app/Actions/Post/AssignTikTokVideoId.php index dd990a7c6..3428f4539 100644 --- a/app/Actions/Post/AssignTikTokVideoId.php +++ b/app/Actions/Post/AssignTikTokVideoId.php @@ -26,18 +26,29 @@ public function __construct( public function handle(Post $post, string $videoId): void { DB::transaction(function () use ($post, $videoId): void { - $account = $post->socialAccount; - $publication = $post->analyticsPublication()->first() - ?? (filled($account) ? $this->syncPublication->handle($post) : null); + $locked = Post::query()->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]); + } } - $post->writePublication([ + $locked->writePublication([ 'platform_post_id' => $videoId, - 'platform_url' => (filled($account) ? TikTokPublisher::postUrl($account, $videoId) : null) - ?? $post->platform_url, + 'platform_url' => $videoUrl ?? $previousUrl, ]); }); } diff --git a/app/Actions/Post/ImportExternalPosts.php b/app/Actions/Post/ImportExternalPosts.php index ccccf9102..15da571fc 100644 --- a/app/Actions/Post/ImportExternalPosts.php +++ b/app/Actions/Post/ImportExternalPosts.php @@ -255,6 +255,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/Jobs/Analytics/CollectPublicationMetrics.php b/app/Jobs/Analytics/CollectPublicationMetrics.php index d3b020a0f..78c07bb2e 100644 --- a/app/Jobs/Analytics/CollectPublicationMetrics.php +++ b/app/Jobs/Analytics/CollectPublicationMetrics.php @@ -114,7 +114,7 @@ public function handle( if ($collector instanceof TikTokPublicationMetricsCollector && ! ctype_digit($publication->remote_id) - && $publication->post) { + && filled($publication->post)) { $assignVideoId->handle($publication->post, $collector->publicVideoId($publication)); $publication->refresh()->setRelation('socialAccount', $channel); } diff --git a/app/Models/Post.php b/app/Models/Post.php index e5c55ca09..bee8c9e20 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -419,9 +419,9 @@ public function hasChannel(): bool } /** - * A public TikTok post TryPost published that still carries the Content - * Posting `publish_id`: TikTok reports the video id only after moderation, - * and never for a private post. + * 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 { @@ -430,7 +430,7 @@ public function awaitsTikTokVideoId(): bool && $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::SelfOnly; + && PrivacyLevel::tryFrom((string) data_get($this->meta, 'privacy_level')) === PrivacyLevel::PublicToEveryone; } /** diff --git a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php index fa51f6d41..f2c892641 100644 --- a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php +++ b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php @@ -37,7 +37,17 @@ function tiktokPostAwaitingVideo(SocialAccount $account, string $platformPostId ->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); + ->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 () { @@ -47,7 +57,7 @@ function tiktokPostAwaitingVideo(SocialAccount $account, string $platformPostId providerPostId: '7694860629638940686', publishedAt: now()->subDays(2)->toImmutable(), contentType: PublicationContentType::Video, - permalink: 'https://www.tiktok.com/@tiktoker/video/7694860629638940686', + permalink: 'https://www.tiktok.com/@tiktoker/video/7694860629638940686?share=1', )); ImportExternalPosts::execute($account); $imported = Post::query()->imported()->sole(); @@ -58,6 +68,7 @@ function tiktokPostAwaitingVideo(SocialAccount $account, string $platformPostId ->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(); }); diff --git a/tests/Feature/Analytics/PublicationMetricsJobsTest.php b/tests/Feature/Analytics/PublicationMetricsJobsTest.php index 6c765e60f..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; @@ -106,6 +107,54 @@ function discoveredFor(AnalyticsPublication $publication): DiscoveredPublication 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/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 62a8fcdd8..a6c14bb2f 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -2393,6 +2393,7 @@ function linkedInRejection(int $status): LinkedInPublishException '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 () { diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index b0dfd5514..8e135aa7a 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -116,6 +116,8 @@ function resolveTikTokVideoId(Post $post): void 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]], ]); @@ -174,6 +176,7 @@ function resolveTikTokVideoId(Post $post): void })->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]], 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(); From a026fbc6ccac6abeeaa3ea034b16be98f21a60d7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 12:02:53 -0300 Subject: [PATCH 13/18] Retry deadlocks when assigning a TikTok video id and log held videos once a day From a second independent review: - AssignTikTokVideoId retries its transaction on a deadlock: it locks the post before the publication while the importer locks them the other way. - A video another TryPost post holds is logged once a day by the resolver instead of failing the job on every recheck for 30 days. - The sweep filters public posts in SQL. - Tests: a check already queued is not queued twice, a publication without a link gets the video link, the once-a-day log. --- .ai/rules/tiktok.md | 2 +- app/Actions/Post/AssignTikTokVideoId.php | 8 +++++++- app/Actions/Post/ImportExternalPosts.php | 3 ++- .../Commands/ResolveTikTokVideoIds.php | 2 ++ app/Jobs/ResolveTikTokVideoId.php | 16 ++++++++++++++- .../Actions/Post/AssignTikTokVideoIdTest.php | 10 ++++++++++ .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 20 +++++++++++++++++-- 7 files changed, 55 insertions(+), 6 deletions(-) diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md index 65f843bcf..1d3c4c4c8 100644 --- a/.ai/rules/tiktok.md +++ b/.ai/rules/tiktok.md @@ -9,4 +9,4 @@ paths: # 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 still on its publish_id, 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. +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 still on its publish_id, 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 index 3428f4539..6987f0419 100644 --- a/app/Actions/Post/AssignTikTokVideoId.php +++ b/app/Actions/Post/AssignTikTokVideoId.php @@ -18,6 +18,12 @@ */ class AssignTikTokVideoId { + /** + * The importer locks the publication before the post, this action the post + * before the publication; a rare deadlock between them is retried. + */ + private const int DEADLOCK_ATTEMPTS = 3; + public function __construct( private readonly SyncTryPostPublication $syncPublication, private readonly UpsertAnalyticsPublication $publications, @@ -50,6 +56,6 @@ public function handle(Post $post, string $videoId): void '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 15da571fc..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 { diff --git a/app/Console/Commands/ResolveTikTokVideoIds.php b/app/Console/Commands/ResolveTikTokVideoIds.php index ec961c28c..6422a82d1 100644 --- a/app/Console/Commands/ResolveTikTokVideoIds.php +++ b/app/Console/Commands/ResolveTikTokVideoIds.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; +use App\Enums\TikTok\PrivacyLevel; use App\Jobs\ResolveTikTokVideoId; use App\Models\Post; use Illuminate\Console\Command; @@ -31,6 +32,7 @@ public function handle(): int { Post::query() ->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']) diff --git a/app/Jobs/ResolveTikTokVideoId.php b/app/Jobs/ResolveTikTokVideoId.php index 5abc27b5f..d3a49bf8c 100644 --- a/app/Jobs/ResolveTikTokVideoId.php +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -10,6 +10,9 @@ use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; +use LogicException; /** * Asks TikTok again for the public video id of a post it published before @@ -53,8 +56,19 @@ public function handle(TikTokPublisher $publisher, AssignTikTokVideoId $assignVi $videoId = $publisher->publicVideoId($this->post); $this->post->writePublication(['last_reconciled_at' => now()]); - if (filled($videoId)) { + if (blank($videoId)) { + return; + } + + try { $assignVideoId->handle($this->post, $videoId); + } 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/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php index f2c892641..24017cce7 100644 --- a/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php +++ b/tests/Feature/Actions/Post/AssignTikTokVideoIdTest.php @@ -120,3 +120,13 @@ function tiktokPostAwaitingVideo(SocialAccount $account, string $platformPostId ->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/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index 8e135aa7a..b6b366f53 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -18,6 +18,7 @@ use Illuminate\Console\Scheduling\Schedule; use Illuminate\Queue\SyncQueue; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; beforeEach(function () { @@ -87,7 +88,8 @@ function resolveTikTokVideoId(Post $post): void Http::assertSentCount(1); }); -test('a video held by another post records the check so the sweep waits before asking again', function () { +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'])); @@ -97,7 +99,12 @@ function resolveTikTokVideoId(Post $post): void 'error' => ['code' => 'ok'], ])]); - expect(fn () => resolveTikTokVideoId($post))->toThrow(LogicException::class); + 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') @@ -182,6 +189,15 @@ function resolveTikTokVideoId(Post $post): void '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(); From ee4965e566187fa85f9c8a3fcf50ee081990f2c5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 12:11:13 -0300 Subject: [PATCH 14/18] Leave a TikTok post for the next check when its video is discovered at the same moment A unique-index race with discovery inserting the same video is not a deadlock, so it was not retried and failed the job; the next sweep merges the discovered row, so the job now just returns. --- app/Jobs/ResolveTikTokVideoId.php | 3 +++ .../Feature/Jobs/ResolveTikTokVideoIdTest.php | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/Jobs/ResolveTikTokVideoId.php b/app/Jobs/ResolveTikTokVideoId.php index d3a49bf8c..b6d095528 100644 --- a/app/Jobs/ResolveTikTokVideoId.php +++ b/app/Jobs/ResolveTikTokVideoId.php @@ -9,6 +9,7 @@ use App\Services\Social\TikTokPublisher; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -62,6 +63,8 @@ public function handle(TikTokPublisher $publisher, AssignTikTokVideoId $assignVi 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.', [ diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index b6b366f53..2b6f5c489 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\Analytics\SyncTryPostPublication; +use App\Actions\Post\AssignTikTokVideoId; use App\Console\Commands\ResolveTikTokVideoIds; use App\Enums\Post\Origin; use App\Enums\Post\PublishStatus; @@ -16,10 +17,12 @@ use Carbon\CarbonImmutable; use Illuminate\Console\Scheduling\Event; use Illuminate\Console\Scheduling\Schedule; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Queue\SyncQueue; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; +use PDOException; beforeEach(function () { Queue::fake(); @@ -139,6 +142,24 @@ function resolveTikTokVideoId(Post $post): void 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); From 3efe2d7c88a9284f47176032b357822167ba265a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 12:14:16 -0300 Subject: [PATCH 15/18] Drop a global use statement that broke the suite in a namespaceless test file --- tests/Feature/Jobs/ResolveTikTokVideoIdTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php index 2b6f5c489..2b3b77642 100644 --- a/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php +++ b/tests/Feature/Jobs/ResolveTikTokVideoIdTest.php @@ -22,7 +22,6 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; -use PDOException; beforeEach(function () { Queue::fake(); From a216557615d3e38fea73cd87f57753c354d97284 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 12:27:18 -0300 Subject: [PATCH 16/18] Settle TikTok videos several posts hold in the repair Running the repair on the prod clone left six video ids held by more than one post: TryPost posts that took a later post's video (the post page's caption match scanned newest first) and an imported copy orphaned once its video went back to its post. A second phase keeps each such video with the one TryPost post published right after it was created, points the other TryPost posts back at the profile with no video id, and deletes the imported copies. A video without exactly one such post is left alone. The dry run now runs both phases in a transaction and rolls it back, so its numbers include what the first phase uncovers; media file deletions are only queued for after a commit and are dropped with it. Imported copies the first phase deletes are counted too. --- .../Scripts/RepairTikTokVideoIdsCommand.php | 148 ++++++++++++++++-- .../RepairTikTokVideoIdsCommandTest.php | 95 ++++++++++- 2 files changed, 226 insertions(+), 17 deletions(-) diff --git a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php index cbe163f96..743198f3d 100644 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -6,6 +6,7 @@ use App\Actions\Media\DeleteOwnedMedia; use App\Enums\Analytics\PublicationOrigin; +use App\Enums\Post\Origin; use App\Enums\SocialAccount\Platform; use App\Jobs\ResolveTikTokVideoId; use App\Models\AnalyticsPublication; @@ -17,9 +18,10 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Throwable; -#[Signature('tiktok:repair-video-ids {--dry-run : List the repairs without writing them}')] -#[Description('Give TikTok posts back the videos a caption match handed to the post published after them')] +#[Signature('tiktok:repair-video-ids {--dry-run : Run the repairs and roll them back, listing what would change}')] +#[Description('Give TikTok posts back the videos a caption match handed to other posts, and settle videos several posts hold')] class RepairTikTokVideoIdsCommand extends Command { /** TikTok creates the video about half a minute before the publish completes. */ @@ -30,7 +32,21 @@ class RepairTikTokVideoIdsCommand extends Command public function handle(): int { - $repairs = $this->repairs(); + $dryRun = (bool) $this->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'], @@ -42,6 +58,16 @@ public function handle(): int ])->all(), ); + $this->table( + ['Video', 'Kept by', '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, 'released')->pluck('id')->implode(', ') ?: '-', + data_get($video, 'copies')->pluck('id')->implode(', ') ?: '-', + ])->all(), + ); + $awaiting = Post::query() ->publishedToTikTok() ->whereHas('socialAccount', fn (Builder $query): Builder => $query->connected()) @@ -49,24 +75,18 @@ public function handle(): int ->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId()) ->collect(); - if ($this->option('dry-run')) { - $this->info("{$repairs->count()} post(s) would get their own video back; {$awaiting->count()} post(s) would ask TikTok for their video id."); + $released = $settled->sum(fn (array $video): int => data_get($video, 'released')->count()); + $copies = $deletedWithRepairs + $settled->sum(fn (array $video): int => data_get($video, 'copies')->count()); + + if ($dryRun) { + $this->info("{$repairs->count()} 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; } - DB::transaction(function () use ($repairs): void { - $repairs->each(fn (array $repair) => data_get($repair, 'held')->update([ - 'post_id' => null, - 'origin' => PublicationOrigin::External, - ])); - - $repairs->each(fn (array $repair) => $this->giveBack(data_get($repair, 'post'), data_get($repair, 'own')->fresh())); - }); - $awaiting->each(fn (Post $post) => ResolveTikTokVideoId::dispatch($post)); - $this->info("{$repairs->count()} post(s) got their own video back; {$awaiting->count()} post(s) are asking TikTok for their video id."); + $this->info("{$repairs->count()} 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; } @@ -105,6 +125,20 @@ private function repairs(): Collection 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 @@ -155,7 +189,10 @@ private function ownVideo(AnalyticsPublication $held): ?AnalyticsPublication return $candidates->count() === 1 && ctype_digit($candidates->sole()->remote_id) ? $candidates->sole() : null; } - private function giveBack(Post $post, AnalyticsPublication $own): void + /** + * @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; @@ -172,5 +209,84 @@ private function giveBack(Post $post, AnalyticsPublication $own): void ?? 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 keeps it; the other TryPost + * posts lose it and point at the profile again, and the imported copies + * are deleted. A video without exactly one such post is left alone. + * + * @return Collection, copies: Collection}> + */ + private function settleSharedVideos(): Collection + { + return Post::query() + ->where('platform', Platform::TikTok) + ->has('socialAccount') + ->whereNotNull('platform_post_id') + ->with('socialAccount') + ->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($posts)) + ->filter() + ->values() + ->collect(); + } + + /** + * @param Collection $posts + * @return array{video: AnalyticsPublication, owner: Post, released: Collection, copies: Collection}|null + */ + private function settle(Collection $posts): ?array + { + $sample = $posts->first(); + $video = 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) + ->first(); + + if (blank($video)) { + return null; + } + + $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(); + + 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(); + $released = $others->filter(fn (Post $post): bool => $post->origin === Origin::TryPost)->values(); + $copies = $others->filter(fn (Post $post): bool => $post->origin === Origin::Network)->values(); + + $video->update(['post_id' => $owner->id, 'origin' => PublicationOrigin::TryPost]); + + $released->each(fn (Post $post) => $post->writePublication([ + 'platform_post_id' => null, + 'platform_url' => TikTokPublisher::postUrl($post->socialAccount) ?? $post->platform_url, + ])); + + if ($copies->isNotEmpty()) { + DeleteOwnedMedia::forPosts($copies->pluck('id')->all()); + Post::query()->whereKey($copies->pluck('id')->all())->delete(); + } + + return ['video' => $video, 'owner' => $owner, 'released' => $released, 'copies' => $copies]; } } diff --git a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php index 213f484eb..53db6a44c 100644 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -9,6 +9,7 @@ use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Enums\TikTok\PrivacyLevel; +use App\Jobs\Media\DeleteMediaFiles; use App\Jobs\ResolveTikTokVideoId; use App\Models\AnalyticsPublication; use App\Models\Media; @@ -164,7 +165,7 @@ function claimedTikTokVideoChain(): array $chain = claimedTikTokVideoChain(); $this->artisan('tiktok:repair-video-ids', ['--dry-run' => true]) - ->expectsOutputToContain('1 post(s) would get their own video back; 1 post(s) would ask TikTok for their video id.') + ->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') @@ -237,3 +238,95 @@ function claimedTikTokVideoChain(): array '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-08: 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-08 10:00:30'); + $earlier = repairTikTokPost('7000000000000000041', '2026-10-08 09:00:30'); + $copy = Post::factory()->forAccount(test()->account, ContentType::TikTokVideo)->imported()->create([ + 'platform_post_id' => '7000000000000000041', + 'published_at' => '2026-10-08 10:00:00', + ]); + $video = repairTikTokVideo('7000000000000000041', '2026-10-08 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(); + Queue::assertNotPushed(ResolveTikTokVideoId::class, fn (ResolveTikTokVideoId $job): bool => $job->post->is($shared['earlier'])); +})->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-08 10:01:00'), + 'none right after' => $shared['owner']->forceFill(['published_at' => '2026-10-08 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-08 10:00:30', $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(); +}); From 50b1dce72f1aeafaf5e67afa75c34e7597ab60fa Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 12:42:22 -0300 Subject: [PATCH 17/18] Make the repair's second phase prove ownership and give released posts their own video From a review of the second phase: - A post keeps a shared video only when that video is the only one created in the two minutes before it was published, the same evidence the first phase requires; otherwise a native video could be deleted or a wrong post kept. Both phases share one lookup, videoCreatedRightBefore(). - A post that loses a shared video gets its own video back when exactly one free video was created right before it (deleting its imported copy), and only otherwise drops the id. - Only published TryPost posts and imported posts take part. - A shared id must match exactly one analytics publication. - Tests: a native video next to another, a released post getting its own video back, failed posts, ids without a publication or on other channels, and a failure rolling every repair back. --- .ai/rules/tiktok.md | 2 +- .../Scripts/RepairTikTokVideoIdsCommand.php | 91 ++++++++++++------ .../RepairTikTokVideoIdsCommandTest.php | 96 +++++++++++++++++-- 3 files changed, 152 insertions(+), 37 deletions(-) diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md index 1d3c4c4c8..1d6fd2aaa 100644 --- a/.ai/rules/tiktok.md +++ b/.ai/rules/tiktok.md @@ -9,4 +9,4 @@ paths: # 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 still on its publish_id, 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. +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/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php index 743198f3d..32b39214c 100644 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -59,12 +59,13 @@ public function handle(): int ); $this->table( - ['Video', 'Kept by', 'Released posts', 'Deleted imported copies'], + ['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')->pluck('id')->implode(', ') ?: '-', + data_get($video, 'copies'), ])->all(), ); @@ -75,18 +76,19 @@ public function handle(): int ->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')->count()); + $copies = $deletedWithRepairs + $settled->sum(fn (array $video): int => data_get($video, 'copies')); if ($dryRun) { - $this->info("{$repairs->count()} 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."); + $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("{$repairs->count()} 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."); + $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; } @@ -111,7 +113,7 @@ private function repairs(): Collection ->map(fn (AnalyticsPublication $held): array => [ 'post' => $held->post, 'held' => $held, - 'own' => $this->ownVideo($held), + 'own' => $this->videoCreatedRightBefore($held->post, $held), ]) ->filter(fn (array $repair): bool => filled(data_get($repair, 'own'))) ->values() @@ -173,15 +175,18 @@ private function publishedRightAfter(AnalyticsPublication $held): bool ->exists(); } - private function ownVideo(AnalyticsPublication $held): ?AnalyticsPublication + /** + * 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 = $held->post->published_at; + $publishedAt = $post->published_at; $candidates = AnalyticsPublication::query() - ->where('workspace_id', $held->workspace_id) - ->where('social_account_key', $held->social_account_key) - ->where('network', $held->network) - ->whereKeyNot($held->id) + ->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(); @@ -215,16 +220,20 @@ private function giveBack(Post $post, AnalyticsPublication $own): bool /** * Every video id more than one post of a channel holds. The TryPost post - * published right after the video was created keeps it; the other TryPost - * posts lose it and point at the profile again, and the imported copies - * are deleted. A video without exactly one such post is left alone. + * 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, copies: Collection}> + * @return Collection, released: Collection, copies: int}> */ private function settleSharedVideos(): Collection { 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') ->with('socialAccount') @@ -240,22 +249,24 @@ private function settleSharedVideos(): Collection /** * @param Collection $posts - * @return array{video: AnalyticsPublication, owner: Post, released: Collection, copies: Collection}|null + * @return array{video: AnalyticsPublication, owner: Post, returned: Collection, released: Collection, copies: int}|null */ private function settle(Collection $posts): ?array { $sample = $posts->first(); - $video = AnalyticsPublication::query() + $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) - ->first(); + ->limit(2) + ->get(); - if (blank($video)) { + 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) @@ -266,27 +277,53 @@ private function settle(Collection $posts): ?array } $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(); - $released = $others->filter(fn (Post $post): bool => $post->origin === Origin::TryPost)->values(); $copies = $others->filter(fn (Post $post): bool => $post->origin === Origin::Network)->values(); $video->update(['post_id' => $owner->id, 'origin' => PublicationOrigin::TryPost]); - $released->each(fn (Post $post) => $post->writePublication([ - 'platform_post_id' => null, - 'platform_url' => TikTokPublisher::postUrl($post->socialAccount) ?? $post->platform_url, - ])); - if ($copies->isNotEmpty()) { DeleteOwnedMedia::forPosts($copies->pluck('id')->all()); Post::query()->whereKey($copies->pluck('id')->all())->delete(); } - return ['video' => $video, 'owner' => $owner, 'released' => $released, 'copies' => $copies]; + $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/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php index 53db6a44c..ca053e3c1 100644 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -5,6 +5,7 @@ use App\Enums\Analytics\PublicationContentType; use App\Enums\Analytics\PublicationOrigin; use App\Enums\Post\Origin; +use App\Enums\Post\PublishStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; @@ -16,6 +17,7 @@ use App\Models\Post; use App\Models\SocialAccount; use Carbon\CarbonImmutable; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Queue; beforeEach(function () { @@ -241,20 +243,20 @@ function claimedTikTokVideoChain(): array /** * Three posts of one channel hold video 7000000000000000041, created at - * 10:00:00 on 2026-10-08: the one published 30 seconds later, one published + * 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-08 10:00:30'); - $earlier = repairTikTokPost('7000000000000000041', '2026-10-08 09:00:30'); + $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-08 10:00:00', + 'published_at' => '2026-10-05 10:00:00', ]); - $video = repairTikTokVideo('7000000000000000041', '2026-10-08 10:00:00', $publicationOnEarlierPost ? $earlier : $owner); + $video = repairTikTokVideo('7000000000000000041', '2026-10-05 10:00:00', $publicationOnEarlierPost ? $earlier : $owner); return compact('owner', 'earlier', 'copy', 'video'); } @@ -276,7 +278,6 @@ function sharedTikTokVideo(bool $publicationOnEarlierPost = false): array ->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(); - Queue::assertNotPushed(ResolveTikTokVideoId::class, fn (ResolveTikTokVideoId $job): bool => $job->post->is($shared['earlier'])); })->with([ 'publication on the owner' => [false], 'publication on a post that does not own it' => [true], @@ -286,10 +287,10 @@ function sharedTikTokVideo(bool $publicationOnEarlierPost = false): array $shared = sharedTikTokVideo(); match ($case) { - 'two posts right after' => repairTikTokPost('7000000000000000041', '2026-10-08 10:01:00'), - 'none right after' => $shared['owner']->forceFill(['published_at' => '2026-10-08 10:30:00'])->save(), + '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-08 10:00:30', $shared['owner']), + && repairTikTokVideo('v_pub_url~v2-1.owner', '2026-10-05 08:00:00', $shared['owner']), }; $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); @@ -330,3 +331,80 @@ function sharedTikTokVideo(bool $publicationOnEarlierPost = false): array 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(); +}); From ce0622d86d77611a66849bb7db7243bd434da194 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 12:55:53 -0300 Subject: [PATCH 18/18] Settle each shared TikTok video with the posts holding it now The second phase grouped every post once before changing anything, so a video handed back while settling an earlier group was settled with its old holders and could end on two posts. Each group now reads its posts again. --- .../Scripts/RepairTikTokVideoIdsCommand.php | 43 ++++++++++++++----- .../RepairTikTokVideoIdsCommandTest.php | 17 ++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php index 32b39214c..01d51b401 100644 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php @@ -230,29 +230,52 @@ private function giveBack(Post $post, AnalyticsPublication $own): bool */ private function settleSharedVideos(): Collection { - 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') - ->with('socialAccount') + 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($posts)) + ->map(fn (Collection $posts): ?array => $this->settle(data_get($posts->first(), 'social_account_id'), data_get($posts->first(), 'platform_post_id'))) ->filter() ->values() ->collect(); } /** - * @param Collection $posts + * 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(Collection $posts): ?array + 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) diff --git a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php index ca053e3c1..1e08189c1 100644 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php @@ -408,3 +408,20 @@ function sharedTikTokVideo(bool $publicationOnEarlierPost = false): array 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); +});