true, 'is_public' => true, 'published_at' => now()->subDays(40), ]); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subDays(40)->startOfHour(), 'views_count' => 10, 'downloads_count' => 1, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subDays(10)->startOfHour(), 'views_count' => 20, 'downloads_count' => 2, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subHour()->startOfHour(), 'views_count' => 30, 'downloads_count' => 3, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); $this->artisan('nova:prune-metric-snapshots --keep-days=30 --sleep-ms=0') ->assertSuccessful(); $hours = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id) ->orderBy('bucket_hour') ->pluck('views_count') ->all(); expect($hours)->toBe([20, 30]); }); it('deletes in bounded batches and is idempotent', function () { $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDays(20), ]); foreach (range(1, 5) as $daysAgo) { ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subDays($daysAgo + 10)->startOfHour(), 'views_count' => $daysAgo, 'downloads_count' => 0, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); } $this->artisan('nova:prune-metric-snapshots --keep-days=7 --chunk=2 --sleep-ms=0') ->assertSuccessful(); expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(0); $this->artisan('nova:prune-metric-snapshots --keep-days=7 --chunk=2 --sleep-ms=0') ->assertSuccessful(); expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(0); }); it('dry-run does not delete rows', function () { $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDays(20), ]); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subDays(14)->startOfHour(), 'views_count' => 1, 'downloads_count' => 0, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); $this->artisan('nova:prune-metric-snapshots --keep-days=7 --dry-run') ->assertSuccessful(); expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(1); }); it('uses config retention when keep-days is omitted', function () { config(['metrics.hourly_snapshot_retention_days' => 7]); $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDays(20), ]); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subDays(10)->startOfHour(), 'views_count' => 1, 'downloads_count' => 0, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subDays(2)->startOfHour(), 'views_count' => 2, 'downloads_count' => 0, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); $this->artisan('nova:prune-metric-snapshots --sleep-ms=0') ->assertSuccessful(); expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(1); }); it('honours max-batches so a run cannot delete the whole table at once', function () { $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDays(20), ]); foreach (range(1, 4) as $n) { ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subDays(10 + $n)->startOfHour(), 'views_count' => $n, 'downloads_count' => 0, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); } $this->artisan('nova:prune-metric-snapshots --keep-days=7 --chunk=1 --max-batches=2 --sleep-ms=0') ->assertSuccessful(); expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(2); });