From 3037536b91c8f020779b552269f409c228be01e3 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 10 Oct 2026 13:12:58 -0300 Subject: [PATCH] Remove the one-off TikTok video id repair command tiktok:repair-video-ids fixed the posts the old caption match had given the wrong video, and it has run in production. New posts are resolved by ResolveTikTokVideoId, so the command and its tests go; the TikTok rule now records that it ran. --- .ai/rules/tiktok.md | 2 +- .../Scripts/RepairTikTokVideoIdsCommand.php | 352 --------------- .../RepairTikTokVideoIdsCommandTest.php | 427 ------------------ 3 files changed, 1 insertion(+), 780 deletions(-) delete mode 100644 app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php delete mode 100644 tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php diff --git a/.ai/rules/tiktok.md b/.ai/rules/tiktok.md index 1d6fd2aaa..ba6e2cfcb 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 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. +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 one-off repair run in October 2026), 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 such a post needs a manual fix (the one-off `tiktok:repair-video-ids` ran in production in October 2026 and was removed). diff --git a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php b/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php deleted file mode 100644 index 01d51b401..000000000 --- a/app/Console/Commands/Scripts/RepairTikTokVideoIdsCommand.php +++ /dev/null @@ -1,352 +0,0 @@ -option('dry-run'); - - DB::beginTransaction(); - - try { - $repairs = $this->repairs(); - $deletedWithRepairs = $this->applyRepairs($repairs); - $settled = $this->settleSharedVideos(); - } catch (Throwable $exception) { - DB::rollBack(); - - throw $exception; - } - - $dryRun ? DB::rollBack() : DB::commit(); - - $this->table( - ['Post', 'Published at', 'Held video', 'Own video'], - $repairs->map(fn (array $repair): array => [ - data_get($repair, 'post.id'), - data_get($repair, 'post.published_at')->toDateTimeString(), - data_get($repair, 'held.remote_id'), - data_get($repair, 'own.remote_id'), - ])->all(), - ); - - $this->table( - ['Video', 'Kept by', 'Got their own video back', 'Released posts', 'Deleted imported copies'], - $settled->map(fn (array $video): array => [ - data_get($video, 'video.remote_id'), - data_get($video, 'owner.id'), - data_get($video, 'returned')->pluck('id')->implode(', ') ?: '-', - data_get($video, 'released')->pluck('id')->implode(', ') ?: '-', - data_get($video, 'copies'), - ])->all(), - ); - - $awaiting = Post::query() - ->publishedToTikTok() - ->whereHas('socialAccount', fn (Builder $query): Builder => $query->connected()) - ->lazyById() - ->filter(fn (Post $post): bool => $post->awaitsTikTokVideoId()) - ->collect(); - - $returned = $repairs->count() + $settled->sum(fn (array $video): int => data_get($video, 'returned')->count()); - $released = $settled->sum(fn (array $video): int => data_get($video, 'released')->count()); - $copies = $deletedWithRepairs + $settled->sum(fn (array $video): int => data_get($video, 'copies')); - - if ($dryRun) { - $this->info("{$returned} post(s) would get their own video back; {$released} post(s) would lose a video another post owns; {$copies} imported copy(ies) would be deleted; {$awaiting->count()} post(s) would ask TikTok for their video id."); - - return self::SUCCESS; - } - - $awaiting->each(fn (Post $post) => ResolveTikTokVideoId::dispatch($post)); - - $this->info("{$returned} post(s) got their own video back; {$released} post(s) lost a video another post owns; {$copies} imported copy(ies) deleted; {$awaiting->count()} post(s) are asking TikTok for their video id."); - - return self::SUCCESS; - } - - /** - * A post holds the wrong video when that video was created right before - * another post of the channel was published, and one video was created - * right before the post itself. - * - * @return Collection - */ - private function repairs(): Collection - { - $repairs = AnalyticsPublication::query() - ->where('network', Platform::TikTok->network()) - ->whereHas('post', fn (Builder $query): Builder => $query->publishedToTikTok()->whereNotNull('published_at')->has('socialAccount')) - ->with('post.socialAccount') - ->lazyById() - ->filter(fn (AnalyticsPublication $held): bool => ctype_digit($held->remote_id) - && $held->provider_published_at->lessThan($held->post->published_at->subMinutes(self::LATE_VIDEO_MINUTES)) - && $this->publishedRightAfter($held)) - ->map(fn (AnalyticsPublication $held): array => [ - 'post' => $held->post, - 'held' => $held, - 'own' => $this->videoCreatedRightBefore($held->post, $held), - ]) - ->filter(fn (array $repair): bool => filled(data_get($repair, 'own'))) - ->values() - ->collect(); - - do { - $before = $repairs->count(); - $repairs = $this->withFreeOwnVideo($repairs); - } while ($repairs->count() !== $before); - - return $repairs; - } - - /** - * @param Collection $repairs - * @return int imported copies deleted - */ - private function applyRepairs(Collection $repairs): int - { - $repairs->each(fn (array $repair) => data_get($repair, 'held')->update([ - 'post_id' => null, - 'origin' => PublicationOrigin::External, - ])); - - return $repairs->filter(fn (array $repair): bool => $this->giveBack(data_get($repair, 'post'), data_get($repair, 'own')->fresh()))->count(); - } - - /** - * Keeps a repair only when its own video is claimed by no other repair and - * is free once the repairs run: unlinked, imported, or held by a post that - * is itself repaired. Dropping one repair can strand another, so the caller - * repeats this until nothing changes. - * - * @param Collection $repairs - * @return Collection - */ - private function withFreeOwnVideo(Collection $repairs): Collection - { - $repairedPostIds = $repairs->map(fn (array $repair): string => data_get($repair, 'post.id')); - $claims = $repairs->countBy(fn (array $repair): string => data_get($repair, 'own.id')); - - return $repairs - ->filter(fn (array $repair): bool => $claims->get(data_get($repair, 'own.id')) === 1 - && (blank(data_get($repair, 'own.post_id')) - || $repairedPostIds->contains(data_get($repair, 'own.post_id')) - || Post::query()->imported()->whereKey(data_get($repair, 'own.post_id'))->exists())) - ->values(); - } - - private function publishedRightAfter(AnalyticsPublication $held): bool - { - $createdAt = $held->provider_published_at; - - return Post::query() - ->createdInTryPost() - ->where('social_account_id', $held->post->social_account_id) - ->whereKeyNot($held->post_id) - ->whereBetween('published_at', [$createdAt, $createdAt->addMinutes(self::CREATE_TO_PUBLISH_MINUTES)]) - ->exists(); - } - - /** - * The one video of the channel created in the two minutes before the post - * was published, or null when there is none or more than one. - */ - private function videoCreatedRightBefore(Post $post, AnalyticsPublication $sameChannel): ?AnalyticsPublication - { - $publishedAt = $post->published_at; - - $candidates = AnalyticsPublication::query() - ->where('workspace_id', $sameChannel->workspace_id) - ->where('social_account_key', $sameChannel->social_account_key) - ->where('network', $sameChannel->network) - ->whereBetween('provider_published_at', [$publishedAt->subMinutes(self::CREATE_TO_PUBLISH_MINUTES), $publishedAt]) - ->limit(2) - ->get(); - - return $candidates->count() === 1 && ctype_digit($candidates->sole()->remote_id) ? $candidates->sole() : null; - } - - /** - * @return bool whether an imported copy of the video was deleted - */ - private function giveBack(Post $post, AnalyticsPublication $own): bool - { - $imported = filled($own->post_id) ? Post::query()->imported()->whereKey($own->post_id)->first() : null; - - if (filled($imported)) { - DeleteOwnedMedia::forPosts([$imported->id]); - Post::withoutEvents(fn (): ?bool => $imported->delete()); - } - - $own->update(['post_id' => $post->id, 'origin' => PublicationOrigin::TryPost]); - - $post->writePublication([ - 'platform_post_id' => $own->remote_id, - 'platform_url' => $own->permalink - ?? TikTokPublisher::postUrl($post->socialAccount, $own->remote_id) - ?? $post->platform_url, - ]); - - return filled($imported); - } - - /** - * Every video id more than one post of a channel holds. The TryPost post - * published right after the video was created, with no other video in that - * window, keeps it. Each other TryPost post gets its own video back when - * exactly one free video was created right before it, and otherwise loses - * the id and points at the profile again; imported copies are deleted. A - * video without exactly one such owner is left alone. - * - * @return Collection, released: Collection, copies: int}> - */ - private function settleSharedVideos(): Collection - { - return $this->sharedVideoPosts() - ->select(['id', 'social_account_id', 'platform_post_id']) - ->lazyById() - ->filter(fn (Post $post): bool => ctype_digit((string) $post->platform_post_id)) - ->groupBy(fn (Post $post): string => "{$post->social_account_id}:{$post->platform_post_id}") - ->filter(fn (Collection $posts): bool => $posts->count() > 1) - ->map(fn (Collection $posts): ?array => $this->settle(data_get($posts->first(), 'social_account_id'), data_get($posts->first(), 'platform_post_id'))) - ->filter() - ->values() - ->collect(); - } - - /** - * Published TryPost posts and imported posts on TikTok channels that still - * exist, carrying a video id. - * - * @return Builder - */ - private function sharedVideoPosts(): Builder - { - return Post::query() - ->where('platform', Platform::TikTok) - ->where(fn (Builder $query): Builder => $query->imported() - ->orWhere(fn (Builder $published): Builder => $published->createdInTryPost()->publicationPublished())) - ->has('socialAccount') - ->whereNotNull('platform_post_id'); - } - - /** - * Reads the posts holding the video again, since settling an earlier video - * can hand one of them its own video back. - * - * @return array{video: AnalyticsPublication, owner: Post, returned: Collection, released: Collection, copies: int}|null - */ - private function settle(string $socialAccountId, string $videoId): ?array - { - $posts = $this->sharedVideoPosts() - ->where('social_account_id', $socialAccountId) - ->where('platform_post_id', $videoId) - ->with('socialAccount') - ->get(); - - if ($posts->count() < 2) { - return null; - } - - $sample = $posts->first(); - $videos = AnalyticsPublication::query() - ->where('workspace_id', $sample->workspace_id) - ->where('social_account_id', $sample->social_account_id) - ->where('network', Platform::TikTok->network()) - ->where('remote_id', $sample->platform_post_id) - ->limit(2) - ->get(); - - if ($videos->count() !== 1) { - return null; - } - - $video = $videos->sole(); - $createdAt = $video->provider_published_at; - $owners = $posts->filter(fn (Post $post): bool => $post->origin === Origin::TryPost - && filled($post->published_at) - && $post->published_at->betweenIncluded($createdAt, $createdAt->addMinutes(self::CREATE_TO_PUBLISH_MINUTES))); - - if ($owners->count() !== 1) { - return null; - } - - $owner = $owners->sole(); - $ownerVideo = $this->videoCreatedRightBefore($owner, $video); - - if (blank($ownerVideo) || ! $ownerVideo->is($video)) { - return null; - } - - if ($video->post_id !== $owner->id && AnalyticsPublication::query()->where('post_id', $owner->id)->exists()) { - return null; - } - - $others = $posts->reject(fn (Post $post): bool => $post->is($owner))->values(); - $copies = $others->filter(fn (Post $post): bool => $post->origin === Origin::Network)->values(); - - $video->update(['post_id' => $owner->id, 'origin' => PublicationOrigin::TryPost]); - - if ($copies->isNotEmpty()) { - DeleteOwnedMedia::forPosts($copies->pluck('id')->all()); - Post::query()->whereKey($copies->pluck('id')->all())->delete(); - } - - $returned = new Collection; - $released = new Collection; - $deletedWithReturns = 0; - - $others->filter(fn (Post $post): bool => $post->origin === Origin::TryPost)->each(function (Post $post) use ($video, $returned, $released, &$deletedWithReturns): void { - $own = filled($post->published_at) ? $this->videoCreatedRightBefore($post, $video) : null; - - if (filled($own) && ! $own->is($video) && (blank($own->post_id) || Post::query()->imported()->whereKey($own->post_id)->exists())) { - $deletedWithReturns += (int) $this->giveBack($post, $own); - $returned->push($post); - - return; - } - - $post->writePublication([ - 'platform_post_id' => null, - 'platform_url' => TikTokPublisher::postUrl($post->socialAccount) ?? $post->platform_url, - ]); - $released->push($post); - }); - - return [ - 'video' => $video, - 'owner' => $owner, - 'returned' => $returned, - 'released' => $released, - 'copies' => $copies->count() + $deletedWithReturns, - ]; - } -} diff --git a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php b/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php deleted file mode 100644 index 1e08189c1..000000000 --- a/tests/Feature/Console/RepairTikTokVideoIdsCommandTest.php +++ /dev/null @@ -1,427 +0,0 @@ -account = SocialAccount::factory()->tiktok()->create(['username' => 'tiktoker']); -}); - -function repairTikTokPost(string $platformPostId, string $publishedAt): Post -{ - return Post::factory()->forAccount(test()->account, ContentType::TikTokVideo)->published()->create([ - 'platform_post_id' => $platformPostId, - 'platform_url' => 'https://www.tiktok.com/@tiktoker', - 'published_at' => $publishedAt, - 'meta' => ['privacy_level' => PrivacyLevel::PublicToEveryone->value], - ]); -} - -function repairTikTokVideo(string $remoteId, string $createdAt, ?Post $post = null): AnalyticsPublication -{ - $account = test()->account; - - return AnalyticsPublication::factory()->create([ - 'workspace_id' => $account->workspace_id, - 'social_account_id' => $account->id, - 'social_account_key' => $account->id, - 'network' => Platform::TikTok->network(), - 'platform' => Platform::TikTok, - 'platform_user_id' => $account->platform_user_id, - 'content_type' => PublicationContentType::Video, - 'remote_id' => $remoteId, - 'permalink' => "https://www.tiktok.com/@tiktoker/video/{$remoteId}", - 'provider_published_at' => $createdAt, - 'post_id' => $post?->id, - 'origin' => blank($post) || $post->origin === Origin::Network ? PublicationOrigin::External : PublicationOrigin::TryPost, - ]); -} - -/** - * Post A was published before TikTok reported its video, and post B, a day - * later, took A's video by caption while its own video was imported apart. - * - * @return array{first: Post, second: Post, firstVideo: AnalyticsPublication, secondVideo: AnalyticsPublication, imported: Post, provisional: AnalyticsPublication} - */ -function claimedTikTokVideoChain(): array -{ - $first = repairTikTokPost('v_pub_url~v2-1.first', '2026-10-08 10:00:30'); - $provisional = repairTikTokVideo('v_pub_url~v2-1.first', '2026-10-08 10:00:30', $first); - $second = repairTikTokPost('7000000000000000001', '2026-10-09 10:00:30'); - $firstVideo = repairTikTokVideo('7000000000000000001', '2026-10-08 10:00:00', $second); - $imported = Post::factory()->forAccount(test()->account, ContentType::TikTokVideo)->imported()->create([ - 'platform_post_id' => '7000000000000000002', - ]); - $secondVideo = repairTikTokVideo('7000000000000000002', '2026-10-09 10:00:00', $imported); - - return compact('first', 'second', 'firstVideo', 'secondVideo', 'imported', 'provisional'); -} - -test('a post gets its own video back and the video it took is freed for the post that published it', function () { - $chain = claimedTikTokVideoChain(); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($chain['second']->fresh()) - ->platform_post_id->toBe('7000000000000000002') - ->platform_url->toBe('https://www.tiktok.com/@tiktoker/video/7000000000000000002') - ->and($chain['secondVideo']->fresh()) - ->post_id->toBe($chain['second']->id) - ->origin->toBe(PublicationOrigin::TryPost) - ->and($chain['firstVideo']->fresh()) - ->post_id->toBeNull() - ->origin->toBe(PublicationOrigin::External) - ->and(Post::query()->whereKey($chain['imported']->id)->exists())->toBeFalse() - ->and($chain['first']->fresh()->platform_post_id)->toBe('v_pub_url~v2-1.first'); - - Queue::assertPushed(ResolveTikTokVideoId::class, fn (ResolveTikTokVideoId $job): bool => $job->post->is($chain['first'])); -}); - -test('a second run finds nothing left to repair', function () { - $chain = claimedTikTokVideoChain(); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - $this->artisan('tiktok:repair-video-ids', ['--dry-run' => true]) - ->expectsOutputToContain('0 post(s) would get their own video back') - ->assertSuccessful(); - - expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000002'); -}); - -test('the copy the importer made of a post own video goes with its media', function () { - $chain = claimedTikTokVideoChain(); - $media = Media::factory()->ownedByPost($chain['imported'])->create(); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect(Media::query()->whereKey($media->id)->exists())->toBeFalse(); -}); - -test('a post gets a video url built from its channel when the video has no permalink', function (?string $username, string $expectedUrl) { - $chain = claimedTikTokVideoChain(); - $chain['secondVideo']->update(['permalink' => null]); - $this->account->update(['username' => $username]); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($chain['second']->fresh()->platform_url)->toBe($expectedUrl); -})->with([ - 'with a username' => ['tiktoker', 'https://www.tiktok.com/@tiktoker/video/7000000000000000002'], - 'without a username' => [null, 'https://www.tiktok.com/@tiktoker'], -]); - -test('posts on a channel that is not connected are not sent to TikTok', function () { - $chain = claimedTikTokVideoChain(); - $this->account->update(['status' => Status::Disconnected]); - - $this->artisan('tiktok:repair-video-ids') - ->expectsOutputToContain('0 post(s) are asking TikTok for their video id.') - ->assertSuccessful(); - - expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000002'); - Queue::assertNotPushed(ResolveTikTokVideoId::class); -}); - -test('posts whose channel is gone are left alone', function () { - $chain = claimedTikTokVideoChain(); - $chain['secondVideo']->update(['permalink' => null]); - Post::query()->whereKey([$chain['first']->id, $chain['second']->id])->update(['social_account_id' => null]); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000001') - ->and($chain['firstVideo']->fresh()->post_id)->toBe($chain['second']->id); -}); - -test('a post whose publish only finished long after TikTok created its video keeps that video', function () { - $slow = repairTikTokPost('7000000000000000003', '2026-10-09 14:40:00'); - $video = repairTikTokVideo('7000000000000000003', '2026-10-09 14:00:00', $slow); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($slow->fresh()->platform_post_id)->toBe('7000000000000000003') - ->and($video->fresh()->post_id)->toBe($slow->id); -}); - -test('a dry run lists the repairs without writing them', function () { - $chain = claimedTikTokVideoChain(); - - $this->artisan('tiktok:repair-video-ids', ['--dry-run' => true]) - ->expectsOutputToContain('1 post(s) would get their own video back; 0 post(s) would lose a video another post owns; 1 imported copy(ies) would be deleted; 1 post(s) would ask TikTok for their video id.') - ->assertSuccessful(); - - expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000001') - ->and($chain['firstVideo']->fresh()->post_id)->toBe($chain['second']->id) - ->and(Post::query()->whereKey($chain['imported']->id)->exists())->toBeTrue(); - - Queue::assertNotPushed(ResolveTikTokVideoId::class); -}); - -test('a run of posts that each took the previous post video all get their own back', function () { - $first = repairTikTokPost('v_pub_url~v2-1.first', '2026-10-07 10:00:30'); - repairTikTokVideo('v_pub_url~v2-1.first', '2026-10-07 10:00:30', $first); - $second = repairTikTokPost('7000000000000000011', '2026-10-08 10:00:30'); - $firstVideo = repairTikTokVideo('7000000000000000011', '2026-10-07 10:00:00', $second); - $third = repairTikTokPost('7000000000000000012', '2026-10-09 10:00:30'); - $secondVideo = repairTikTokVideo('7000000000000000012', '2026-10-08 10:00:00', $third); - $thirdVideo = repairTikTokVideo('7000000000000000013', '2026-10-09 10:00:00'); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($second->fresh()->platform_post_id)->toBe('7000000000000000012') - ->and($third->fresh()->platform_post_id)->toBe('7000000000000000013') - ->and($secondVideo->fresh()->post_id)->toBe($second->id) - ->and($thirdVideo->fresh()->post_id)->toBe($third->id) - ->and($firstVideo->fresh()->post_id)->toBeNull(); - - Queue::assertPushed(ResolveTikTokVideoId::class, fn (ResolveTikTokVideoId $job): bool => $job->post->is($first)); -}); - -test('a post is left alone when the post holding its own video cannot be repaired', function () { - $first = repairTikTokPost('v_pub_url~v2-1.first', '2026-10-07 10:00:30'); - repairTikTokVideo('v_pub_url~v2-1.first', '2026-10-07 10:00:30', $first); - $second = repairTikTokPost('7000000000000000021', '2026-10-08 10:00:30'); - repairTikTokVideo('7000000000000000021', '2026-10-07 10:00:00', $second); - $third = repairTikTokPost('7000000000000000022', '2026-10-09 10:00:30'); - repairTikTokVideo('7000000000000000022', '2026-10-08 10:00:00', $third); - $correct = repairTikTokPost('7000000000000000023', '2026-10-09 10:00:35'); - repairTikTokVideo('7000000000000000023', '2026-10-09 10:00:00', $correct); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($second->fresh()->platform_post_id)->toBe('7000000000000000021') - ->and($third->fresh()->platform_post_id)->toBe('7000000000000000022') - ->and($correct->fresh()->platform_post_id)->toBe('7000000000000000023'); -}); - -test('posts are left alone when their own video is unknown, ambiguous or claimed by another post', function (string $case) { - $previous = repairTikTokPost('v_pub_url~v2-1.previous', '2026-10-07 10:00:30'); - repairTikTokVideo('v_pub_url~v2-1.previous', '2026-10-07 10:00:30', $previous); - $post = repairTikTokPost('7000000000000000031', '2026-10-08 10:00:30'); - $held = repairTikTokVideo('7000000000000000031', '2026-10-07 10:00:00', $post); - $video = repairTikTokVideo('7000000000000000032', $case === 'unknown' ? '2026-10-08 09:50:00' : '2026-10-08 10:00:20'); - - if ($case === 'ambiguous') { - repairTikTokVideo('7000000000000000033', '2026-10-08 09:59:40'); - } elseif ($case === 'claimed') { - $other = repairTikTokPost('v_pub_url~v2-1.other', '2026-10-07 12:00:30'); - repairTikTokVideo('v_pub_url~v2-1.other', '2026-10-07 12:00:30', $other); - $sibling = repairTikTokPost('7000000000000000034', '2026-10-08 10:01:00'); - repairTikTokVideo('7000000000000000034', '2026-10-07 12:00:00', $sibling); - } - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($post->fresh()->platform_post_id)->toBe('7000000000000000031') - ->and($held->fresh()->post_id)->toBe($post->id) - ->and($video->fresh()->post_id)->toBeNull(); -})->with([ - 'no video in its window' => ['unknown'], - 'two videos in its window' => ['ambiguous'], - 'another post claims the same video' => ['claimed'], -]); - -/** - * Three posts of one channel hold video 7000000000000000041, created at - * 10:00:00 on 2026-10-05: the one published 30 seconds later, one published - * an hour before the video existed, and an imported copy. - * - * @return array{owner: Post, earlier: Post, copy: Post, video: AnalyticsPublication} - */ -function sharedTikTokVideo(bool $publicationOnEarlierPost = false): array -{ - $owner = repairTikTokPost('7000000000000000041', '2026-10-05 10:00:30'); - $earlier = repairTikTokPost('7000000000000000041', '2026-10-05 09:00:30'); - $copy = Post::factory()->forAccount(test()->account, ContentType::TikTokVideo)->imported()->create([ - 'platform_post_id' => '7000000000000000041', - 'published_at' => '2026-10-05 10:00:00', - ]); - $video = repairTikTokVideo('7000000000000000041', '2026-10-05 10:00:00', $publicationOnEarlierPost ? $earlier : $owner); - - return compact('owner', 'earlier', 'copy', 'video'); -} - -test('a video several posts hold stays with the post published right after it was created', function (bool $publicationOnEarlierPost) { - $shared = sharedTikTokVideo($publicationOnEarlierPost); - $media = Media::factory()->ownedByPost($shared['copy'])->create(); - - $this->artisan('tiktok:repair-video-ids') - ->expectsOutputToContain('0 post(s) got their own video back; 1 post(s) lost a video another post owns; 1 imported copy(ies) deleted;') - ->assertSuccessful(); - - expect($shared['owner']->fresh()->platform_post_id)->toBe('7000000000000000041') - ->and($shared['video']->fresh()) - ->post_id->toBe($shared['owner']->id) - ->origin->toBe(PublicationOrigin::TryPost) - ->and($shared['earlier']->fresh()) - ->platform_post_id->toBeNull() - ->platform_url->toBe('https://www.tiktok.com/@tiktoker') - ->and(Post::query()->whereKey($shared['copy']->id)->exists())->toBeFalse() - ->and(Media::query()->whereKey($media->id)->exists())->toBeFalse(); -})->with([ - 'publication on the owner' => [false], - 'publication on a post that does not own it' => [true], -]); - -test('a video several posts hold is left alone without exactly one post published right after it', function (string $case) { - $shared = sharedTikTokVideo(); - - match ($case) { - 'two posts right after' => repairTikTokPost('7000000000000000041', '2026-10-05 10:01:00'), - 'none right after' => $shared['owner']->forceFill(['published_at' => '2026-10-05 10:30:00'])->save(), - 'owner holds another publication' => $shared['video']->update(['post_id' => $shared['earlier']->id]) - && repairTikTokVideo('v_pub_url~v2-1.owner', '2026-10-05 08:00:00', $shared['owner']), - }; - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($shared['earlier']->fresh()->platform_post_id)->toBe('7000000000000000041') - ->and(Post::query()->whereKey($shared['copy']->id)->exists())->toBeTrue(); -})->with([ - 'two posts right after' => ['two posts right after'], - 'none right after' => ['none right after'], - 'owner holds another publication' => ['owner holds another publication'], -]); - -test('a dry run rolls both repairs back and only queues media file deletions for after a commit that never comes', function () { - $chain = claimedTikTokVideoChain(); - $shared = sharedTikTokVideo(); - $media = Media::factory()->ownedByPost($shared['copy'])->create(); - - $this->artisan('tiktok:repair-video-ids', ['--dry-run' => true]) - ->expectsOutputToContain('1 post(s) would get their own video back; 1 post(s) would lose a video another post owns; 2 imported copy(ies) would be deleted;') - ->assertSuccessful(); - - expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000001') - ->and($shared['earlier']->fresh()->platform_post_id)->toBe('7000000000000000041') - ->and(Post::query()->whereKey([$chain['imported']->id, $shared['copy']->id])->count())->toBe(2) - ->and(Media::query()->whereKey($media->id)->exists())->toBeTrue(); - Queue::assertPushed(DeleteMediaFiles::class, fn (DeleteMediaFiles $job): bool => $job->afterCommit === true); -}); - -test('an imported copy left behind when a post gets its own video back is deleted in the same run', function () { - $chain = claimedTikTokVideoChain(); - $orphan = Post::factory()->forAccount($this->account, ContentType::TikTokVideo)->imported()->create([ - 'platform_post_id' => '7000000000000000002', - 'published_at' => '2026-10-09 10:00:00', - ]); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($chain['second']->fresh()->platform_post_id)->toBe('7000000000000000002') - ->and(Post::query()->whereKey($orphan->id)->exists())->toBeFalse(); -}); - -test('a native video is never deleted when the post holding its id had another video created in its window', function () { - $native = Post::factory()->forAccount($this->account, ContentType::TikTokVideo)->imported()->create([ - 'platform_post_id' => '7000000000000000051', - 'published_at' => '2026-10-05 10:00:00', - ]); - $nativeVideo = repairTikTokVideo('7000000000000000051', '2026-10-05 10:00:00', $native); - $post = repairTikTokPost('7000000000000000051', '2026-10-05 10:01:00'); - repairTikTokVideo('7000000000000000052', '2026-10-05 10:00:40'); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect(Post::query()->whereKey($native->id)->exists())->toBeTrue() - ->and($nativeVideo->fresh()->post_id)->toBe($native->id) - ->and($post->fresh()->platform_post_id)->toBe('7000000000000000051'); -}); - -test('a post that loses a video another post owns gets its own video back when there is one', function () { - $shared = sharedTikTokVideo(); - $ownCopy = Post::factory()->forAccount($this->account, ContentType::TikTokVideo)->imported()->create([ - 'platform_post_id' => '7000000000000000042', - 'published_at' => '2026-10-05 09:00:00', - ]); - $ownVideo = repairTikTokVideo('7000000000000000042', '2026-10-05 09:00:00', $ownCopy); - - $this->artisan('tiktok:repair-video-ids') - ->expectsOutputToContain('1 post(s) got their own video back; 0 post(s) lost a video another post owns; 2 imported copy(ies) deleted;') - ->assertSuccessful(); - - expect($shared['earlier']->fresh()->platform_post_id)->toBe('7000000000000000042') - ->and($ownVideo->fresh()->post_id)->toBe($shared['earlier']->id) - ->and(Post::query()->whereKey($ownCopy->id)->exists())->toBeFalse() - ->and($shared['owner']->fresh()->platform_post_id)->toBe('7000000000000000041'); -}); - -test('a shared video is left alone when the posts are not published, have no publication or sit on other channels', function (string $case) { - $posts = match ($case) { - 'a failed post' => [ - repairTikTokPost('7000000000000000061', '2026-10-05 10:00:30'), - tap(repairTikTokPost('7000000000000000061', '2026-10-05 09:00:30'), fn (Post $post) => $post->forceFill(['publish_status' => PublishStatus::Failed])->save()), - ], - 'no publication' => [ - repairTikTokPost('7000000000000000061', '2026-10-05 10:00:30'), - repairTikTokPost('7000000000000000061', '2026-10-05 09:00:30'), - ], - 'another channel' => [ - repairTikTokPost('7000000000000000061', '2026-10-05 10:00:30'), - Post::factory()->forAccount(SocialAccount::factory()->tiktok()->create(), ContentType::TikTokVideo)->published()->create([ - 'platform_post_id' => '7000000000000000061', - 'published_at' => '2026-10-05 09:00:30', - ]), - ], - }; - - if ($case !== 'no publication') { - repairTikTokVideo('7000000000000000061', '2026-10-05 10:00:00', $posts[0]); - } - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect(Post::query()->whereKey(array_map(fn (Post $post): string => $post->id, $posts))->pluck('platform_post_id')->unique()->all()) - ->toBe(['7000000000000000061']); -})->with([ - 'a failed post' => ['a failed post'], - 'no publication' => ['no publication'], - 'another channel' => ['another channel'], -]); - -test('a failure rolls every repair back', function () { - $chain = claimedTikTokVideoChain(); - Post::updating(fn () => throw new RuntimeException('Write failed')); - - expect(fn () => Artisan::call('tiktok:repair-video-ids'))->toThrow(RuntimeException::class, 'Write failed'); - - expect($chain['firstVideo']->fresh()->post_id)->toBe($chain['second']->id) - ->and(Post::query()->whereKey($chain['imported']->id)->exists())->toBeTrue(); -}); - -test('a video handed back while settling an earlier shared video is settled with its current holders', function () { - $shared = sharedTikTokVideo(); - $ownCopy = Post::factory()->forAccount($this->account, ContentType::TikTokVideo)->imported()->create([ - 'platform_post_id' => '7000000000000000043', - 'published_at' => '2026-10-05 09:00:00', - ]); - $ownVideo = repairTikTokVideo('7000000000000000043', '2026-10-05 09:00:00', $ownCopy); - $later = repairTikTokPost('7000000000000000043', '2026-10-05 12:01:00'); - - $this->artisan('tiktok:repair-video-ids')->assertSuccessful(); - - expect($shared['earlier']->fresh()->platform_post_id)->toBe('7000000000000000043') - ->and($ownVideo->fresh()->post_id)->toBe($shared['earlier']->id) - ->and($later->fresh()->platform_post_id)->toBeNull() - ->and(Post::query()->where('platform_post_id', '7000000000000000043')->count())->toBe(1); -});