create($attrs); }); } // ─── Snapshot Collection Command ─────────────────────────────────────────── it('nova:metrics-snapshot-hourly runs without errors', function () { $this->artisan('nova:metrics-snapshot-hourly --dry-run') ->assertSuccessful(); }); it('creates snapshot rows for eligible artworks', function () { $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDay(), ]); ArtworkStats::upsert([ [ 'artwork_id' => $artwork->id, 'views' => 100, 'downloads' => 10, 'favorites' => 5, 'comments_count' => 2, 'shares_count' => 1, ], ], ['artwork_id']); $this->artisan('nova:metrics-snapshot-hourly') ->assertSuccessful(); $snapshot = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->first(); expect($snapshot)->not->toBeNull(); expect((int) $snapshot->views_count)->toBe(100); expect((int) $snapshot->downloads_count)->toBe(10); expect((int) $snapshot->favourites_count)->toBe(5); }); it('upserts on duplicate bucket_hour', function () { $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDay(), ]); ArtworkStats::upsert([ [ 'artwork_id' => $artwork->id, 'views' => 50, 'downloads' => 5, 'favorites' => 2, ], ], ['artwork_id']); // Run twice — should not throw $this->artisan('nova:metrics-snapshot-hourly')->assertSuccessful(); // Update stats and run again ArtworkStats::where('artwork_id', $artwork->id)->update(['views' => 75]); $this->artisan('nova:metrics-snapshot-hourly')->assertSuccessful(); $count = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count(); expect($count)->toBe(1); // upserted, not duplicated $snapshot = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->first(); expect((int) $snapshot->views_count)->toBe(75); }); // ─── Heat Recalculation Command ──────────────────────────────────────────── it('nova:recalculate-heat runs without errors', function () { $this->artisan('nova:recalculate-heat --dry-run') ->assertSuccessful(); }); it('computes heat_score from snapshot deltas', function () { $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subHours(2), ]); ArtworkStats::upsert([ ['artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0], ], ['artwork_id']); $prevHour = now()->startOfHour()->subHour(); $currentHour = now()->startOfHour(); // Previous hour snapshot ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => $prevHour, 'views_count' => 10, 'downloads_count' => 2, 'favourites_count' => 1, 'comments_count' => 0, 'shares_count' => 0, ]); // Current hour snapshot (engagement grew) ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => $currentHour, 'views_count' => 30, 'downloads_count' => 5, 'favourites_count' => 4, 'comments_count' => 2, 'shares_count' => 1, ]); $this->artisan('nova:recalculate-heat') ->assertSuccessful(); $stat = ArtworkStats::where('artwork_id', $artwork->id)->first(); expect((float) $stat->heat_score)->toBeGreaterThan(0); // Verify delta values cached on stats expect((int) $stat->views_1h)->toBe(20); // 30 - 10 expect((int) $stat->downloads_1h)->toBe(3); // 5 - 2 expect((int) $stat->favourites_1h)->toBe(3); // 4 - 1 expect((int) $stat->comments_1h)->toBe(2); // 2 - 0 expect((int) $stat->shares_1h)->toBe(1); // 1 - 0 }); it('handles negative deltas gracefully by clamping to zero', function () { $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subHour(), ]); ArtworkStats::upsert([ ['artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0], ], ['artwork_id']); $prevHour = now()->startOfHour()->subHour(); $currentHour = now()->startOfHour(); // Simulate counter reset: current < previous ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => $prevHour, 'views_count' => 100, 'downloads_count' => 50, 'favourites_count' => 20, 'comments_count' => 10, 'shares_count' => 5, ]); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => $currentHour, 'views_count' => 50, // < prev 'downloads_count' => 30, // < prev 'favourites_count' => 10, // < prev 'comments_count' => 5, // < prev 'shares_count' => 2, // < prev ]); $this->artisan('nova:recalculate-heat') ->assertSuccessful(); $stat = ArtworkStats::where('artwork_id', $artwork->id)->first(); expect((float) $stat->heat_score)->toBe(0.0); // all deltas negative → clamped to 0 expect((int) $stat->views_1h)->toBe(0); expect((int) $stat->downloads_1h)->toBe(0); }); // ─── Pruning Command ────────────────────────────────────────────────────── it('nova:prune-metric-snapshots removes old data', function () { $artwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDays(30), ]); // Old snapshot (10 days ago) ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subDays(10)->startOfHour(), 'views_count' => 50, 'downloads_count' => 5, 'favourites_count' => 2, 'comments_count' => 0, 'shares_count' => 0, ]); // Recent snapshot (1 hour ago) ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artwork->id, 'bucket_hour' => now()->subHour()->startOfHour(), 'views_count' => 100, 'downloads_count' => 10, 'favourites_count' => 5, 'comments_count' => 1, 'shares_count' => 0, ]); $this->artisan('nova:prune-metric-snapshots --keep-days=7') ->assertSuccessful(); $remaining = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count(); expect($remaining)->toBe(1); // only the recent one survives }); // ─── Heat Formula Unit Check ─────────────────────────────────────────────── it('heat formula applies age factor correctly', function () { // Newer artwork should get higher heat than older one with same deltas $newArtwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subHours(1), 'created_at' => now()->subHours(1), ]); $oldArtwork = createArtworkWithoutObserver([ 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDays(30), 'created_at' => now()->subDays(30), ]); $prevHour = now()->startOfHour()->subHour(); $currentHour = now()->startOfHour(); foreach ([$newArtwork, $oldArtwork] as $art) { ArtworkStats::upsert([ ['artwork_id' => $art->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0], ], ['artwork_id']); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $art->id, 'bucket_hour' => $prevHour, 'views_count' => 0, 'downloads_count' => 0, 'favourites_count' => 0, 'comments_count' => 0, 'shares_count' => 0, ]); ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $art->id, 'bucket_hour' => $currentHour, 'views_count' => 100, 'downloads_count' => 10, 'favourites_count' => 5, 'comments_count' => 3, 'shares_count' => 2, ]); } $this->artisan('nova:recalculate-heat')->assertSuccessful(); $newStat = ArtworkStats::where('artwork_id', $newArtwork->id)->first(); $oldStat = ArtworkStats::where('artwork_id', $oldArtwork->id)->first(); expect((float) $newStat->heat_score)->toBeGreaterThan(0); expect((float) $oldStat->heat_score)->toBeGreaterThan(0); // Newer artwork should have higher heat score due to age factor expect((float) $newStat->heat_score)->toBeGreaterThan((float) $oldStat->heat_score); });