Compare commits

...

10 commits

41 changed files with 2234 additions and 51 deletions

1
.gitignore vendored
View file

@ -26,3 +26,4 @@ yarn-error.log
/.php-cs-fixer.dist.php
/.php-cs-fixer.cache
/.codewhale
.aider*

View file

@ -0,0 +1,53 @@
<?php
namespace App\Dashboard\Stats;
use App\Enums\ApprovalStatusEnum;
use App\Models\RouteArticle;
use App\Support\DateRange;
class ApprovalRate extends DailySeriesStat
{
public function key(): string
{
return 'approval-rate';
}
public function label(): string
{
return 'Approval Rate';
}
protected function series(DateRange $range, array $days): SeriesResult
{
$decisions = RouteArticle::query()
->toBase()
->whereNotNull('decided_at')
->whereBetween('decided_at', [$range->from, $range->to])
->whereIn('approval_status', ApprovalStatusEnum::decidedValues())
->selectRaw(
'DATE(decided_at) as bucket, COUNT(*) as total, SUM(CASE WHEN approval_status = ? THEN 1 ELSE 0 END) as approved',
[ApprovalStatusEnum::APPROVED->value],
)
->groupBy('bucket')
->get()
->keyBy('bucket');
$values = array_map(
function (string $day) use ($decisions): ?float {
$decision = $decisions->get($day);
if ($decision === null || (int) $decision->total === 0) {
return null;
}
return round(((int) $decision->approved / (int) $decision->total) * 100, 1);
},
$days,
);
return new SeriesResult($days, [
new Series('Approval Rate', $values, zeroIsMeaningful: true),
]);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Dashboard\Stats;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Support\DateRange;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class ArticlesTrend extends DailySeriesStat
{
public function key(): string
{
return 'articles-trend';
}
public function label(): string
{
return 'Articles Fetched vs Published';
}
protected function series(DateRange $range, array $days): SeriesResult
{
return new SeriesResult($days, [
new Series('Fetched', $this->countByDay(Article::query(), 'created_at', $range, $days)),
new Series('Published', $this->countByDay(ArticlePublication::query(), 'published_at', $range, $days)),
]);
}
/**
* @param Builder<covariant Model> $query
* @param array<int, string> $days
* @return array<int, int>
*/
private function countByDay(Builder $query, string $column, DateRange $range, array $days): array
{
/** @var array<string, int> $counts */
$counts = $query
->whereBetween($column, [$range->from, $range->to])
->selectRaw("DATE({$column}) as bucket, COUNT(*) as aggregate")
->groupBy('bucket')
->pluck('aggregate', 'bucket')
->all();
return array_map(
fn (string $day): int => (int) ($counts[$day] ?? 0),
$days,
);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Dashboard\Stats;
use App\Support\DateRange;
abstract class DailySeriesStat implements SeriesStat
{
final public function for(DateRange $range): SeriesResult
{
if (! $range->isBucketableByDay()) {
return SeriesResult::tooWide();
}
return $this->series($range, $range->days());
}
/**
* @param array<int, string> $days
*/
abstract protected function series(DateRange $range, array $days): SeriesResult;
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Dashboard\Stats;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Support\DateRange;
class PublicationsPerChannel implements BreakdownStat
{
public function key(): string
{
return 'publications-per-channel';
}
public function label(): string
{
return 'Publications per Channel';
}
public function for(DateRange $range): BreakdownResult
{
/** @var array<int, int> $counts */
$counts = ArticlePublication::query()
->whereBetween('published_at', [$range->from, $range->to])
->selectRaw('platform_channel_id, COUNT(*) as aggregate')
->groupBy('platform_channel_id')
->pluck('aggregate', 'platform_channel_id')
->all();
// Zero-fill in PHP; assumes the channel table stays small enough to load whole.
$rows = PlatformChannel::query()
->get()
->map(fn (PlatformChannel $channel): Breakdown => new Breakdown(
$channel->display_name,
(int) ($counts[$channel->id] ?? 0),
))
->all();
usort(
$rows,
fn (Breakdown $a, Breakdown $b): int => [$b->count, $a->label] <=> [$a->count, $b->label],
);
return new BreakdownResult($rows);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Dashboard\Stats;
use App\Enums\PublishStatusEnum;
use App\Models\RouteArticle;
use App\Support\DateRange;
class PublishSuccessRate extends DailySeriesStat
{
public function key(): string
{
return 'publish-success-rate';
}
public function label(): string
{
return 'Publish Success Rate';
}
protected function series(DateRange $range, array $days): SeriesResult
{
// Buckets follow updated_at, so a retry re-dates its article to the retry day.
$attempts = RouteArticle::query()
->toBase()
->whereBetween('updated_at', [$range->from, $range->to])
->whereIn('publish_status', PublishStatusEnum::settledValues())
->selectRaw(
'DATE(updated_at) as bucket, COUNT(*) as total, SUM(CASE WHEN publish_status = ? THEN 1 ELSE 0 END) as published',
[PublishStatusEnum::PUBLISHED->value],
)
->groupBy('bucket')
->get()
->keyBy('bucket');
$values = array_map(
function (string $day) use ($attempts): ?float {
$attempt = $attempts->get($day);
if ($attempt === null || (int) $attempt->total === 0) {
return null;
}
return round(((int) $attempt->published / (int) $attempt->total) * 100, 1);
},
$days,
);
return new SeriesResult($days, [
new Series('Publish Success Rate', $values, zeroIsMeaningful: true),
]);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Dashboard\Stats;
class Series
{
/**
* @param array<int, int|float|null> $values
* @param bool $zeroIsMeaningful A rate of 0 is a real measurement; a count of 0 is an absence.
*/
public function __construct(
public readonly string $name,
public readonly array $values,
public readonly bool $zeroIsMeaningful = false,
) {}
public function hasData(): bool
{
foreach ($this->values as $value) {
if ($value === null) {
continue;
}
if ($this->zeroIsMeaningful || $value != 0) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Dashboard\Stats;
use InvalidArgumentException;
class SeriesResult
{
private bool $tooWide = false;
/**
* @param array<int, string> $labels
* @param array<int, Series> $series
*/
public function __construct(
public readonly array $labels,
public readonly array $series,
) {
foreach ($series as $one) {
if (count($one->values) !== count($labels)) {
throw new InvalidArgumentException(
"Series [{$one->name}] has ".count($one->values).' values for '.count($labels).' labels.'
);
}
}
}
public static function tooWide(): self
{
$result = new self([], []);
$result->tooWide = true;
return $result;
}
public function isTooWide(): bool
{
return $this->tooWide;
}
/** True only when no axis was built; a real range always has one label per day. */
public function isEmpty(): bool
{
return ! $this->tooWide && $this->labels === [];
}
/** True when no series carries a measurement — an axis exists but nothing happened on it. */
public function hasNoData(): bool
{
foreach ($this->series as $one) {
if ($one->hasData()) {
return false;
}
}
return true;
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Dashboard\Stats;
use App\Support\DateRange;
interface SeriesStat extends Stat
{
public function for(DateRange $range): SeriesResult;
}

View file

@ -7,4 +7,20 @@ enum ApprovalStatusEnum: string
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function isDecided(): bool
{
return $this !== self::PENDING;
}
/**
* @return array<int, string>
*/
public static function decidedValues(): array
{
return array_values(array_map(
fn (self $status): string => $status->value,
array_filter(self::cases(), fn (self $status): bool => $status->isDecided()),
));
}
}

View file

@ -9,4 +9,21 @@ enum PublishStatusEnum: string
case PUBLISHED = 'published';
case SKIPPED = 'skipped';
case ERROR = 'error';
/** Skipped articles were never attempted, so they are not a publish outcome. */
public function isSettled(): bool
{
return $this === self::PUBLISHED || $this === self::ERROR;
}
/**
* @return array<int, string>
*/
public static function settledValues(): array
{
return array_values(array_map(
fn (self $status): string => $status->value,
array_filter(self::cases(), fn (self $status): bool => $status->isSettled()),
));
}
}

View file

@ -71,7 +71,10 @@ public function reject(RouteArticle $routeArticle): JsonResponse
public function restore(RouteArticle $routeArticle): JsonResponse
{
try {
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
$routeArticle->update([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
return $this->sendResponse(
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
@ -88,7 +91,10 @@ public function clear(): JsonResponse
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
->update([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_at' => now(),
]);
return $this->sendResponse(
['rejected_count' => $count],

View file

@ -74,13 +74,19 @@ public function reject(int $routeArticleId): void
public function restore(int $routeArticleId): void
{
$routeArticle = RouteArticle::findOrFail($routeArticleId);
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
$routeArticle->update([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
}
public function clear(): void
{
$feed = $this->feedId !== null ? Feed::find($this->feedId) : null;
$cleared = $this->clearableQuery()->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
$cleared = $this->clearableQuery()->update([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_at' => now(),
]);
if ($cleared > 0) {
ActivityLogged::dispatch(

View file

@ -2,8 +2,14 @@
namespace App\Livewire;
use App\Dashboard\Stats\ApprovalRate;
use App\Dashboard\Stats\ArticlesPerFeed;
use App\Dashboard\Stats\ArticlesTrend;
use App\Dashboard\Stats\BreakdownResult;
use App\Dashboard\Stats\BreakdownStat;
use App\Dashboard\Stats\PublicationsPerChannel;
use App\Dashboard\Stats\PublishSuccessRate;
use App\Dashboard\Stats\SeriesResult;
use App\Services\DashboardStatsService;
use App\Support\DateRange;
use Illuminate\Contracts\View\View;
@ -20,7 +26,7 @@ class Dashboard extends Component
public function mount(): void
{
$this->applyPreset('today');
$this->applyPreset('month');
}
/**
@ -28,7 +34,14 @@ public function mount(): void
*
* @var array<int, string>
*/
private const RANGE_DEPENDENT_ISLANDS = ['article-statistics', 'articles-per-feed'];
private const RANGE_DEPENDENT_ISLANDS = [
'article-statistics',
'articles-trend',
'approval-rate',
'publish-success-rate',
'articles-per-feed',
'publications-per-channel',
];
public function applyPreset(string $preset): void
{
@ -113,12 +126,56 @@ public function articleStats(): array
}
#[Computed]
public function articlesPerFeed(): BreakdownResult
public function articlesTrend(): SeriesResult
{
$range = $this->range();
return $range instanceof DateRange
? app(ArticlesPerFeed::class)->for($range)
? app(ArticlesTrend::class)->for($range)
: new SeriesResult([], []);
}
#[Computed]
public function approvalRate(): SeriesResult
{
$range = $this->range();
return $range instanceof DateRange
? app(ApprovalRate::class)->for($range)
: new SeriesResult([], []);
}
#[Computed]
public function publishSuccessRate(): SeriesResult
{
$range = $this->range();
return $range instanceof DateRange
? app(PublishSuccessRate::class)->for($range)
: new SeriesResult([], []);
}
#[Computed]
public function articlesPerFeed(): BreakdownResult
{
return $this->breakdown(ArticlesPerFeed::class);
}
#[Computed]
public function publicationsPerChannel(): BreakdownResult
{
return $this->breakdown(PublicationsPerChannel::class);
}
/**
* @param class-string<BreakdownStat> $stat
*/
private function breakdown(string $stat): BreakdownResult
{
$range = $this->range();
return $range instanceof DateRange
? app($stat)->for($range)
: new BreakdownResult([]);
}

View file

@ -24,6 +24,7 @@
* @property int $publish_attempts
* @property Carbon|null $next_attempt_at
* @property Carbon|null $validated_at
* @property Carbon|null $decided_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
@ -41,6 +42,7 @@ class RouteArticle extends Model
'publish_attempts',
'next_attempt_at',
'validated_at',
'decided_at',
];
protected $casts = [
@ -49,6 +51,7 @@ class RouteArticle extends Model
'publish_attempts' => 'integer',
'next_attempt_at' => 'datetime',
'validated_at' => 'datetime',
'decided_at' => 'datetime',
];
/**
@ -105,7 +108,10 @@ public function approve(): void
return;
}
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
$this->update([
'approval_status' => ApprovalStatusEnum::APPROVED,
'decided_at' => now(),
]);
ActivityLogged::dispatch(
ActivityTypeEnum::APPROVE,
@ -123,7 +129,10 @@ public function reject(): void
return;
}
$this->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
$this->update([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_at' => now(),
]);
ActivityLogged::dispatch(
ActivityTypeEnum::REJECT,

View file

@ -83,6 +83,7 @@ private function createRouteArticles(Article $article, string $content): void
[
'approval_status' => $status,
'validated_at' => now(),
'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
]
);
}

View file

@ -46,6 +46,11 @@ public static function presets(): array
];
}
public function isBucketableByDay(): bool
{
return $this->from->copy()->startOfDay()->diffInDays($this->to->copy()->startOfDay()) < self::MAX_DAYS;
}
/**
* Every day the range touches, as Y-m-d, so callers can zero-fill empty buckets.
*
@ -57,7 +62,7 @@ public function days(): array
$cursor = $this->from->copy()->startOfDay();
$last = $this->to->copy()->startOfDay();
if ($cursor->diffInDays($last) >= self::MAX_DAYS) {
if (! $this->isBucketableByDay()) {
throw new InvalidArgumentException(
'A range wider than '.self::MAX_DAYS.' days cannot be bucketed by day; bucket by month instead.'
);

View file

@ -62,6 +62,7 @@ public function pending(): static
{
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
}
@ -70,6 +71,7 @@ public function approved(): static
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::APPROVED,
'validated_at' => now(),
'decided_at' => now(),
]);
}
@ -78,6 +80,7 @@ public function rejected(): static
return $this->state(fn (array $attributes) => [
'approval_status' => ApprovalStatusEnum::REJECTED,
'validated_at' => now(),
'decided_at' => now(),
]);
}
}

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('route_articles', function (Blueprint $table) {
$table->timestamp('decided_at')->nullable()->after('validated_at');
$table->index('decided_at');
});
}
public function down(): void
{
Schema::table('route_articles', function (Blueprint $table) {
$table->dropIndex(['decided_at']);
$table->dropColumn('decided_at');
});
}
};

View file

@ -15,7 +15,7 @@
"vite": "^6.2.4"
},
"dependencies": {
"alpinejs": "^3.14.8",
"axios": "^1.8.0"
"axios": "^1.8.0",
"chart.js": "^4.5.1"
}
}

View file

@ -1 +1,7 @@
import "./bootstrap";
import { Alpine, Livewire } from '../../vendor/livewire/livewire/dist/livewire.esm';
import './bootstrap';
import trendChart from './chart';
Alpine.data('trendChart', trendChart);
Livewire.start();

74
resources/js/chart.js Normal file
View file

@ -0,0 +1,74 @@
import {
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from 'chart.js';
Chart.register(
CategoryScale,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
);
const palette = ['#3b82f6', '#10b981'];
export default function trendChart({ labels = [], series = [], suffix = '', max = null } = {}) {
return {
chart: null,
init() {
this.chart = new Chart(this.$refs.canvas, {
type: 'line',
data: {
labels,
datasets: series.map((one, index) => ({
label: one.name,
data: one.values,
borderColor: palette[index % palette.length],
backgroundColor: palette[index % palette.length],
spanGaps: false,
tension: 0.3,
})),
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
y: {
beginAtZero: true,
max,
ticks: {
precision: 0,
callback: (value) => `${value}${suffix}`,
},
},
},
plugins: {
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${ctx.formattedValue}${suffix}`,
},
},
},
},
});
},
destroy() {
this.chart?.destroy();
this.chart = null;
},
};
}

View file

@ -119,6 +119,6 @@ class="px-4 border-r border-gray-200 dark:border-gray-700 text-gray-500 dark:tex
</div>
</div>
@livewireScripts
@livewireScriptConfig
</body>
</html>

View file

@ -47,6 +47,6 @@
<p>Route your feeds to the Fediverse</p>
</div>
</div>
@livewireScripts
@livewireScriptConfig
</body>
</html>

View file

@ -67,6 +67,6 @@
<p>Route your feeds to the Fediverse</p>
</div>
</div>
@livewireScripts
@livewireScriptConfig
</body>
</html>

View file

@ -183,36 +183,73 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland
</div>
<!-- Articles per Feed -->
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
<!-- Trend and breakdowns -->
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-3">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
@island('articles-per-feed')
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
@if ($this->articlesPerFeed->rows === [])
<p class="text-sm text-gray-500 dark:text-gray-400">No feeds are configured yet.</p>
@else
<ul class="space-y-3">
@foreach ($this->articlesPerFeed->rows as $row)
<li>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200">{{ $row->label }}</span>
<span class="text-gray-500 dark:text-gray-400">
{{ $row->count }}
<span class="ml-1 text-xs">({{ $this->articlesPerFeed->shareOf($row) }}%)</span>
</span>
</div>
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div
class="h-full rounded-full bg-blue-500"
style="width: {{ $this->articlesPerFeed->shareOf($row) }}%"
></div>
</div>
</li>
@endforeach
</ul>
@endif
@island('articles-trend')
@include('livewire.partials.trend-panel', [
'island' => 'articles-trend',
'result' => $this->articlesTrend,
'emptyMessage' => 'No articles or publications in this range.',
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
])
@endisland
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Approval Rate</h2>
@island('approval-rate')
@include('livewire.partials.trend-panel', [
'island' => 'approval-rate',
'result' => $this->approvalRate,
'emptyMessage' => 'No approval decisions in this range.',
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
'suffix' => '%',
'max' => 100,
])
@endisland
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publish Success Rate</h2>
@island('publish-success-rate')
@include('livewire.partials.trend-panel', [
'island' => 'publish-success-rate',
'result' => $this->publishSuccessRate,
'emptyMessage' => 'No publish attempts in this range.',
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
'suffix' => '%',
'max' => 100,
])
@endisland
</div>
<div class="space-y-6">
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
@island('articles-per-feed')
@include('livewire.partials.breakdown-panel', [
'result' => $this->articlesPerFeed,
'emptyMessage' => 'No feeds are configured yet.',
])
@endisland
</div>
<div>
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Publications per Channel</h2>
@island('publications-per-channel')
@include('livewire.partials.breakdown-panel', [
'result' => $this->publicationsPerChannel,
'emptyMessage' => 'No channels are configured yet.',
])
@endisland
</div>
</div>
@endisland
</div>
</div>

View file

@ -0,0 +1,22 @@
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
@if ($result->rows === [])
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p>
@else
<ul class="space-y-3">
@foreach ($result->rows as $row)
<li>
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200">{{ $row->label }}</span>
<span class="text-gray-500 dark:text-gray-400">
{{ $row->count }}
<span class="ml-1 text-xs">({{ $result->shareOf($row) }}%)</span>
</span>
</div>
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div class="h-full rounded-full bg-blue-500" style="width: {{ $result->shareOf($row) }}%"></div>
</div>
</li>
@endforeach
</ul>
@endif
</div>

View file

@ -0,0 +1,17 @@
@php($payload = ['labels' => $result->labels, 'series' => collect($result->series)->map(fn ($series) => ['name' => $series->name, 'values' => $series->values])->all(), 'suffix' => $suffix ?? '', 'max' => $max ?? null])
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
@if ($result->isTooWide())
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $tooWideMessage }}</p>
@elseif ($result->isEmpty() || $result->hasNoData())
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $emptyMessage }}</p>
@else
<div
wire:key="chart-{{ md5($island.json_encode($payload)) }}"
x-data="trendChart({{ Js::from($payload) }})"
class="relative h-72"
>
<canvas x-ref="canvas"></canvas>
</div>
@endif
</div>

View file

@ -2,9 +2,13 @@
namespace Tests\Feature\Livewire;
use App\Enums\ApprovalStatusEnum;
use App\Enums\PublishStatusEnum;
use App\Livewire\Dashboard;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Livewire\Features\SupportTesting\Testable;
@ -15,13 +19,13 @@ class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_it_defaults_to_the_current_day(): void
public function test_it_defaults_to_the_current_month(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
Livewire::test(Dashboard::class)
->assertSet('from', '2026-07-15')
->assertSet('to', '2026-07-15');
->assertSet('from', '2026-07-01')
->assertSet('to', '2026-07-31');
}
public function test_it_recomputes_stats_when_the_range_changes(): void
@ -62,8 +66,8 @@ public function test_it_ignores_an_unknown_preset(): void
Livewire::test(Dashboard::class)
->call('applyPreset', 'fortnight')
->assertSet('from', '2026-07-15')
->assertSet('to', '2026-07-15')
->assertSet('from', '2026-07-01')
->assertSet('to', '2026-07-31')
->assertHasNoErrors();
}
@ -223,6 +227,29 @@ public function test_it_refreshes_the_articles_per_feed_island_when_the_range_ch
$this->assertStringContainsString('Example Feed', $fragments);
}
public function test_it_renders_the_publications_per_channel_breakdown_on_mount(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
PlatformChannel::factory()->create(['display_name' => 'Example Channel']);
Livewire::test(Dashboard::class)
->assertSee('Publications per Channel')
->assertSee('Example Channel');
}
public function test_it_refreshes_the_publications_per_channel_island_when_the_range_changes(): void
{
PlatformChannel::factory()->create(['display_name' => 'Example Channel']);
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('name=publications-per-channel|', $fragments);
$this->assertStringContainsString('Example Channel', $fragments);
}
public function test_it_re_renders_the_article_statistics_island_when_a_preset_is_applied(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
@ -235,4 +262,223 @@ public function test_it_re_renders_the_article_statistics_island_when_a_preset_i
$this->assertMatchesRegularExpression('/Articles Fetched.*?>\s*2\s*</s', $fragments);
}
public function test_it_renders_the_trend_chart_on_mount(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
Article::factory()->create(['created_at' => Carbon::parse('2026-07-15 09:00:00')]);
Livewire::test(Dashboard::class)
->assertSee('Fetched vs Published')
->assertSee('trendChart(', false)
->assertSee('x-ref="canvas"', false);
}
public function test_it_renders_the_trend_chart_in_its_own_island(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('name=articles-trend|', $fragments);
}
public function test_it_refreshes_the_trend_island_when_the_range_changes(): void
{
Article::factory()->count(2)->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('name=articles-trend|', $fragments);
$this->assertStringContainsString('2026-07-10', $fragments);
$this->assertStringContainsString('Fetched', $fragments);
$this->assertStringContainsString('Published', $fragments);
}
public function test_it_reports_a_too_wide_range_instead_of_charting_it(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyPreset', 'all')
);
$this->assertStringContainsString('too wide to chart by day', $fragments);
$this->assertStringNotContainsString('x-ref="canvas"', $fragments);
}
public function test_it_reports_an_empty_range_separately_from_a_too_wide_one(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('No articles or publications in this range.', $fragments);
$this->assertStringNotContainsString('too wide to chart by day', $fragments);
}
public function test_it_keys_the_chart_on_its_data_so_a_range_change_replaces_it(): void
{
Article::factory()->create(['created_at' => Carbon::parse('2026-07-10 12:00:00')]);
Article::factory()->create(['created_at' => Carbon::parse('2026-08-10 12:00:00')]);
$july = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$august = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-08-01', '2026-08-31')
);
preg_match('/wire:key="(chart-[a-f0-9]+)"/', $july, $julyKey);
preg_match('/wire:key="(chart-[a-f0-9]+)"/', $august, $augustKey);
$this->assertNotEmpty($julyKey);
$this->assertNotEmpty($augustKey);
$this->assertNotSame($julyKey[1], $augustKey[1]);
}
public function test_it_keys_each_chart_separately_when_their_payloads_match(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
preg_match_all('/wire:key="(chart-[a-f0-9]+)"/', $fragments, $keys);
$this->assertSame($keys[1], array_unique($keys[1]));
}
public function test_it_renders_the_approval_rate_chart_on_mount(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::APPROVED,
'decided_at' => Carbon::parse('2026-07-15 09:00:00'),
]);
Livewire::test(Dashboard::class)
->assertSee('Approval Rate')
->assertSee('trendChart(', false)
->assertSee('x-ref="canvas"', false);
}
public function test_it_refreshes_the_approval_rate_island_when_the_range_changes(): void
{
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::APPROVED,
'decided_at' => Carbon::parse('2026-07-10 12:00:00'),
]);
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('name=approval-rate|', $fragments);
$this->assertStringContainsString('Approval Rate', $fragments);
$this->assertStringContainsString('2026-07-10', $fragments);
}
public function test_it_charts_a_day_where_everything_was_rejected(): void
{
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_at' => Carbon::parse('2026-07-10 12:00:00'),
]);
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringNotContainsString('No approval decisions in this range.', $fragments);
}
public function test_it_reports_a_range_with_no_approval_decisions(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('No approval decisions in this range.', $fragments);
}
public function test_it_renders_an_empty_trend_for_an_invalid_range(): void
{
Livewire::test(Dashboard::class)
->set('from', 'not-a-date')
->assertSee('Fetched vs Published')
->assertDontSee('x-ref="canvas"', false);
}
private function publishAttempt(PublishStatusEnum $status, string $updatedAt): void
{
$routeArticle = RouteArticle::factory()->create(['publish_status' => $status]);
// Model::update() would re-stamp updated_at to now(); bypass via the query builder.
RouteArticle::query()
->whereKey($routeArticle->getKey())
->update(['updated_at' => Carbon::parse($updatedAt)]);
}
public function test_it_renders_the_publish_success_rate_chart_on_mount(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
$this->publishAttempt(PublishStatusEnum::PUBLISHED, '2026-07-15 09:00:00');
Livewire::test(Dashboard::class)
->assertSee('Publish Success Rate')
->assertSee('trendChart(', false)
->assertSee('x-ref="canvas"', false);
}
public function test_it_refreshes_the_publish_success_rate_island_when_the_range_changes(): void
{
$this->publishAttempt(PublishStatusEnum::PUBLISHED, '2026-07-10 12:00:00');
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('name=publish-success-rate|', $fragments);
$this->assertStringContainsString('Publish Success Rate', $fragments);
$this->assertStringContainsString('2026-07-10', $fragments);
}
public function test_it_charts_a_day_where_every_publish_failed(): void
{
$this->publishAttempt(PublishStatusEnum::ERROR, '2026-07-10 12:00:00');
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringNotContainsString('No publish attempts in this range.', $fragments);
}
public function test_it_reports_a_range_with_no_publish_attempts(): void
{
$this->publishAttempt(PublishStatusEnum::SKIPPED, '2026-07-10 12:00:00');
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
$this->assertStringContainsString('No publish attempts in this range.', $fragments);
}
public function test_every_range_dependent_island_re_renders_on_a_range_change(): void
{
$fragments = $this->islandFragments(
Livewire::test(Dashboard::class)->call('applyRange', '2026-07-01', '2026-07-31')
);
foreach (['articles-trend', 'approval-rate', 'publish-success-rate', 'articles-per-feed', 'publications-per-channel'] as $island) {
$this->assertStringContainsString("name={$island}|", $fragments);
}
}
}

View file

@ -0,0 +1,174 @@
<?php
namespace Tests\Feature;
use App\Enums\ApprovalStatusEnum;
use App\Livewire\Articles;
use App\Models\Article;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\RouteArticle;
use App\Models\User;
use App\Services\Article\ArticleFetcher;
use App\Services\Article\ValidationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class RouteArticleDecisionStampingTest extends TestCase
{
use RefreshDatabase;
public function test_clearing_pending_articles_stamps_every_rejected_row(): void
{
$pending = RouteArticle::factory()->count(3)->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
Livewire::test(Articles::class)->call('clear');
foreach ($pending as $routeArticle) {
/** @var RouteArticle $fresh */
$fresh = $routeArticle->fresh();
$this->assertNotNull($fresh->decided_at);
}
}
public function test_the_api_clear_endpoint_stamps_every_rejected_row(): void
{
$pending = RouteArticle::factory()->count(3)->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
$this->actingAs(User::factory()->create())
->postJson('/api/v1/route-articles/clear')
->assertOk();
foreach ($pending as $routeArticle) {
/** @var RouteArticle $fresh */
$fresh = $routeArticle->fresh();
$this->assertNotNull($fresh->decided_at);
}
}
public function test_restoring_to_pending_clears_the_decision_time(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_at' => now(),
]);
Livewire::test(Articles::class)->call('restore', $routeArticle->id);
$this->assertNull($routeArticle->fresh()->decided_at);
}
public function test_the_api_restore_endpoint_clears_the_decision_time(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::REJECTED,
'decided_at' => now(),
]);
$this->actingAs(User::factory()->create())
->postJson("/api/v1/route-articles/{$routeArticle->id}/restore")
->assertOk();
$this->assertNull($routeArticle->fresh()->decided_at);
}
public function test_auto_approved_articles_are_stamped_on_creation(): void
{
$article = $this->articleOnRouteWithoutKeywords(autoApprove: true);
$this->validate($article);
$routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
$this->assertSame(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status);
$this->assertNotNull($routeArticle->decided_at);
}
public function test_keyword_rejected_articles_are_stamped_on_creation(): void
{
$article = $this->articleOnRouteWithKeyword('nothing-that-matches');
$this->validate($article);
$routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
$this->assertSame(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status);
$this->assertNotNull($routeArticle->decided_at);
}
public function test_pending_articles_are_not_stamped_on_creation(): void
{
$article = $this->articleOnRouteWithoutKeywords(autoApprove: false);
$this->validate($article);
$routeArticle = RouteArticle::where('article_id', $article->id)->firstOrFail();
$this->assertSame(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
$this->assertNull($routeArticle->decided_at);
}
private function validate(Article $article): void
{
$fetcher = Mockery::mock(ArticleFetcher::class);
$fetcher->shouldReceive('fetchArticleData')
->with($article)
->once()
->andReturn([
'title' => 'Test Title',
'description' => 'Test description',
'full_article' => 'Body text',
]);
(new ValidationService($fetcher))->validate($article);
}
private function articleOnRouteWithoutKeywords(bool $autoApprove): Article
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
Route::factory()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
'auto_approve' => $autoApprove,
]);
return Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => null]);
}
private function articleOnRouteWithKeyword(string $keyword): Article
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
Route::factory()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true,
]);
Keyword::factory()->active()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'keyword' => $keyword,
]);
return Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => null]);
}
}

View file

@ -0,0 +1,149 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\ApprovalRate;
use App\Dashboard\Stats\DailySeriesStat;
use App\Enums\ApprovalStatusEnum;
use App\Models\RouteArticle;
use App\Support\DateRange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ApprovalRateTest extends TestCase
{
use RefreshDatabase;
private function range(): DateRange
{
return new DateRange(
Carbon::parse('2026-07-01 00:00:00'),
Carbon::parse('2026-07-31 23:59:59'),
);
}
/**
* @return array<string, float|null>
*/
private function values(): array
{
$result = (new ApprovalRate)->for($this->range());
return array_combine($result->labels, $result->series[0]->values);
}
private function decision(ApprovalStatusEnum $status, string $decidedAt): void
{
RouteArticle::factory()->create([
'approval_status' => $status,
'decided_at' => Carbon::parse($decidedAt),
]);
}
public function test_it_computes_the_approval_share_of_daily_decisions(): void
{
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 10:00:00');
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 11:00:00');
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 12:00:00');
$this->assertSame(75.0, $this->values()['2026-07-10']);
}
public function test_it_excludes_pending_articles_from_the_denominator(): void
{
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
$this->assertSame(100.0, $this->values()['2026-07-10']);
}
public function test_it_reports_no_rate_for_a_day_with_no_decisions(): void
{
$this->decision(ApprovalStatusEnum::APPROVED, '2026-07-10 09:00:00');
$this->assertNull($this->values()['2026-07-11']);
}
public function test_it_reports_a_zero_rate_when_everything_was_rejected(): void
{
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 09:00:00');
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 10:00:00');
$this->assertSame(0.0, $this->values()['2026-07-10']);
}
public function test_it_buckets_by_decision_time_not_creation_time(): void
{
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::APPROVED,
'created_at' => Carbon::parse('2026-07-05 09:00:00'),
'decided_at' => Carbon::parse('2026-07-20 09:00:00'),
]);
$values = $this->values();
$this->assertNull($values['2026-07-05']);
$this->assertSame(100.0, $values['2026-07-20']);
}
public function test_it_ignores_articles_decided_before_the_column_existed(): void
{
RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::APPROVED,
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
'decided_at' => null,
]);
$this->assertNull($this->values()['2026-07-10']);
}
public function test_it_excludes_decisions_outside_the_range(): void
{
$this->decision(ApprovalStatusEnum::APPROVED, '2026-06-30 23:59:59');
$this->decision(ApprovalStatusEnum::APPROVED, '2026-08-01 00:00:00');
$this->assertSame(
[],
array_filter($this->values(), fn (?float $value): bool => $value !== null)
);
}
public function test_it_reports_a_meaningful_zero_as_data(): void
{
$this->decision(ApprovalStatusEnum::REJECTED, '2026-07-10 09:00:00');
$this->assertFalse((new ApprovalRate)->for($this->range())->hasNoData());
}
public function test_it_counts_every_decided_status_in_the_denominator(): void
{
$decided = array_filter(
ApprovalStatusEnum::cases(),
fn (ApprovalStatusEnum $status): bool => $status->isDecided(),
);
foreach ($decided as $status) {
$this->decision($status, '2026-07-10 09:00:00');
}
$this->assertSame(
round(100 / count($decided), 1),
$this->values()['2026-07-10'],
);
}
public function test_it_is_a_daily_series_stat(): void
{
$stat = new ApprovalRate;
$this->assertInstanceOf(DailySeriesStat::class, $stat);
$this->assertTrue($stat->for(DateRange::preset('all'))->isTooWide());
}
}

View file

@ -0,0 +1,257 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\ArticlesTrend;
use App\Dashboard\Stats\DailySeriesStat;
use App\Dashboard\Stats\Series;
use App\Dashboard\Stats\SeriesResult;
use App\Enums\PublishStatusEnum;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Models\RouteArticle;
use App\Support\DateRange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ArticlesTrendTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-07-15 12:00:00'));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
private function range(): DateRange
{
return new DateRange(
Carbon::parse('2026-07-01 00:00:00'),
Carbon::parse('2026-07-31 23:59:59'),
);
}
/**
* @return array<string, Series>
*/
private function seriesByName(SeriesResult $result): array
{
$out = [];
foreach ($result->series as $one) {
$out[$one->name] = $one;
}
return $out;
}
public function test_it_counts_articles_fetched_per_day(): void
{
Article::factory()->count(2)->create([
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-11 09:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$days = $result->labels;
$fetched = $this->seriesByName($result)['Fetched'];
$this->assertSame(2, $fetched->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(1, $fetched->values[array_search('2026-07-11', $days, true)]);
}
public function test_it_zero_fills_days_with_no_articles(): void
{
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-12 09:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$days = $result->labels;
$fetched = $this->seriesByName($result)['Fetched'];
$this->assertSame(0, $fetched->values[array_search('2026-07-11', $days, true)]);
$this->assertSame(1, $fetched->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(1, $fetched->values[array_search('2026-07-12', $days, true)]);
}
public function test_it_excludes_articles_outside_the_range(): void
{
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-01 00:00:00'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-31 23:59:59'),
]);
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'),
]);
$result = (new ArticlesTrend)->for($this->range());
$fetched = $this->seriesByName($result)['Fetched'];
$this->assertSame(2, array_sum($fetched->values));
}
public function test_it_counts_publications_per_day_from_article_publications(): void
{
$article = Article::factory()->create([
'created_at' => Carbon::parse('2026-01-01 00:00:00'),
]);
ArticlePublication::factory()->create([
'article_id' => $article->id,
'published_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
ArticlePublication::factory()->count(2)->create([
'article_id' => $article->id,
'published_at' => Carbon::parse('2026-07-11 09:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$days = $result->labels;
$published = $this->seriesByName($result)['Published'];
$this->assertSame(1, $published->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(2, $published->values[array_search('2026-07-11', $days, true)]);
}
public function test_it_does_not_source_the_published_series_from_route_article_publish_status(): void
{
$article = Article::factory()->create([
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
RouteArticle::factory()->create([
'article_id' => $article->id,
'publish_status' => PublishStatusEnum::PUBLISHED,
'created_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
$result = (new ArticlesTrend)->for($this->range());
$published = $this->seriesByName($result)['Published'];
$this->assertSame(0, array_sum($published->values));
}
public function test_it_counts_each_channel_publication_of_one_article_separately(): void
{
$article = Article::factory()->create([
'created_at' => Carbon::parse('2026-01-01 00:00:00'),
]);
$channels = PlatformChannel::factory()->count(3)->create();
foreach ($channels as $channel) {
ArticlePublication::factory()->create([
'article_id' => $article->id,
'platform_channel_id' => $channel->id,
'published_at' => Carbon::parse('2026-07-10 09:00:00'),
]);
}
$result = (new ArticlesTrend)->for($this->range());
$published = $this->seriesByName($result)['Published'];
$this->assertSame(3, array_sum($published->values));
}
public function test_it_returns_both_series_over_one_shared_label_axis(): void
{
$result = (new ArticlesTrend)->for($this->range());
$this->assertCount(count($this->range()->days()), $result->labels);
$this->assertCount(2, $result->series);
$names = array_map(fn ($series) => $series->name, $result->series);
$this->assertSame(['Fetched', 'Published'], $names);
foreach ($result->series as $series) {
$this->assertCount(count($result->labels), $series->values);
}
}
public function test_it_splits_days_at_midnight(): void
{
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-10 23:59:59'),
]);
Article::factory()->create([
'created_at' => Carbon::parse('2026-07-11 00:00:01'),
]);
$publishedArticle = Article::factory()->create([
'created_at' => Carbon::parse('2026-01-01 00:00:00'),
]);
ArticlePublication::factory()->create([
'article_id' => $publishedArticle->id,
'published_at' => Carbon::parse('2026-07-10 23:59:59'),
]);
ArticlePublication::factory()->create([
'article_id' => $publishedArticle->id,
'published_at' => Carbon::parse('2026-07-11 00:00:01'),
]);
$result = (new ArticlesTrend)->for($this->range());
$days = $result->labels;
$fetched = $this->seriesByName($result)['Fetched'];
$published = $this->seriesByName($result)['Published'];
$this->assertSame(1, $fetched->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(1, $fetched->values[array_search('2026-07-11', $days, true)]);
$this->assertSame(2, array_sum($fetched->values));
$this->assertSame(1, $published->values[array_search('2026-07-10', $days, true)]);
$this->assertSame(1, $published->values[array_search('2026-07-11', $days, true)]);
$this->assertSame(2, array_sum($published->values));
}
public function test_it_is_a_daily_series_stat_and_reports_too_wide_without_querying(): void
{
$stat = new ArticlesTrend;
$this->assertInstanceOf(DailySeriesStat::class, $stat);
Article::factory()->create([
'created_at' => Carbon::now(),
]);
$queries = 0;
DB::listen(function () use (&$queries) {
$queries++;
});
$result = $stat->for(DateRange::preset('all'));
$this->assertTrue($result->isTooWide());
$this->assertSame(0, $queries);
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\DailySeriesStat;
use App\Dashboard\Stats\Series;
use App\Dashboard\Stats\SeriesResult;
use App\Support\DateRange;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class DailySeriesStatTest extends TestCase
{
private function bucketableRange(): DateRange
{
return new DateRange(
Carbon::parse('2026-07-01 00:00:00'),
Carbon::parse('2026-07-07 23:59:59'),
);
}
public function test_for_calls_series_and_returns_its_result_on_a_bucketable_range(): void
{
$range = $this->bucketableRange();
$expected = new SeriesResult($range->days(), [new Series('Approved', [1, 2, 3, 4, 5, 6, 7])]);
$stat = new class($expected) extends DailySeriesStat
{
public bool $seriesWasCalled = false;
public function __construct(private readonly SeriesResult $result) {}
public function key(): string
{
return 'fake';
}
public function label(): string
{
return 'Fake';
}
protected function series(DateRange $range, array $days): SeriesResult
{
$this->seriesWasCalled = true;
return $this->result;
}
};
$result = $stat->for($range);
$this->assertTrue($stat->seriesWasCalled);
$this->assertSame($expected, $result);
}
public function test_for_returns_a_too_wide_result_without_calling_series_on_a_non_bucketable_range(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
$range = DateRange::preset('all');
$stat = new class extends DailySeriesStat
{
public bool $seriesWasCalled = false;
public function key(): string
{
return 'fake';
}
public function label(): string
{
return 'Fake';
}
protected function series(DateRange $range, array $days): SeriesResult
{
$this->seriesWasCalled = true;
return new SeriesResult([], []);
}
};
$result = $stat->for($range);
$this->assertFalse($stat->seriesWasCalled);
$this->assertTrue($result->isTooWide());
}
public function test_for_passes_the_ranges_days_through_to_series(): void
{
$range = $this->bucketableRange();
$stat = new class extends DailySeriesStat
{
/** @var array<int, string>|null */
public ?array $receivedDays = null;
public function key(): string
{
return 'fake';
}
public function label(): string
{
return 'Fake';
}
protected function series(DateRange $range, array $days): SeriesResult
{
$this->receivedDays = $days;
return new SeriesResult($days, []);
}
};
$stat->for($range);
$this->assertSame($range->days(), $stat->receivedDays);
}
}

View file

@ -0,0 +1,126 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\Breakdown;
use App\Dashboard\Stats\PublicationsPerChannel;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Support\DateRange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class PublicationsPerChannelTest extends TestCase
{
use RefreshDatabase;
private function range(): DateRange
{
return new DateRange(
Carbon::parse('2026-07-01 00:00:00'),
Carbon::parse('2026-07-31 23:59:59'),
);
}
private function publish(PlatformChannel $channel, string $at): void
{
ArticlePublication::factory()->create([
'platform_channel_id' => $channel->id,
'published_at' => Carbon::parse($at),
]);
}
public function test_it_counts_publications_per_channel(): void
{
$busy = PlatformChannel::factory()->create(['display_name' => 'Busy Channel']);
$quiet = PlatformChannel::factory()->create(['display_name' => 'Quiet Channel']);
$this->publish($busy, '2026-07-10 12:00:00');
$this->publish($busy, '2026-07-11 12:00:00');
$this->publish($quiet, '2026-07-12 12:00:00');
$rows = (new PublicationsPerChannel)->for($this->range())->rows;
$this->assertSame(2, $this->pluck($rows)['Busy Channel']);
$this->assertSame(1, $this->pluck($rows)['Quiet Channel']);
}
public function test_it_reports_zero_for_a_channel_with_no_publications(): void
{
PlatformChannel::factory()->create(['display_name' => 'Silent Channel']);
$rows = (new PublicationsPerChannel)->for($this->range())->rows;
$this->assertSame(['Silent Channel' => 0], $this->pluck($rows));
}
public function test_it_excludes_publications_outside_the_range(): void
{
$channel = PlatformChannel::factory()->create(['display_name' => 'Channel']);
$this->publish($channel, '2026-06-30 23:59:59');
$this->publish($channel, '2026-08-01 00:00:00');
$rows = (new PublicationsPerChannel)->for($this->range())->rows;
$this->assertSame(0, $this->pluck($rows)['Channel']);
}
public function test_it_breaks_ties_alphabetically(): void
{
foreach (['Zulu', 'Alpha'] as $name) {
$channel = PlatformChannel::factory()->create(['display_name' => $name]);
$this->publish($channel, '2026-07-10 12:00:00');
}
$labels = array_map(
fn (Breakdown $row): string => $row->label,
(new PublicationsPerChannel)->for($this->range())->rows,
);
$this->assertSame(['Alpha', 'Zulu'], array_slice($labels, 0, 2));
}
public function test_it_labels_channels_by_display_name(): void
{
PlatformChannel::factory()->create(['name' => 'raw-name', 'display_name' => 'Friendly Name']);
$rows = (new PublicationsPerChannel)->for($this->range())->rows;
$this->assertArrayHasKey('Friendly Name', $this->pluck($rows));
}
public function test_it_reports_the_range_total(): void
{
$channel = PlatformChannel::factory()->create();
$this->publish($channel, '2026-07-10 12:00:00');
$this->publish($channel, '2026-07-11 12:00:00');
$this->assertSame(2, (new PublicationsPerChannel)->for($this->range())->total());
}
public function test_it_exposes_a_stable_key_and_label(): void
{
$stat = new PublicationsPerChannel;
$this->assertSame('publications-per-channel', $stat->key());
$this->assertSame('Publications per Channel', $stat->label());
}
/**
* @param array<int, Breakdown> $rows
* @return array<string, int>
*/
private function pluck(array $rows): array
{
$out = [];
foreach ($rows as $row) {
$out[$row->label] = $row->count;
}
return $out;
}
}

View file

@ -0,0 +1,143 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\PublishSuccessRate;
use App\Enums\PublishStatusEnum;
use App\Models\RouteArticle;
use App\Support\DateRange;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class PublishSuccessRateTest extends TestCase
{
use RefreshDatabase;
private function range(): DateRange
{
return new DateRange(
Carbon::parse('2026-07-01 00:00:00'),
Carbon::parse('2026-07-31 23:59:59'),
);
}
/**
* @return array<string, float|null>
*/
private function values(): array
{
$result = (new PublishSuccessRate)->for($this->range());
return array_combine($result->labels, $result->series[0]->values);
}
private function attempt(PublishStatusEnum $status, string $updatedAt): RouteArticle
{
/** @var RouteArticle $article */
$article = RouteArticle::factory()->create(['publish_status' => $status]);
// Model::update() would re-stamp updated_at to now(); bypass via the query builder.
RouteArticle::query()
->whereKey($article->getKey())
->update(['updated_at' => Carbon::parse($updatedAt)]);
return $article;
}
public function test_it_computes_the_published_share_of_daily_attempts(): void
{
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00');
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 10:00:00');
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 11:00:00');
$this->attempt(PublishStatusEnum::ERROR, '2026-07-10 12:00:00');
$this->assertSame(75.0, $this->values()['2026-07-10']);
}
public function test_it_excludes_skipped_articles_from_the_denominator(): void
{
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00');
$this->attempt(PublishStatusEnum::SKIPPED, '2026-07-10 10:00:00');
$this->assertSame(100.0, $this->values()['2026-07-10']);
}
public function test_it_excludes_articles_still_in_flight(): void
{
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00');
$this->attempt(PublishStatusEnum::PUBLISHING, '2026-07-10 10:00:00');
$this->attempt(PublishStatusEnum::UNPUBLISHED, '2026-07-10 11:00:00');
$this->assertSame(100.0, $this->values()['2026-07-10']);
}
public function test_it_reports_no_rate_for_a_day_with_no_attempts(): void
{
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00');
$this->assertNull($this->values()['2026-07-11']);
}
public function test_it_reports_a_zero_rate_when_every_attempt_failed(): void
{
$this->attempt(PublishStatusEnum::ERROR, '2026-07-10 09:00:00');
$this->assertSame(0.0, $this->values()['2026-07-10']);
}
public function test_it_counts_an_article_on_the_day_it_was_last_touched(): void
{
$this->attempt(PublishStatusEnum::ERROR, '2026-07-10 09:00:00');
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-12 09:00:00');
$values = $this->values();
$this->assertSame(0.0, $values['2026-07-10']);
$this->assertSame(100.0, $values['2026-07-12']);
}
public function test_a_retry_moves_the_article_to_the_retry_day(): void
{
$article = $this->attempt(PublishStatusEnum::ERROR, '2026-07-10 09:00:00');
RouteArticle::query()
->whereKey($article->getKey())
->update([
'publish_status' => PublishStatusEnum::PUBLISHED,
'updated_at' => Carbon::parse('2026-07-12 09:00:00'),
]);
$values = $this->values();
$this->assertNull($values['2026-07-10']);
$this->assertSame(100.0, $values['2026-07-12']);
}
public function test_it_ignores_attempts_outside_the_range(): void
{
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-06-30 23:00:00');
$this->attempt(PublishStatusEnum::ERROR, '2026-08-01 01:00:00');
$this->assertSame([], array_filter($this->values(), fn (?float $value): bool => $value !== null));
}
public function test_it_rounds_the_rate_to_one_decimal(): void
{
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 09:00:00');
$this->attempt(PublishStatusEnum::PUBLISHED, '2026-07-10 10:00:00');
$this->attempt(PublishStatusEnum::ERROR, '2026-07-10 11:00:00');
$this->assertSame(66.7, $this->values()['2026-07-10']);
}
public function test_it_refuses_to_bucket_a_range_wider_than_the_day_limit(): void
{
$result = (new PublishSuccessRate)->for(new DateRange(
Carbon::parse('2020-01-01 00:00:00'),
Carbon::parse('2026-12-31 23:59:59'),
));
$this->assertTrue($result->isTooWide());
}
}

View file

@ -0,0 +1,123 @@
<?php
namespace Tests\Unit\Dashboard\Stats;
use App\Dashboard\Stats\Series;
use App\Dashboard\Stats\SeriesResult;
use InvalidArgumentException;
use Tests\TestCase;
class SeriesResultTest extends TestCase
{
public function test_a_series_result_aligns_every_series_to_the_shared_label_axis(): void
{
$this->expectException(InvalidArgumentException::class);
new SeriesResult(
['2026-07-01', '2026-07-02', '2026-07-03'],
[new Series('Approved', [1, 2])],
);
}
public function test_a_series_result_accepts_multiple_series_that_all_align(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[
new Series('Approved', [1, 2]),
new Series('Rejected', [0, 1]),
],
);
$this->assertSame(['2026-07-01', '2026-07-02'], $result->labels);
$this->assertCount(2, $result->series);
}
public function test_a_too_wide_result_reports_itself_as_too_wide(): void
{
$result = SeriesResult::tooWide();
$this->assertTrue($result->isTooWide());
}
public function test_a_too_wide_result_is_not_reported_as_merely_empty(): void
{
$result = SeriesResult::tooWide();
$this->assertFalse($result->isEmpty());
}
public function test_an_empty_but_valid_result_is_not_reported_as_too_wide(): void
{
$result = new SeriesResult([], []);
$this->assertFalse($result->isTooWide());
}
public function test_an_empty_but_valid_result_reports_itself_as_empty(): void
{
$result = new SeriesResult([], []);
$this->assertTrue($result->isEmpty());
}
public function test_a_result_whose_series_are_all_zero_has_no_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Fetched', [0, 0]), new Series('Published', [0, 0])],
);
$this->assertTrue($result->hasNoData());
}
public function test_a_result_with_any_non_zero_value_has_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Fetched', [0, 0]), new Series('Published', [0, 1])],
);
$this->assertFalse($result->hasNoData());
}
public function test_a_result_of_only_nulls_has_no_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Approval Rate', [null, null])],
);
$this->assertTrue($result->hasNoData());
}
public function test_a_zero_count_is_an_absence_of_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Fetched', [null, 0])],
);
$this->assertTrue($result->hasNoData());
}
public function test_a_zero_is_data_when_the_series_says_zero_is_meaningful(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Approval Rate', [null, 0.0], zeroIsMeaningful: true)],
);
$this->assertFalse($result->hasNoData());
}
public function test_a_meaningful_zero_series_of_only_nulls_still_has_no_data(): void
{
$result = new SeriesResult(
['2026-07-01', '2026-07-02'],
[new Series('Approval Rate', [null, null], zeroIsMeaningful: true)],
);
$this->assertTrue($result->hasNoData());
}
}

View file

@ -4,6 +4,7 @@
use App\Dashboard\Stats\ArticlesPerFeed;
use App\Dashboard\Stats\BreakdownStat;
use App\Dashboard\Stats\SeriesStat;
use App\Dashboard\Stats\Stat;
use Tests\TestCase;
@ -30,4 +31,9 @@ public function test_articles_per_feed_is_a_breakdown_stat(): void
{
$this->assertInstanceOf(BreakdownStat::class, new ArticlesPerFeed);
}
public function test_a_series_stat_is_a_stat(): void
{
$this->assertTrue(is_subclass_of(SeriesStat::class, Stat::class));
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Tests\Unit\Enums;
use App\Enums\ApprovalStatusEnum;
use PHPUnit\Framework\TestCase;
class ApprovalStatusEnumTest extends TestCase
{
public function test_pending_is_not_a_decision(): void
{
$this->assertFalse(ApprovalStatusEnum::PENDING->isDecided());
}
public function test_approved_and_rejected_are_decisions(): void
{
$this->assertTrue(ApprovalStatusEnum::APPROVED->isDecided());
$this->assertTrue(ApprovalStatusEnum::REJECTED->isDecided());
}
public function test_decided_values_lists_every_decided_case(): void
{
$this->assertSame(['approved', 'rejected'], ApprovalStatusEnum::decidedValues());
}
public function test_decided_values_stays_in_step_with_the_cases(): void
{
$expected = array_values(array_map(
fn (ApprovalStatusEnum $status): string => $status->value,
array_filter(
ApprovalStatusEnum::cases(),
fn (ApprovalStatusEnum $status): bool => $status->isDecided(),
),
));
$this->assertSame($expected, ApprovalStatusEnum::decidedValues());
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Tests\Unit\Enums;
use App\Enums\PublishStatusEnum;
use PHPUnit\Framework\TestCase;
class PublishStatusEnumTest extends TestCase
{
public function test_published_and_error_are_settled(): void
{
$this->assertTrue(PublishStatusEnum::PUBLISHED->isSettled());
$this->assertTrue(PublishStatusEnum::ERROR->isSettled());
}
public function test_skipped_is_not_a_publish_outcome(): void
{
$this->assertFalse(PublishStatusEnum::SKIPPED->isSettled());
}
public function test_in_flight_statuses_are_not_settled(): void
{
$this->assertFalse(PublishStatusEnum::UNPUBLISHED->isSettled());
$this->assertFalse(PublishStatusEnum::PUBLISHING->isSettled());
}
public function test_settled_values_lists_every_settled_case(): void
{
$this->assertSame(['published', 'error'], PublishStatusEnum::settledValues());
}
public function test_settled_values_stays_in_step_with_the_cases(): void
{
$expected = array_values(array_map(
fn (PublishStatusEnum $status): string => $status->value,
array_filter(
PublishStatusEnum::cases(),
fn (PublishStatusEnum $status): bool => $status->isSettled(),
),
));
$this->assertSame($expected, PublishStatusEnum::settledValues());
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace Tests\Unit\Models;
use App\Enums\ApprovalStatusEnum;
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RouteArticleDecisionTest extends TestCase
{
use RefreshDatabase;
public function test_approving_stamps_the_decision_time(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
$this->freezeTime(function () use ($routeArticle) {
$routeArticle->approve();
$this->assertSame(
now()->format('Y-m-d H:i:s'),
$routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
);
});
}
public function test_rejecting_stamps_the_decision_time(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
$this->freezeTime(function () use ($routeArticle) {
$routeArticle->reject();
$this->assertSame(
now()->format('Y-m-d H:i:s'),
$routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
);
});
}
public function test_re_approving_does_not_move_the_decision_time(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
$routeArticle->approve();
$first = $routeArticle->fresh()->decided_at;
$this->travel(1)->hours();
$routeArticle->fresh()->approve();
$this->assertSame(
$first->format('Y-m-d H:i:s'),
$routeArticle->fresh()->decided_at->format('Y-m-d H:i:s')
);
}
public function test_rejecting_an_approved_article_moves_the_decision_time(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
$routeArticle->approve();
$first = $routeArticle->fresh()->decided_at;
$this->travel(1)->hours();
$routeArticle->fresh()->reject();
$this->assertTrue($routeArticle->fresh()->decided_at->greaterThan($first));
}
}

View file

@ -183,4 +183,47 @@ public function test_it_refuses_to_list_days_for_the_all_preset(): void
DateRange::preset('all')->days();
}
public function test_it_is_bucketable_by_day_for_a_normal_range(): void
{
$this->assertTrue(DateRange::preset('week')->isBucketableByDay());
$this->assertTrue(DateRange::preset('month')->isBucketableByDay());
}
public function test_it_is_bucketable_by_day_at_the_maximum_span(): void
{
$from = Carbon::parse('2026-01-01 00:00:00');
$range = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS - 1)->endOfDay());
$this->assertTrue($range->isBucketableByDay());
}
public function test_it_is_not_bucketable_by_day_one_day_beyond_the_maximum_span(): void
{
$from = Carbon::parse('2026-01-01 00:00:00');
$range = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS)->endOfDay());
$this->assertFalse($range->isBucketableByDay());
}
public function test_it_is_not_bucketable_by_day_for_the_all_preset(): void
{
Carbon::setTestNow('2026-07-15 13:45:00');
$this->assertFalse(DateRange::preset('all')->isBucketableByDay());
}
public function test_is_bucketable_by_day_agrees_with_days_at_every_boundary(): void
{
$from = Carbon::parse('2026-01-01 00:00:00');
$withinBounds = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS - 1)->endOfDay());
$withinBounds->days();
$this->assertTrue($withinBounds->isBucketableByDay());
$beyondBounds = new DateRange($from, $from->copy()->addDays(DateRange::MAX_DAYS)->endOfDay());
$this->assertFalse($beyondBounds->isBucketableByDay());
$this->expectException(InvalidArgumentException::class);
$beyondBounds->days();
}
}