Compare commits

...

8 commits

39 changed files with 1680 additions and 221 deletions

View file

@ -42,8 +42,7 @@ public function execute(RouteArticle $routeArticle): PublishOutcome
? $this->publishingService->publishRouteArticle($routeArticle, $extractedData) ? $this->publishingService->publishRouteArticle($routeArticle, $extractedData)
: PublishOutcome::failure('Could not recover the article content to publish'); : PublishOutcome::failure('Could not recover the article content to publish');
} catch (Exception $e) { } catch (Exception $e) {
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); $routeArticle->recordPublishFailed($e->getMessage());
$routeArticle->recordPublishAttemptFailed();
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [ ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
@ -103,8 +102,10 @@ private function resolvePublishData(Article $article): array
private function recordPublished(RouteArticle $routeArticle): void private function recordPublished(RouteArticle $routeArticle): void
{ {
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]); $routeArticle->update([
$routeArticle->clearPublishAttempts(); 'publish_status' => PublishStatusEnum::PUBLISHED,
'publish_error' => null,
]);
ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [ ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [
'article_id' => $routeArticle->article->id, 'article_id' => $routeArticle->article->id,
@ -141,20 +142,18 @@ private function recordFailed(RouteArticle $routeArticle, PublishOutcome $outcom
{ {
$article = $routeArticle->article; $article = $routeArticle->article;
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); $routeArticle->recordPublishFailed($outcome->reason ?? 'Publishing failed');
$routeArticle->recordPublishAttemptFailed();
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [ ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
'article_id' => $article->id, 'article_id' => $article->id,
'title' => $article->title, 'title' => $article->title,
'reason' => $outcome->reason, 'reason' => $outcome->reason,
'attempt' => $routeArticle->publish_attempts,
]); ]);
ActivityLogged::dispatch( ActivityLogged::dispatch(
ActivityTypeEnum::ERROR, ActivityTypeEnum::ERROR,
"Failed to publish \"{$article->title}\"", "Failed to publish \"{$article->title}\"",
['reason' => $outcome->reason, 'attempt' => $routeArticle->publish_attempts], ['reason' => $outcome->reason],
$article, $article,
); );

View file

@ -22,9 +22,4 @@ public function shareOf(Breakdown $row): float
return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0; return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0;
} }
public function isEmpty(): bool
{
return $this->total() === 0;
}
} }

View file

@ -5,7 +5,7 @@
interface Stat interface Stat
{ {
/** /**
* Stable identifier, also used as the island name for this stat's panel. * Stable identifier. Island names in dashboard.blade.php are written to match by hand, not derived from this.
*/ */
public function key(): string; public function key(): string;

View file

@ -20,8 +20,8 @@ public function __construct(
public function stats(Request $request): JsonResponse public function stats(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'from' => ['nullable', 'date'], 'from' => ['nullable', 'date', 'required_with:to'],
'to' => ['nullable', 'date', 'after_or_equal:from'], 'to' => ['nullable', 'date', 'after_or_equal:from', 'required_with:from'],
]); ]);
$range = isset($validated['from'], $validated['to']) $range = isset($validated['from'], $validated['to'])

View file

@ -4,12 +4,9 @@
use App\Enums\ActivityTypeEnum; use App\Enums\ActivityTypeEnum;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Article;
use App\Services\Activity\ActivitySummary; use App\Services\Activity\ActivitySummary;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Livewire\Component; use Livewire\Component;
use Livewire\WithPagination; use Livewire\WithPagination;
@ -37,11 +34,7 @@ public function updatedDays(): void
private function query(): Builder private function query(): Builder
{ {
return ActivityLog::query() return ActivityLog::query()
->with(['subject' => function (Relation $morphTo): void { ->withSubjectDetails()
if ($morphTo instanceof MorphTo) {
$morphTo->morphWith([Article::class => ['feed']]);
}
}])
->since(now()->subDays($this->days)) ->since(now()->subDays($this->days))
->when( ->when(
$this->type !== '', $this->type !== '',

View file

@ -121,9 +121,28 @@ public function refresh(): void
$this->dispatch('refresh-started'); $this->dispatch('refresh-started');
} }
public function retryPublish(int $routeArticleId): void
{
$routeArticle = RouteArticle::failed()->find($routeArticleId);
if (! $routeArticle instanceof RouteArticle) {
return;
}
$routeArticle->clearPublishFailure();
ActivityLogged::dispatch(
ActivityTypeEnum::PUBLISH,
"Queued \"{$routeArticle->article->title}\" for another publish attempt",
['route_article_id' => $routeArticle->id],
$routeArticle->article,
);
}
public function render(): View public function render(): View
{ {
$pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); $pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
$failedCount = RouteArticle::failed()->count();
if ($this->tab === 'pending') { if ($this->tab === 'pending') {
return view('livewire.articles', [ return view('livewire.articles', [
@ -131,6 +150,7 @@ public function render(): View
'pendingFeeds' => $this->pendingFeeds(), 'pendingFeeds' => $this->pendingFeeds(),
'feedOptions' => $this->feedOptions(), 'feedOptions' => $this->feedOptions(),
'pendingCount' => $pendingCount, 'pendingCount' => $pendingCount,
'failedCount' => $failedCount,
'clearableCount' => $this->clearableQuery()->count(), 'clearableCount' => $this->clearableQuery()->count(),
])->layout('layouts.app'); ])->layout('layouts.app');
} }
@ -138,6 +158,10 @@ public function render(): View
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel']) $query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
if ($this->tab === 'failed') {
$query->failed();
}
if ($this->feedId !== null) { if ($this->feedId !== null) {
$query->where('feed_id', $this->feedId); $query->where('feed_id', $this->feedId);
} }
@ -155,6 +179,7 @@ public function render(): View
'pendingFeeds' => null, 'pendingFeeds' => null,
'feedOptions' => $this->feedOptions(), 'feedOptions' => $this->feedOptions(),
'pendingCount' => $pendingCount, 'pendingCount' => $pendingCount,
'failedCount' => $failedCount,
'clearableCount' => 0, 'clearableCount' => 0,
])->layout('layouts.app'); ])->layout('layouts.app');
} }

View file

@ -34,6 +34,14 @@ class Channels extends Component
public string $newDescription = ''; public string $newDescription = '';
public ?int $editingChannelId = null;
public string $editDisplayName = '';
public ?int $editLanguageId = null;
public string $editDescription = '';
public function toggle(int $channelId): void public function toggle(int $channelId): void
{ {
$channel = PlatformChannel::findOrFail($channelId); $channel = PlatformChannel::findOrFail($channelId);
@ -127,6 +135,45 @@ public function createChannel(CreateChannelAction $action): void
$this->closeCreateModal(); $this->closeCreateModal();
} }
public function openEditModal(int $channelId): void
{
$channel = PlatformChannel::findOrFail($channelId);
$this->resetErrorBag();
$this->editingChannelId = $channelId;
$this->editDisplayName = $channel->display_name;
$this->editLanguageId = $channel->language_id;
$this->editDescription = $channel->description ?? '';
}
public function closeEditModal(): void
{
$this->editingChannelId = null;
}
// The community pairing (name, channel_id, platform_instance_id) is deliberately immutable:
// it is the channel's remote identity, unique per instance, and re-pointing it would change
// the meaning of every route already attached.
public function updateChannel(): void
{
if ($this->editingChannelId === null) {
return;
}
$this->validate([
'editDisplayName' => 'required|string|max:255',
'editLanguageId' => 'nullable|integer|exists:languages,id',
]);
PlatformChannel::findOrFail($this->editingChannelId)->update([
'display_name' => $this->editDisplayName,
'language_id' => $this->editLanguageId,
'description' => $this->editDescription !== '' ? $this->editDescription : null,
]);
$this->closeEditModal();
}
public function openAccountModal(int $channelId): void public function openAccountModal(int $channelId): void
{ {
$this->managingChannelId = $channelId; $this->managingChannelId = $channelId;
@ -177,6 +224,9 @@ public function render(): View
return view('livewire.channels', [ return view('livewire.channels', [
'channels' => $channels, 'channels' => $channels,
'managingChannel' => $managingChannel, 'managingChannel' => $managingChannel,
'editingChannel' => $this->editingChannelId !== null
? PlatformChannel::with('platformInstance')->find($this->editingChannelId)
: null,
'availableAccounts' => $availableAccounts, 'availableAccounts' => $availableAccounts,
'platformInstances' => PlatformInstance::where('is_active', true)->orderBy('name')->get(), 'platformInstances' => PlatformInstance::where('is_active', true)->orderBy('name')->get(),
'languages' => Language::where('is_active', true)->orderBy('name')->get(), 'languages' => Language::where('is_active', true)->orderBy('name')->get(),

View file

@ -10,9 +10,11 @@
use App\Dashboard\Stats\PublicationsPerChannel; use App\Dashboard\Stats\PublicationsPerChannel;
use App\Dashboard\Stats\PublishSuccessRate; use App\Dashboard\Stats\PublishSuccessRate;
use App\Dashboard\Stats\SeriesResult; use App\Dashboard\Stats\SeriesResult;
use App\Models\ActivityLog;
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;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use InvalidArgumentException; use InvalidArgumentException;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
@ -43,6 +45,8 @@ public function mount(): void
'publications-per-channel', 'publications-per-channel',
]; ];
private const RECENT_ACTIVITY_LIMIT = 5;
public function applyPreset(string $preset): void public function applyPreset(string $preset): void
{ {
try { try {
@ -179,6 +183,21 @@ private function breakdown(string $stat): BreakdownResult
: new BreakdownResult([]); : new BreakdownResult([]);
} }
/**
* Deliberately range-independent: "recent" means latest, not latest within the filter.
*
* @return Collection<int, ActivityLog>
*/
#[Computed]
public function recentActivity(): Collection
{
return ActivityLog::query()
->withSubjectDetails()
->latestFirst()
->limit(self::RECENT_ACTIVITY_LIMIT)
->get();
}
/** /**
* @return array<string, int> * @return array<string, int>
*/ */

View file

@ -21,6 +21,12 @@ class Feeds extends Component
public string $newDescription = ''; public string $newDescription = '';
public ?int $editingFeedId = null;
public string $editName = '';
public string $editDescription = '';
public function toggle(int $feedId): void public function toggle(int $feedId): void
{ {
$feed = Feed::findOrFail($feedId); $feed = Feed::findOrFail($feedId);
@ -67,6 +73,41 @@ public function createFeed(CreateFeedAction $action): void
$this->closeCreateModal(); $this->closeCreateModal();
} }
public function openEditModal(int $feedId): void
{
$feed = Feed::findOrFail($feedId);
$this->resetErrorBag();
$this->editingFeedId = $feedId;
$this->editName = $feed->name;
$this->editDescription = $feed->description ?? '';
}
public function closeEditModal(): void
{
$this->editingFeedId = null;
}
// Provider and language are deliberately not editable: CreateFeedAction derives the unique
// feeds.url from that pair, so changing either re-points the feed and orphans its articles.
public function updateFeed(): void
{
if ($this->editingFeedId === null) {
return;
}
$this->validate([
'editName' => 'required|string|max:255',
]);
Feed::findOrFail($this->editingFeedId)->update([
'name' => $this->editName,
'description' => $this->editDescription !== '' ? $this->editDescription : null,
]);
$this->closeEditModal();
}
/** /**
* @return array<string, array<string, mixed>> * @return array<string, array<string, mixed>>
*/ */
@ -86,6 +127,9 @@ public function render(): View
'feeds' => $feeds, 'feeds' => $feeds,
'providers' => $this->activeProviders(), 'providers' => $this->activeProviders(),
'languages' => Language::where('is_active', true)->orderBy('name')->get(), 'languages' => Language::where('is_active', true)->orderBy('name')->get(),
'editingFeed' => $this->editingFeedId !== null
? Feed::with('language')->find($this->editingFeedId)
: null,
])->layout('layouts.app'); ])->layout('layouts.app');
} }
} }

View file

@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
/** /**
@ -77,4 +78,17 @@ public function scopeLatestFirst(Builder $query): Builder
{ {
return $query->orderByDesc('logged_at')->orderByDesc('id'); return $query->orderByDesc('logged_at')->orderByDesc('id');
} }
/**
* @param Builder<ActivityLog> $query
* @return Builder<ActivityLog>
*/
public function scopeWithSubjectDetails(Builder $query): Builder
{
return $query->with(['subject' => function (Relation $morphTo): void {
if ($morphTo instanceof MorphTo) {
$morphTo->morphWith([Article::class => ['feed']]);
}
}]);
}
} }

View file

@ -20,7 +20,7 @@
* @property string $provider * @property string $provider
* @property int|null $language_id * @property int|null $language_id
* @property Language|null $language * @property Language|null $language
* @property string $description * @property string|null $description
* @property FeedColorEnum|null $color * @property FeedColorEnum|null $color
* @property array<string, mixed> $settings * @property array<string, mixed> $settings
* @property bool $is_active * @property bool $is_active

View file

@ -17,7 +17,9 @@
* @property PlatformInstance $platformInstance * @property PlatformInstance $platformInstance
* @property int $channel_id * @property int $channel_id
* @property string $name * @property string $name
* @property int $language_id * @property string $display_name
* @property string|null $description
* @property int|null $language_id
* @property Language|null $language * @property Language|null $language
* @property bool $is_active * @property bool $is_active
*/ */

View file

@ -21,8 +21,7 @@
* @property int $article_id * @property int $article_id
* @property ApprovalStatusEnum $approval_status * @property ApprovalStatusEnum $approval_status
* @property PublishStatusEnum $publish_status * @property PublishStatusEnum $publish_status
* @property int $publish_attempts * @property string|null $publish_error
* @property Carbon|null $next_attempt_at
* @property Carbon|null $validated_at * @property Carbon|null $validated_at
* @property Carbon|null $decided_at * @property Carbon|null $decided_at
* @property Carbon $created_at * @property Carbon $created_at
@ -39,8 +38,7 @@ class RouteArticle extends Model
'article_id', 'article_id',
'approval_status', 'approval_status',
'publish_status', 'publish_status',
'publish_attempts', 'publish_error',
'next_attempt_at',
'validated_at', 'validated_at',
'decided_at', 'decided_at',
]; ];
@ -48,8 +46,6 @@ class RouteArticle extends Model
protected $casts = [ protected $casts = [
'approval_status' => ApprovalStatusEnum::class, 'approval_status' => ApprovalStatusEnum::class,
'publish_status' => PublishStatusEnum::class, 'publish_status' => PublishStatusEnum::class,
'publish_attempts' => 'integer',
'next_attempt_at' => 'datetime',
'validated_at' => 'datetime', 'validated_at' => 'datetime',
'decided_at' => 'datetime', 'decided_at' => 'datetime',
]; ];
@ -142,41 +138,39 @@ public function reject(): void
); );
} }
private const RETRY_BACKOFF_MINUTES = [5, 30, 120, 360]; public function recordPublishFailed(string $reason): void
public const MAX_PUBLISH_ATTEMPTS = 4;
public function recordPublishAttemptFailed(): void
{ {
$attempts = $this->publish_attempts + 1;
$backoff = self::RETRY_BACKOFF_MINUTES;
$this->update([ $this->update([
'publish_attempts' => $attempts, 'publish_status' => PublishStatusEnum::ERROR,
'next_attempt_at' => now()->addMinutes($backoff[$attempts - 1] ?? end($backoff)), 'publish_error' => $reason,
]); ]);
} }
public function clearPublishAttempts(): void public function clearPublishFailure(): void
{ {
$this->update(['publish_attempts' => 0, 'next_attempt_at' => null]); $this->update([
'publish_status' => PublishStatusEnum::UNPUBLISHED,
'publish_error' => null,
]);
} }
public function hasExhaustedPublishAttempts(): bool /**
* A failed article is never picked up again on its own; the user retries it from the Articles page.
*
* @param Builder<RouteArticle> $query
* @return Builder<RouteArticle>
*/
public function scopeDueForPublishing(Builder $query): Builder
{ {
return $this->publish_attempts >= self::MAX_PUBLISH_ATTEMPTS; return $query->where('publish_status', '!=', PublishStatusEnum::ERROR);
} }
/** /**
* @param Builder<RouteArticle> $query * @param Builder<RouteArticle> $query
* @return Builder<RouteArticle> * @return Builder<RouteArticle>
*/ */
public function scopeDueForPublishing(Builder $query): Builder public function scopeFailed(Builder $query): Builder
{ {
return $query->where('publish_attempts', '<', self::MAX_PUBLISH_ATTEMPTS) return $query->where('publish_status', PublishStatusEnum::ERROR);
->where(function (Builder $query) {
$query->whereNull('next_attempt_at')
->orWhere('next_attempt_at', '<=', now());
});
} }
} }

View file

@ -7,6 +7,9 @@
class LemmyRequest class LemmyRequest
{ {
// Uploads carry an image payload; the 30s used for JSON calls is not enough.
private const UPLOAD_TIMEOUT_SECONDS = 60;
private string $instance; private string $instance;
private ?string $token; private ?string $token;
@ -83,6 +86,27 @@ public function post(string $endpoint, array $data = []): Response
return $request->post($url, $data); return $request->post($url, $data);
} }
/**
* pict-rs is mounted outside the /api/v3 prefix, so this takes a root-relative path.
*
* @param string $path Root-relative, e.g. 'pictrs/image'
* @param string $name Multipart field name, e.g. 'images[]'
* @param string $contents Raw file bytes
* @param string $filename Filename sent with the part
*/
public function postMultipart(string $path, string $name, string $contents, string $filename): Response
{
$url = sprintf('%s://%s/%s', $this->scheme, $this->instance, ltrim($path, '/'));
$request = Http::timeout(self::UPLOAD_TIMEOUT_SECONDS);
if ($this->token) {
$request = $request->withToken($this->token);
}
return $request->attach($name, $contents, $filename)->post($url);
}
public function withToken(string $token): self public function withToken(string $token): self
{ {
$this->token = $token; $this->token = $token;

View file

@ -7,6 +7,7 @@
use App\Models\PlatformAccount; use App\Models\PlatformAccount;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Services\Auth\LemmyAuthService; use App\Services\Auth\LemmyAuthService;
use App\Services\Log\LogSaver;
use Exception; use Exception;
class LemmyPublisher class LemmyPublisher
@ -15,10 +16,13 @@ class LemmyPublisher
private PlatformAccount $account; private PlatformAccount $account;
private ThumbnailUploader $thumbnailUploader;
public function __construct(PlatformAccount $account) public function __construct(PlatformAccount $account)
{ {
$this->api = new LemmyApiService($account->instance_url); $this->api = new LemmyApiService($account->instance_url);
$this->account = $account; $this->account = $account;
$this->thumbnailUploader = new ThumbnailUploader($account->instance_url);
} }
/** /**
@ -33,24 +37,53 @@ public function publishToChannel(Article $article, array $extractedData, Platfor
$authService = resolve(LemmyAuthService::class); $authService = resolve(LemmyAuthService::class);
$token = $authService->getToken($this->account); $token = $authService->getToken($this->account);
$thumbnail = $this->hostedThumbnail($extractedData, $channel, $article, $token);
try { try {
return $this->createPost($token, $extractedData, $channel, $article); return $this->createPost($token, $extractedData, $channel, $article, $thumbnail);
} catch (Exception $e) { } catch (Exception $e) {
// If the cached token was stale, refresh and retry once // If the cached token was stale, refresh and retry once
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) { if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
$token = $authService->refreshToken($this->account); $token = $authService->refreshToken($this->account);
return $this->createPost($token, $extractedData, $channel, $article); return $this->createPost($token, $extractedData, $channel, $article, $thumbnail);
} }
throw $e; throw $e;
} }
} }
/**
* Uploaded once per publish, outside the stale-token retry: the upload is the expensive
* part and a retry would otherwise re-download, re-encode and re-log.
*
* @param array<string, mixed> $extractedData
*/
private function hostedThumbnail(array $extractedData, PlatformChannel $channel, Article $article, string $token): ?string
{
$source = $extractedData['thumbnail'] ?? null;
$source = is_string($source) && $source !== '' ? $source : null;
if ($source === null) {
return null;
}
$hosted = $this->thumbnailUploader->upload($source, $token);
if ($hosted === null) {
app(LogSaver::class)->warning('Thumbnail upload failed; publishing without one', $channel, [
'article_id' => $article->id,
'source' => $source,
]);
}
return $hosted;
}
/** /**
* @param array<string, mixed> $extractedData * @param array<string, mixed> $extractedData
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article): array private function createPost(string $token, array $extractedData, PlatformChannel $channel, Article $article, ?string $thumbnail = null): array
{ {
$languageId = $extractedData['language_id'] ?? null; $languageId = $extractedData['language_id'] ?? null;
@ -60,7 +93,7 @@ private function createPost(string $token, array $extractedData, PlatformChannel
$extractedData['description'] ?? '', $extractedData['description'] ?? '',
$channel->channel_id, $channel->channel_id,
$article->url, $article->url,
$extractedData['thumbnail'] ?? null, $thumbnail,
$languageId $languageId
); );
} }

View file

@ -0,0 +1,125 @@
<?php
namespace App\Modules\Lemmy\Services;
use App\Modules\Lemmy\LemmyRequest;
use Illuminate\Support\Facades\Http;
use Throwable;
class ThumbnailUploader
{
private const MAX_WIDTH = 600;
// A 4000x2256 JPEG decodes to ~27MB in GD; the worker runs with memory_limit=128M.
private const MAX_SOURCE_BYTES = 10_485_760;
private const MAX_SOURCE_PIXELS = 50_000_000;
private const JPEG_QUALITY = 82;
public function __construct(private string $instance) {}
/**
* Returns an instance-hosted URL for a downscaled copy, or null if anything fails.
*/
public function upload(?string $sourceUrl, string $token): ?string
{
if ($sourceUrl === null || $sourceUrl === '') {
return null;
}
try {
$source = $this->download($sourceUrl);
if ($source === null) {
return null;
}
$resized = $this->resize($source);
if ($resized === null) {
return null;
}
return $this->store($resized, $token);
} catch (Throwable) {
return null;
}
}
private function download(string $url): ?string
{
$response = Http::timeout(30)->get($url);
if (! $response->successful()) {
return null;
}
$body = $response->body();
return strlen($body) > self::MAX_SOURCE_BYTES ? null : $body;
}
private function resize(string $source): ?string
{
$info = @getimagesizefromstring($source);
if ($info === false) {
return null;
}
[$width, $height] = $info;
if ($width < 1 || $height < 1 || $width * $height > self::MAX_SOURCE_PIXELS) {
return null;
}
if ($width <= self::MAX_WIDTH) {
return $source;
}
$image = @imagecreatefromstring($source);
if ($image === false) {
return null;
}
$targetHeight = (int) max(1, round($height * (self::MAX_WIDTH / $width)));
$resized = imagescale($image, self::MAX_WIDTH, $targetHeight);
imagedestroy($image);
if ($resized === false) {
return null;
}
ob_start();
try {
imagejpeg($resized, null, self::JPEG_QUALITY);
} finally {
$bytes = (string) ob_get_clean();
imagedestroy($resized);
}
return $bytes === '' ? null : $bytes;
}
private function store(string $bytes, string $token): ?string
{
$response = (new LemmyRequest($this->instance, $token))
->postMultipart('pictrs/image', 'images[]', $bytes, 'thumbnail.jpg');
if (! $response->successful()) {
return null;
}
$file = $response->json('files.0.file');
if (! is_string($file) || $file === '') {
return null;
}
// $instance is a full scheme-qualified URL — platform_accounts.instance_url is validated as a URL.
return sprintf('%s/pictrs/image/%s', rtrim($this->instance, '/'), $file);
}
}

View file

@ -0,0 +1,30 @@
<?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->text('publish_error')->nullable()->after('publish_status');
$table->dropIndex(['next_attempt_at']);
$table->dropColumn(['publish_attempts', 'next_attempt_at']);
});
}
public function down(): void
{
Schema::table('route_articles', function (Blueprint $table) {
$table->dropColumn('publish_error');
$table->unsignedTinyInteger('publish_attempts')->default(0)->after('publish_status');
$table->timestamp('next_attempt_at')->nullable()->after('publish_attempts');
$table->index('next_attempt_at');
});
}
};

View file

@ -12,18 +12,6 @@ parameters:
count: 1 count: 1
path: tests/Unit/Actions/CreateChannelActionTest.php path: tests/Unit/Actions/CreateChannelActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with int will always evaluate to false\.$#'
identifier: method.impossibleType
count: 1
path: tests/Unit/Actions/CreateChannelActionTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with string will always evaluate to false\.$#'
identifier: method.impossibleType
count: 2
path: tests/Unit/Actions/CreateFeedActionTest.php
- -
message: '#^Access to an undefined property App\\Models\\Route\:\:\$id\.$#' message: '#^Access to an undefined property App\\Models\\Route\:\:\$id\.$#'
identifier: property.notFound identifier: property.notFound

View file

@ -23,8 +23,31 @@ class="flex items-center flex-wrap gap-x-1.5 text-xs font-medium mb-1.5"
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
{{ $routeArticle->created_at->format('M d, Y H:i') }} {{ $routeArticle->created_at->format('M d, Y H:i') }}
</div> </div>
@if ($routeArticle->publish_error !== null)
<p class="mt-2 rounded-md bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300">
{{ $routeArticle->publish_error }}
</p>
@endif
</div> </div>
<div class="flex items-center space-x-2 ml-4"> <div class="flex items-center space-x-2 ml-4">
@if ($tab === 'failed')
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
Failed
</span>
<button
wire:click="retryPublish({{ $routeArticle->id }})"
class="inline-flex items-center p-1.5 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md dark:text-blue-400"
title="Retry publishing"
aria-label="Retry publishing {{ $routeArticle->article->title ?? 'article' }}"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
</button>
@endif
{{-- Status badge (All tab) --}} {{-- Status badge (All tab) --}}
@if ($tab === 'all') @if ($tab === 'all')
@if ($routeArticle->isApproved()) @if ($routeArticle->isApproved())

View file

@ -37,29 +37,7 @@ class="w-44 shrink-0 pl-3 pr-10 py-2 border border-gray-300 rounded-md text-sm f
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
@forelse ($entries as $entry) @forelse ($entries as $entry)
<div class="flex items-start gap-x-3 px-5 py-4 border-b border-gray-100 dark:border-gray-700 last:border-b-0"> @include('livewire.partials.activity-entry', ['entry' => $entry])
<span class="mt-0.5 inline-flex shrink-0 items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $entry->type === App\Enums\ActivityTypeEnum::ERROR ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }}">
{{ $entry->type->label() }}
</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-900 dark:text-gray-100">{{ $entry->message }}</p>
@if ($entry->subject instanceof App\Models\Article)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->subject->feed?->name }}</p>
@endif
@if (($entry->context['reason'] ?? null) !== null)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->context['reason'] }}</p>
@endif
</div>
<time
datetime="{{ $entry->logged_at->toIso8601String() }}"
title="{{ $entry->logged_at->toDayDateTimeString() }}"
class="shrink-0 text-xs text-gray-500 dark:text-gray-400"
>
{{ $entry->logged_at->diffForHumans() }}
</time>
</div>
@empty @empty
<div class="px-5 py-12 text-center"> <div class="px-5 py-12 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">No activity in this period.</p> <p class="text-sm text-gray-500 dark:text-gray-400">No activity in this period.</p>

View file

@ -29,6 +29,17 @@ class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'p
</span> </span>
@endif @endif
</button> </button>
<button
wire:click="setTab('failed')"
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'failed' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600' }}"
>
Failed
@if ($failedCount > 0)
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
{{ $failedCount }}
</span>
@endif
</button>
<button <button
wire:click="setTab('all')" wire:click="setTab('all')"
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'all' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600' }}" class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'all' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:border-gray-600' }}"
@ -128,6 +139,8 @@ class="border-t border-gray-100 bg-gray-50 p-4 space-y-4 dark:border-gray-700 da
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100"> <h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
@if ($tab === 'pending') @if ($tab === 'pending')
No pending articles No pending articles
@elseif ($tab === 'failed')
No failed articles
@else @else
No articles found No articles found
@endif @endif
@ -137,6 +150,8 @@ class="border-t border-gray-100 bg-gray-50 p-4 space-y-4 dark:border-gray-700 da
No pending articles for the selected feed. No pending articles for the selected feed.
@elseif ($tab === 'pending') @elseif ($tab === 'pending')
All route articles have been reviewed. All route articles have been reviewed.
@elseif ($tab === 'failed')
Nothing has failed to publish.
@elseif ($search !== '') @elseif ($search !== '')
No results for "{{ $search }}". No results for "{{ $search }}".
@elseif ($feedId !== null) @elseif ($feedId !== null)

View file

@ -35,15 +35,28 @@ class="text-sm text-blue-500 hover:underline">
@endif @endif
</div> </div>
</div> </div>
<button <div class="flex shrink-0 items-center space-x-1">
wire:click="toggle({{ $channel->id }})" <button
class="p-1 rounded-full {{ $channel->is_active ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500' }}" wire:click="openEditModal({{ $channel->id }})"
title="{{ $channel->is_active ? 'Active - Click to disable' : 'Inactive - Click to enable' }}" class="p-1 rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300"
> title="Edit channel"
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> aria-label="Edit {{ $channel->display_name }}"
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /> >
</svg> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
</button> <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
</button>
<button
wire:click="toggle({{ $channel->id }})"
class="p-1 rounded-full {{ $channel->is_active ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500' }}"
title="{{ $channel->is_active ? 'Active - Click to disable' : 'Inactive - Click to enable' }}"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div> </div>
@if ($channel->description) @if ($channel->description)
@ -259,4 +272,82 @@ class="inline-flex justify-center rounded-md border border-transparent shadow-xs
</form> </form>
</x-form-modal> </x-form-modal>
@endif @endif
<!-- Edit Channel Modal -->
@if ($editingChannel)
<x-form-modal title="Edit Channel" close="closeEditModal">
<form wire:submit="updateChannel" class="space-y-4">
<div>
<label for="edit-channel-display-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200">Display name</label>
<input
type="text"
id="edit-channel-display-name"
wire:model="editDisplayName"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:border-gray-600"
/>
@error('editDisplayName') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div>
<label for="edit-channel-language" class="block text-sm font-medium text-gray-700 dark:text-gray-200">Language <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
<select
id="edit-channel-language"
wire:model="editLanguageId"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:border-gray-600"
>
<option value="">No language</option>
@foreach ($languages as $language)
<option value="{{ $language->id }}">{{ $language->name }}</option>
@endforeach
</select>
@error('editLanguageId') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div>
<label for="edit-channel-description" class="block text-sm font-medium text-gray-700 dark:text-gray-200">Description <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
<textarea
id="edit-channel-description"
wire:model="editDescription"
rows="2"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:border-gray-600"
></textarea>
@error('editDescription') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-700/30">
<dl class="space-y-1 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Community</dt>
<dd class="font-medium text-gray-700 dark:text-gray-200">{{ $editingChannel->name }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Instance</dt>
<dd class="font-medium text-gray-700 dark:text-gray-200">{{ $editingChannel->platformInstance->name }}</dd>
</div>
</dl>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
The community and instance identify this channel on the remote platform and cannot be changed. Every route attached to this channel publishes here. Create a new channel to post somewhere else.
</p>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
wire:click="closeEditModal"
class="inline-flex justify-center rounded-md border border-gray-300 shadow-xs px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700/50"
>
Cancel
</button>
<button
type="submit"
wire:loading.attr="disabled"
wire:target="updateChannel"
class="inline-flex justify-center rounded-md border border-transparent shadow-xs px-4 py-2 bg-blue-600 text-sm font-medium text-white hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save changes
</button>
</div>
</form>
</x-form-modal>
@endif
</div> </div>

View file

@ -251,5 +251,17 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
@endisland @endisland
</div> </div>
</div> </div>
<div>
<div class="mb-4 flex items-baseline justify-between gap-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Recent Activity</h2>
<a href="{{ route('activity') }}" wire:navigate class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
View all
</a>
</div>
@include('livewire.partials.activity-panel', ['entries' => $this->recentActivity])
</div>
</div> </div>
</div> </div>

View file

@ -38,15 +38,28 @@ class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-me
@endif @endif
</div> </div>
</div> </div>
<button <div class="flex shrink-0 items-center space-x-1">
wire:click="toggle({{ $feed->id }})" <button
class="p-1 rounded-full {{ $feed->is_active ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500' }}" wire:click="openEditModal({{ $feed->id }})"
title="{{ $feed->is_active ? 'Active - Click to disable' : 'Inactive - Click to enable' }}" class="p-1 rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300"
> title="Edit feed"
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> aria-label="Edit {{ $feed->name }}"
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /> >
</svg> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
</button> <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125" />
</svg>
</button>
<button
wire:click="toggle({{ $feed->id }})"
class="p-1 rounded-full {{ $feed->is_active ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-400 dark:bg-gray-700 dark:text-gray-500' }}"
title="{{ $feed->is_active ? 'Active - Click to disable' : 'Inactive - Click to enable' }}"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
@ -177,4 +190,67 @@ class="inline-flex justify-center rounded-md border border-transparent shadow-xs
</form> </form>
</x-form-modal> </x-form-modal>
@endif @endif
<!-- Edit Feed Modal -->
@if ($editingFeed)
<x-form-modal title="Edit Feed" close="closeEditModal">
<form wire:submit="updateFeed" class="space-y-4">
<div>
<label for="edit-feed-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200">Name</label>
<input
type="text"
id="edit-feed-name"
wire:model="editName"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:border-gray-600"
/>
@error('editName') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div>
<label for="edit-feed-description" class="block text-sm font-medium text-gray-700 dark:text-gray-200">Description <span class="text-gray-400 dark:text-gray-500">(optional)</span></label>
<textarea
id="edit-feed-description"
wire:model="editDescription"
rows="2"
class="mt-1 block w-full rounded-md border border-gray-300 shadow-xs focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:border-gray-600"
></textarea>
@error('editDescription') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div class="rounded-md bg-gray-50 p-3 dark:bg-gray-700/30">
<dl class="space-y-1 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Provider</dt>
<dd class="font-medium text-gray-700 dark:text-gray-200">{{ $providers[$editingFeed->provider]['name'] ?? $editingFeed->provider }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Language</dt>
<dd class="font-medium text-gray-700 dark:text-gray-200">{{ $editingFeed->language?->name ?? '—' }}</dd>
</div>
</dl>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Provider and language define the feed's source URL and cannot be changed. Create a new feed to use a different source.
</p>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
wire:click="closeEditModal"
class="inline-flex justify-center rounded-md border border-gray-300 shadow-xs px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700/50"
>
Cancel
</button>
<button
type="submit"
wire:loading.attr="disabled"
wire:target="updateFeed"
class="inline-flex justify-center rounded-md border border-transparent shadow-xs px-4 py-2 bg-blue-600 text-sm font-medium text-white hover:bg-blue-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save changes
</button>
</div>
</form>
</x-form-modal>
@endif
</div> </div>

View file

@ -0,0 +1,23 @@
<div class="flex items-start gap-x-3 px-5 py-4 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<span class="mt-0.5 inline-flex shrink-0 items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $entry->type === App\Enums\ActivityTypeEnum::ERROR ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }}">
{{ $entry->type->label() }}
</span>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-900 dark:text-gray-100">{{ $entry->message }}</p>
@if ($entry->subject instanceof App\Models\Article)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->subject->feed?->name }}</p>
@endif
@if (($entry->context['reason'] ?? null) !== null)
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->context['reason'] }}</p>
@endif
</div>
<time
datetime="{{ $entry->logged_at->toIso8601String() }}"
title="{{ $entry->logged_at->toDayDateTimeString() }}"
class="shrink-0 text-xs text-gray-500 dark:text-gray-400"
>
{{ $entry->logged_at->diffForHumans() }}
</time>
</div>

View file

@ -0,0 +1,9 @@
<div class="bg-white rounded-lg shadow-sm overflow-hidden dark:bg-gray-800">
@forelse ($entries as $entry)
@include('livewire.partials.activity-entry', ['entry' => $entry])
@empty
<div class="px-5 py-12 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Nothing has happened yet.</p>
</div>
@endforelse
</div>

View file

@ -99,6 +99,22 @@ public function test_stats_rejects_a_malformed_date(): void
->assertStatus(422); ->assertStatus(422);
} }
public function test_stats_rejects_a_from_without_a_to(): void
{
$this
->getJson('/api/v1/dashboard/stats?from=2026-07-01')
->assertStatus(422)
->assertJsonValidationErrors('to');
}
public function test_stats_rejects_a_to_without_a_from(): void
{
$this
->getJson('/api/v1/dashboard/stats?to=2026-07-31')
->assertStatus(422)
->assertJsonValidationErrors('from');
}
public function test_stats_with_sample_data(): void public function test_stats_with_sample_data(): void
{ {
$feed = Feed::factory()->create(['is_active' => true]); $feed = Feed::factory()->create(['is_active' => true]);

View file

@ -558,4 +558,93 @@ public function test_empty_state_on_all_tab(): void
->call('setTab', 'all') ->call('setTab', 'all')
->assertSee('No route articles have been created yet.'); ->assertSee('No route articles have been created yet.');
} }
private function failedRouteArticle(string $reason = 'couldnt_find_community'): RouteArticle
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->approved()->create();
$routeArticle->recordPublishFailed($reason);
return $routeArticle;
}
public function test_the_failed_tab_lists_only_failed_articles(): void
{
$failed = $this->failedRouteArticle();
/** @var RouteArticle $ok */
$ok = RouteArticle::factory()->approved()->create();
Livewire::test(Articles::class)
->call('setTab', 'failed')
->assertSee($failed->article->title)
->assertDontSee($ok->article->title);
}
public function test_the_failed_tab_shows_the_failure_reason(): void
{
$this->failedRouteArticle('couldnt_find_community');
Livewire::test(Articles::class)
->call('setTab', 'failed')
->assertSee('couldnt_find_community');
}
public function test_the_failed_tab_badge_counts_failed_articles(): void
{
$this->failedRouteArticle();
$this->failedRouteArticle();
RouteArticle::factory()->approved()->create();
Livewire::test(Articles::class)
->assertViewHas('failedCount', 2);
}
public function test_the_failed_tab_is_empty_when_nothing_failed(): void
{
RouteArticle::factory()->approved()->create();
Livewire::test(Articles::class)
->call('setTab', 'failed')
->assertSee('Nothing has failed to publish.')
->assertViewHas('failedCount', 0);
}
public function test_retrying_clears_the_failure_and_requeues_the_article(): void
{
$routeArticle = $this->failedRouteArticle();
Livewire::test(Articles::class)
->call('setTab', 'failed')
->call('retryPublish', $routeArticle->id);
$routeArticle->refresh();
$this->assertNull($routeArticle->publish_error);
$this->assertTrue(
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
);
}
public function test_retrying_keeps_the_approval_decision_intact(): void
{
$routeArticle = $this->failedRouteArticle();
$decidedAt = $routeArticle->decided_at;
Livewire::test(Articles::class)->call('retryPublish', $routeArticle->id);
$routeArticle->refresh();
$this->assertTrue($routeArticle->isApproved());
$this->assertEquals($decidedAt, $routeArticle->decided_at);
}
public function test_retrying_an_article_that_did_not_fail_does_nothing(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->approved()->create();
Livewire::test(Articles::class)->call('retryPublish', $routeArticle->id);
$this->assertNull($routeArticle->fresh()->publish_error);
}
} }

View file

@ -248,4 +248,166 @@ public function test_toggle_flips_active_state(): void
$this->assertFalse($channel->fresh()->is_active); $this->assertFalse($channel->fresh()->is_active);
} }
public function test_channel_cards_show_an_edit_action(): void
{
PlatformChannel::factory()->create(['display_name' => 'Tech Community']);
Livewire::test(Channels::class)
->assertSee('Edit Tech Community');
}
public function test_open_edit_modal_prefills_the_current_values(): void
{
$language = Language::factory()->create();
$channel = PlatformChannel::factory()->create([
'display_name' => 'Tech Community',
'description' => 'A place for tech',
'language_id' => $language->id,
]);
Livewire::test(Channels::class)
->assertSet('editingChannelId', null)
->call('openEditModal', $channel->id)
->assertSet('editingChannelId', $channel->id)
->assertSet('editDisplayName', 'Tech Community')
->assertSet('editDescription', 'A place for tech')
->assertSet('editLanguageId', $language->id)
->assertSee('Edit Channel');
}
public function test_open_edit_modal_prefills_a_null_description_as_blank(): void
{
$channel = PlatformChannel::factory()->create(['description' => null]);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->assertSet('editDescription', '');
}
public function test_update_channel_persists_the_changes_and_closes_the_modal(): void
{
$language = Language::factory()->create();
$channel = PlatformChannel::factory()->create([
'display_name' => 'Old Name',
'description' => 'Old description',
'language_id' => null,
]);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->set('editDisplayName', 'New Name')
->set('editDescription', 'New description')
->set('editLanguageId', $language->id)
->call('updateChannel')
->assertSet('editingChannelId', null)
->assertHasNoErrors();
$channel->refresh();
$this->assertSame('New Name', $channel->display_name);
$this->assertSame('New description', $channel->description);
$this->assertSame($language->id, $channel->language_id);
}
public function test_update_channel_requires_a_display_name(): void
{
$channel = PlatformChannel::factory()->create(['display_name' => 'Original']);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->set('editDisplayName', '')
->call('updateChannel')
->assertHasErrors(['editDisplayName' => 'required']);
$this->assertSame('Original', $channel->fresh()->display_name);
}
public function test_update_channel_rejects_a_display_name_over_the_length_limit(): void
{
$channel = PlatformChannel::factory()->create(['display_name' => 'Original']);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->set('editDisplayName', str_repeat('a', 256))
->call('updateChannel')
->assertHasErrors(['editDisplayName' => 'max']);
$this->assertSame('Original', $channel->fresh()->display_name);
}
public function test_update_channel_rejects_an_unknown_language(): void
{
$channel = PlatformChannel::factory()->create(['display_name' => 'Original']);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->set('editLanguageId', 999999)
->call('updateChannel')
->assertHasErrors(['editLanguageId' => 'exists']);
}
public function test_update_channel_allows_clearing_the_language(): void
{
$language = Language::factory()->create();
$channel = PlatformChannel::factory()->create(['language_id' => $language->id]);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->set('editLanguageId', null)
->call('updateChannel')
->assertHasNoErrors();
$this->assertNull($channel->fresh()->language_id);
}
public function test_update_channel_stores_a_blank_description_as_null(): void
{
$channel = PlatformChannel::factory()->create(['description' => 'Has one']);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->set('editDescription', '')
->call('updateChannel');
$this->assertNull($channel->fresh()->description);
}
public function test_update_channel_leaves_the_community_pairing_untouched(): void
{
$channel = PlatformChannel::factory()->create(['display_name' => 'Original']);
$before = $channel->only(['name', 'channel_id', 'platform_instance_id']);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->set('editDisplayName', 'Renamed')
->call('updateChannel');
$this->assertSame($before, $channel->fresh()->only(['name', 'channel_id', 'platform_instance_id']));
}
public function test_close_edit_modal_discards_the_edit(): void
{
$channel = PlatformChannel::factory()->create(['display_name' => 'Original']);
Livewire::test(Channels::class)
->call('openEditModal', $channel->id)
->set('editDisplayName', 'Discarded')
->call('closeEditModal')
->assertSet('editingChannelId', null);
$this->assertSame('Original', $channel->fresh()->display_name);
}
public function test_update_channel_does_nothing_without_an_open_modal(): void
{
$channel = PlatformChannel::factory()->create(['display_name' => 'Original']);
Livewire::test(Channels::class)
->set('editDisplayName', 'Should not apply')
->call('updateChannel')
->assertHasNoErrors();
$this->assertSame('Original', $channel->fresh()->display_name);
}
} }

View file

@ -2,15 +2,18 @@
namespace Tests\Feature\Livewire; namespace Tests\Feature\Livewire;
use App\Enums\ActivityTypeEnum;
use App\Enums\ApprovalStatusEnum; use App\Enums\ApprovalStatusEnum;
use App\Enums\PublishStatusEnum; use App\Enums\PublishStatusEnum;
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
use App\Models\ActivityLog;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Livewire\Features\SupportTesting\Testable; use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
use Tests\TestCase; use Tests\TestCase;
@ -471,6 +474,111 @@ public function test_it_reports_a_range_with_no_publish_attempts(): void
$this->assertStringContainsString('No publish attempts in this range.', $fragments); $this->assertStringContainsString('No publish attempts in this range.', $fragments);
} }
public function test_it_lists_the_most_recent_activity(): void
{
ActivityLog::factory()->type(ActivityTypeEnum::PUBLISH)->loggedAt(Carbon::parse('2026-07-10 09:00:00'))->create(['message' => 'Older entry']);
ActivityLog::factory()->type(ActivityTypeEnum::ERROR)->loggedAt(Carbon::parse('2026-07-12 09:00:00'))->create(['message' => 'Newer entry']);
Livewire::test(Dashboard::class)
->assertSee('Recent Activity')
->assertSee('Newer entry')
->assertSee('Older entry')
->assertSeeInOrder(['Newer entry', 'Older entry']);
}
public function test_it_caps_the_activity_panel_at_five_entries(): void
{
foreach (range(1, 8) as $index) {
ActivityLog::factory()
->loggedAt(Carbon::parse('2026-07-10 09:00:00')->addMinutes($index))
->create(['message' => "Entry {$index}"]);
}
$component = Livewire::test(Dashboard::class);
$component->assertSee('Entry 8')->assertSee('Entry 4');
$component->assertDontSee('Entry 3');
}
public function test_the_activity_panel_ignores_the_global_range(): void
{
ActivityLog::factory()
->loggedAt(Carbon::parse('2026-07-10 09:00:00'))
->create(['message' => 'Activity outside the range']);
Livewire::test(Dashboard::class)
->call('applyRange', '2026-08-01', '2026-08-31')
->assertSee('Activity outside the range');
}
public function test_it_reports_an_empty_activity_panel(): void
{
Livewire::test(Dashboard::class)->assertSee('Nothing has happened yet.');
}
public function test_it_links_the_activity_panel_to_the_activity_page(): void
{
Livewire::test(Dashboard::class)->assertSee(route('activity'), false);
}
public function test_it_shows_the_feed_name_for_an_article_subject(): void
{
$feed = Feed::factory()->create(['name' => 'Belga News']);
$article = Article::factory()->create(['feed_id' => $feed->id]);
ActivityLog::factory()
->forSubject($article)
->create(['message' => 'Fetched something']);
Livewire::test(Dashboard::class)
->assertSee('Fetched something')
->assertSee('Belga News');
}
public function test_it_renders_an_activity_entry_without_a_subject(): void
{
ActivityLog::factory()->create(['message' => 'Subjectless entry']);
Livewire::test(Dashboard::class)->assertSee('Subjectless entry');
}
public function test_it_renders_an_activity_entry_whose_subject_was_deleted(): void
{
$article = Article::factory()->create();
ActivityLog::factory()->forSubject($article)->create(['message' => 'Orphaned entry']);
$article->delete();
Livewire::test(Dashboard::class)->assertSee('Orphaned entry');
}
private function dashboardQueryCount(int $activityEntries): int
{
$feed = Feed::factory()->create();
foreach (range(1, $activityEntries) as $index) {
$article = Article::factory()->create(['feed_id' => $feed->id]);
ActivityLog::factory()->forSubject($article)->create(['message' => "Entry {$index}"]);
}
DB::flushQueryLog();
DB::enableQueryLog();
Livewire::test(Dashboard::class);
$queries = count(DB::getQueryLog());
DB::disableQueryLog();
return $queries;
}
public function test_it_does_not_fan_out_queries_per_activity_entry(): void
{
$this->assertSame(
$this->dashboardQueryCount(1),
$this->dashboardQueryCount(20),
);
}
public function test_every_range_dependent_island_re_renders_on_a_range_change(): void public function test_every_range_dependent_island_re_renders_on_a_range_change(): void
{ {
$fragments = $this->islandFragments( $fragments = $this->islandFragments(

View file

@ -154,4 +154,134 @@ public function test_toggle_flips_active_state(): void
$this->assertFalse($feed->fresh()->is_active); $this->assertFalse($feed->fresh()->is_active);
} }
public function test_feed_cards_show_an_edit_action(): void
{
Feed::factory()->create(['name' => 'VRT Nieuws']);
Livewire::test(Feeds::class)
->assertSee('Edit VRT Nieuws');
}
public function test_open_edit_modal_prefills_the_current_values(): void
{
$feed = Feed::factory()->create([
'name' => 'VRT Nieuws',
'description' => 'Flemish public broadcaster',
]);
Livewire::test(Feeds::class)
->assertSet('editingFeedId', null)
->call('openEditModal', $feed->id)
->assertSet('editingFeedId', $feed->id)
->assertSet('editName', 'VRT Nieuws')
->assertSet('editDescription', 'Flemish public broadcaster')
->assertSee('Edit Feed');
}
public function test_open_edit_modal_prefills_a_null_description_as_blank(): void
{
$feed = Feed::factory()->create(['description' => null]);
Livewire::test(Feeds::class)
->call('openEditModal', $feed->id)
->assertSet('editDescription', '');
}
public function test_update_feed_persists_the_changes_and_closes_the_modal(): void
{
$feed = Feed::factory()->create([
'name' => 'Old Name',
'description' => 'Old description',
]);
Livewire::test(Feeds::class)
->call('openEditModal', $feed->id)
->set('editName', 'New Name')
->set('editDescription', 'New description')
->call('updateFeed')
->assertSet('editingFeedId', null)
->assertHasNoErrors();
$feed->refresh();
$this->assertSame('New Name', $feed->name);
$this->assertSame('New description', $feed->description);
}
public function test_update_feed_requires_a_name(): void
{
$feed = Feed::factory()->create(['name' => 'Original']);
Livewire::test(Feeds::class)
->call('openEditModal', $feed->id)
->set('editName', '')
->call('updateFeed')
->assertHasErrors(['editName' => 'required']);
$this->assertSame('Original', $feed->fresh()->name);
}
public function test_update_feed_rejects_a_name_over_the_length_limit(): void
{
$feed = Feed::factory()->create(['name' => 'Original']);
Livewire::test(Feeds::class)
->call('openEditModal', $feed->id)
->set('editName', str_repeat('a', 256))
->call('updateFeed')
->assertHasErrors(['editName' => 'max']);
$this->assertSame('Original', $feed->fresh()->name);
}
public function test_update_feed_stores_a_blank_description_as_null(): void
{
$feed = Feed::factory()->create(['description' => 'Has one']);
Livewire::test(Feeds::class)
->call('openEditModal', $feed->id)
->set('editDescription', '')
->call('updateFeed');
$this->assertNull($feed->fresh()->description);
}
public function test_update_feed_leaves_the_source_identity_untouched(): void
{
$feed = Feed::factory()->create(['name' => 'Original']);
$before = $feed->only(['url', 'provider', 'language_id', 'type']);
Livewire::test(Feeds::class)
->call('openEditModal', $feed->id)
->set('editName', 'Renamed')
->call('updateFeed');
$this->assertSame($before, $feed->fresh()->only(['url', 'provider', 'language_id', 'type']));
}
public function test_close_edit_modal_discards_the_edit(): void
{
$feed = Feed::factory()->create(['name' => 'Original']);
Livewire::test(Feeds::class)
->call('openEditModal', $feed->id)
->set('editName', 'Discarded')
->call('closeEditModal')
->assertSet('editingFeedId', null);
$this->assertSame('Original', $feed->fresh()->name);
}
public function test_update_feed_does_nothing_without_an_open_modal(): void
{
$feed = Feed::factory()->create(['name' => 'Original']);
Livewire::test(Feeds::class)
->set('editName', 'Should not apply')
->call('updateFeed')
->assertHasNoErrors();
$this->assertSame('Original', $feed->fresh()->name);
}
} }

View file

@ -177,7 +177,7 @@ public function test_publish_fails_when_fallback_fetch_returns_nothing(): void
]); ]);
} }
public function test_repeated_failures_exhaust_the_retry_attempts(): void public function test_a_failure_stores_the_reason_and_stops_further_attempts(): void
{ {
$routeArticle = $this->createRouteArticle([], unvalidated: true); $routeArticle = $this->createRouteArticle([], unvalidated: true);
@ -185,23 +185,25 @@ public function test_repeated_failures_exhaust_the_retry_attempts(): void
$fetcher->shouldReceive('fetchArticleData')->andReturn([]); $fetcher->shouldReceive('fetchArticleData')->andReturn([]);
$publishingService = Mockery::mock(ArticlePublishingService::class); $publishingService = Mockery::mock(ArticlePublishingService::class);
$action = new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService);
for ($i = 0; $i < RouteArticle::MAX_PUBLISH_ATTEMPTS; $i++) { (new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
$action->execute($routeArticle); ->execute($routeArticle);
$routeArticle->refresh();
}
$this->assertSame(RouteArticle::MAX_PUBLISH_ATTEMPTS, $routeArticle->publish_attempts); $routeArticle->refresh();
$this->assertTrue($routeArticle->hasExhaustedPublishAttempts());
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->publish_status);
$this->assertNotNull($routeArticle->publish_error);
$this->assertFalse(
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
);
} }
public function test_a_successful_publish_resets_earlier_failed_attempts(): void public function test_a_successful_publish_clears_an_earlier_failure(): void
{ {
$routeArticle = $this->createRouteArticle([ $routeArticle = $this->createRouteArticle([
'description' => 'Stored description', 'description' => 'Stored description',
]); ]);
$routeArticle->update(['publish_attempts' => 2, 'next_attempt_at' => now()->subMinute()]); $routeArticle->recordPublishFailed('an earlier failure');
$fetcher = Mockery::mock(ArticleFetcher::class); $fetcher = Mockery::mock(ArticleFetcher::class);
$fetcher->shouldNotReceive('fetchArticleData'); $fetcher->shouldNotReceive('fetchArticleData');
@ -216,8 +218,8 @@ public function test_a_successful_publish_resets_earlier_failed_attempts(): void
$routeArticle->refresh(); $routeArticle->refresh();
$this->assertSame(0, $routeArticle->publish_attempts); $this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->publish_status);
$this->assertNull($routeArticle->next_attempt_at); $this->assertNull($routeArticle->publish_error);
} }
public function test_publish_fails_when_fallback_recovers_only_a_title(): void public function test_publish_fails_when_fallback_recovers_only_a_title(): void

View file

@ -40,19 +40,4 @@ public function test_it_reports_a_zero_share_when_nothing_was_counted(): void
$this->assertSame(0.0, $result->shareOf($result->rows[0])); $this->assertSame(0.0, $result->shareOf($result->rows[0]));
} }
public function test_it_has_no_data_without_rows(): void
{
$this->assertTrue((new BreakdownResult([]))->isEmpty());
}
public function test_it_has_no_data_when_every_row_is_zero(): void
{
$this->assertTrue((new BreakdownResult([new Breakdown('A', 0)]))->isEmpty());
}
public function test_it_has_data_when_any_row_is_counted(): void
{
$this->assertFalse((new BreakdownResult([new Breakdown('A', 1)]))->isEmpty());
}
} }

View file

@ -515,10 +515,10 @@ public function test_handle_creates_notification_on_publish_exception(): void
$this->assertStringContainsString('Failing Article', $notification->title); $this->assertStringContainsString('Failing Article', $notification->title);
} }
public function test_handle_skips_route_articles_that_are_not_due_for_retry(): void public function test_handle_skips_route_articles_that_previously_failed(): void
{ {
$routeArticle = $this->createApprovedRouteArticle(); $routeArticle = $this->createApprovedRouteArticle();
$routeArticle->update(['publish_attempts' => 1, 'next_attempt_at' => now()->addMinutes(5)]); $routeArticle->recordPublishFailed('couldnt_find_community');
$articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData'); $articleFetcherMock->shouldNotReceive('fetchArticleData');
@ -529,37 +529,14 @@ public function test_handle_skips_route_articles_that_are_not_due_for_retry(): v
$job = new PublishNextArticleJob; $job = new PublishNextArticleJob;
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService)); $job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertSame(PublishStatusEnum::UNPUBLISHED, $routeArticle->fresh()->publish_status); $this->assertSame(PublishStatusEnum::ERROR, $routeArticle->fresh()->publish_status);
} }
public function test_handle_skips_route_articles_that_exhausted_their_attempts(): void public function test_handle_publishes_a_later_article_when_the_oldest_has_failed(): void
{
$routeArticle = $this->createApprovedRouteArticle();
$routeArticle->update([
'publish_attempts' => RouteArticle::MAX_PUBLISH_ATTEMPTS,
'next_attempt_at' => now()->subDay(),
]);
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
$articleFetcherMock->shouldNotReceive('fetchArticleData');
$publishingServiceMock = Mockery::mock(ArticlePublishingService::class);
$publishingServiceMock->shouldNotReceive('publishRouteArticle');
$job = new PublishNextArticleJob;
$job->handle(new PublishRouteArticleAction($articleFetcherMock, $publishingServiceMock, $this->notificationService));
$this->assertSame(PublishStatusEnum::UNPUBLISHED, $routeArticle->fresh()->publish_status);
}
public function test_handle_publishes_a_later_article_when_the_oldest_is_backing_off(): void
{ {
$blocked = $this->createApprovedRouteArticle(['title' => 'Blocked Article']); $blocked = $this->createApprovedRouteArticle(['title' => 'Blocked Article']);
$blocked->update([ $blocked->update(['created_at' => now()->subDays(2)]);
'created_at' => now()->subDays(2), $blocked->recordPublishFailed('couldnt_find_community');
'publish_attempts' => 1,
'next_attempt_at' => now()->addMinutes(5),
]);
$next = $this->createApprovedRouteArticle(['title' => 'Next Article']); $next = $this->createApprovedRouteArticle(['title' => 'Next Article']);
$next->update(['created_at' => now()->subDay()]); $next->update(['created_at' => now()->subDay()]);

View file

@ -67,6 +67,26 @@ public function test_re_approving_does_not_move_the_decision_time(): void
); );
} }
public function test_re_rejecting_does_not_move_the_decision_time(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->create([
'approval_status' => ApprovalStatusEnum::PENDING,
'decided_at' => null,
]);
$routeArticle->reject();
$first = $routeArticle->fresh()->decided_at;
$this->travel(1)->hours();
$routeArticle->fresh()->reject();
$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 public function test_rejecting_an_approved_article_moves_the_decision_time(): void
{ {
/** @var RouteArticle $routeArticle */ /** @var RouteArticle $routeArticle */

View file

@ -2,6 +2,7 @@
namespace Tests\Unit\Models; namespace Tests\Unit\Models;
use App\Enums\PublishStatusEnum;
use App\Models\RouteArticle; use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -10,67 +11,80 @@ class RouteArticleRetryTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_first_failure_schedules_the_shortest_backoff(): void public function test_recording_a_failure_stores_the_reason(): void
{ {
/** @var RouteArticle $routeArticle */ /** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->approved()->create(); $routeArticle = RouteArticle::factory()->approved()->create();
$this->freezeTime(function () use ($routeArticle) { $routeArticle->recordPublishFailed('couldnt_find_community');
$routeArticle->recordPublishAttemptFailed();
$this->assertSame(1, $routeArticle->publish_attempts); $routeArticle->refresh();
$this->assertSame(
now()->addMinutes(5)->format('Y-m-d H:i:s'), $this->assertSame(PublishStatusEnum::ERROR, $routeArticle->publish_status);
$routeArticle->next_attempt_at->format('Y-m-d H:i:s') $this->assertSame('couldnt_find_community', $routeArticle->publish_error);
);
});
} }
public function test_backoff_grows_with_each_failure(): void public function test_clearing_a_failure_makes_it_publishable_again(): void
{ {
/** @var RouteArticle $routeArticle */ /** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->approved()->create(); $routeArticle = RouteArticle::factory()->approved()->create();
$routeArticle->recordPublishFailed('boom');
$this->freezeTime(function () use ($routeArticle) { $routeArticle->clearPublishFailure();
foreach ([5, 30, 120, 360] as $expectedDelay) {
$routeArticle->recordPublishAttemptFailed();
$this->assertSame( $routeArticle->refresh();
now()->addMinutes($expectedDelay)->format('Y-m-d H:i:s'),
$routeArticle->next_attempt_at->format('Y-m-d H:i:s'), $this->assertSame(PublishStatusEnum::UNPUBLISHED, $routeArticle->publish_status);
"Attempt {$routeArticle->publish_attempts} should wait {$expectedDelay} minutes" $this->assertNull($routeArticle->publish_error);
);
}
});
} }
public function test_attempts_are_exhausted_after_the_configured_maximum(): void public function test_a_failed_article_is_not_due_for_publishing(): void
{ {
/** @var RouteArticle $routeArticle */ /** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->approved()->create(); $routeArticle = RouteArticle::factory()->approved()->create();
$routeArticle->recordPublishFailed('boom');
for ($i = 0; $i < RouteArticle::MAX_PUBLISH_ATTEMPTS - 1; $i++) { $this->assertFalse(
$routeArticle->recordPublishAttemptFailed(); RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
$this->assertFalse($routeArticle->hasExhaustedPublishAttempts()); );
}
$routeArticle->recordPublishAttemptFailed();
$this->assertTrue($routeArticle->hasExhaustedPublishAttempts());
} }
public function test_a_successful_publish_clears_previous_attempts(): void public function test_clearing_the_failure_returns_it_to_the_publish_queue(): void
{ {
/** @var RouteArticle $routeArticle */ /** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->approved()->create(); $routeArticle = RouteArticle::factory()->approved()->create();
$routeArticle->recordPublishFailed('boom');
$routeArticle->clearPublishFailure();
$routeArticle->recordPublishAttemptFailed(); $this->assertTrue(
$routeArticle->recordPublishAttemptFailed(); RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
);
}
$routeArticle->clearPublishAttempts(); public function test_the_failed_scope_returns_only_failed_articles(): void
{
/** @var RouteArticle $failed */
$failed = RouteArticle::factory()->approved()->create();
$failed->recordPublishFailed('boom');
$this->assertSame(0, $routeArticle->publish_attempts); RouteArticle::factory()->approved()->create();
$this->assertNull($routeArticle->next_attempt_at);
$this->assertFalse($routeArticle->hasExhaustedPublishAttempts()); $ids = RouteArticle::failed()->pluck('id')->all();
$this->assertSame([$failed->id], $ids);
}
public function test_a_failure_does_not_change_the_approval_decision(): void
{
/** @var RouteArticle $routeArticle */
$routeArticle = RouteArticle::factory()->approved()->create();
$decidedAt = $routeArticle->decided_at;
$routeArticle->recordPublishFailed('boom');
$routeArticle->refresh();
$this->assertTrue($routeArticle->isApproved());
$this->assertEquals($decidedAt, $routeArticle->decided_at);
} }
} }

View file

@ -263,6 +263,61 @@ public function test_chaining_methods(): void
}); });
} }
public function test_post_multipart_targets_a_root_relative_path(): void
{
Http::fake(['*' => Http::response(['files' => []])]);
$request = new LemmyRequest('lemmy.world', 'test-token');
$response = $request->postMultipart('pictrs/image', 'images[]', 'binary-data', 'thumb.jpg');
$this->assertInstanceOf(Response::class, $response);
Http::assertSent(function ($httpRequest) {
return $httpRequest->url() === 'https://lemmy.world/pictrs/image'
&& $httpRequest->header('Authorization')[0] === 'Bearer test-token';
});
}
public function test_post_multipart_does_not_use_the_api_prefix(): void
{
Http::fake(['*' => Http::response(['files' => []])]);
(new LemmyRequest('lemmy.world'))->postMultipart('/pictrs/image', 'images[]', 'data', 'thumb.jpg');
Http::assertSent(fn ($httpRequest) => ! str_contains($httpRequest->url(), '/api/v3/'));
}
public function test_post_multipart_sends_the_file_as_multipart_data(): void
{
Http::fake(['*' => Http::response(['files' => []])]);
(new LemmyRequest('lemmy.world'))->postMultipart('pictrs/image', 'images[]', 'binary-data', 'thumb.jpg');
Http::assertSent(function ($httpRequest) {
return str_contains($httpRequest->header('Content-Type')[0], 'multipart/form-data')
&& str_contains($httpRequest->body(), 'binary-data')
&& str_contains($httpRequest->body(), 'thumb.jpg');
});
}
public function test_post_multipart_omits_authorization_without_a_token(): void
{
Http::fake(['*' => Http::response(['files' => []])]);
(new LemmyRequest('lemmy.world'))->postMultipart('pictrs/image', 'images[]', 'data', 'thumb.jpg');
Http::assertSent(fn ($httpRequest) => ! $httpRequest->hasHeader('Authorization'));
}
public function test_post_multipart_respects_the_scheme(): void
{
Http::fake(['*' => Http::response(['files' => []])]);
(new LemmyRequest('http://lemmy.world'))->postMultipart('pictrs/image', 'images[]', 'data', 'thumb.jpg');
Http::assertSent(fn ($httpRequest) => $httpRequest->url() === 'http://lemmy.world/pictrs/image');
}
private function getPrivateProperty(object $object, string $property): mixed private function getPrivateProperty(object $object, string $property): mixed
{ {
$reflection = new \ReflectionClass($object); $reflection = new \ReflectionClass($object);

View file

@ -9,7 +9,9 @@
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Modules\Lemmy\Services\LemmyApiService; use App\Modules\Lemmy\Services\LemmyApiService;
use App\Modules\Lemmy\Services\LemmyPublisher; use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Modules\Lemmy\Services\ThumbnailUploader;
use App\Services\Auth\LemmyAuthService; use App\Services\Auth\LemmyAuthService;
use App\Services\Log\LogSaver;
use Exception; use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery; use Mockery;
@ -25,6 +27,29 @@ protected function tearDown(): void
parent::tearDown(); parent::tearDown();
} }
/**
* Swaps in a mocked API and an uploader that echoes its input back, so existing
* expectations can keep asserting on the thumbnail they passed in.
*/
private function injectMocks(LemmyPublisher $publisher, LemmyApiService $api, ?ThumbnailUploader $uploader = null): void
{
if (! $uploader instanceof ThumbnailUploader) {
$passthrough = Mockery::mock(ThumbnailUploader::class);
$passthrough->shouldReceive('upload')->andReturnUsing(fn (?string $url): ?string => $url);
$uploader = $passthrough;
}
$reflection = new \ReflectionClass($publisher);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $api);
$uploaderProperty = $reflection->getProperty('thumbnailUploader');
$uploaderProperty->setAccessible(true);
$uploaderProperty->setValue($publisher, $uploader);
}
public function test_constructor_initializes_api_service(): void public function test_constructor_initializes_api_service(): void
{ {
$account = PlatformAccount::factory()->make([ $account = PlatformAccount::factory()->make([
@ -92,10 +117,7 @@ public function test_publish_to_channel_with_all_data(): void
// Create publisher and inject mocked API using reflection // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$result = $publisher->publishToChannel($article, $extractedData, $channel); $result = $publisher->publishToChannel($article, $extractedData, $channel);
@ -145,10 +167,7 @@ public function test_publish_to_channel_with_minimal_data(): void
// Create publisher and inject mocked API using reflection // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$result = $publisher->publishToChannel($article, $extractedData, $channel); $result = $publisher->publishToChannel($article, $extractedData, $channel);
@ -201,10 +220,7 @@ public function test_publish_to_channel_without_thumbnail(): void
// Create publisher and inject mocked API using reflection // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$result = $publisher->publishToChannel($article, $extractedData, $channel); $result = $publisher->publishToChannel($article, $extractedData, $channel);
@ -273,10 +289,7 @@ public function test_publish_to_channel_throws_api_exception(): void
// Create publisher and inject mocked API using reflection // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$this->expectException(Exception::class); $this->expectException(Exception::class);
$this->expectExceptionMessage('API Error'); $this->expectExceptionMessage('API Error');
@ -327,13 +340,163 @@ public function test_publish_to_channel_forwards_resolved_community_id_to_create
// Create publisher and inject mocked API using reflection // Create publisher and inject mocked API using reflection
$publisher = new LemmyPublisher($account); $publisher = new LemmyPublisher($account);
$reflection = new \ReflectionClass($publisher); $this->injectMocks($publisher, $apiMock);
$apiProperty = $reflection->getProperty('api');
$apiProperty->setAccessible(true);
$apiProperty->setValue($publisher, $apiMock);
$result = $publisher->publishToChannel($article, $extractedData, $channel); $result = $publisher->publishToChannel($article, $extractedData, $channel);
$this->assertEquals(['success' => true], $result); $this->assertEquals(['success' => true], $result);
} }
public function test_it_passes_the_uploaded_thumbnail_url_to_create_post(): void
{
$account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']);
$article = Article::factory()->make(['url' => 'https://example.com/article']);
$channel = PlatformChannel::factory()->make(['channel_id' => 7]);
$authMock = Mockery::mock(LemmyAuthService::class);
$authMock->shouldReceive('getToken')->andReturn('tok');
$this->app->instance(LemmyAuthService::class, $authMock);
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('createPost')
->once()
->with('tok', 'T', '', 7, 'https://example.com/article', 'https://lemmy.world/pictrs/image/x.jpg', null)
->andReturn(['ok' => true]);
$uploader = Mockery::mock(ThumbnailUploader::class);
$uploader->shouldReceive('upload')
->once()
->with('https://cdn.example.com/big.jpg', 'tok')
->andReturn('https://lemmy.world/pictrs/image/x.jpg');
$publisher = new LemmyPublisher($account);
$this->injectMocks($publisher, $apiMock, $uploader);
$this->assertSame(
['ok' => true],
$publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/big.jpg'], $channel)
);
}
public function test_it_logs_a_warning_when_the_thumbnail_upload_fails(): void
{
$account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']);
$article = Article::factory()->create();
$channel = PlatformChannel::factory()->create();
$authMock = Mockery::mock(LemmyAuthService::class);
$authMock->shouldReceive('getToken')->andReturn('tok');
$this->app->instance(LemmyAuthService::class, $authMock);
$logMock = Mockery::mock(LogSaver::class);
$logMock->shouldReceive('warning')
->once()
->withArgs(fn (string $message, ?PlatformChannel $c, array $context): bool => str_contains($message, 'Thumbnail upload failed')
&& $context['source'] === 'https://cdn.example.com/big.jpg');
$this->app->instance(LogSaver::class, $logMock);
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]);
$uploader = Mockery::mock(ThumbnailUploader::class);
$uploader->shouldReceive('upload')->once()->andReturn(null);
$publisher = new LemmyPublisher($account);
$this->injectMocks($publisher, $apiMock, $uploader);
$this->assertSame(
['ok' => true],
$publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/big.jpg'], $channel)
);
}
public function test_it_does_not_log_when_there_was_no_thumbnail_to_upload(): void
{
$account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']);
$article = Article::factory()->make(['url' => 'https://example.com/article']);
$channel = PlatformChannel::factory()->make(['channel_id' => 7]);
$authMock = Mockery::mock(LemmyAuthService::class);
$authMock->shouldReceive('getToken')->andReturn('tok');
$this->app->instance(LemmyAuthService::class, $authMock);
$logMock = Mockery::mock(LogSaver::class);
$logMock->shouldNotReceive('warning');
$this->app->instance(LogSaver::class, $logMock);
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]);
$uploader = Mockery::mock(ThumbnailUploader::class);
$uploader->shouldNotReceive('upload');
$publisher = new LemmyPublisher($account);
$this->injectMocks($publisher, $apiMock, $uploader);
$this->assertSame(['ok' => true], $publisher->publishToChannel($article, ['title' => 'T'], $channel));
}
public function test_a_stale_token_retry_does_not_re_upload_the_thumbnail(): void
{
$account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']);
$article = Article::factory()->create();
$channel = PlatformChannel::factory()->create();
$authMock = Mockery::mock(LemmyAuthService::class);
$authMock->shouldReceive('getToken')->once()->andReturn('stale');
$authMock->shouldReceive('refreshToken')->once()->andReturn('fresh');
$this->app->instance(LemmyAuthService::class, $authMock);
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('createPost')
->once()
->with('stale', Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any(), 'https://lemmy.world/pictrs/image/x.jpg', Mockery::any())
->andThrow(new Exception('not_logged_in'));
$apiMock->shouldReceive('createPost')
->once()
->with('fresh', Mockery::any(), Mockery::any(), Mockery::any(), Mockery::any(), 'https://lemmy.world/pictrs/image/x.jpg', Mockery::any())
->andReturn(['ok' => true]);
$uploader = Mockery::mock(ThumbnailUploader::class);
$uploader->shouldReceive('upload')->once()->andReturn('https://lemmy.world/pictrs/image/x.jpg');
$publisher = new LemmyPublisher($account);
$this->injectMocks($publisher, $apiMock, $uploader);
$this->assertSame(
['ok' => true],
$publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/b.jpg'], $channel)
);
}
public function test_a_stale_token_retry_logs_the_upload_failure_only_once(): void
{
$account = PlatformAccount::factory()->make(['instance_url' => 'https://lemmy.world']);
$article = Article::factory()->create();
$channel = PlatformChannel::factory()->create();
$authMock = Mockery::mock(LemmyAuthService::class);
$authMock->shouldReceive('getToken')->andReturn('stale');
$authMock->shouldReceive('refreshToken')->andReturn('fresh');
$this->app->instance(LemmyAuthService::class, $authMock);
$logMock = Mockery::mock(LogSaver::class);
$logMock->shouldReceive('warning')->once();
$this->app->instance(LogSaver::class, $logMock);
$apiMock = Mockery::mock(LemmyApiService::class);
$apiMock->shouldReceive('createPost')->once()->andThrow(new Exception('not_logged_in'));
$apiMock->shouldReceive('createPost')->once()->andReturn(['ok' => true]);
$uploader = Mockery::mock(ThumbnailUploader::class);
$uploader->shouldReceive('upload')->once()->andReturn(null);
$publisher = new LemmyPublisher($account);
$this->injectMocks($publisher, $apiMock, $uploader);
$this->assertSame(
['ok' => true],
$publisher->publishToChannel($article, ['title' => 'T', 'thumbnail' => 'https://cdn.example.com/b.jpg'], $channel)
);
}
} }

View file

@ -0,0 +1,176 @@
<?php
namespace Tests\Unit\Modules\Lemmy\Services;
use App\Modules\Lemmy\Services\ThumbnailUploader;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class ThumbnailUploaderTest extends TestCase
{
/**
* @param int<1, max> $width
* @param int<1, max> $height
*/
private function jpeg(int $width, int $height): string
{
$image = imagecreatetruecolor($width, $height);
$colour = imagecolorallocate($image, 120, 90, 60);
imagefilledrectangle($image, 0, 0, $width - 1, $height - 1, $colour === false ? 0 : $colour);
ob_start();
imagejpeg($image, null, 90);
$bytes = (string) ob_get_clean();
imagedestroy($image);
return $bytes;
}
private function uploader(): ThumbnailUploader
{
return new ThumbnailUploader('https://lemmy.world');
}
private function fake(string $sourceBody, mixed $uploadResponse, int $uploadStatus = 200): void
{
Http::fake([
'https://cdn.example.com/*' => Http::response($sourceBody, 200, ['Content-Type' => 'image/jpeg']),
'https://lemmy.world/pictrs/image' => Http::response($uploadResponse, $uploadStatus),
]);
}
public function test_it_returns_an_instance_hosted_url(): void
{
$this->fake($this->jpeg(1200, 800), ['files' => [['file' => 'abc123.jpg', 'delete_token' => 'tok']]]);
$url = $this->uploader()->upload('https://cdn.example.com/big.jpg', 'token');
$this->assertSame('https://lemmy.world/pictrs/image/abc123.jpg', $url);
}
public function test_it_downscales_a_wide_image_before_upload(): void
{
$source = $this->jpeg(2000, 1000);
$this->fake($source, ['files' => [['file' => 'abc123.jpg']]]);
$this->uploader()->upload('https://cdn.example.com/big.jpg', 'token');
Http::assertSent(function (Request $request) use ($source): bool {
if ($request->url() !== 'https://lemmy.world/pictrs/image') {
return false;
}
$uploaded = $this->multipartFileContents($request);
$size = getimagesizefromstring($uploaded);
return $size !== false
&& $size[0] === 600
&& $size[1] === 300
&& $uploaded !== $source
&& strlen($uploaded) < strlen($source);
});
}
public function test_it_uploads_a_small_image_untouched(): void
{
$source = $this->jpeg(320, 240);
$this->fake($source, ['files' => [['file' => 'abc123.jpg']]]);
$this->uploader()->upload('https://cdn.example.com/small.jpg', 'token');
Http::assertSent(function (Request $request) use ($source): bool {
return $request->url() !== 'https://lemmy.world/pictrs/image'
|| $this->multipartFileContents($request) === $source;
});
}
public function test_it_sends_the_token_as_a_bearer_header(): void
{
$this->fake($this->jpeg(800, 600), ['files' => [['file' => 'abc123.jpg']]]);
$this->uploader()->upload('https://cdn.example.com/big.jpg', 'secret-token');
Http::assertSent(fn (Request $request): bool => $request->url() !== 'https://lemmy.world/pictrs/image'
|| $request->header('Authorization')[0] === 'Bearer secret-token');
}
public function test_it_returns_null_for_a_null_source(): void
{
Http::fake();
$this->assertNull($this->uploader()->upload(null, 'token'));
Http::assertNothingSent();
}
public function test_it_returns_null_for_an_empty_source(): void
{
Http::fake();
$this->assertNull($this->uploader()->upload('', 'token'));
Http::assertNothingSent();
}
public function test_it_returns_null_when_the_download_fails(): void
{
Http::fake(['https://cdn.example.com/*' => Http::response('', 404)]);
$this->assertNull($this->uploader()->upload('https://cdn.example.com/gone.jpg', 'token'));
}
public function test_it_returns_null_when_the_source_is_not_an_image(): void
{
$this->fake('<html>not an image</html>', ['files' => [['file' => 'abc123.jpg']]]);
$this->assertNull($this->uploader()->upload('https://cdn.example.com/page.html', 'token'));
}
public function test_it_returns_null_when_the_upload_fails(): void
{
$this->fake($this->jpeg(1200, 800), ['error' => 'nope'], 500);
$this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token'));
}
public function test_it_returns_null_when_the_upload_response_has_no_file(): void
{
$this->fake($this->jpeg(1200, 800), ['files' => []]);
$this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token'));
}
public function test_it_returns_null_when_the_source_exceeds_the_size_cap(): void
{
$this->fake(str_repeat('x', 10_485_761), ['files' => [['file' => 'abc123.jpg']]]);
$this->assertNull($this->uploader()->upload('https://cdn.example.com/huge.jpg', 'token'));
}
public function test_it_returns_null_when_the_source_exceeds_the_pixel_cap(): void
{
// A small file can still decode to a huge bitmap; this is the guard that protects the worker.
$this->fake($this->jpeg(9000, 6000), ['files' => [['file' => 'abc123.jpg']]]);
$this->assertNull($this->uploader()->upload('https://cdn.example.com/huge-dimensions.jpg', 'token'));
}
public function test_it_returns_null_when_the_download_throws(): void
{
Http::fake(fn () => throw new \RuntimeException('connection refused'));
$this->assertNull($this->uploader()->upload('https://cdn.example.com/big.jpg', 'token'));
}
private function multipartFileContents(Request $request): string
{
foreach ($request->data() as $part) {
if (($part['name'] ?? null) === 'images[]') {
return (string) $part['contents'];
}
}
return '';
}
}