From 9fe1fe55af19c48cbdce4be842a2930bd8e2b7f9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 29 Aug 2026 16:18:26 +0930 Subject: [PATCH 1/5] Add fix:followercount command to resync drifted follower/following counts profiles.followers_count/following_count are cached columns reconciled lazily by FollowServiceWarmCache (throttled up to 7 days), so they can drift from the followers table. This command recomputes them from the source-of-truth table for a single profile or --all drifted local profiles, with --dry-run to report and --dispatch to queue the warm-cache job (which also rebuilds the Redis sets). Mirrors the existing fix:statuscount convention. --- app/Console/Commands/FixFollowerCount.php | 157 ++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 app/Console/Commands/FixFollowerCount.php diff --git a/app/Console/Commands/FixFollowerCount.php b/app/Console/Commands/FixFollowerCount.php new file mode 100644 index 000000000..a78621da4 --- /dev/null +++ b/app/Console/Commands/FixFollowerCount.php @@ -0,0 +1,157 @@ +argument('id'); + $all = $this->option('all'); + + if (! $id && ! $all) { + $this->error('Provide a profile id/username, or pass --all.'); + + return 1; + } + + if ($id && $all) { + $this->error('Pass either an id or --all, not both.'); + + return 1; + } + + if ($id) { + $profile = ctype_digit((string) $id) + ? Profile::find($id) + : Profile::whereNull('domain')->where('username', $id)->first(); + + if (! $profile) { + $this->error('No profile found for "'.$id.'".'); + + return 1; + } + + $this->resyncOne($profile); + + return 0; + } + + // --all: only touch profiles whose cached count actually drifted. + $dryRun = $this->option('dry-run'); + if (! $dryRun && ! $this->confirm('Resync follower/following counts for all drifted local profiles?', true)) { + $this->comment('Aborted.'); + + return 0; + } + + $fixed = 0; + $scanned = 0; + Profile::whereNull('domain')->whereNull('deleted_at')->lazyById(500)->each(function ($profile) use (&$fixed, &$scanned) { + $scanned++; + if ($this->resyncOne($profile, true)) { + $fixed++; + } + }); + + $this->newLine(); + $this->info('Scanned '.$scanned.' local profiles; '.($this->option('dry-run') ? 'drifted' : 'resynced').': '.$fixed.'.'); + + return 0; + } + + /** + * Recompute (or report) the counts for a single profile. + * + * @return bool whether the profile was drifted + */ + protected function resyncOne(Profile $profile, bool $quiet = false): bool + { + $liveFollowers = (int) Follower::whereFollowingId($profile->id)->count(); + $liveFollowing = (int) Follower::whereProfileId($profile->id)->count(); + + $cachedFollowers = (int) $profile->followers_count; + $cachedFollowing = (int) $profile->following_count; + + $drifted = $liveFollowers !== $cachedFollowers || $liveFollowing !== $cachedFollowing; + + if (! $drifted) { + if (! $quiet) { + $this->info($profile->username.' (id '.$profile->id.') is already in sync (followers='.$cachedFollowers.', following='.$cachedFollowing.').'); + } + + return false; + } + + if (! $quiet) { + $this->warn($profile->username.' (id '.$profile->id.') drift detected:'); + $this->line(' followers: cached='.$cachedFollowers.' live='.$liveFollowers); + $this->line(' following: cached='.$cachedFollowing.' live='.$liveFollowing); + } + + if ($this->option('dry-run')) { + return true; + } + + if ($this->option('dispatch')) { + // Clear the throttle sync keys so the warm-cache job actually + // re-runs, then queue it (also rebuilds the Redis sets). + Cache::forget(FollowerService::FOLLOWERS_SYNC_KEY.$profile->id); + Cache::forget(FollowerService::FOLLOWING_SYNC_KEY.$profile->id); + FollowServiceWarmCache::dispatch($profile->id)->onQueue('low'); + if (! $quiet) { + $this->info(' queued FollowServiceWarmCache for profile '.$profile->id.'.'); + } + + return true; + } + + // Inline recompute of the DB columns from the source-of-truth table. + $profile->followers_count = (int) DB::table('followers')->whereFollowingId($profile->id)->count(); + $profile->following_count = (int) DB::table('followers')->whereProfileId($profile->id)->count(); + $profile->save(); + + // Bust the derived caches so reads reflect the corrected values. + Cache::forget('profile:follower_count:'.$profile->id); + Cache::forget('profile:following_count:'.$profile->id); + AccountService::del($profile->id); + + if (! $quiet) { + $this->info(' resynced to followers='.$profile->followers_count.', following='.$profile->following_count.'.'); + } + + return true; + } +} From 037f1ac0b987b2b31cd954d8685f6304afcc3c72 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 29 Aug 2026 16:23:10 +0930 Subject: [PATCH 2/5] Add fix:profilecounts (total profile cache resync); remove redundant count commands Consolidate cached-count reconciliation into a single fix:profilecounts command that resyncs followers_count, following_count and status_count from the source-of-truth tables for one profile or --all. Only reports profiles with actual drift (silent when in sync); supports --dry-run and --dispatch (queues FollowServiceWarmCache and rebuilds Redis sets). Removes the superseded manual commands fix:followercount, fix:statuscount and fix:rpc. Keeps app:account-post-count-stat-update, which is scheduled (runs every 6 hours) and queue-driven. --- app/Console/Commands/FixFollowerCount.php | 157 ---------------- app/Console/Commands/FixProfileCounts.php | 193 ++++++++++++++++++++ app/Console/Commands/FixRemotePostCount.php | 53 ------ app/Console/Commands/FixStatusCount.php | 137 -------------- 4 files changed, 193 insertions(+), 347 deletions(-) delete mode 100644 app/Console/Commands/FixFollowerCount.php create mode 100644 app/Console/Commands/FixProfileCounts.php delete mode 100644 app/Console/Commands/FixRemotePostCount.php delete mode 100644 app/Console/Commands/FixStatusCount.php diff --git a/app/Console/Commands/FixFollowerCount.php b/app/Console/Commands/FixFollowerCount.php deleted file mode 100644 index a78621da4..000000000 --- a/app/Console/Commands/FixFollowerCount.php +++ /dev/null @@ -1,157 +0,0 @@ -argument('id'); - $all = $this->option('all'); - - if (! $id && ! $all) { - $this->error('Provide a profile id/username, or pass --all.'); - - return 1; - } - - if ($id && $all) { - $this->error('Pass either an id or --all, not both.'); - - return 1; - } - - if ($id) { - $profile = ctype_digit((string) $id) - ? Profile::find($id) - : Profile::whereNull('domain')->where('username', $id)->first(); - - if (! $profile) { - $this->error('No profile found for "'.$id.'".'); - - return 1; - } - - $this->resyncOne($profile); - - return 0; - } - - // --all: only touch profiles whose cached count actually drifted. - $dryRun = $this->option('dry-run'); - if (! $dryRun && ! $this->confirm('Resync follower/following counts for all drifted local profiles?', true)) { - $this->comment('Aborted.'); - - return 0; - } - - $fixed = 0; - $scanned = 0; - Profile::whereNull('domain')->whereNull('deleted_at')->lazyById(500)->each(function ($profile) use (&$fixed, &$scanned) { - $scanned++; - if ($this->resyncOne($profile, true)) { - $fixed++; - } - }); - - $this->newLine(); - $this->info('Scanned '.$scanned.' local profiles; '.($this->option('dry-run') ? 'drifted' : 'resynced').': '.$fixed.'.'); - - return 0; - } - - /** - * Recompute (or report) the counts for a single profile. - * - * @return bool whether the profile was drifted - */ - protected function resyncOne(Profile $profile, bool $quiet = false): bool - { - $liveFollowers = (int) Follower::whereFollowingId($profile->id)->count(); - $liveFollowing = (int) Follower::whereProfileId($profile->id)->count(); - - $cachedFollowers = (int) $profile->followers_count; - $cachedFollowing = (int) $profile->following_count; - - $drifted = $liveFollowers !== $cachedFollowers || $liveFollowing !== $cachedFollowing; - - if (! $drifted) { - if (! $quiet) { - $this->info($profile->username.' (id '.$profile->id.') is already in sync (followers='.$cachedFollowers.', following='.$cachedFollowing.').'); - } - - return false; - } - - if (! $quiet) { - $this->warn($profile->username.' (id '.$profile->id.') drift detected:'); - $this->line(' followers: cached='.$cachedFollowers.' live='.$liveFollowers); - $this->line(' following: cached='.$cachedFollowing.' live='.$liveFollowing); - } - - if ($this->option('dry-run')) { - return true; - } - - if ($this->option('dispatch')) { - // Clear the throttle sync keys so the warm-cache job actually - // re-runs, then queue it (also rebuilds the Redis sets). - Cache::forget(FollowerService::FOLLOWERS_SYNC_KEY.$profile->id); - Cache::forget(FollowerService::FOLLOWING_SYNC_KEY.$profile->id); - FollowServiceWarmCache::dispatch($profile->id)->onQueue('low'); - if (! $quiet) { - $this->info(' queued FollowServiceWarmCache for profile '.$profile->id.'.'); - } - - return true; - } - - // Inline recompute of the DB columns from the source-of-truth table. - $profile->followers_count = (int) DB::table('followers')->whereFollowingId($profile->id)->count(); - $profile->following_count = (int) DB::table('followers')->whereProfileId($profile->id)->count(); - $profile->save(); - - // Bust the derived caches so reads reflect the corrected values. - Cache::forget('profile:follower_count:'.$profile->id); - Cache::forget('profile:following_count:'.$profile->id); - AccountService::del($profile->id); - - if (! $quiet) { - $this->info(' resynced to followers='.$profile->followers_count.', following='.$profile->following_count.'.'); - } - - return true; - } -} diff --git a/app/Console/Commands/FixProfileCounts.php b/app/Console/Commands/FixProfileCounts.php new file mode 100644 index 000000000..a82e6c04b --- /dev/null +++ b/app/Console/Commands/FixProfileCounts.php @@ -0,0 +1,193 @@ + + */ + protected const STATUS_SCOPES = ['public', 'private', 'unlisted']; + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $id = $this->argument('id'); + $all = $this->option('all'); + + if (! $id && ! $all) { + $this->error('Provide a profile id/username, or pass --all.'); + + return 1; + } + + if ($id && $all) { + $this->error('Pass either an id or --all, not both.'); + + return 1; + } + + if ($id) { + $profile = ctype_digit((string) $id) + ? Profile::find($id) + : Profile::where('username', $id)->first(); + + if (! $profile) { + $this->error('No profile found for "'.$id.'".'); + + return 1; + } + + $this->resyncOne($profile); + + return 0; + } + + // --all: scan every profile, only touch/report the ones that drifted. + $dryRun = $this->option('dry-run'); + if (! $dryRun && ! $this->confirm('Resync cached counts for all drifted profiles?', true)) { + $this->comment('Aborted.'); + + return 0; + } + + $fixed = 0; + $scanned = 0; + Profile::whereNull('deleted_at')->lazyById(500)->each(function ($profile) use (&$fixed, &$scanned) { + $scanned++; + if ($this->resyncOne($profile)) { + $fixed++; + } + }); + + $this->newLine(); + $this->info('Scanned '.$scanned.' profiles; '.($this->option('dry-run') ? 'drifted' : 'resynced').': '.$fixed.'.'); + + return 0; + } + + /** + * Recompute (or report) the cached counts for a single profile. + * Only emits output when drift is detected; silent otherwise. + * + * @return bool whether the profile was drifted + */ + protected function resyncOne(Profile $profile): bool + { + $liveFollowers = (int) Follower::whereFollowingId($profile->id)->count(); + $liveFollowing = (int) Follower::whereProfileId($profile->id)->count(); + $liveStatuses = (int) $this->liveStatusCount($profile); + + $cachedFollowers = (int) $profile->followers_count; + $cachedFollowing = (int) $profile->following_count; + $cachedStatuses = (int) $profile->status_count; + + $followersDrift = $liveFollowers !== $cachedFollowers; + $followingDrift = $liveFollowing !== $cachedFollowing; + $statusesDrift = $liveStatuses !== $cachedStatuses; + + if (! $followersDrift && ! $followingDrift && ! $statusesDrift) { + // No drift: stay silent. + return false; + } + + // Drift detected: report exactly what drifted. + $this->warn($profile->username.' (id '.$profile->id.') drift detected:'); + if ($followersDrift) { + $this->line(' followers: cached='.$cachedFollowers.' live='.$liveFollowers); + } + if ($followingDrift) { + $this->line(' following: cached='.$cachedFollowing.' live='.$liveFollowing); + } + if ($statusesDrift) { + $this->line(' statuses: cached='.$cachedStatuses.' live='.$liveStatuses); + } + + if ($this->option('dry-run')) { + return true; + } + + // status_count is always recomputed inline (no queue involved). + if ($statusesDrift) { + $profile->status_count = $liveStatuses; + } + + if ($this->option('dispatch') && ($followersDrift || $followingDrift)) { + // Persist any status fix first, then let the warm-cache job own + // the follower/following columns and rebuild the Redis sets. + if ($statusesDrift) { + $profile->save(); + Cache::forget('profile:status_count:'.$profile->id); + } + Cache::forget(FollowerService::FOLLOWERS_SYNC_KEY.$profile->id); + Cache::forget(FollowerService::FOLLOWING_SYNC_KEY.$profile->id); + FollowServiceWarmCache::dispatch($profile->id)->onQueue('low'); + $this->info(' queued FollowServiceWarmCache for profile '.$profile->id.'; statuses='.$profile->status_count.'.'); + + return true; + } + + // Inline recompute of the drifted columns from source-of-truth tables. + if ($followersDrift) { + $profile->followers_count = (int) DB::table('followers')->whereFollowingId($profile->id)->count(); + } + if ($followingDrift) { + $profile->following_count = (int) DB::table('followers')->whereProfileId($profile->id)->count(); + } + $profile->save(); + + // Bust the derived caches so reads reflect the corrected values. + Cache::forget('profile:follower_count:'.$profile->id); + Cache::forget('profile:following_count:'.$profile->id); + Cache::forget('profile:status_count:'.$profile->id); + AccountService::del($profile->id); + + $this->info(' resynced to followers='.$profile->followers_count.', following='.$profile->following_count.', statuses='.$profile->status_count.'.'); + + return true; + } + + /** + * Source-of-truth status count for a profile. + */ + protected function liveStatusCount(Profile $profile): int + { + return $profile->statuses() + ->getQuery() + ->whereIn('scope', self::STATUS_SCOPES) + ->count(); + } +} diff --git a/app/Console/Commands/FixRemotePostCount.php b/app/Console/Commands/FixRemotePostCount.php deleted file mode 100644 index 6ca0a1df6..000000000 --- a/app/Console/Commands/FixRemotePostCount.php +++ /dev/null @@ -1,53 +0,0 @@ -chunk(50, function ($profiles) { - foreach ($profiles as $profile) { - $count = Status::whereNull(['in_reply_to_id', 'reblog_of_id'])->whereProfileId($profile->id)->count(); - $this->info("Checking {$profile->id} {$profile->username} - found {$count} statuses"); - $profile->status_count = $count; - $profile->save(); - } - }); - - return 0; - } -} diff --git a/app/Console/Commands/FixStatusCount.php b/app/Console/Commands/FixStatusCount.php deleted file mode 100644 index 332b24528..000000000 --- a/app/Console/Commands/FixStatusCount.php +++ /dev/null @@ -1,137 +0,0 @@ -confirm('Are you sure you want to run the fix status command?')) { - return; - } - $this->line(' '); - $this->info('Running fix status command...'); - $now = now(); - - $nulls = ['domain', 'status', 'last_fetched_at']; - - $resync = $this->option('resync'); - $resync24hours = false; - - if ($resync) { - $resyncChoices = ['Only resync accounts that havent been synced in 24 hours', 'Resync all accounts']; - $rsc = $this->choice( - 'Do you want to resync all accounts, or just accounts that havent been resynced for 24 hours?', - $resyncChoices, - 0 - ); - $rsci = array_search($rsc, $resyncChoices); - if ($rsci === 0) { - $resync24hours = true; - $nulls = ['status', 'domain', 'last_fetched_at']; - } else { - $resync24hours = false; - $nulls = ['status', 'domain']; - } - } - - $remote = $this->option('remote'); - - if ($remote) { - $ni = array_search('domain', $nulls); - unset($nulls[$ni]); - $ni = array_search('last_fetched_at', $nulls); - unset($nulls[$ni]); - } - - $remoteOnly = $this->option('remote-only'); - - if ($remoteOnly) { - $ni = array_search('domain', $nulls); - unset($nulls[$ni]); - $ni = array_search('last_fetched_at', $nulls); - unset($nulls[$ni]); - $nulls[] = 'user_id'; - } - - $dlog = $this->option('dlog'); - - $nulls = array_values($nulls); - - foreach ( - Profile::when($resync24hours, function ($query, $resync24hours) use ($nulls) { - if (in_array('domain', $nulls)) { - return $query->whereNull('domain') - ->whereNull('last_fetched_at') - ->orWhere('last_fetched_at', '<', now()->subHours(24)); - } else { - return $query->whereNull('last_fetched_at') - ->orWhere('last_fetched_at', '<', now()->subHours(24)); - } - }) - ->when($remoteOnly, function ($query, $remoteOnly) { - return $query->whereNull('last_fetched_at') - ->orWhere('last_fetched_at', '<', now()->subHours(24)); - }) - ->whereNull($nulls) - ->lazyById(50, 'id') as $profile - ) { - $ogc = $profile->status_count; - $upc = $profile->statuses() - ->getQuery() - ->whereIn('scope', ['public', 'private', 'unlisted']) - ->count(); - if ($ogc != $upc) { - $profile->status_count = $upc; - $profile->last_fetched_at = $now; - $profile->save(); - if ($dlog) { - $this->info($profile->id.':'.$profile->username.' : '.$upc); - } - } else { - $profile->last_fetched_at = $now; - $profile->save(); - if ($dlog) { - $this->info($profile->id.':'.$profile->username.' : '.$upc); - } - } - } - - $this->line(' '); - $this->info('Finished fix status count command!'); - - return 0; - } -} From a187ab6639be43ca6381be710bed34f396bf3c53 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 29 Aug 2026 16:33:44 +0930 Subject: [PATCH 3/5] Refactor profile count reconciliation into shared AccountStatService methods Extract canonical source-of-truth count logic into AccountStatService: recalculateStatusCount/FollowerCount/FollowingCount and a reconcileProfileCounts() that fixes only drifted columns and busts caches. Both the scheduled app:account-post-count-stat-update (status-only, its correct scope) and fix:profilecounts now use these instead of duplicating the SQL. Also corrects the status_count definition to match the actual increment logic in StatusEntityLexer/StatusDelete (media post types only: photo/video albums), rather than the previous inconsistent all-statuses / scoped counts that could themselves cause drift. The scheduled updater keeps its incremental, dirty-set design and remains status-only; follower/following stay owned by FollowServiceWarmCache. --- .../Commands/AccountPostCountStatUpdate.php | 22 ++-- app/Console/Commands/FixProfileCounts.php | 91 +++++--------- app/Services/Account/AccountStatService.php | 114 ++++++++++++++++++ 3 files changed, 152 insertions(+), 75 deletions(-) diff --git a/app/Console/Commands/AccountPostCountStatUpdate.php b/app/Console/Commands/AccountPostCountStatUpdate.php index 060664f61..e0d52dd15 100644 --- a/app/Console/Commands/AccountPostCountStatUpdate.php +++ b/app/Console/Commands/AccountPostCountStatUpdate.php @@ -3,7 +3,6 @@ namespace App\Console\Commands; use App\Models\Profile; -use App\Models\Status; use App\Services\Account\AccountStatService; use App\Services\AccountService; use Illuminate\Console\Command; @@ -61,21 +60,18 @@ class AccountPostCountStatUpdate extends Command return; } - $statusCount = Status::whereProfileId($id)->count(); - if ($statusCount != $acct['statuses_count']) { - $profile = Profile::find($id); - if (! $profile) { - AccountStatService::removeFromPostCount($id); - - return; - } - - $profile->status_count = $statusCount; - $profile->save(); + $profile = Profile::find($id); + if (! $profile) { + AccountStatService::removeFromPostCount($id); - AccountService::del($id); + return; } + // Reconcile only the status_count column (this queue is fed by + // status create/delete events). Shared recompute logic lives in + // AccountStatService so it stays consistent with fix:profilecounts. + AccountStatService::reconcileProfileCounts($profile, ['statuses']); + AccountStatService::removeFromPostCount($id); } } diff --git a/app/Console/Commands/FixProfileCounts.php b/app/Console/Commands/FixProfileCounts.php index a82e6c04b..0d3818b4e 100644 --- a/app/Console/Commands/FixProfileCounts.php +++ b/app/Console/Commands/FixProfileCounts.php @@ -3,13 +3,11 @@ namespace App\Console\Commands; use App\Jobs\FollowPipeline\FollowServiceWarmCache; -use App\Models\Follower; use App\Models\Profile; -use App\Services\AccountService; +use App\Services\Account\AccountStatService; use App\Services\FollowerService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; class FixProfileCounts extends Command { @@ -31,13 +29,6 @@ class FixProfileCounts extends Command */ protected $description = 'Resync a profile\'s cached counts (followers, following, statuses) from source-of-truth tables'; - /** - * Visible scopes counted for status_count (matches profile status semantics). - * - * @var array - */ - protected const STATUS_SCOPES = ['public', 'private', 'unlisted']; - /** * Execute the console command. * @@ -107,17 +98,24 @@ class FixProfileCounts extends Command */ protected function resyncOne(Profile $profile): bool { - $liveFollowers = (int) Follower::whereFollowingId($profile->id)->count(); - $liveFollowing = (int) Follower::whereProfileId($profile->id)->count(); - $liveStatuses = (int) $this->liveStatusCount($profile); - - $cachedFollowers = (int) $profile->followers_count; - $cachedFollowing = (int) $profile->following_count; - $cachedStatuses = (int) $profile->status_count; - - $followersDrift = $liveFollowers !== $cachedFollowers; - $followingDrift = $liveFollowing !== $cachedFollowing; - $statusesDrift = $liveStatuses !== $cachedStatuses; + // Compute drift for all three metrics using the canonical + // source-of-truth logic (shared with the scheduled updater). + $followers = [ + 'cached' => (int) $profile->followers_count, + 'live' => AccountStatService::recalculateFollowerCount($profile->id), + ]; + $following = [ + 'cached' => (int) $profile->following_count, + 'live' => AccountStatService::recalculateFollowingCount($profile->id), + ]; + $statuses = [ + 'cached' => (int) $profile->status_count, + 'live' => AccountStatService::recalculateStatusCount($profile->id), + ]; + + $followersDrift = $followers['cached'] !== $followers['live']; + $followingDrift = $following['cached'] !== $following['live']; + $statusesDrift = $statuses['cached'] !== $statuses['live']; if (! $followersDrift && ! $followingDrift && ! $statusesDrift) { // No drift: stay silent. @@ -127,67 +125,36 @@ class FixProfileCounts extends Command // Drift detected: report exactly what drifted. $this->warn($profile->username.' (id '.$profile->id.') drift detected:'); if ($followersDrift) { - $this->line(' followers: cached='.$cachedFollowers.' live='.$liveFollowers); + $this->line(' followers: cached='.$followers['cached'].' live='.$followers['live']); } if ($followingDrift) { - $this->line(' following: cached='.$cachedFollowing.' live='.$liveFollowing); + $this->line(' following: cached='.$following['cached'].' live='.$following['live']); } if ($statusesDrift) { - $this->line(' statuses: cached='.$cachedStatuses.' live='.$liveStatuses); + $this->line(' statuses: cached='.$statuses['cached'].' live='.$statuses['live']); } if ($this->option('dry-run')) { return true; } - // status_count is always recomputed inline (no queue involved). - if ($statusesDrift) { - $profile->status_count = $liveStatuses; - } - if ($this->option('dispatch') && ($followersDrift || $followingDrift)) { - // Persist any status fix first, then let the warm-cache job own - // the follower/following columns and rebuild the Redis sets. - if ($statusesDrift) { - $profile->save(); - Cache::forget('profile:status_count:'.$profile->id); - } + // Fix status_count now (via the shared reconciler), then let the + // warm-cache job own the follower/following columns and rebuild + // the Redis sets. + AccountStatService::reconcileProfileCounts($profile, ['statuses']); Cache::forget(FollowerService::FOLLOWERS_SYNC_KEY.$profile->id); Cache::forget(FollowerService::FOLLOWING_SYNC_KEY.$profile->id); FollowServiceWarmCache::dispatch($profile->id)->onQueue('low'); - $this->info(' queued FollowServiceWarmCache for profile '.$profile->id.'; statuses='.$profile->status_count.'.'); + $this->info(' queued FollowServiceWarmCache for profile '.$profile->id.'; statuses='.$statuses['live'].'.'); return true; } - // Inline recompute of the drifted columns from source-of-truth tables. - if ($followersDrift) { - $profile->followers_count = (int) DB::table('followers')->whereFollowingId($profile->id)->count(); - } - if ($followingDrift) { - $profile->following_count = (int) DB::table('followers')->whereProfileId($profile->id)->count(); - } - $profile->save(); - - // Bust the derived caches so reads reflect the corrected values. - Cache::forget('profile:follower_count:'.$profile->id); - Cache::forget('profile:following_count:'.$profile->id); - Cache::forget('profile:status_count:'.$profile->id); - AccountService::del($profile->id); - + // Inline recompute of all drifted columns via the shared reconciler. + AccountStatService::reconcileProfileCounts($profile); $this->info(' resynced to followers='.$profile->followers_count.', following='.$profile->following_count.', statuses='.$profile->status_count.'.'); return true; } - - /** - * Source-of-truth status count for a profile. - */ - protected function liveStatusCount(Profile $profile): int - { - return $profile->statuses() - ->getQuery() - ->whereIn('scope', self::STATUS_SCOPES) - ->count(); - } } diff --git a/app/Services/Account/AccountStatService.php b/app/Services/Account/AccountStatService.php index 4cb794659..7d04e613d 100644 --- a/app/Services/Account/AccountStatService.php +++ b/app/Services/Account/AccountStatService.php @@ -2,12 +2,126 @@ namespace App\Services\Account; +use App\Models\Follower; +use App\Models\Profile; +use App\Models\Status; +use App\Services\AccountService; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Redis; class AccountStatService { const REFRESH_CACHE_KEY = 'pf:services:accountstats:refresh:daily'; + /** + * Status types that count toward a profile's status_count. + * + * Must mirror the increment/decrement logic in StatusEntityLexer and + * StatusDelete, which only adjust status_count for media post types. + * + * @var array + */ + const COUNTABLE_STATUS_TYPES = [ + 'photo', + 'photo:album', + 'video', + 'video:album', + 'photo:video:album', + ]; + + /** + * Canonical source-of-truth status_count for a profile. + */ + public static function recalculateStatusCount($pid): int + { + return (int) Status::whereProfileId($pid) + ->whereIn('type', self::COUNTABLE_STATUS_TYPES) + ->count(); + } + + /** + * Canonical source-of-truth follower count for a profile. + */ + public static function recalculateFollowerCount($pid): int + { + return (int) Follower::whereFollowingId($pid)->count(); + } + + /** + * Canonical source-of-truth following count for a profile. + */ + public static function recalculateFollowingCount($pid): int + { + return (int) Follower::whereProfileId($pid)->count(); + } + + /** + * Reconcile a profile's cached count columns against source-of-truth + * tables. Only writes/clears caches for columns that actually drifted. + * + * @param array $only Restrict to a subset of + * ['statuses','followers','following']. + * @return array + * Per-metric before/after summary. + */ + public static function reconcileProfileCounts($profile, array $only = ['statuses', 'followers', 'following']): array + { + if (! $profile instanceof Profile) { + $profile = Profile::find($profile); + } + + if (! $profile) { + return []; + } + + $summary = []; + $changed = false; + + if (in_array('statuses', $only, true)) { + $cached = (int) $profile->status_count; + $live = self::recalculateStatusCount($profile->id); + $drift = $cached !== $live; + if ($drift) { + $profile->status_count = $live; + $changed = true; + } + $summary['statuses'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; + } + + if (in_array('followers', $only, true)) { + $cached = (int) $profile->followers_count; + $live = self::recalculateFollowerCount($profile->id); + $drift = $cached !== $live; + if ($drift) { + $profile->followers_count = $live; + $changed = true; + } + $summary['followers'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; + } + + if (in_array('following', $only, true)) { + $cached = (int) $profile->following_count; + $live = self::recalculateFollowingCount($profile->id); + $drift = $cached !== $live; + if ($drift) { + $profile->following_count = $live; + $changed = true; + } + $summary['following'] = ['cached' => $cached, 'live' => $live, 'drifted' => $drift]; + } + + if ($changed) { + $profile->save(); + + Cache::forget('profile:status_count:'.$profile->id); + Cache::forget('profile:follower_count:'.$profile->id); + Cache::forget('profile:following_count:'.$profile->id); + AccountService::del($profile->id); + } + + return $summary; + } + public static function incrementPostCount($pid) { return Redis::zadd(self::REFRESH_CACHE_KEY, $pid, $pid); From 698ba224e38d76fbc8105de92d1888c18ebac595 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 29 Aug 2026 16:37:10 +0930 Subject: [PATCH 4/5] Schedule weekly profile-count reconcile and add reconciliation tests - Add --force flag to fix:profilecounts for unattended runs and schedule 'fix:profilecounts --all --force' weekly (Sun 03:37) as a safety-net reconcile. Kept as a low-frequency full scan rather than a new event-driven dirty-set; it only writes profiles that actually drifted. - Add Feature tests for AccountStatService recompute helpers and reconcileProfileCounts (media-type status_count semantics, follower/ following counts, drift/no-drift/no-write, metric restriction, missing profile) plus fix:profilecounts command behavior (silent-when-synced, dry-run makes no changes). --- app/Console/Commands/FixProfileCounts.php | 5 +- .../Account/ProfileCountReconcileTest.php | 162 ++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/Account/ProfileCountReconcileTest.php diff --git a/app/Console/Commands/FixProfileCounts.php b/app/Console/Commands/FixProfileCounts.php index 0d3818b4e..9cc583d17 100644 --- a/app/Console/Commands/FixProfileCounts.php +++ b/app/Console/Commands/FixProfileCounts.php @@ -20,7 +20,8 @@ class FixProfileCounts extends Command {id? : Profile id or username to resync (omit with --all)} {--all : Scan all profiles and resync any with drifted counts} {--dispatch : Queue FollowServiceWarmCache for follower/following instead of recomputing inline} - {--dry-run : Report drift without changing anything}'; + {--dry-run : Report drift without changing anything} + {--force : Skip the confirmation prompt (for scheduled/unattended runs)}'; /** * The console command description. @@ -69,7 +70,7 @@ class FixProfileCounts extends Command // --all: scan every profile, only touch/report the ones that drifted. $dryRun = $this->option('dry-run'); - if (! $dryRun && ! $this->confirm('Resync cached counts for all drifted profiles?', true)) { + if (! $dryRun && ! $this->option('force') && ! $this->confirm('Resync cached counts for all drifted profiles?', true)) { $this->comment('Aborted.'); return 0; diff --git a/tests/Feature/Account/ProfileCountReconcileTest.php b/tests/Feature/Account/ProfileCountReconcileTest.php new file mode 100644 index 000000000..598ac7976 --- /dev/null +++ b/tests/Feature/Account/ProfileCountReconcileTest.php @@ -0,0 +1,162 @@ +create(); + $user->refresh(); + $pid = $user->profile->id; + + // 2 photos + 1 video = 3 countable; text + reply are NOT counted. + Status::factory()->count(2)->photo()->create(['profile_id' => $pid]); + Status::factory()->video()->create(['profile_id' => $pid]); + Status::factory()->create(['profile_id' => $pid, 'type' => 'text']); + Status::factory()->reply()->create(['profile_id' => $pid]); + + expect(AccountStatService::recalculateStatusCount($pid))->toBe(3); + }); + + it('counts followers and following from the followers table', function () { + $a = User::factory()->create(); + $a->refresh(); + $b = User::factory()->create(); + $b->refresh(); + $c = User::factory()->create(); + $c->refresh(); + + // b and c follow a; a follows c. + Follower::create(['profile_id' => $b->profile->id, 'following_id' => $a->profile->id]); + Follower::create(['profile_id' => $c->profile->id, 'following_id' => $a->profile->id]); + Follower::create(['profile_id' => $a->profile->id, 'following_id' => $c->profile->id]); + + expect(AccountStatService::recalculateFollowerCount($a->profile->id))->toBe(2); + expect(AccountStatService::recalculateFollowingCount($a->profile->id))->toBe(1); + }); +}); + +describe('AccountStatService::reconcileProfileCounts', function () { + it('fixes all drifted columns and reports the summary', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + + Status::factory()->count(2)->photo()->create(['profile_id' => $profile->id]); + + // Deliberately set wrong cached values. + $profile->status_count = 99; + $profile->followers_count = 42; + $profile->following_count = 7; + $profile->save(); + + $summary = AccountStatService::reconcileProfileCounts($profile->fresh()); + + expect($summary['statuses']['drifted'])->toBeTrue(); + expect($summary['statuses']['live'])->toBe(2); + expect($summary['followers']['live'])->toBe(0); + expect($summary['following']['live'])->toBe(0); + + $profile->refresh(); + expect((int) $profile->status_count)->toBe(2); + expect((int) $profile->followers_count)->toBe(0); + expect((int) $profile->following_count)->toBe(0); + }); + + it('reports no drift and writes nothing when counts are correct', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + + Status::factory()->photo()->create(['profile_id' => $profile->id]); + $profile->status_count = 1; + $profile->followers_count = 0; + $profile->following_count = 0; + $profile->save(); + $updatedAt = $profile->fresh()->updated_at; + + $summary = AccountStatService::reconcileProfileCounts($profile->fresh()); + + expect($summary['statuses']['drifted'])->toBeFalse(); + expect($summary['followers']['drifted'])->toBeFalse(); + expect($summary['following']['drifted'])->toBeFalse(); + + // No write should have occurred (updated_at unchanged). + expect($profile->fresh()->updated_at->eq($updatedAt))->toBeTrue(); + }); + + it('restricts reconciliation to the requested metrics only', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + + Status::factory()->count(3)->photo()->create(['profile_id' => $profile->id]); + + $profile->status_count = 0; // drifted, should be fixed + $profile->followers_count = 50; // drifted, but must be left alone + $profile->save(); + + $summary = AccountStatService::reconcileProfileCounts($profile->fresh(), ['statuses']); + + expect($summary)->toHaveKey('statuses'); + expect($summary)->not->toHaveKey('followers'); + + $profile->refresh(); + expect((int) $profile->status_count)->toBe(3); + expect((int) $profile->followers_count)->toBe(50); + }); + + it('returns an empty summary for a missing profile id', function () { + expect(AccountStatService::reconcileProfileCounts(999999999999))->toBe([]); + }); +}); + +describe('fix:profilecounts command', function () { + it('is silent for an in-sync profile and reports drift otherwise', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + Status::factory()->count(2)->photo()->create(['profile_id' => $profile->id]); + $profile->status_count = 5; // drift + $profile->save(); + + $this->artisan('fix:profilecounts', ['id' => (string) $profile->id]) + ->expectsOutputToContain('drift detected') + ->assertExitCode(0); + + // Now in sync -> no drift output. + $this->artisan('fix:profilecounts', ['id' => (string) $profile->id]) + ->doesntExpectOutputToContain('drift detected') + ->assertExitCode(0); + }); + + it('does not modify anything in dry-run mode', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + Status::factory()->photo()->create(['profile_id' => $profile->id]); + $profile->status_count = 88; + $profile->save(); + + $this->artisan('fix:profilecounts', ['id' => (string) $profile->id, '--dry-run' => true]) + ->assertExitCode(0); + + expect((int) $profile->fresh()->status_count)->toBe(88); + }); +}); From 96f26405f1eef5b0e403d00da3311a91cbb2f650 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 29 Aug 2026 16:47:12 +0930 Subject: [PATCH 5/5] Rename to admin:fixProfileCounts, make --active its own mode, add --type - Rename command signature fix:profilecounts -> admin:fixProfileCounts. - --active is now its own bulk mode (recently-active local accounts), mutually exclusive with --all and a single id. - Add --type=followers|following|statuses to restrict reconciliation to a single metric (validated). - Update/extend tests for the new name, --type restriction and invalid-type rejection. --- app/Console/Commands/FixProfileCounts.php | 166 +++++++++++++----- .../Account/ProfileCountReconcileTest.php | 37 +++- 2 files changed, 152 insertions(+), 51 deletions(-) diff --git a/app/Console/Commands/FixProfileCounts.php b/app/Console/Commands/FixProfileCounts.php index 9cc583d17..50698fb9c 100644 --- a/app/Console/Commands/FixProfileCounts.php +++ b/app/Console/Commands/FixProfileCounts.php @@ -16,19 +16,33 @@ class FixProfileCounts extends Command * * @var string */ - protected $signature = 'fix:profilecounts + protected $signature = 'admin:fixProfileCounts {id? : Profile id or username to resync (omit with --all)} {--all : Scan all profiles and resync any with drifted counts} + {--active=* : Scan only local accounts active within N days (default 30). Bulk mode; mutually exclusive with --all} + {--type= : Restrict to a single metric: followers, following, or statuses (default: all three)} {--dispatch : Queue FollowServiceWarmCache for follower/following instead of recomputing inline} {--dry-run : Report drift without changing anything} {--force : Skip the confirmation prompt (for scheduled/unattended runs)}'; + /** + * Default active window (days) when --active is passed without a value. + */ + protected const DEFAULT_ACTIVE_DAYS = 30; + + /** + * Metrics this command can reconcile. + * + * @var array + */ + protected const METRICS = ['followers', 'following', 'statuses']; + /** * The console command description. * * @var string */ - protected $description = 'Resync a profile\'s cached counts (followers, following, statuses) from source-of-truth tables'; + protected $description = 'Resync a profile\'s cached counts (followers, following, statuses) from source-of-truth tables. Use --all or --active for bulk reconciliation.'; /** * Execute the console command. @@ -39,15 +53,27 @@ class FixProfileCounts extends Command { $id = $this->argument('id'); $all = $this->option('all'); + $activeDays = $this->resolveActiveDays(); + $active = $activeDays !== null; + + // Exactly one mode: a single id, --all, or --active. + $modes = (int) (bool) $id + (int) $all + (int) $active; - if (! $id && ! $all) { - $this->error('Provide a profile id/username, or pass --all.'); + if ($modes === 0) { + $this->error('Provide a profile id/username, or pass --all or --active.'); return 1; } - if ($id && $all) { - $this->error('Pass either an id or --all, not both.'); + if ($modes > 1) { + $this->error('Pass only one of: , --all, or --active.'); + + return 1; + } + + $type = $this->option('type'); + if ($type !== null && ! in_array($type, self::METRICS, true)) { + $this->error('Invalid --type "'.$type.'". Use one of: '.implode(', ', self::METRICS).'.'); return 1; } @@ -68,17 +94,35 @@ class FixProfileCounts extends Command return 0; } - // --all: scan every profile, only touch/report the ones that drifted. + // Bulk mode (--all or --active): scan and only touch drifted profiles. $dryRun = $this->option('dry-run'); - if (! $dryRun && ! $this->option('force') && ! $this->confirm('Resync cached counts for all drifted profiles?', true)) { + + $scope = $active + ? 'local accounts active in the last '.$activeDays.' days' + : 'all drifted profiles'; + + if (! $dryRun && ! $this->option('force') && ! $this->confirm('Resync cached counts for '.$scope.'?', true)) { $this->comment('Aborted.'); return 0; } + $query = Profile::whereNull('deleted_at'); + + if ($active) { + // Restrict to LOCAL profiles whose linked user logged in recently. + // Remote profiles have no user row, so they are excluded here. + $cutoff = now()->subDays($activeDays); + $query->whereNotNull('user_id') + ->whereHas('user', function ($q) use ($cutoff) { + $q->whereNotNull('last_active_at') + ->where('last_active_at', '>=', $cutoff); + }); + } + $fixed = 0; $scanned = 0; - Profile::whereNull('deleted_at')->lazyById(500)->each(function ($profile) use (&$fixed, &$scanned) { + $query->lazyById(500)->each(function ($profile) use (&$fixed, &$scanned) { $scanned++; if ($this->resyncOne($profile)) { $fixed++; @@ -86,11 +130,34 @@ class FixProfileCounts extends Command }); $this->newLine(); - $this->info('Scanned '.$scanned.' profiles; '.($this->option('dry-run') ? 'drifted' : 'resynced').': '.$fixed.'.'); + $this->info('Scanned '.$scanned.' profiles ('.$scope.'); '.($this->option('dry-run') ? 'drifted' : 'resynced').': '.$fixed.'.'); return 0; } + /** + * Resolve the --active window in days, or null if the flag was not passed. + */ + protected function resolveActiveDays(): ?int + { + $values = (array) $this->option('active'); + + if (empty($values)) { + return null; + } + + $last = end($values); + + // `--active` with no value arrives as an empty string / null. + if ($last === null || $last === '') { + return self::DEFAULT_ACTIVE_DAYS; + } + + $days = (int) $last; + + return $days > 0 ? $days : self::DEFAULT_ACTIVE_DAYS; + } + /** * Recompute (or report) the cached counts for a single profile. * Only emits output when drift is detected; silent otherwise. @@ -99,61 +166,68 @@ class FixProfileCounts extends Command */ protected function resyncOne(Profile $profile): bool { - // Compute drift for all three metrics using the canonical + // Which metrics to consider: a single --type, or all three. + $type = $this->option('type'); + $metrics = $type !== null ? [$type] : self::METRICS; + + // Compute drift only for the selected metrics, using the canonical // source-of-truth logic (shared with the scheduled updater). - $followers = [ - 'cached' => (int) $profile->followers_count, - 'live' => AccountStatService::recalculateFollowerCount($profile->id), - ]; - $following = [ - 'cached' => (int) $profile->following_count, - 'live' => AccountStatService::recalculateFollowingCount($profile->id), - ]; - $statuses = [ - 'cached' => (int) $profile->status_count, - 'live' => AccountStatService::recalculateStatusCount($profile->id), - ]; - - $followersDrift = $followers['cached'] !== $followers['live']; - $followingDrift = $following['cached'] !== $following['live']; - $statusesDrift = $statuses['cached'] !== $statuses['live']; - - if (! $followersDrift && ! $followingDrift && ! $statusesDrift) { - // No drift: stay silent. + $drift = []; + if (in_array('followers', $metrics, true)) { + $drift['followers'] = [ + 'cached' => (int) $profile->followers_count, + 'live' => AccountStatService::recalculateFollowerCount($profile->id), + ]; + } + if (in_array('following', $metrics, true)) { + $drift['following'] = [ + 'cached' => (int) $profile->following_count, + 'live' => AccountStatService::recalculateFollowingCount($profile->id), + ]; + } + if (in_array('statuses', $metrics, true)) { + $drift['statuses'] = [ + 'cached' => (int) $profile->status_count, + 'live' => AccountStatService::recalculateStatusCount($profile->id), + ]; + } + + $drifted = array_filter($drift, fn ($m) => $m['cached'] !== $m['live']); + + if (empty($drifted)) { + // No drift on the selected metrics: stay silent. return false; } // Drift detected: report exactly what drifted. $this->warn($profile->username.' (id '.$profile->id.') drift detected:'); - if ($followersDrift) { - $this->line(' followers: cached='.$followers['cached'].' live='.$followers['live']); - } - if ($followingDrift) { - $this->line(' following: cached='.$following['cached'].' live='.$following['live']); - } - if ($statusesDrift) { - $this->line(' statuses: cached='.$statuses['cached'].' live='.$statuses['live']); + foreach ($drifted as $metric => $m) { + $this->line(' '.str_pad($metric.':', 11).'cached='.$m['cached'].' live='.$m['live']); } if ($this->option('dry-run')) { return true; } - if ($this->option('dispatch') && ($followersDrift || $followingDrift)) { - // Fix status_count now (via the shared reconciler), then let the - // warm-cache job own the follower/following columns and rebuild - // the Redis sets. - AccountStatService::reconcileProfileCounts($profile, ['statuses']); + $followOrFollowingDrift = isset($drifted['followers']) || isset($drifted['following']); + + if ($this->option('dispatch') && $followOrFollowingDrift) { + // Fix status_count now (if in scope), then let the warm-cache job + // own the follower/following columns and rebuild the Redis sets. + if (isset($drift['statuses'])) { + AccountStatService::reconcileProfileCounts($profile, ['statuses']); + } Cache::forget(FollowerService::FOLLOWERS_SYNC_KEY.$profile->id); Cache::forget(FollowerService::FOLLOWING_SYNC_KEY.$profile->id); FollowServiceWarmCache::dispatch($profile->id)->onQueue('low'); - $this->info(' queued FollowServiceWarmCache for profile '.$profile->id.'; statuses='.$statuses['live'].'.'); + $this->info(' queued FollowServiceWarmCache for profile '.$profile->id.'.'); return true; } - // Inline recompute of all drifted columns via the shared reconciler. - AccountStatService::reconcileProfileCounts($profile); + // Inline recompute of the selected metrics via the shared reconciler. + AccountStatService::reconcileProfileCounts($profile, $metrics); + $profile->refresh(); $this->info(' resynced to followers='.$profile->followers_count.', following='.$profile->following_count.', statuses='.$profile->status_count.'.'); return true; diff --git a/tests/Feature/Account/ProfileCountReconcileTest.php b/tests/Feature/Account/ProfileCountReconcileTest.php index 598ac7976..5ef3ce6b2 100644 --- a/tests/Feature/Account/ProfileCountReconcileTest.php +++ b/tests/Feature/Account/ProfileCountReconcileTest.php @@ -14,7 +14,7 @@ uses(LazilyRefreshDatabase::class); |-------------------------------------------------------------------------- | | Tests for the shared AccountStatService recompute helpers used by both the -| scheduled app:account-post-count-stat-update command and fix:profilecounts. +| scheduled app:account-post-count-stat-update command and admin:fixProfileCounts. | status_count must mirror the increment logic (media post types only). | */ @@ -127,7 +127,7 @@ describe('AccountStatService::reconcileProfileCounts', function () { }); }); -describe('fix:profilecounts command', function () { +describe('admin:fixProfileCounts command', function () { it('is silent for an in-sync profile and reports drift otherwise', function () { $user = User::factory()->create(); $user->refresh(); @@ -136,12 +136,12 @@ describe('fix:profilecounts command', function () { $profile->status_count = 5; // drift $profile->save(); - $this->artisan('fix:profilecounts', ['id' => (string) $profile->id]) + $this->artisan('admin:fixProfileCounts', ['id' => (string) $profile->id]) ->expectsOutputToContain('drift detected') ->assertExitCode(0); // Now in sync -> no drift output. - $this->artisan('fix:profilecounts', ['id' => (string) $profile->id]) + $this->artisan('admin:fixProfileCounts', ['id' => (string) $profile->id]) ->doesntExpectOutputToContain('drift detected') ->assertExitCode(0); }); @@ -154,9 +154,36 @@ describe('fix:profilecounts command', function () { $profile->status_count = 88; $profile->save(); - $this->artisan('fix:profilecounts', ['id' => (string) $profile->id, '--dry-run' => true]) + $this->artisan('admin:fixProfileCounts', ['id' => (string) $profile->id, '--dry-run' => true]) ->assertExitCode(0); expect((int) $profile->fresh()->status_count)->toBe(88); }); + + it('with --type restricts the fix to a single metric', function () { + $user = User::factory()->create(); + $user->refresh(); + $profile = $user->profile; + Status::factory()->count(2)->photo()->create(['profile_id' => $profile->id]); + + // All three drifted. + $profile->status_count = 42; + $profile->followers_count = 100; + $profile->following_count = 50; + $profile->save(); + + $this->artisan('admin:fixProfileCounts', ['id' => (string) $profile->id, '--type' => 'statuses']) + ->assertExitCode(0); + + $profile->refresh(); + // Only statuses reconciled; followers/following left untouched. + expect((int) $profile->status_count)->toBe(2); + expect((int) $profile->followers_count)->toBe(100); + expect((int) $profile->following_count)->toBe(50); + }); + + it('rejects an invalid --type', function () { + $this->artisan('admin:fixProfileCounts', ['id' => '1', '--type' => 'bogus']) + ->assertExitCode(1); + }); });