Compare commits
10 commits
8a1001d274
...
bf734feb4a
| Author | SHA1 | Date | |
|---|---|---|---|
| bf734feb4a | |||
| cc45b74a63 | |||
| df5013f86d | |||
| 8375c948c6 | |||
| 4b895263e2 | |||
| 7e6e6ce4e1 | |||
| 5f4ef40a76 | |||
| 5b4e311d2d | |||
| f6067eec38 | |||
| 55f76d0d09 |
41 changed files with 2234 additions and 51 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -26,3 +26,4 @@ yarn-error.log
|
||||||
/.php-cs-fixer.dist.php
|
/.php-cs-fixer.dist.php
|
||||||
/.php-cs-fixer.cache
|
/.php-cs-fixer.cache
|
||||||
/.codewhale
|
/.codewhale
|
||||||
|
.aider*
|
||||||
|
|
|
||||||
53
app/Dashboard/Stats/ApprovalRate.php
Normal file
53
app/Dashboard/Stats/ApprovalRate.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Dashboard/Stats/ArticlesTrend.php
Normal file
51
app/Dashboard/Stats/ArticlesTrend.php
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Dashboard/Stats/DailySeriesStat.php
Normal file
22
app/Dashboard/Stats/DailySeriesStat.php
Normal 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;
|
||||||
|
}
|
||||||
47
app/Dashboard/Stats/PublicationsPerChannel.php
Normal file
47
app/Dashboard/Stats/PublicationsPerChannel.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Dashboard/Stats/PublishSuccessRate.php
Normal file
53
app/Dashboard/Stats/PublishSuccessRate.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Dashboard/Stats/Series.php
Normal file
31
app/Dashboard/Stats/Series.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Dashboard/Stats/SeriesResult.php
Normal file
58
app/Dashboard/Stats/SeriesResult.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/Dashboard/Stats/SeriesStat.php
Normal file
10
app/Dashboard/Stats/SeriesStat.php
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dashboard\Stats;
|
||||||
|
|
||||||
|
use App\Support\DateRange;
|
||||||
|
|
||||||
|
interface SeriesStat extends Stat
|
||||||
|
{
|
||||||
|
public function for(DateRange $range): SeriesResult;
|
||||||
|
}
|
||||||
|
|
@ -7,4 +7,20 @@ enum ApprovalStatusEnum: string
|
||||||
case PENDING = 'pending';
|
case PENDING = 'pending';
|
||||||
case APPROVED = 'approved';
|
case APPROVED = 'approved';
|
||||||
case REJECTED = 'rejected';
|
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()),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,21 @@ enum PublishStatusEnum: string
|
||||||
case PUBLISHED = 'published';
|
case PUBLISHED = 'published';
|
||||||
case SKIPPED = 'skipped';
|
case SKIPPED = 'skipped';
|
||||||
case ERROR = 'error';
|
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()),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,10 @@ public function reject(RouteArticle $routeArticle): JsonResponse
|
||||||
public function restore(RouteArticle $routeArticle): JsonResponse
|
public function restore(RouteArticle $routeArticle): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
|
$routeArticle->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->sendResponse(
|
return $this->sendResponse(
|
||||||
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
|
new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])),
|
||||||
|
|
@ -88,7 +91,10 @@ public function clear(): JsonResponse
|
||||||
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
$count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
||||||
|
|
||||||
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
|
RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)
|
||||||
->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
|
->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
return $this->sendResponse(
|
return $this->sendResponse(
|
||||||
['rejected_count' => $count],
|
['rejected_count' => $count],
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,19 @@ public function reject(int $routeArticleId): void
|
||||||
public function restore(int $routeArticleId): void
|
public function restore(int $routeArticleId): void
|
||||||
{
|
{
|
||||||
$routeArticle = RouteArticle::findOrFail($routeArticleId);
|
$routeArticle = RouteArticle::findOrFail($routeArticleId);
|
||||||
$routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]);
|
$routeArticle->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clear(): void
|
public function clear(): void
|
||||||
{
|
{
|
||||||
$feed = $this->feedId !== null ? Feed::find($this->feedId) : null;
|
$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) {
|
if ($cleared > 0) {
|
||||||
ActivityLogged::dispatch(
|
ActivityLogged::dispatch(
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,14 @@
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Dashboard\Stats\ApprovalRate;
|
||||||
use App\Dashboard\Stats\ArticlesPerFeed;
|
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||||
|
use App\Dashboard\Stats\ArticlesTrend;
|
||||||
use App\Dashboard\Stats\BreakdownResult;
|
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\Services\DashboardStatsService;
|
||||||
use App\Support\DateRange;
|
use App\Support\DateRange;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
|
@ -20,7 +26,7 @@ class Dashboard extends Component
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->applyPreset('today');
|
$this->applyPreset('month');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,7 +34,14 @@ public function mount(): void
|
||||||
*
|
*
|
||||||
* @var array<int, string>
|
* @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
|
public function applyPreset(string $preset): void
|
||||||
{
|
{
|
||||||
|
|
@ -113,12 +126,56 @@ public function articleStats(): array
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Computed]
|
#[Computed]
|
||||||
public function articlesPerFeed(): BreakdownResult
|
public function articlesTrend(): SeriesResult
|
||||||
{
|
{
|
||||||
$range = $this->range();
|
$range = $this->range();
|
||||||
|
|
||||||
return $range instanceof DateRange
|
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([]);
|
: new BreakdownResult([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
* @property int $publish_attempts
|
* @property int $publish_attempts
|
||||||
* @property Carbon|null $next_attempt_at
|
* @property Carbon|null $next_attempt_at
|
||||||
* @property Carbon|null $validated_at
|
* @property Carbon|null $validated_at
|
||||||
|
* @property Carbon|null $decided_at
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
*/
|
*/
|
||||||
|
|
@ -41,6 +42,7 @@ class RouteArticle extends Model
|
||||||
'publish_attempts',
|
'publish_attempts',
|
||||||
'next_attempt_at',
|
'next_attempt_at',
|
||||||
'validated_at',
|
'validated_at',
|
||||||
|
'decided_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|
@ -49,6 +51,7 @@ class RouteArticle extends Model
|
||||||
'publish_attempts' => 'integer',
|
'publish_attempts' => 'integer',
|
||||||
'next_attempt_at' => 'datetime',
|
'next_attempt_at' => 'datetime',
|
||||||
'validated_at' => 'datetime',
|
'validated_at' => 'datetime',
|
||||||
|
'decided_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -105,7 +108,10 @@ public function approve(): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
|
$this->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
ActivityLogged::dispatch(
|
ActivityLogged::dispatch(
|
||||||
ActivityTypeEnum::APPROVE,
|
ActivityTypeEnum::APPROVE,
|
||||||
|
|
@ -123,7 +129,10 @@ public function reject(): void
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
|
$this->update([
|
||||||
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
|
'decided_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
ActivityLogged::dispatch(
|
ActivityLogged::dispatch(
|
||||||
ActivityTypeEnum::REJECT,
|
ActivityTypeEnum::REJECT,
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ private function createRouteArticles(Article $article, string $content): void
|
||||||
[
|
[
|
||||||
'approval_status' => $status,
|
'approval_status' => $status,
|
||||||
'validated_at' => now(),
|
'validated_at' => now(),
|
||||||
|
'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* 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();
|
$cursor = $this->from->copy()->startOfDay();
|
||||||
$last = $this->to->copy()->startOfDay();
|
$last = $this->to->copy()->startOfDay();
|
||||||
|
|
||||||
if ($cursor->diffInDays($last) >= self::MAX_DAYS) {
|
if (! $this->isBucketableByDay()) {
|
||||||
throw new InvalidArgumentException(
|
throw new InvalidArgumentException(
|
||||||
'A range wider than '.self::MAX_DAYS.' days cannot be bucketed by day; bucket by month instead.'
|
'A range wider than '.self::MAX_DAYS.' days cannot be bucketed by day; bucket by month instead.'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ public function pending(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'approval_status' => ApprovalStatusEnum::PENDING,
|
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||||
|
'decided_at' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +71,7 @@ public function approved(): static
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'approval_status' => ApprovalStatusEnum::APPROVED,
|
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||||
'validated_at' => now(),
|
'validated_at' => now(),
|
||||||
|
'decided_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,6 +80,7 @@ public function rejected(): static
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'approval_status' => ApprovalStatusEnum::REJECTED,
|
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||||
'validated_at' => now(),
|
'validated_at' => now(),
|
||||||
|
'decided_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"vite": "^6.2.4"
|
"vite": "^6.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alpinejs": "^3.14.8",
|
"axios": "^1.8.0",
|
||||||
"axios": "^1.8.0"
|
"chart.js": "^4.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
74
resources/js/chart.js
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -119,6 +119,6 @@ class="px-4 border-r border-gray-200 dark:border-gray-700 text-gray-500 dark:tex
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@livewireScripts
|
@livewireScriptConfig
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,6 @@
|
||||||
<p>Route your feeds to the Fediverse</p>
|
<p>Route your feeds to the Fediverse</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@livewireScripts
|
@livewireScriptConfig
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,6 @@
|
||||||
<p>Route your feeds to the Fediverse</p>
|
<p>Route your feeds to the Fediverse</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@livewireScripts
|
@livewireScriptConfig
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -183,36 +183,73 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
||||||
@endisland
|
@endisland
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Articles per Feed -->
|
<!-- Trend and breakdowns -->
|
||||||
<div class="mt-8">
|
<div class="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Articles per Feed</h2>
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Fetched vs Published</h2>
|
||||||
|
|
||||||
@island('articles-per-feed')
|
@island('articles-trend')
|
||||||
<div @class(['bg-white p-6 rounded-lg shadow-sm dark:bg-gray-800', 'opacity-40' => ! $rangeIsValid])>
|
@include('livewire.partials.trend-panel', [
|
||||||
@if ($this->articlesPerFeed->rows === [])
|
'island' => 'articles-trend',
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No feeds are configured yet.</p>
|
'result' => $this->articlesTrend,
|
||||||
@else
|
'emptyMessage' => 'No articles or publications in this range.',
|
||||||
<ul class="space-y-3">
|
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
|
||||||
@foreach ($this->articlesPerFeed->rows as $row)
|
])
|
||||||
<li>
|
@endisland
|
||||||
<div class="flex items-center justify-between text-sm">
|
</div>
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-200">{{ $row->label }}</span>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">
|
<div>
|
||||||
{{ $row->count }}
|
<h2 class="text-lg font-semibold text-gray-900 mb-4 dark:text-gray-100">Approval Rate</h2>
|
||||||
<span class="ml-1 text-xs">({{ $this->articlesPerFeed->shareOf($row) }}%)</span>
|
|
||||||
</span>
|
@island('approval-rate')
|
||||||
</div>
|
@include('livewire.partials.trend-panel', [
|
||||||
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
'island' => 'approval-rate',
|
||||||
<div
|
'result' => $this->approvalRate,
|
||||||
class="h-full rounded-full bg-blue-500"
|
'emptyMessage' => 'No approval decisions in this range.',
|
||||||
style="width: {{ $this->articlesPerFeed->shareOf($row) }}%"
|
'tooWideMessage' => 'This range is too wide to chart by day. Pick a range under two years.',
|
||||||
></div>
|
'suffix' => '%',
|
||||||
</div>
|
'max' => 100,
|
||||||
</li>
|
])
|
||||||
@endforeach
|
@endisland
|
||||||
</ul>
|
</div>
|
||||||
@endif
|
|
||||||
|
<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>
|
</div>
|
||||||
@endisland
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
22
resources/views/livewire/partials/breakdown-panel.blade.php
Normal file
22
resources/views/livewire/partials/breakdown-panel.blade.php
Normal 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>
|
||||||
17
resources/views/livewire/partials/trend-panel.blade.php
Normal file
17
resources/views/livewire/partials/trend-panel.blade.php
Normal 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>
|
||||||
|
|
@ -2,9 +2,13 @@
|
||||||
|
|
||||||
namespace Tests\Feature\Livewire;
|
namespace Tests\Feature\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\ApprovalStatusEnum;
|
||||||
|
use App\Enums\PublishStatusEnum;
|
||||||
use App\Livewire\Dashboard;
|
use App\Livewire\Dashboard;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Models\RouteArticle;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
|
|
@ -15,13 +19,13 @@ class DashboardTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
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');
|
Carbon::setTestNow('2026-07-15 13:45:00');
|
||||||
|
|
||||||
Livewire::test(Dashboard::class)
|
Livewire::test(Dashboard::class)
|
||||||
->assertSet('from', '2026-07-15')
|
->assertSet('from', '2026-07-01')
|
||||||
->assertSet('to', '2026-07-15');
|
->assertSet('to', '2026-07-31');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_recomputes_stats_when_the_range_changes(): void
|
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)
|
Livewire::test(Dashboard::class)
|
||||||
->call('applyPreset', 'fortnight')
|
->call('applyPreset', 'fortnight')
|
||||||
->assertSet('from', '2026-07-15')
|
->assertSet('from', '2026-07-01')
|
||||||
->assertSet('to', '2026-07-15')
|
->assertSet('to', '2026-07-31')
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,6 +227,29 @@ public function test_it_refreshes_the_articles_per_feed_island_when_the_range_ch
|
||||||
$this->assertStringContainsString('Example Feed', $fragments);
|
$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
|
public function test_it_re_renders_the_article_statistics_island_when_a_preset_is_applied(): void
|
||||||
{
|
{
|
||||||
Carbon::setTestNow('2026-07-15 13:45:00');
|
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);
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
174
tests/Feature/RouteArticleDecisionStampingTest.php
Normal file
174
tests/Feature/RouteArticleDecisionStampingTest.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
149
tests/Unit/Dashboard/Stats/ApprovalRateTest.php
Normal file
149
tests/Unit/Dashboard/Stats/ApprovalRateTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
257
tests/Unit/Dashboard/Stats/ArticlesTrendTest.php
Normal file
257
tests/Unit/Dashboard/Stats/ArticlesTrendTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
tests/Unit/Dashboard/Stats/DailySeriesStatTest.php
Normal file
121
tests/Unit/Dashboard/Stats/DailySeriesStatTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
tests/Unit/Dashboard/Stats/PublicationsPerChannelTest.php
Normal file
126
tests/Unit/Dashboard/Stats/PublicationsPerChannelTest.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
143
tests/Unit/Dashboard/Stats/PublishSuccessRateTest.php
Normal file
143
tests/Unit/Dashboard/Stats/PublishSuccessRateTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
123
tests/Unit/Dashboard/Stats/SeriesResultTest.php
Normal file
123
tests/Unit/Dashboard/Stats/SeriesResultTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Dashboard\Stats\ArticlesPerFeed;
|
use App\Dashboard\Stats\ArticlesPerFeed;
|
||||||
use App\Dashboard\Stats\BreakdownStat;
|
use App\Dashboard\Stats\BreakdownStat;
|
||||||
|
use App\Dashboard\Stats\SeriesStat;
|
||||||
use App\Dashboard\Stats\Stat;
|
use App\Dashboard\Stats\Stat;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -30,4 +31,9 @@ public function test_articles_per_feed_is_a_breakdown_stat(): void
|
||||||
{
|
{
|
||||||
$this->assertInstanceOf(BreakdownStat::class, new ArticlesPerFeed);
|
$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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
tests/Unit/Enums/ApprovalStatusEnumTest.php
Normal file
38
tests/Unit/Enums/ApprovalStatusEnumTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
44
tests/Unit/Enums/PublishStatusEnumTest.php
Normal file
44
tests/Unit/Enums/PublishStatusEnumTest.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tests/Unit/Models/RouteArticleDecisionTest.php
Normal file
86
tests/Unit/Models/RouteArticleDecisionTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -183,4 +183,47 @@ public function test_it_refuses_to_list_days_for_the_all_preset(): void
|
||||||
|
|
||||||
DateRange::preset('all')->days();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue