diff --git a/.env.example b/.env.example index c513317f9..f6d6a5c12 100644 --- a/.env.example +++ b/.env.example @@ -323,7 +323,7 @@ FORUM_FRONTEND_ENABLED=false STREAM_FORK_OUTPUT=false -# API hot-path caching, asynchronous audit persistence, and instrumentation. +# API hot-path caching, asynchronous access metadata, and instrumentation. API_RELEASE_CACHE_TTL=600 API_RELEASE_CACHE_JITTER=60 API_RELEASE_CACHE_STALE_TTL=900 diff --git a/app/Console/Commands/BackfillUserActivityStats.php b/app/Console/Commands/BackfillUserActivityStats.php index 1417f9088..e461c28f5 100644 --- a/app/Console/Commands/BackfillUserActivityStats.php +++ b/app/Console/Commands/BackfillUserActivityStats.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace App\Console\Commands; use App\Models\UserActivityStat; -use App\Models\UserDownload; -use App\Models\UserRequest; use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -29,7 +27,7 @@ class BackfillUserActivityStats extends Command * * @var string */ - protected $description = 'Backfill user activity stats (daily or hourly) from existing user_downloads and user_requests data'; + protected $description = 'Backfill hourly activity from raw logs or daily activity from retained hourly aggregates'; /** * Execute the console command. @@ -77,24 +75,7 @@ class BackfillUserActivityStats extends Command continue; } - // Count downloads for the date - $downloadsCount = UserDownload::query() - ->whereRaw('DATE(timestamp) = ?', [$date]) - ->count(); - - // Count API hits for the date - $apiHitsCount = UserRequest::query() - ->whereRaw('DATE(timestamp) = ?', [$date]) - ->count(); - - // Store or update the stats - UserActivityStat::updateOrCreate( - ['stat_date' => $date], - [ - 'downloads_count' => $downloadsCount, - 'api_hits_count' => $apiHitsCount, - ] - ); + UserActivityStat::collectDailyStats($date); $statsCollected++; $progressBar->advance(); @@ -137,28 +118,7 @@ class BackfillUserActivityStats extends Command continue; } - // Count downloads for the hour - $downloadsCount = UserDownload::query() - ->where('timestamp', '>=', $hour) - ->where('timestamp', '<', Carbon::parse($hour)->addHour()->format('Y-m-d H:00:00')) - ->count(); - - // Count API hits for the hour - $apiHitsCount = UserRequest::query() - ->where('timestamp', '>=', $hour) - ->where('timestamp', '<', Carbon::parse($hour)->addHour()->format('Y-m-d H:00:00')) - ->count(); - - // Store or update the stats - DB::table('user_activity_stats_hourly')->updateOrInsert( - ['stat_hour' => $hour], - [ - 'downloads_count' => $downloadsCount, - 'api_hits_count' => $apiHitsCount, - 'updated_at' => now(), - 'created_at' => DB::raw('COALESCE(created_at, NOW())'), - ] - ); + UserActivityStat::collectHourlyStats($hour); $statsCollected++; $progressBar->advance(); diff --git a/app/Console/Commands/CollectStats.php b/app/Console/Commands/CollectStats.php index cb5d012d4..b9513a09a 100644 --- a/app/Console/Commands/CollectStats.php +++ b/app/Console/Commands/CollectStats.php @@ -44,10 +44,10 @@ class CollectStats extends Command $this->info('New users by month collected.'); RoleStat::insertUsersByRole(); $this->info('Users by role collected.'); - UserActivityStat::collectDailyStats(); - $this->info('User activity daily stats collected.'); UserActivityStat::collectHourlyStats(); $this->info('User activity hourly stats collected.'); + UserActivityStat::collectDailyStats(); + $this->info('User activity daily stats collected.'); $this->info('Site stats collected.'); } } diff --git a/app/Jobs/RecordApiUsage.php b/app/Jobs/RecordApiUsage.php index 63481a9ba..7d0f4b544 100644 --- a/app/Jobs/RecordApiUsage.php +++ b/app/Jobs/RecordApiUsage.php @@ -37,11 +37,8 @@ final class RecordApiUsage implements ShouldQueue public function handle(): void { - UserRequest::query()->insert([ - 'users_id' => $this->userId, - 'request' => $this->requestUri, - 'timestamp' => $this->occurredAt, - ]); + // Legacy job retained while pre-deployment audit jobs drain. + UserRequest::recordApiRequest($this->userId, $this->requestUri, $this->occurredAt); $lockKey = 'api_access_update:'.$this->userId; $interval = max(1, (int) config('nntmux.api.access_update_interval', 60)); diff --git a/app/Jobs/UpdateUserApiAccess.php b/app/Jobs/UpdateUserApiAccess.php new file mode 100644 index 000000000..2083fa83a --- /dev/null +++ b/app/Jobs/UpdateUserApiAccess.php @@ -0,0 +1,56 @@ +onQueue((string) config('nntmux.api.audit_queue', 'api-audit')); + } + + public function handle(): void + { + $lockKey = 'api_access_update:'.$this->userId; + $interval = max(1, (int) config('nntmux.api.access_update_interval', 60)); + + try { + if (! Cache::add($lockKey, true, $interval)) { + return; + } + } catch (Throwable) { + // Cache degradation must not prevent the metadata update. + } + + $update = ['apiaccess' => $this->occurredAt]; + if ($this->ip !== null) { + $update['host'] = $this->ip; + } + + DB::table('users')->where('id', $this->userId)->update($update); + } +} diff --git a/app/Models/UserActivityStat.php b/app/Models/UserActivityStat.php index 1905c64f1..b1cd9b53d 100644 --- a/app/Models/UserActivityStat.php +++ b/app/Models/UserActivityStat.php @@ -22,29 +22,34 @@ class UserActivityStat extends Model /** * Collect and store user activity stats for a specific date - * This aggregates data from user_downloads and user_requests tables + * This rolls up the retained hourly aggregates so the result cannot shrink + * after the raw user_downloads and user_requests rows are pruned. */ public static function collectDailyStats(?string $date = null): void { $statDate = $date ? Carbon::parse($date)->format('Y-m-d') : Carbon::yesterday()->format('Y-m-d'); + $dayStart = Carbon::parse($statDate)->startOfDay(); + $dayEnd = $dayStart->copy()->addDay(); - // Count downloads for the date - $downloadsCount = UserDownload::query() - ->whereRaw('DATE(timestamp) = ?', [$statDate]) - ->count(); + $totals = DB::table('user_activity_stats_hourly') + ->where('stat_hour', '>=', $dayStart->format('Y-m-d H:00:00')) + ->where('stat_hour', '<', $dayEnd->format('Y-m-d H:00:00')) + ->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits') + ->first(); - // Count API hits for the date - $apiHitsCount = UserRequest::query() - ->whereRaw('DATE(timestamp) = ?', [$statDate]) - ->count(); + $values = [ + 'downloads_count' => (int) ($totals->downloads ?? 0), + 'api_hits_count' => (int) ($totals->api_hits ?? 0), + 'updated_at' => now(), + ]; - // Store or update the stats - self::updateOrCreate( + if (! DB::table('user_activity_stats')->where('stat_date', $statDate)->exists()) { + $values['created_at'] = now(); + } + + DB::table('user_activity_stats')->updateOrInsert( ['stat_date' => $statDate], - [ - 'downloads_count' => $downloadsCount, - 'api_hits_count' => $apiHitsCount, - ] + $values, ); } @@ -111,9 +116,8 @@ class UserActivityStat extends Model /** * Get total downloads across the last N days inclusive of today. * - * Sums closed days from `user_activity_stats` and today's closed hours - * from `user_activity_stats_hourly`. The current in-progress hour is not - * included here — callers needing live "today" totals should use + * Sums closed hours from `user_activity_stats_hourly`. The current + * in-progress hour is not included here — callers needing live totals use * {@see UserStatsService::getSummaryStats()} which layers * the live tables on top. */ @@ -134,20 +138,13 @@ class UserActivityStat extends Model private static function sumLastNDays(int $days, string $column): int { - $today = Carbon::now()->startOfDay(); $weekStart = Carbon::now()->subDays($days - 1)->startOfDay(); + $currentHour = Carbon::now()->startOfHour(); - $closedDays = (int) self::query() - ->where('stat_date', '>=', $weekStart->format('Y-m-d')) - ->where('stat_date', '<', $today->format('Y-m-d')) + return (int) DB::table('user_activity_stats_hourly') + ->where('stat_hour', '>=', $weekStart->format('Y-m-d H:00:00')) + ->where('stat_hour', '<', $currentHour->format('Y-m-d H:00:00')) ->sum($column); - - $todayClosedHours = (int) DB::table('user_activity_stats_hourly') - ->where('stat_hour', '>=', $today->format('Y-m-d H:00:00')) - ->where('stat_hour', '<', Carbon::now()->startOfHour()->format('Y-m-d H:00:00')) - ->sum($column); - - return $closedDays + $todayClosedHours; } /** diff --git a/app/Models/UserRequest.php b/app/Models/UserRequest.php index 73ea2b82b..02b8497b3 100644 --- a/app/Models/UserRequest.php +++ b/app/Models/UserRequest.php @@ -132,7 +132,16 @@ class UserRequest extends Model } else { $userID = User::query()->select(['id'])->where('api_token', $tokenOrUserId)->value('id'); } - self::query()->insert(['users_id' => $userID, 'request' => $request, 'timestamp' => now()]); + self::recordApiRequest((int) $userID, $request, now()->toDateTimeString()); + } + + public static function recordApiRequest(int $userId, string $request, string $occurredAt): void + { + self::query()->insert([ + 'users_id' => $userId, + 'request' => $request, + 'timestamp' => $occurredAt, + ]); } /** diff --git a/app/Services/Api/ApiUsageService.php b/app/Services/Api/ApiUsageService.php index 8311b2fd6..8e3b4e80c 100644 --- a/app/Services/Api/ApiUsageService.php +++ b/app/Services/Api/ApiUsageService.php @@ -4,8 +4,9 @@ declare(strict_types=1); namespace App\Services\Api; -use App\Jobs\RecordApiUsage; +use App\Jobs\UpdateUserApiAccess; use App\Models\User; +use App\Models\UserRequest; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -30,18 +31,27 @@ final class ApiUsageService public function record(User $user, Request $request): void { - $job = new RecordApiUsage( + $occurredAt = now()->toDateTimeString(); + + // Request accounting drives quotas and admin statistics, so it must be + // durable before the response returns even when no queue worker runs. + UserRequest::recordApiRequest( $user->id, $request->getRequestUri(), + $occurredAt, + ); + + $job = new UpdateUserApiAccess( + $user->id, $request->ip(), - now()->toDateTimeString(), + $occurredAt, ); if ((bool) config('nntmux.api.async_audit', true)) { try { dispatch($job); } catch (Throwable) { - // Preserve audit and quota correctness when the queue backend is unavailable. + // Keep access metadata current when the queue backend is unavailable. $job->handle(); } diff --git a/app/Services/UserStatsService.php b/app/Services/UserStatsService.php index d7c730f25..0968b6249 100644 --- a/app/Services/UserStatsService.php +++ b/app/Services/UserStatsService.php @@ -38,11 +38,8 @@ class UserStatsService /** * Get downloads per day for the last N days (inclusive of today). * - * Source layering — designed to avoid the 1-day rolling purge of - * `user_downloads` (see routes/console.php cleanup-api-request-logs): - * - Closed days (today-(N-1) … yesterday) come from `user_activity_stats`. - * - Today is composed of closed hours from `user_activity_stats_hourly` - * + the in-progress hour from the live `user_downloads` table. + * Closed hours come from `user_activity_stats_hourly`; the current hour + * comes from the live `user_downloads` table. * * @return list> */ @@ -65,8 +62,15 @@ class UserStatsService */ public function getDownloadsPerHour(int $hours = 168): array { - // Use the aggregated hourly stats from UserActivityStat model - return UserActivityStat::getDownloadsPerHour($hours); + $result = UserActivityStat::getDownloadsPerHour($hours); + + if ($result !== []) { + $result[count($result) - 1]['count'] = UserDownload::query() + ->where('timestamp', '>=', Carbon::now()->startOfHour()) + ->count(); + } + + return $result; } /** @@ -77,14 +81,12 @@ class UserStatsService public function getDownloadsPerMinute(int $minutes = 60): array { $startTime = Carbon::now()->subMinutes($minutes); + $minuteExpression = $this->minuteBucketExpression(); $downloads = UserDownload::query() - ->select( - DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00") as minute'), - DB::raw('COUNT(*) as count') - ) + ->selectRaw($minuteExpression.' as minute, COUNT(*) as count') ->where('timestamp', '>=', $startTime) - ->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")')) + ->groupByRaw($minuteExpression) ->orderBy('minute', 'asc') ->get() ->keyBy('minute'); @@ -107,9 +109,8 @@ class UserStatsService /** * Get API hits per day for the last N days (inclusive of today). * - * Mirrors {@see self::getDownloadsPerDay()} — closed days from - * `user_activity_stats`, today composed of `user_activity_stats_hourly` - * + the in-progress hour from `user_requests`. + * Mirrors {@see self::getDownloadsPerDay()} using combined authenticated + * API v1/v2 and RSS requests from `user_requests`. * * @return list> */ @@ -132,8 +133,15 @@ class UserStatsService */ public function getApiHitsPerHour(int $hours = 168): array { - // Use the aggregated hourly stats from UserActivityStat model - return UserActivityStat::getApiHitsPerHour($hours); + $result = UserActivityStat::getApiHitsPerHour($hours); + + if ($result !== []) { + $result[count($result) - 1]['count'] = UserRequest::query() + ->where('timestamp', '>=', Carbon::now()->startOfHour()) + ->count(); + } + + return $result; } /** @@ -144,15 +152,13 @@ class UserStatsService public function getApiHitsPerMinute(int $minutes = 60): array { $startTime = Carbon::now()->subMinutes($minutes); + $minuteExpression = $this->minuteBucketExpression(); // Track actual API requests from user_requests table $apiHits = UserRequest::query() - ->select( - DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00") as minute'), - DB::raw('COUNT(*) as count') - ) + ->selectRaw($minuteExpression.' as minute, COUNT(*) as count') ->where('timestamp', '>=', $startTime) - ->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")')) + ->groupByRaw($minuteExpression) ->orderBy('minute', 'asc') ->get() ->keyBy('minute'); @@ -177,12 +183,8 @@ class UserStatsService * * Window: "today" and "(7d) = last 7 calendar days inclusive of today". * - * The live `user_downloads` / `user_requests` tables are pruned hourly to - * the last ~24h (see routes/console.php), so we layer sources to keep the - * totals consistent regardless of when the purge ran: - * - Closed days (today-6 … yesterday) → `user_activity_stats`. - * - Today's closed hours → `user_activity_stats_hourly`. - * - Current in-progress hour → live `user_downloads` / `user_requests`. + * Closed hours come from `user_activity_stats_hourly`; the current + * in-progress hour comes from live `user_downloads` / `user_requests`. * * @return array{total_users: int, downloads_today: int, downloads_week: int, api_hits_today: int, api_hits_week: int} */ @@ -192,10 +194,9 @@ class UserStatsService $weekStart = Carbon::now()->subDays(6)->startOfDay(); $currentHourStart = Carbon::now()->startOfHour(); - // Closed days: [today-6 .. yesterday] - $historical = UserActivityStat::query() - ->where('stat_date', '>=', $weekStart->format('Y-m-d')) - ->where('stat_date', '<', $today->format('Y-m-d')) + $weekClosed = DB::table('user_activity_stats_hourly') + ->where('stat_hour', '>=', $weekStart->format('Y-m-d H:00:00')) + ->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00')) ->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits') ->first(); @@ -220,9 +221,9 @@ class UserStatsService return [ 'total_users' => User::whereNull('deleted_at')->count(), 'downloads_today' => $downloadsToday, - 'downloads_week' => (int) ($historical->downloads ?? 0) + $downloadsToday, + 'downloads_week' => (int) ($weekClosed->downloads ?? 0) + $downloadsCurrentHour, 'api_hits_today' => $apiHitsToday, - 'api_hits_week' => (int) ($historical->api_hits ?? 0) + $apiHitsToday, + 'api_hits_week' => (int) ($weekClosed->api_hits ?? 0) + $apiHitsCurrentHour, ]; } @@ -247,10 +248,8 @@ class UserStatsService } /** - * Build a "last N days inclusive of today" series, layering closed days - * (from `user_activity_stats`), today's closed hours (from - * `user_activity_stats_hourly`) and the current in-progress hour from a - * caller-supplied live counter. + * Build a "last N days inclusive of today" series from hourly aggregates + * plus the current in-progress hour from a caller-supplied live counter. * * @param callable(Carbon): int $liveCurrentHourCounter * @return list @@ -260,42 +259,42 @@ class UserStatsService $today = Carbon::now()->startOfDay(); $startDate = $today->copy()->subDays($days - 1); - $historical = UserActivityStat::query() - ->select('stat_date', $statColumn) - ->where('stat_date', '>=', $startDate->format('Y-m-d')) - ->where('stat_date', '<', $today->format('Y-m-d')) - ->orderBy('stat_date', 'asc') - ->get() - ->keyBy(static fn (UserActivityStat $s): string => $s->stat_date instanceof Carbon - ? $s->stat_date->format('Y-m-d') - : (string) $s->stat_date); + $currentHourStart = Carbon::now()->startOfHour(); + $hourly = DB::table('user_activity_stats_hourly') + ->select('stat_hour', $statColumn) + ->where('stat_hour', '>=', $startDate->format('Y-m-d H:00:00')) + ->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00')) + ->orderBy('stat_hour') + ->get(); + + $dailyCounts = []; + foreach ($hourly as $stat) { + $date = Carbon::parse((string) $stat->stat_hour)->format('Y-m-d'); + $dailyCounts[$date] = ($dailyCounts[$date] ?? 0) + (int) $stat->{$statColumn}; + } $result = []; $cursor = $startDate->copy(); - while ($cursor->lt($today)) { - $stat = $historical->get($cursor->format('Y-m-d')); + while ($cursor->lte($today)) { + $date = $cursor->format('Y-m-d'); $result[] = [ 'date' => $cursor->format('M d'), - 'count' => $stat ? (int) $stat->{$statColumn} : 0, + 'count' => $dailyCounts[$date] ?? 0, ]; $cursor->addDay(); } - // Today: closed hours from hourly aggregate + the current hour from live. - $currentHourStart = Carbon::now()->startOfHour(); - $hourlyColumn = $statColumn === 'api_hits_count' ? 'api_hits_count' : 'downloads_count'; - $todayClosed = (int) DB::table('user_activity_stats_hourly') - ->where('stat_hour', '>=', $today->format('Y-m-d H:00:00')) - ->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00')) - ->sum($hourlyColumn); - - $todayLive = (int) $liveCurrentHourCounter($currentHourStart); - - $result[] = [ - 'date' => $today->format('M d'), - 'count' => $todayClosed + $todayLive, - ]; + if ($result !== []) { + $result[count($result) - 1]['count'] += (int) $liveCurrentHourCounter($currentHourStart); + } return $result; } + + private function minuteBucketExpression(): string + { + return DB::connection()->getDriverName() === 'sqlite' + ? "strftime('%Y-%m-%d %H:%M:00', timestamp)" + : 'DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'; + } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index af9ae7833..5def260fe 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1003,12 +1003,6 @@ parameters: count: 1 path: app/Services/UserStatsService.php - - - message: '#^Method App\\Services\\UserStatsService\:\:getDownloadsPerHour\(\) should return list\\> but returns array\\.$#' - identifier: return.type - count: 1 - path: app/Services/UserStatsService.php - - message: '#^Method App\\Services\\UserStatsService\:\:getDownloadsPerMinute\(\) should return array\ but returns list\\>\.$#' identifier: return.type diff --git a/resources/js/alpine/components/admin/dashboard.js b/resources/js/alpine/components/admin/dashboard.js index 3351039af..ce03a945f 100644 --- a/resources/js/alpine/components/admin/dashboard.js +++ b/resources/js/alpine/components/admin/dashboard.js @@ -52,7 +52,7 @@ Alpine.data('adminDashboard', () => ({ this._scheduleRefresh(); }, _scheduleRefresh() { - const interval = Number.parseInt(this.$el.dataset.refreshInterval ?? '', 10) || (15 * 60 * 1000); + const interval = Number.parseInt(this.$el.dataset.refreshInterval ?? '', 10) || (60 * 1000); this._refreshInterval = window.setInterval(() => this._loadDashboardData(), interval); this._visibilityHandler = () => { if (!document.hidden && Date.now() - this._lastRefreshAt >= interval) { @@ -159,8 +159,8 @@ Alpine.data('adminDashboard', () => ({ { label: 'Total Users', value: summary.total_users, theme: 'blue' }, { label: 'Downloads Today', value: summary.downloads_today, theme: 'green' }, { label: 'Downloads (last 7d incl. today)', value: summary.downloads_week, theme: 'purple' }, - { label: 'API Hits Today', value: summary.api_hits_today, theme: 'orange' }, - { label: 'API Hits (last 7d incl. today)', value: summary.api_hits_week, theme: 'pink' }, + { label: 'API/RSS Hits Today', value: summary.api_hits_today, theme: 'orange' }, + { label: 'API/RSS Hits (last 7d incl. today)', value: summary.api_hits_week, theme: 'pink' }, ]; const summaryGrid = widget.querySelector('[data-summary-grid]'); if (summaryGrid) { @@ -209,11 +209,11 @@ Alpine.data('adminDashboard', () => ({ } this._renderChart('downloadsChart', 'bar', 'Downloads', stats.downloads_per_hour ?? [], 'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false); - this._renderChart('apiHitsChart', 'line', 'API Hits', stats.api_hits_per_hour ?? [], + this._renderChart('apiHitsChart', 'line', 'API/RSS Hits', stats.api_hits_per_hour ?? [], 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); this._renderChart('downloadsMinuteChart', 'line', 'Downloads', stats.downloads_per_minute ?? [], 'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true); - this._renderChart('apiHitsMinuteChart', 'line', 'API Hits', stats.api_hits_per_minute ?? [], + this._renderChart('apiHitsMinuteChart', 'line', 'API/RSS Hits', stats.api_hits_per_minute ?? [], 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); }, _renderSystemMetrics(metrics) { diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index a6e85dd4f..f6483f439 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -15,7 +15,7 @@
+ data-refresh-interval="{{ 60 * 1000 }}">
@@ -28,7 +28,7 @@

Last dashboard refresh: {{ $dashboardLastRefreshedAt }}

-

Auto-refreshes every 15 minutes

+

Auto-refreshes every minute

@@ -322,7 +322,7 @@

- API Hits (Last 7 Days - Hourly) + API/RSS Hits (Last 7 Days - Hourly)

@@ -336,7 +336,7 @@

- API Hits (Last 60 Minutes) + API/RSS Hits (Last 60 Minutes)

diff --git a/tests/Feature/AdminDashboardAutoRefreshTest.php b/tests/Feature/AdminDashboardAutoRefreshTest.php index bb2c9ac38..2882f8a01 100644 --- a/tests/Feature/AdminDashboardAutoRefreshTest.php +++ b/tests/Feature/AdminDashboardAutoRefreshTest.php @@ -16,14 +16,14 @@ class AdminDashboardAutoRefreshTest extends TestCase $this->assertStringContainsString('x-data="adminDashboard"', $content); $this->assertStringContainsString('data-data-url="{{ route(\'admin.api.dashboard-data\') }}"', $content); - $this->assertStringContainsString('data-refresh-interval="{{ 15 * 60 * 1000 }}"', $content); + $this->assertStringContainsString('data-refresh-interval="{{ 60 * 1000 }}"', $content); $this->assertStringContainsString('data-dashboard-content', $content); // The previous full-page-reload approach has been removed in favour of // updating headline tiles + widgets directly from the JSON payload. $this->assertStringNotContainsString('data-refresh-url=', $content); } - public function test_dashboard_blade_labels_match_fifteen_minute_refresh(): void + public function test_dashboard_blade_labels_match_one_minute_refresh(): void { $bladePath = resource_path('views/admin/dashboard.blade.php'); @@ -31,7 +31,7 @@ class AdminDashboardAutoRefreshTest extends TestCase $content = file_get_contents($bladePath); - $this->assertStringContainsString('Auto-refreshes every 15 minutes', $content); + $this->assertStringContainsString('Auto-refreshes every minute', $content); $this->assertStringContainsString('Last dashboard refresh:', $content); $this->assertStringContainsString('$dashboardLastRefreshedAt', $content); $this->assertStringContainsString('data-stat="last-refresh"', $content); @@ -47,8 +47,7 @@ class AdminDashboardAutoRefreshTest extends TestCase $content = file_get_contents($scriptPath); - // Auto-refresh interval is preserved. - $this->assertStringContainsString('15 * 60 * 1000', $content); + $this->assertStringContainsString('60 * 1000', $content); // The fetch hits the cached JSON endpoint. $this->assertStringContainsString('this.$el.dataset.dataUrl', $content); // Each tick re-renders headline tiles + registration status + the diff --git a/tests/Feature/AdminDashboardSummaryWindowTest.php b/tests/Feature/AdminDashboardSummaryWindowTest.php index 065376862..c5cd85f31 100644 --- a/tests/Feature/AdminDashboardSummaryWindowTest.php +++ b/tests/Feature/AdminDashboardSummaryWindowTest.php @@ -10,8 +10,7 @@ use Tests\TestCase; * Locks in the "(7d) = last 7 calendar days inclusive of today" semantics * and the source layering used by the headline summary tiles: * - * - Closed days come from `user_activity_stats`. - * - Today's closed hours come from `user_activity_stats_hourly`. + * - Every completed hour comes from `user_activity_stats_hourly`. * - The current in-progress hour comes from the live `user_downloads` / * `user_requests` tables (which are pruned hourly to ~24h). * @@ -21,7 +20,7 @@ use Tests\TestCase; */ class AdminDashboardSummaryWindowTest extends TestCase { - public function test_summary_window_layers_daily_hourly_and_live_sources(): void + public function test_summary_window_layers_hourly_and_live_sources(): void { $servicePath = app_path('Services/UserStatsService.php'); @@ -36,18 +35,16 @@ class AdminDashboardSummaryWindowTest extends TestCase 'Summary window must be the last 7 calendar days inclusive of today.' ); - // Closed days from the daily aggregate, strictly before today. - $this->assertStringContainsString('UserActivityStat::query()', $content); + // Every completed hour in the seven-day window comes from the retained + // hourly aggregate, including completed hours from today. $this->assertStringContainsString( - "->where('stat_date', '>=', \$weekStart->format('Y-m-d'))", + "->where('stat_hour', '>=', \$weekStart->format('Y-m-d H:00:00'))", $content ); $this->assertStringContainsString( - "->where('stat_date', '<', \$today->format('Y-m-d'))", + "->where('stat_hour', '<', \$currentHourStart->format('Y-m-d H:00:00'))", $content ); - - // Today's closed hours from the hourly aggregate. $this->assertStringContainsString( "DB::table('user_activity_stats_hourly')", $content @@ -67,13 +64,13 @@ class AdminDashboardSummaryWindowTest extends TestCase $content ); - // Today and week totals must combine all three sources. + // Today and week totals combine closed hourly data with live data. $this->assertStringContainsString( "'downloads_today' => \$downloadsToday,", $content ); $this->assertStringContainsString( - "'downloads_week' => (int) (\$historical->downloads ?? 0) + \$downloadsToday,", + "'downloads_week' => (int) (\$weekClosed->downloads ?? 0) + \$downloadsCurrentHour,", $content ); $this->assertStringContainsString( @@ -81,7 +78,7 @@ class AdminDashboardSummaryWindowTest extends TestCase $content ); $this->assertStringContainsString( - "'api_hits_week' => (int) (\$historical->api_hits ?? 0) + \$apiHitsToday,", + "'api_hits_week' => (int) (\$weekClosed->api_hits ?? 0) + \$apiHitsCurrentHour,", $content ); diff --git a/tests/Unit/ApiUsageServiceTest.php b/tests/Unit/ApiUsageServiceTest.php index 4b3ffb963..dc83ff9e7 100644 --- a/tests/Unit/ApiUsageServiceTest.php +++ b/tests/Unit/ApiUsageServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit; use App\Jobs\RecordApiUsage; +use App\Jobs\UpdateUserApiAccess; use App\Models\User; use App\Services\Api\ApiUsageService; use Illuminate\Database\Schema\Blueprint; @@ -44,7 +45,7 @@ final class ApiUsageServiceTest extends TestCase DB::table('users')->insert(['id' => 1]); } - public function test_async_record_dispatches_id_only_audit_job(): void + public function test_async_record_persists_usage_before_dispatching_metadata_update(): void { Queue::fake(); config(['nntmux.api.async_audit' => true]); @@ -54,12 +55,16 @@ final class ApiUsageServiceTest extends TestCase (new ApiUsageService)->record($user, $request); - Queue::assertPushed(RecordApiUsage::class, static fn (RecordApiUsage $job): bool => $job->userId === 1 && $job->requestUri === '/api/v2/search?api_token=secret&id=test' + Queue::assertPushed(UpdateUserApiAccess::class, static fn (UpdateUserApiAccess $job): bool => $job->userId === 1 + && $job->ip === '127.0.0.1' ); - $this->assertSame(0, DB::table('user_requests')->count()); + $this->assertDatabaseHas('user_requests', [ + 'users_id' => 1, + 'request' => '/api/v2/search?api_token=secret&id=test', + ]); } - public function test_audit_job_persists_each_request_and_coalesces_user_updates(): void + public function test_legacy_audit_job_persists_queued_requests_and_coalesces_user_updates(): void { config(['nntmux.api.access_update_interval' => 60]); diff --git a/tests/Unit/UserActivityStatsTest.php b/tests/Unit/UserActivityStatsTest.php new file mode 100644 index 000000000..aef63a3fc --- /dev/null +++ b/tests/Unit/UserActivityStatsTest.php @@ -0,0 +1,167 @@ + 'sqlite', + 'database.connections.sqlite.database' => ':memory:', + 'cache.default' => 'array', + ]); + DB::purge(); + DB::reconnect(); + Cache::flush(); + Carbon::setTestNow('2026-07-20 14:37:00'); + + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->dateTime('deleted_at')->nullable(); + }); + Schema::create('settings', function (Blueprint $table): void { + $table->string('name')->primary(); + $table->text('value')->nullable(); + }); + Schema::create('user_requests', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('users_id'); + $table->text('request'); + $table->dateTime('timestamp'); + }); + Schema::create('user_downloads', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('users_id'); + $table->dateTime('timestamp'); + }); + Schema::create('user_activity_stats', function (Blueprint $table): void { + $table->id(); + $table->date('stat_date')->unique(); + $table->integer('downloads_count')->default(0); + $table->integer('api_hits_count')->default(0); + $table->timestamps(); + }); + Schema::create('user_activity_stats_hourly', function (Blueprint $table): void { + $table->id(); + $table->dateTime('stat_hour')->unique(); + $table->integer('downloads_count')->default(0); + $table->integer('api_hits_count')->default(0); + $table->timestamps(); + }); + + DB::table('users')->insert(['id' => 1]); + DB::table('settings')->insert([ + ['name' => 'categorizeforeign', 'value' => '0'], + ['name' => 'catwebdl', 'value' => '0'], + ['name' => 'delaytime', 'value' => '0'], + ['name' => 'innerfileblacklist', 'value' => ''], + ]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + public function test_summary_and_charts_combine_hourly_api_and_rss_hits_with_the_live_hour(): void + { + $this->insertHourly('2026-07-14 09:00:00', 3, 7); + $this->insertHourly('2026-07-20 13:00:00', 2, 4); + + DB::table('user_activity_stats')->insert([ + 'stat_date' => '2026-07-19', + 'downloads_count' => 999, + 'api_hits_count' => 999, + ]); + DB::table('user_requests')->insert([ + ['users_id' => 1, 'request' => '/api/v2/search?id=one', 'timestamp' => '2026-07-20 14:37:00'], + ['users_id' => 1, 'request' => '/rss/full-feed?api_token=test', 'timestamp' => '2026-07-20 14:37:30'], + ]); + DB::table('user_downloads')->insert([ + 'users_id' => 1, + 'timestamp' => '2026-07-20 14:36:00', + ]); + + $service = new UserStatsService; + $summary = $service->getSummaryStats(); + + $this->assertSame(6, $summary['api_hits_today']); + $this->assertSame(13, $summary['api_hits_week']); + $this->assertSame(3, $summary['downloads_today']); + $this->assertSame(6, $summary['downloads_week']); + + $hourly = $service->getApiHitsPerHour(2); + $this->assertSame([4, 2], array_column($hourly, 'count')); + + $daily = $service->getApiHitsPerDay(7); + $this->assertSame(7, $daily[0]['count']); + $this->assertSame(6, $daily[6]['count']); + + $perMinute = $service->getApiHitsPerMinute(60); + $this->assertSame(2, $perMinute[59]['count']); + } + + public function test_daily_rollup_remains_stable_after_raw_rows_are_pruned(): void + { + $this->insertHourly('2026-07-19 01:00:00', 2, 3); + $this->insertHourly('2026-07-19 23:00:00', 4, 5); + DB::table('user_requests')->insert([ + 'users_id' => 1, + 'request' => '/api/v1/api?t=search', + 'timestamp' => '2026-07-19 23:30:00', + ]); + + UserActivityStat::collectDailyStats('2026-07-19'); + DB::table('user_requests')->delete(); + UserActivityStat::collectDailyStats('2026-07-19'); + + $daily = DB::table('user_activity_stats')->where('stat_date', '2026-07-19')->first(); + $this->assertSame(6, $daily->downloads_count); + $this->assertSame(8, $daily->api_hits_count); + } + + public function test_forced_daily_backfill_rebuilds_retained_history_from_hourly_totals(): void + { + $this->insertHourly('2026-07-19 10:00:00', 11, 17); + DB::table('user_activity_stats')->insert([ + 'stat_date' => '2026-07-19', + 'downloads_count' => 1, + 'api_hits_count' => 1, + ]); + + $this->artisan('nntmux:backfill-user-activity-stats', [ + '--type' => 'daily', + '--days' => 2, + '--force' => true, + ])->assertSuccessful(); + + $daily = DB::table('user_activity_stats')->where('stat_date', '2026-07-19')->first(); + $this->assertSame(11, $daily->downloads_count); + $this->assertSame(17, $daily->api_hits_count); + } + + private function insertHourly(string $hour, int $downloads, int $apiHits): void + { + DB::table('user_activity_stats_hourly')->insert([ + 'stat_hour' => $hour, + 'downloads_count' => $downloads, + 'api_hits_count' => $apiHits, + ]); + } +}