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); -});