diff --git a/app/Console/Commands/PruneStaleDeviceTokens.php b/app/Console/Commands/PruneStaleDeviceTokens.php new file mode 100644 index 0000000..a1c60ec --- /dev/null +++ b/app/Console/Commands/PruneStaleDeviceTokens.php @@ -0,0 +1,35 @@ +option('days'), FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1], + ]); + + if ($days === false) { + $this->error('The --days option must be an integer greater than zero.'); + + return self::FAILURE; + } + + $deleted = DeviceToken::query() + ->where('updated_at', '<', now()->subDays($days)) + ->delete(); + + $this->info("{$deleted} stale device token(s) pruned."); + + return self::SUCCESS; + } +} diff --git a/config/sanctum.php b/config/sanctum.php index 44527d6..adb4e30 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -47,7 +47,7 @@ return [ | */ - 'expiration' => null, + 'expiration' => 129600, /* |-------------------------------------------------------------------------- diff --git a/routes/console.php b/routes/console.php index 370ca60..519c2b2 100644 --- a/routes/console.php +++ b/routes/console.php @@ -17,6 +17,10 @@ Schedule::command('auth:clear-resets') ->daily() ->withoutOverlapping() ->onOneServer(); +Schedule::command('device-tokens:prune-stale --days=180') + ->daily() + ->withoutOverlapping() + ->onOneServer(); Schedule::command('meal-posts:generate-ai') ->daily() ->withoutOverlapping(55) diff --git a/tests/Feature/DeviceTokenPruningTest.php b/tests/Feature/DeviceTokenPruningTest.php new file mode 100644 index 0000000..25c8c1b --- /dev/null +++ b/tests/Feature/DeviceTokenPruningTest.php @@ -0,0 +1,54 @@ +travelTo(now()); + + $user = User::factory()->create(); + $staleToken = $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[stale]', + 'platform' => 'ios', + ]); + $recentToken = $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[recent]', + 'platform' => 'android', + ]); + $staleToken->forceFill([ + 'created_at' => now()->subDays(181), + 'updated_at' => now()->subDays(181), + ])->saveQuietly(); + $recentToken->forceFill([ + 'created_at' => now()->subDays(179), + 'updated_at' => now()->subDays(179), + ])->saveQuietly(); + + $this->artisan('device-tokens:prune-stale --days=180') + ->expectsOutput('1 stale device token(s) pruned.') + ->assertExitCode(0); + + expect(DeviceToken::query()->whereKey($staleToken->getKey())->exists())->toBeFalse() + ->and(DeviceToken::query()->whereKey($recentToken->getKey())->exists())->toBeTrue(); +}); + +it('rejects invalid retention days without deleting device tokens', function () { + $user = User::factory()->create(); + $deviceToken = $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[kept]', + 'platform' => 'ios', + ]); + $deviceToken->forceFill([ + 'created_at' => now()->subYear(), + 'updated_at' => now()->subYear(), + ])->saveQuietly(); + + $this->artisan('device-tokens:prune-stale --days=0') + ->expectsOutput('The --days option must be an integer greater than zero.') + ->assertExitCode(1); + + expect(DeviceToken::query()->whereKey($deviceToken->getKey())->exists())->toBeTrue(); +}); diff --git a/tests/Feature/ScheduledAuthCleanupTest.php b/tests/Feature/ScheduledAuthCleanupTest.php index 3b14cf9..8da742c 100644 --- a/tests/Feature/ScheduledAuthCleanupTest.php +++ b/tests/Feature/ScheduledAuthCleanupTest.php @@ -2,7 +2,7 @@ use Illuminate\Support\Facades\Artisan; -it('schedules expired auth token cleanup commands', function () { +it('schedules expired auth and stale device token cleanup commands', function () { Artisan::call('schedule:list', [ '--json' => true, ]); @@ -12,5 +12,6 @@ it('schedules expired auth token cleanup commands', function () { expect($commands) ->toContain('php artisan sanctum:prune-expired --hours=24') - ->toContain('php artisan auth:clear-resets'); + ->toContain('php artisan auth:clear-resets') + ->toContain('php artisan device-tokens:prune-stale --days=180'); });