\App\Models\Artwork::factory()->create([ 'user_id' => $userId, 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDays(40), 'deleted_at' => null, ])); } function insertHourlySnapshot(int $artworkId, $bucketHour, int $views, int $favourites = 0, int $shares = 0, int $comments = 0, int $downloads = 0): void { ArtworkMetricSnapshotHourly::create([ 'artwork_id' => $artworkId, 'bucket_hour' => $bucketHour, 'views_count' => $views, 'downloads_count' => $downloads, 'favourites_count' => $favourites, 'comments_count' => $comments, 'shares_count' => $shares, ]); } it('computes 30d views as max-min per artwork not sum of cumulatives', function () { $user = \App\Models\User::factory()->create(); $artwork = snapshotArtworkForUser($user->id); insertHourlySnapshot($artwork->id, now()->subDays(10)->startOfHour(), 100, 10, 1, 2, 4); insertHourlySnapshot($artwork->id, now()->subDays(1)->startOfHour(), 130, 16, 4, 5, 9); $window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30); expect($window['views'])->toBe(30) ->and($window['favourites'])->toBe(6) ->and($window['shares'])->toBe(3) ->and($window['comments'])->toBe(3) ->and($window['downloads'])->toBe(5) ->and($window['views'])->not->toBe(230); }); it('sums window deltas across a creator\'s artworks', function () { $user = \App\Models\User::factory()->create(); $a = snapshotArtworkForUser($user->id); $b = snapshotArtworkForUser($user->id); insertHourlySnapshot($a->id, now()->subDays(5)->startOfHour(), 50); insertHourlySnapshot($a->id, now()->subHour()->startOfHour(), 80); insertHourlySnapshot($b->id, now()->subDays(5)->startOfHour(), 10); insertHourlySnapshot($b->id, now()->subHour()->startOfHour(), 15); $window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30); expect($window['views'])->toBe(35); }); it('does not treat zero growth as lifetime totals', function () { $user = \App\Models\User::factory()->create(); $artwork = snapshotArtworkForUser($user->id); DB::table('artwork_stats')->updateOrInsert( ['artwork_id' => $artwork->id], ['views' => 9999, 'favorites' => 50, 'shares_count' => 7] ); insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 40); insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 40); Cache::flush(); $kpis = app(StudioMetricsService::class)->getDashboardKpis($user->id); expect($kpis['views_30d'])->toBe(0) ->and($kpis['favourites_30d'])->toBe(0) ->and($kpis['snapshot_window']['window_complete'])->toBeFalse(); }); it('marks a short table history as an incomplete 30-day window', function () { $user = \App\Models\User::factory()->create(); $artwork = snapshotArtworkForUser($user->id); insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 10); insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 18); $window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30); expect($window['views'])->toBe(8) ->and($window['window_complete'])->toBeFalse() ->and($window['table_coverage_days'])->toBeLessThan(30) ->and($window['requested_days'])->toBe(30); }); it('counts all views for artwork created during the window', function () { $user = \App\Models\User::factory()->create(); $artwork = \App\Models\Artwork::withoutEvents(fn () => \App\Models\Artwork::factory()->create([ 'user_id' => $user->id, 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subDays(3), 'created_at' => now()->subDays(3), 'deleted_at' => null, ])); insertHourlySnapshot($artwork->id, now()->subDays(3)->startOfHour(), 40); insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 90); $window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30); expect($window['views'])->toBe(90); }); it('uses a pre-window snapshot as baseline for older artwork', function () { $user = \App\Models\User::factory()->create(); $artwork = snapshotArtworkForUser($user->id); insertHourlySnapshot($artwork->id, now()->subDays(30)->subHour()->startOfHour(), 200); insertHourlySnapshot($artwork->id, now()->subDays(10)->startOfHour(), 220); insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 250); $window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30); expect($window['views'])->toBe(50); }); it('does not treat a single in-window snapshot of older artwork as lifetime views', function () { $user = \App\Models\User::factory()->create(); $artwork = snapshotArtworkForUser($user->id); insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 400); $window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30); expect($window['views'])->toBe(0); }); it('treats a single snapshot of a new artwork as growth from zero', function () { $user = \App\Models\User::factory()->create(); $artwork = \App\Models\Artwork::withoutEvents(fn () => \App\Models\Artwork::factory()->create([ 'user_id' => $user->id, 'is_approved' => true, 'is_public' => true, 'published_at' => now()->subHours(2), 'created_at' => now()->subHours(2), 'deleted_at' => null, ])); insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 12); $window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30); expect($window['views'])->toBe(12); }); it('ignores creator-journey snapshots older than the retention window', function () { $user = \App\Models\User::factory()->create(); $artwork = snapshotArtworkForUser($user->id); insertHourlySnapshot($artwork->id, now()->subDays(40)->startOfHour(), 10, 0, 0, 0, 1); insertHourlySnapshot($artwork->id, now()->subDays(39)->startOfHour(), 10, 0, 0, 0, 500); $lookbackDays = (int) config('metrics.hourly_snapshot_retention_days', 30); $count = DB::table('artwork_metric_snapshots_hourly') ->where('artwork_id', $artwork->id) ->where('bucket_hour', '>=', now()->subDays($lookbackDays)) ->count(); expect($count)->toBe(0); });