Release v1.4.0 #146
7 changed files with 458 additions and 156 deletions
|
|
@ -2,10 +2,11 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Models\Article;
|
|
||||||
use App\Services\DashboardStatsService;
|
use App\Services\DashboardStatsService;
|
||||||
|
use App\Support\DateRange;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class DashboardController extends BaseController
|
class DashboardController extends BaseController
|
||||||
{
|
{
|
||||||
|
|
@ -18,23 +19,26 @@ public function __construct(
|
||||||
*/
|
*/
|
||||||
public function stats(Request $request): JsonResponse
|
public function stats(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$period = $request->get('period', 'today');
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date'],
|
||||||
|
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$range = isset($validated['from'], $validated['to'])
|
||||||
|
? new DateRange(
|
||||||
|
Carbon::parse($validated['from'])->startOfDay(),
|
||||||
|
Carbon::parse($validated['to'])->endOfDay(),
|
||||||
|
)
|
||||||
|
: DateRange::preset('today');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get article stats from service
|
|
||||||
$articleStats = $this->dashboardStatsService->getStats($period);
|
|
||||||
|
|
||||||
// Get system stats
|
|
||||||
$systemStats = $this->dashboardStatsService->getSystemStats();
|
|
||||||
|
|
||||||
// Get available periods
|
|
||||||
$availablePeriods = $this->dashboardStatsService->getAvailablePeriods();
|
|
||||||
|
|
||||||
return $this->sendResponse([
|
return $this->sendResponse([
|
||||||
'article_stats' => $articleStats,
|
'article_stats' => $this->dashboardStatsService->getStats($range),
|
||||||
'system_stats' => $systemStats,
|
'system_stats' => $this->dashboardStatsService->getSystemStats(),
|
||||||
'available_periods' => $availablePeriods,
|
'range' => [
|
||||||
'current_period' => $period,
|
'from' => $range->from->toDateString(),
|
||||||
|
'to' => $range->to->toDateString(),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return $this->sendError('Failed to fetch dashboard stats: '.$e->getMessage(), [], 500);
|
return $this->sendError('Failed to fetch dashboard stats: '.$e->getMessage(), [], 500);
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,132 @@
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Services\DashboardStatsService;
|
use App\Services\DashboardStatsService;
|
||||||
|
use App\Support\DateRange;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Dashboard extends Component
|
class Dashboard extends Component
|
||||||
{
|
{
|
||||||
public string $period = 'today';
|
public string $from = '';
|
||||||
|
|
||||||
|
public string $to = '';
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
// Default period
|
$this->applyPreset('today');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setPeriod(string $period): void
|
public function applyPreset(string $preset): void
|
||||||
{
|
{
|
||||||
$this->period = $period;
|
try {
|
||||||
|
$range = DateRange::preset($preset);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->from = $range->from->toDateString();
|
||||||
|
$this->to = $range->to->toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function presets(): array
|
||||||
|
{
|
||||||
|
return DateRange::presets();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool $rangeIsValid = true;
|
||||||
|
|
||||||
|
public function range(): ?DateRange
|
||||||
|
{
|
||||||
|
$from = $this->parseBoundary($this->from);
|
||||||
|
$to = $this->parseBoundary($this->to);
|
||||||
|
|
||||||
|
if (! $from instanceof Carbon || ! $to instanceof Carbon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new DateRange($from->startOfDay(), $to->endOfDay());
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseBoundary(string $value): ?Carbon
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value);
|
||||||
|
} catch (\Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function articleStats(): array
|
||||||
|
{
|
||||||
|
$range = $this->range();
|
||||||
|
|
||||||
|
if (! $range instanceof DateRange) {
|
||||||
|
return [
|
||||||
|
'articles_fetched' => 0,
|
||||||
|
'articles_published' => 0,
|
||||||
|
'published_percentage' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return app(DashboardStatsService::class)->getStats($range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function systemStats(): array
|
||||||
|
{
|
||||||
|
return app(DashboardStatsService::class)->getSystemStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated(string $property): void
|
||||||
|
{
|
||||||
|
if (in_array($property, ['from', 'to'], true)) {
|
||||||
|
$this->validateRange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateRange(): void
|
||||||
|
{
|
||||||
|
$this->resetErrorBag(['from', 'to']);
|
||||||
|
|
||||||
|
$from = $this->parseBoundary($this->from);
|
||||||
|
$to = $this->parseBoundary($this->to);
|
||||||
|
|
||||||
|
if (! $from instanceof Carbon) {
|
||||||
|
$this->addError('from', 'The start date is not a valid date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $to instanceof Carbon) {
|
||||||
|
$this->addError('to', 'The end date is not a valid date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($from instanceof Carbon && $to instanceof Carbon && $to->lessThan($from)) {
|
||||||
|
$this->addError('to', 'The end date must not be earlier than the start date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->rangeIsValid = $this->range() instanceof DateRange;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): View
|
public function render(): View
|
||||||
{
|
{
|
||||||
$service = app(DashboardStatsService::class);
|
|
||||||
|
|
||||||
$articleStats = $service->getStats($this->period);
|
|
||||||
$systemStats = $service->getSystemStats();
|
|
||||||
$availablePeriods = $service->getAvailablePeriods();
|
|
||||||
|
|
||||||
return view('livewire.dashboard', [
|
return view('livewire.dashboard', [
|
||||||
'articleStats' => $articleStats,
|
'presets' => $this->presets(),
|
||||||
'systemStats' => $systemStats,
|
|
||||||
'availablePeriods' => $availablePeriods,
|
|
||||||
])->layout('layouts.app');
|
])->layout('layouts.app');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,25 @@
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\Route;
|
use App\Models\Route;
|
||||||
use Carbon\Carbon;
|
use App\Support\DateRange;
|
||||||
|
|
||||||
class DashboardStatsService
|
class DashboardStatsService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function getStats(string $period = 'today'): array
|
public function getStats(DateRange $range): array
|
||||||
{
|
{
|
||||||
$dateRange = $this->getDateRange($period);
|
$bounds = [$range->from, $range->to];
|
||||||
|
|
||||||
// Get articles fetched for the period
|
$articlesFetched = Article::query()
|
||||||
$articlesFetchedQuery = Article::query();
|
->whereBetween('created_at', $bounds)
|
||||||
if ($dateRange) {
|
->count();
|
||||||
$articlesFetchedQuery->whereBetween('created_at', $dateRange);
|
|
||||||
}
|
|
||||||
$articlesFetched = $articlesFetchedQuery->count();
|
|
||||||
|
|
||||||
// Get articles published for the period
|
$articlesPublished = ArticlePublication::query()
|
||||||
$articlesPublishedQuery = ArticlePublication::query()
|
->whereBetween('published_at', $bounds)
|
||||||
->whereNotNull('published_at');
|
->count();
|
||||||
if ($dateRange) {
|
|
||||||
$articlesPublishedQuery->whereBetween('published_at', $dateRange);
|
|
||||||
}
|
|
||||||
$articlesPublished = $articlesPublishedQuery->count();
|
|
||||||
|
|
||||||
// Calculate published percentage
|
|
||||||
$publishedPercentage = $articlesFetched > 0 ? round(($articlesPublished / $articlesFetched) * 100, 1) : 0.0;
|
$publishedPercentage = $articlesFetched > 0 ? round(($articlesPublished / $articlesFetched) * 100, 1) : 0.0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -44,37 +36,6 @@ public function getStats(string $period = 'today'): array
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function getAvailablePeriods(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'today' => 'Today',
|
|
||||||
'week' => 'This Week',
|
|
||||||
'month' => 'This Month',
|
|
||||||
'year' => 'This Year',
|
|
||||||
'all' => 'All Time',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: Carbon, 1: Carbon}|null
|
|
||||||
*/
|
|
||||||
private function getDateRange(string $period): ?array
|
|
||||||
{
|
|
||||||
$now = Carbon::now();
|
|
||||||
|
|
||||||
return match ($period) {
|
|
||||||
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
|
||||||
'week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()],
|
|
||||||
'month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()],
|
|
||||||
'year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()],
|
|
||||||
'all' => null, // No date filtering for all-time stats
|
|
||||||
default => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, int>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Feeds</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Feeds</p>
|
||||||
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ $systemStats['active_feeds'] }}
|
{{ $this->systemStats['active_feeds'] }}
|
||||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $systemStats['total_feeds'] }}</span>
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $this->systemStats['total_feeds'] }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -34,8 +34,8 @@
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Platform Accounts</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Platform Accounts</p>
|
||||||
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ $systemStats['active_platform_accounts'] }}
|
{{ $this->systemStats['active_platform_accounts'] }}
|
||||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $systemStats['total_platform_accounts'] }}</span>
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $this->systemStats['total_platform_accounts'] }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -52,8 +52,8 @@
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Platform Channels</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Platform Channels</p>
|
||||||
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ $systemStats['active_platform_channels'] }}
|
{{ $this->systemStats['active_platform_channels'] }}
|
||||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $systemStats['total_platform_channels'] }}</span>
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $this->systemStats['total_platform_channels'] }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -70,8 +70,8 @@
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Routes</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Routes</p>
|
||||||
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ $systemStats['active_routes'] }}
|
{{ $this->systemStats['active_routes'] }}
|
||||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $systemStats['total_routes'] }}</span>
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $this->systemStats['total_routes'] }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,18 +81,51 @@
|
||||||
|
|
||||||
<!-- Article Statistics -->
|
<!-- Article Statistics -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex flex-col gap-3 mb-4 md:flex-row md:items-end md:justify-between">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Article Statistics</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Article Statistics</h2>
|
||||||
<select
|
|
||||||
wire:model.live="period"
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||||
class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600"
|
<div class="flex flex-wrap gap-1">
|
||||||
>
|
@foreach ($presets as $value => $label)
|
||||||
@foreach ($availablePeriods as $value => $label)
|
<button
|
||||||
<option value="{{ $value }}">{{ $label }}</option>
|
type="button"
|
||||||
@endforeach
|
wire:click="applyPreset('{{ $value }}')"
|
||||||
</select>
|
class="rounded-md border border-gray-300 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>{{ $label }}</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<label for="dashboard-from" class="block text-xs font-medium text-gray-500 dark:text-gray-400">From</label>
|
||||||
|
<input
|
||||||
|
id="dashboard-from"
|
||||||
|
type="date"
|
||||||
|
wire:model.live="from"
|
||||||
|
class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="dashboard-to" class="block text-xs font-medium text-gray-500 dark:text-gray-400">To</label>
|
||||||
|
<input
|
||||||
|
id="dashboard-to"
|
||||||
|
type="date"
|
||||||
|
wire:model.live="to"
|
||||||
|
class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
|
@error('from')
|
||||||
|
<p class="mb-4 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
@error('to')
|
||||||
|
<p class="mb-4 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
<div @class(['grid grid-cols-1 md:grid-cols-3 gap-6', 'opacity-40' => ! $rangeIsValid])>
|
||||||
<!-- Articles Fetched -->
|
<!-- Articles Fetched -->
|
||||||
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
|
|
@ -104,7 +137,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Articles Fetched</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Articles Fetched</p>
|
||||||
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ $articleStats['articles_fetched'] }}
|
{{ $this->articleStats['articles_fetched'] }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,7 +154,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Articles Published</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Articles Published</p>
|
||||||
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ $articleStats['articles_published'] }}
|
{{ $this->articleStats['articles_published'] }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -138,7 +171,7 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Published Rate</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Published Rate</p>
|
||||||
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<p class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{{ $articleStats['published_percentage'] }}%
|
{{ $this->articleStats['published_percentage'] }}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use App\Models\PlatformChannel;
|
use App\Models\PlatformChannel;
|
||||||
use App\Models\Route;
|
use App\Models\Route;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class DashboardControllerTest extends TestCase
|
class DashboardControllerTest extends TestCase
|
||||||
|
|
@ -35,72 +36,103 @@ public function test_stats_returns_successful_response(): void
|
||||||
'total_routes',
|
'total_routes',
|
||||||
'active_routes',
|
'active_routes',
|
||||||
],
|
],
|
||||||
'available_periods',
|
'range' => ['from', 'to'],
|
||||||
'current_period',
|
|
||||||
],
|
],
|
||||||
'message',
|
'message',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_stats_with_different_periods(): void
|
public function test_stats_no_longer_exposes_named_periods(): void
|
||||||
{
|
{
|
||||||
$periods = ['today', 'week', 'month', 'year', 'all'];
|
$this
|
||||||
|
->getJson('/api/v1/dashboard/stats')
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJsonMissingPath('data.available_periods')
|
||||||
|
->assertJsonMissingPath('data.current_period');
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($periods as $period) {
|
public function test_stats_honours_an_explicit_from_and_to_range(): void
|
||||||
$this
|
{
|
||||||
->getJson("/api/v1/dashboard/stats?period={$period}")
|
Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||||
->assertStatus(200)
|
Article::factory()->create(['created_at' => Carbon::parse('2026-08-05 12:00:00')]);
|
||||||
->assertJson([
|
|
||||||
'success' => true,
|
$this
|
||||||
'data' => [
|
->getJson('/api/v1/dashboard/stats?from=2026-07-01&to=2026-07-31')
|
||||||
'current_period' => $period,
|
->assertStatus(200)
|
||||||
],
|
->assertJson([
|
||||||
]);
|
'data' => [
|
||||||
}
|
'article_stats' => ['articles_fetched' => 2],
|
||||||
|
'range' => ['from' => '2026-07-01', 'to' => '2026-07-31'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stats_defaults_to_the_current_day_without_params(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
Article::factory()->create(['created_at' => Carbon::parse('2026-07-15 09:00:00')]);
|
||||||
|
Article::factory()->create(['created_at' => Carbon::parse('2026-07-14 09:00:00')]);
|
||||||
|
|
||||||
|
$this
|
||||||
|
->getJson('/api/v1/dashboard/stats')
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'data' => [
|
||||||
|
'article_stats' => ['articles_fetched' => 1],
|
||||||
|
'range' => ['from' => '2026-07-15', 'to' => '2026-07-15'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stats_rejects_an_inverted_range(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->getJson('/api/v1/dashboard/stats?from=2026-07-31&to=2026-07-01')
|
||||||
|
->assertStatus(422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_stats_rejects_a_malformed_date(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->getJson('/api/v1/dashboard/stats?from=not-a-date&to=2026-07-01')
|
||||||
|
->assertStatus(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_stats_with_sample_data(): void
|
public function test_stats_with_sample_data(): void
|
||||||
{
|
{
|
||||||
// Get initial counts
|
|
||||||
$initialArticles = Article::count();
|
|
||||||
$initialFeeds = Feed::count();
|
|
||||||
$initialChannels = PlatformChannel::count();
|
|
||||||
$initialRoutes = Route::count();
|
|
||||||
$initialPublications = ArticlePublication::count();
|
|
||||||
|
|
||||||
// Create test data
|
|
||||||
$feed = Feed::factory()->create(['is_active' => true]);
|
$feed = Feed::factory()->create(['is_active' => true]);
|
||||||
$channel = PlatformChannel::factory()->create(['is_active' => true]);
|
$channel = PlatformChannel::factory()->create(['is_active' => true]);
|
||||||
$route = Route::factory()->create(['is_active' => true]);
|
Route::factory()->create(['is_active' => true]);
|
||||||
|
|
||||||
// Create articles
|
$articles = Article::factory()->count(3)->create([
|
||||||
$articles = Article::factory()->count(3)->create(['feed_id' => $feed->id]);
|
'feed_id' => $feed->id,
|
||||||
|
'created_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||||
|
]);
|
||||||
|
|
||||||
// Publish one article
|
|
||||||
ArticlePublication::factory()->create([
|
ArticlePublication::factory()->create([
|
||||||
'article_id' => $articles->first()->id,
|
'article_id' => $articles->first()->id,
|
||||||
'platform_channel_id' => $channel->id,
|
'platform_channel_id' => $channel->id,
|
||||||
'published_at' => now(),
|
'published_at' => Carbon::parse('2026-07-10 13:00:00'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->getJson('/api/v1/dashboard/stats?period=all');
|
$response = $this->getJson('/api/v1/dashboard/stats?from=2026-07-01&to=2026-07-31');
|
||||||
|
|
||||||
$response->assertStatus(200)
|
$response->assertStatus(200)
|
||||||
->assertJson([
|
->assertJson([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'article_stats' => [
|
'article_stats' => [
|
||||||
'articles_fetched' => $initialArticles + 3,
|
'articles_fetched' => 3,
|
||||||
'articles_published' => $initialPublications + 1,
|
'articles_published' => 1,
|
||||||
|
],
|
||||||
|
'system_stats' => [
|
||||||
|
'total_feeds' => Feed::count(),
|
||||||
|
'total_platform_channels' => PlatformChannel::count(),
|
||||||
|
'total_routes' => Route::count(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Just verify structure and that we have more items than we started with
|
|
||||||
$responseData = $response->json('data');
|
|
||||||
$this->assertGreaterThanOrEqual($initialFeeds + 1, $responseData['system_stats']['total_feeds']);
|
|
||||||
$this->assertGreaterThanOrEqual($initialChannels + 1, $responseData['system_stats']['total_platform_channels']);
|
|
||||||
$this->assertGreaterThanOrEqual($initialRoutes + 1, $responseData['system_stats']['total_routes']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_stats_returns_empty_data_with_no_records(): void
|
public function test_stats_returns_empty_data_with_no_records(): void
|
||||||
|
|
|
||||||
122
tests/Feature/Livewire/DashboardTest.php
Normal file
122
tests/Feature/Livewire/DashboardTest.php
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Livewire;
|
||||||
|
|
||||||
|
use App\Livewire\Dashboard;
|
||||||
|
use App\Models\Article;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DashboardTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_defaults_to_the_current_day(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->assertSet('from', '2026-07-15')
|
||||||
|
->assertSet('to', '2026-07-15');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_recomputes_stats_when_the_range_changes(): void
|
||||||
|
{
|
||||||
|
Article::factory()->count(3)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->set('from', '2026-07-01')
|
||||||
|
->set('to', '2026-07-31')
|
||||||
|
->assertSee('3')
|
||||||
|
->set('from', '2026-08-01')
|
||||||
|
->set('to', '2026-08-31')
|
||||||
|
->assertSee('0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_populates_the_range_when_a_preset_is_applied(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->call('applyPreset', 'month')
|
||||||
|
->assertSet('from', '2026-07-01')
|
||||||
|
->assertSet('to', '2026-07-31');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_applies_the_all_preset_as_a_bounded_range(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->call('applyPreset', 'all')
|
||||||
|
->assertSet('to', '2026-07-15');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_ignores_an_unknown_preset(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->call('applyPreset', 'fortnight')
|
||||||
|
->assertSet('from', '2026-07-15')
|
||||||
|
->assertSet('to', '2026-07-15')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_an_inverted_range_against_the_end_date(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->set('from', '2026-07-31')
|
||||||
|
->set('to', '2026-07-01')
|
||||||
|
->assertHasErrors('to');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_blames_the_start_date_when_the_start_date_is_malformed(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->set('from', 'not-a-date')
|
||||||
|
->assertHasErrors('from')
|
||||||
|
->assertHasNoErrors('to');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_blames_the_end_date_when_the_end_date_is_malformed(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->set('to', 'not-a-date')
|
||||||
|
->assertHasErrors('to')
|
||||||
|
->assertHasNoErrors('from');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_zero_stats_for_an_invalid_range(): void
|
||||||
|
{
|
||||||
|
Article::factory()->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||||
|
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->set('from', '2026-07-01')
|
||||||
|
->set('to', 'not-a-date')
|
||||||
|
->assertSet('rangeIsValid', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_marks_a_well_formed_range_as_valid(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->set('from', '2026-07-01')
|
||||||
|
->set('to', '2026-07-31')
|
||||||
|
->assertSet('rangeIsValid', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_renders_the_preset_buttons(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Dashboard::class)
|
||||||
|
->assertSee('This Month')
|
||||||
|
->assertSee('All Time');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,41 +2,94 @@
|
||||||
|
|
||||||
namespace Tests\Unit\Services;
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\ArticlePublication;
|
||||||
use App\Services\DashboardStatsService;
|
use App\Services\DashboardStatsService;
|
||||||
use Illuminate\Support\Facades\Http;
|
use App\Support\DateRange;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class DashboardStatsServiceTest extends TestCase
|
class DashboardStatsServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
protected function setUp(): void
|
use RefreshDatabase;
|
||||||
{
|
|
||||||
parent::setUp();
|
|
||||||
|
|
||||||
// Mock HTTP requests to prevent external calls
|
private function service(): DashboardStatsService
|
||||||
Http::fake([
|
{
|
||||||
'*' => Http::response('', 500),
|
return new DashboardStatsService;
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_get_available_periods_returns_correct_options(): void
|
public function test_it_counts_articles_fetched_within_the_range(): void
|
||||||
{
|
{
|
||||||
$service = new DashboardStatsService;
|
Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||||
$periods = $service->getAvailablePeriods();
|
Article::factory()->create(['created_at' => Carbon::parse('2026-08-01 12:00:00')]);
|
||||||
|
|
||||||
$this->assertIsArray($periods);
|
$stats = $this->service()->getStats(new DateRange(
|
||||||
$this->assertArrayHasKey('today', $periods);
|
Carbon::parse('2026-07-01 00:00:00'),
|
||||||
$this->assertArrayHasKey('week', $periods);
|
Carbon::parse('2026-07-31 23:59:59'),
|
||||||
$this->assertArrayHasKey('month', $periods);
|
));
|
||||||
$this->assertArrayHasKey('year', $periods);
|
|
||||||
$this->assertArrayHasKey('all', $periods);
|
|
||||||
|
|
||||||
$this->assertEquals('Today', $periods['today']);
|
$this->assertSame(2, $stats['articles_fetched']);
|
||||||
$this->assertEquals('All Time', $periods['all']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_service_instantiation(): void
|
public function test_it_counts_articles_published_within_the_range(): void
|
||||||
{
|
{
|
||||||
$service = new DashboardStatsService;
|
ArticlePublication::factory()->create(['published_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||||
$this->assertInstanceOf(DashboardStatsService::class, $service);
|
ArticlePublication::factory()->create(['published_at' => Carbon::parse('2026-08-01 12:00:00')]);
|
||||||
|
|
||||||
|
$stats = $this->service()->getStats(new DateRange(
|
||||||
|
Carbon::parse('2026-07-01 00:00:00'),
|
||||||
|
Carbon::parse('2026-07-31 23:59:59'),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame(1, $stats['articles_published']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_calculates_the_published_percentage(): void
|
||||||
|
{
|
||||||
|
$range = new DateRange(
|
||||||
|
Carbon::parse('2026-07-01 00:00:00'),
|
||||||
|
Carbon::parse('2026-07-31 23:59:59'),
|
||||||
|
);
|
||||||
|
|
||||||
|
Article::factory()->count(4)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||||
|
ArticlePublication::factory()->create(['published_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||||
|
|
||||||
|
$stats = $this->service()->getStats($range);
|
||||||
|
|
||||||
|
$this->assertSame(25.0, $stats['published_percentage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_reports_a_zero_percentage_when_nothing_was_fetched(): void
|
||||||
|
{
|
||||||
|
$stats = $this->service()->getStats(DateRange::preset('all'));
|
||||||
|
|
||||||
|
$this->assertSame(0, $stats['articles_fetched']);
|
||||||
|
$this->assertSame(0.0, $stats['published_percentage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_excludes_records_outside_the_range_entirely(): void
|
||||||
|
{
|
||||||
|
Article::factory()->create(['created_at' => Carbon::parse('2026-06-30 23:59:59')]);
|
||||||
|
Article::factory()->create(['created_at' => Carbon::parse('2026-08-01 00:00:00')]);
|
||||||
|
|
||||||
|
$stats = $this->service()->getStats(new DateRange(
|
||||||
|
Carbon::parse('2026-07-01 00:00:00'),
|
||||||
|
Carbon::parse('2026-07-31 23:59:59'),
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->assertSame(0, $stats['articles_fetched']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_counts_everything_for_the_all_preset(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow('2026-07-15 12:00:00');
|
||||||
|
|
||||||
|
Article::factory()->create(['created_at' => Carbon::parse('2020-01-01 00:00:00')]);
|
||||||
|
Article::factory()->create(['created_at' => Carbon::parse('2026-07-15 11:00:00')]);
|
||||||
|
|
||||||
|
$stats = $this->service()->getStats(DateRange::preset('all'));
|
||||||
|
|
||||||
|
$this->assertSame(2, $stats['articles_fetched']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue