From 698ba224e38d76fbc8105de92d1888c18ebac595 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 29 Aug 2026 16:37:10 +0930 Subject: [PATCH] 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); + }); +});