83 - Drive dashboard and API stats from a date range
This commit is contained in:
parent
ef5346ed70
commit
a6eff35586
7 changed files with 458 additions and 156 deletions
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Services\DashboardStatsService;
|
||||
use App\Support\DateRange;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class DashboardController extends BaseController
|
||||
{
|
||||
|
|
@ -18,23 +19,26 @@ public function __construct(
|
|||
*/
|
||||
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 {
|
||||
// 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([
|
||||
'article_stats' => $articleStats,
|
||||
'system_stats' => $systemStats,
|
||||
'available_periods' => $availablePeriods,
|
||||
'current_period' => $period,
|
||||
'article_stats' => $this->dashboardStatsService->getStats($range),
|
||||
'system_stats' => $this->dashboardStatsService->getSystemStats(),
|
||||
'range' => [
|
||||
'from' => $range->from->toDateString(),
|
||||
'to' => $range->to->toDateString(),
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->sendError('Failed to fetch dashboard stats: '.$e->getMessage(), [], 500);
|
||||
|
|
|
|||
|
|
@ -3,35 +3,132 @@
|
|||
namespace App\Livewire;
|
||||
|
||||
use App\Services\DashboardStatsService;
|
||||
use App\Support\DateRange;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Carbon;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Dashboard extends Component
|
||||
{
|
||||
public string $period = 'today';
|
||||
public string $from = '';
|
||||
|
||||
public string $to = '';
|
||||
|
||||
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
|
||||
{
|
||||
$service = app(DashboardStatsService::class);
|
||||
|
||||
$articleStats = $service->getStats($this->period);
|
||||
$systemStats = $service->getSystemStats();
|
||||
$availablePeriods = $service->getAvailablePeriods();
|
||||
|
||||
return view('livewire.dashboard', [
|
||||
'articleStats' => $articleStats,
|
||||
'systemStats' => $systemStats,
|
||||
'availablePeriods' => $availablePeriods,
|
||||
'presets' => $this->presets(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,33 +8,25 @@
|
|||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use Carbon\Carbon;
|
||||
use App\Support\DateRange;
|
||||
|
||||
class DashboardStatsService
|
||||
{
|
||||
/**
|
||||
* @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
|
||||
$articlesFetchedQuery = Article::query();
|
||||
if ($dateRange) {
|
||||
$articlesFetchedQuery->whereBetween('created_at', $dateRange);
|
||||
}
|
||||
$articlesFetched = $articlesFetchedQuery->count();
|
||||
$articlesFetched = Article::query()
|
||||
->whereBetween('created_at', $bounds)
|
||||
->count();
|
||||
|
||||
// Get articles published for the period
|
||||
$articlesPublishedQuery = ArticlePublication::query()
|
||||
->whereNotNull('published_at');
|
||||
if ($dateRange) {
|
||||
$articlesPublishedQuery->whereBetween('published_at', $dateRange);
|
||||
}
|
||||
$articlesPublished = $articlesPublishedQuery->count();
|
||||
$articlesPublished = ArticlePublication::query()
|
||||
->whereBetween('published_at', $bounds)
|
||||
->count();
|
||||
|
||||
// Calculate published percentage
|
||||
$publishedPercentage = $articlesFetched > 0 ? round(($articlesPublished / $articlesFetched) * 100, 1) : 0.0;
|
||||
|
||||
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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
<div class="ml-4">
|
||||
<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">
|
||||
{{ $systemStats['active_feeds'] }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $systemStats['total_feeds'] }}</span>
|
||||
{{ $this->systemStats['active_feeds'] }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $this->systemStats['total_feeds'] }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -34,8 +34,8 @@
|
|||
<div class="ml-4">
|
||||
<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">
|
||||
{{ $systemStats['active_platform_accounts'] }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $systemStats['total_platform_accounts'] }}</span>
|
||||
{{ $this->systemStats['active_platform_accounts'] }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $this->systemStats['total_platform_accounts'] }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -52,8 +52,8 @@
|
|||
<div class="ml-4">
|
||||
<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">
|
||||
{{ $systemStats['active_platform_channels'] }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $systemStats['total_platform_channels'] }}</span>
|
||||
{{ $this->systemStats['active_platform_channels'] }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $this->systemStats['total_platform_channels'] }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -70,8 +70,8 @@
|
|||
<div class="ml-4">
|
||||
<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">
|
||||
{{ $systemStats['active_routes'] }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $systemStats['total_routes'] }}</span>
|
||||
{{ $this->systemStats['active_routes'] }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ $this->systemStats['total_routes'] }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -81,18 +81,51 @@
|
|||
|
||||
<!-- Article Statistics -->
|
||||
<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>
|
||||
<select
|
||||
wire:model.live="period"
|
||||
class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600"
|
||||
>
|
||||
@foreach ($availablePeriods as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach ($presets as $value => $label)
|
||||
<button
|
||||
type="button"
|
||||
wire:click="applyPreset('{{ $value }}')"
|
||||
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
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<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>
|
||||
|
||||
@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 -->
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800">
|
||||
<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">
|
||||
<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">
|
||||
{{ $articleStats['articles_fetched'] }}
|
||||
{{ $this->articleStats['articles_fetched'] }}
|
||||
</p>
|
||||
</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">
|
||||
<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">
|
||||
{{ $articleStats['articles_published'] }}
|
||||
{{ $this->articleStats['articles_published'] }}
|
||||
</p>
|
||||
</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">
|
||||
<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">
|
||||
{{ $articleStats['published_percentage'] }}%
|
||||
{{ $this->articleStats['published_percentage'] }}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\PlatformChannel;
|
||||
use App\Models\Route;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardControllerTest extends TestCase
|
||||
|
|
@ -35,72 +36,103 @@ public function test_stats_returns_successful_response(): void
|
|||
'total_routes',
|
||||
'active_routes',
|
||||
],
|
||||
'available_periods',
|
||||
'current_period',
|
||||
'range' => ['from', 'to'],
|
||||
],
|
||||
'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'];
|
||||
|
||||
foreach ($periods as $period) {
|
||||
$this
|
||||
->getJson("/api/v1/dashboard/stats?period={$period}")
|
||||
->getJson('/api/v1/dashboard/stats')
|
||||
->assertStatus(200)
|
||||
->assertJsonMissingPath('data.available_periods')
|
||||
->assertJsonMissingPath('data.current_period');
|
||||
}
|
||||
|
||||
public function test_stats_honours_an_explicit_from_and_to_range(): void
|
||||
{
|
||||
Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||
Article::factory()->create(['created_at' => Carbon::parse('2026-08-05 12:00:00')]);
|
||||
|
||||
$this
|
||||
->getJson('/api/v1/dashboard/stats?from=2026-07-01&to=2026-07-31')
|
||||
->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'current_period' => $period,
|
||||
'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
|
||||
{
|
||||
// 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]);
|
||||
$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(['feed_id' => $feed->id]);
|
||||
$articles = Article::factory()->count(3)->create([
|
||||
'feed_id' => $feed->id,
|
||||
'created_at' => Carbon::parse('2026-07-10 12:00:00'),
|
||||
]);
|
||||
|
||||
// Publish one article
|
||||
ArticlePublication::factory()->create([
|
||||
'article_id' => $articles->first()->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)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'article_stats' => [
|
||||
'articles_fetched' => $initialArticles + 3,
|
||||
'articles_published' => $initialPublications + 1,
|
||||
'articles_fetched' => 3,
|
||||
'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
|
||||
|
|
|
|||
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;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\ArticlePublication;
|
||||
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;
|
||||
|
||||
class DashboardStatsServiceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
use RefreshDatabase;
|
||||
|
||||
private function service(): DashboardStatsService
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Mock HTTP requests to prevent external calls
|
||||
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;
|
||||
$periods = $service->getAvailablePeriods();
|
||||
Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||
Article::factory()->create(['created_at' => Carbon::parse('2026-08-01 12:00:00')]);
|
||||
|
||||
$this->assertIsArray($periods);
|
||||
$this->assertArrayHasKey('today', $periods);
|
||||
$this->assertArrayHasKey('week', $periods);
|
||||
$this->assertArrayHasKey('month', $periods);
|
||||
$this->assertArrayHasKey('year', $periods);
|
||||
$this->assertArrayHasKey('all', $periods);
|
||||
$stats = $this->service()->getStats(new DateRange(
|
||||
Carbon::parse('2026-07-01 00:00:00'),
|
||||
Carbon::parse('2026-07-31 23:59:59'),
|
||||
));
|
||||
|
||||
$this->assertEquals('Today', $periods['today']);
|
||||
$this->assertEquals('All Time', $periods['all']);
|
||||
$this->assertSame(2, $stats['articles_fetched']);
|
||||
}
|
||||
|
||||
public function test_service_instantiation(): void
|
||||
public function test_it_counts_articles_published_within_the_range(): void
|
||||
{
|
||||
$service = new DashboardStatsService;
|
||||
$this->assertInstanceOf(DashboardStatsService::class, $service);
|
||||
ArticlePublication::factory()->create(['published_at' => Carbon::parse('2026-07-10 12:00:00')]);
|
||||
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