Compare commits
8 commits
bf734feb4a
...
c7b9cd6d0c
| Author | SHA1 | Date | |
|---|---|---|---|
| c7b9cd6d0c | |||
| e16368a662 | |||
| 34b78e6e54 | |||
| 6ea3f922da | |||
| 0163fa53b4 | |||
| 41a1be7ca4 | |||
| a8d96554a7 | |||
| 42fab63eaf |
39 changed files with 1680 additions and 221 deletions
|
|
@ -42,8 +42,7 @@ public function execute(RouteArticle $routeArticle): PublishOutcome
|
|||
? $this->publishingService->publishRouteArticle($routeArticle, $extractedData)
|
||||
: PublishOutcome::failure('Could not recover the article content to publish');
|
||||
} catch (Exception $e) {
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
$routeArticle->recordPublishAttemptFailed();
|
||||
$routeArticle->recordPublishFailed($e->getMessage());
|
||||
|
||||
ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [
|
||||
'article_id' => $article->id,
|
||||
|
|
@ -103,8 +102,10 @@ private function resolvePublishData(Article $article): array
|
|||
|
||||
private function recordPublished(RouteArticle $routeArticle): void
|
||||
{
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]);
|
||||
$routeArticle->clearPublishAttempts();
|
||||
$routeArticle->update([
|
||||
'publish_status' => PublishStatusEnum::PUBLISHED,
|
||||
'publish_error' => null,
|
||||
]);
|
||||
|
||||
ActionPerformed::dispatch('Published article', LogLevelEnum::INFO, [
|
||||
'article_id' => $routeArticle->article->id,
|
||||
|
|
@ -141,20 +142,18 @@ private function recordFailed(RouteArticle $routeArticle, PublishOutcome $outcom
|
|||
{
|
||||
$article = $routeArticle->article;
|
||||
|
||||
$routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]);
|
||||
$routeArticle->recordPublishAttemptFailed();
|
||||
$routeArticle->recordPublishFailed($outcome->reason ?? 'Publishing failed');
|
||||
|
||||
ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [
|
||||
'article_id' => $article->id,
|
||||
'title' => $article->title,
|
||||
'reason' => $outcome->reason,
|
||||
'attempt' => $routeArticle->publish_attempts,
|
||||
]);
|
||||
|
||||
ActivityLogged::dispatch(
|
||||
ActivityTypeEnum::ERROR,
|
||||
"Failed to publish \"{$article->title}\"",
|
||||
['reason' => $outcome->reason, 'attempt' => $routeArticle->publish_attempts],
|
||||
['reason' => $outcome->reason],
|
||||
$article,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,9 +22,4 @@ public function shareOf(Breakdown $row): float
|
|||
|
||||
return $total > 0 ? round(($row->count / $total) * 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return $this->total() === 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ public function __construct(
|
|||
public function stats(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
||||
'from' => ['nullable', 'date', 'required_with:to'],
|
||||
'to' => ['nullable', 'date', 'after_or_equal:from', 'required_with:from'],
|
||||
]);
|
||||
|
||||
$range = isset($validated['from'], $validated['to'])
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@
|
|||
|
||||
use App\Enums\ActivityTypeEnum;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Article;
|
||||
use App\Services\Activity\ActivitySummary;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
|
|
@ -37,11 +34,7 @@ public function updatedDays(): void
|
|||
private function query(): Builder
|
||||
{
|
||||
return ActivityLog::query()
|
||||
->with(['subject' => function (Relation $morphTo): void {
|
||||
if ($morphTo instanceof MorphTo) {
|
||||
$morphTo->morphWith([Article::class => ['feed']]);
|
||||
}
|
||||
}])
|
||||
->withSubjectDetails()
|
||||
->since(now()->subDays($this->days))
|
||||
->when(
|
||||
$this->type !== '',
|
||||
|
|
|
|||
|
|
@ -121,9 +121,28 @@ public function refresh(): void
|
|||
$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
|
||||
{
|
||||
$pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count();
|
||||
$failedCount = RouteArticle::failed()->count();
|
||||
|
||||
if ($this->tab === 'pending') {
|
||||
return view('livewire.articles', [
|
||||
|
|
@ -131,6 +150,7 @@ public function render(): View
|
|||
'pendingFeeds' => $this->pendingFeeds(),
|
||||
'feedOptions' => $this->feedOptions(),
|
||||
'pendingCount' => $pendingCount,
|
||||
'failedCount' => $failedCount,
|
||||
'clearableCount' => $this->clearableQuery()->count(),
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
|
@ -138,6 +158,10 @@ public function render(): View
|
|||
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
if ($this->tab === 'failed') {
|
||||
$query->failed();
|
||||
}
|
||||
|
||||
if ($this->feedId !== null) {
|
||||
$query->where('feed_id', $this->feedId);
|
||||
}
|
||||
|
|
@ -155,6 +179,7 @@ public function render(): View
|
|||
'pendingFeeds' => null,
|
||||
'feedOptions' => $this->feedOptions(),
|
||||
'pendingCount' => $pendingCount,
|
||||
'failedCount' => $failedCount,
|
||||
'clearableCount' => 0,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ class Channels extends Component
|
|||
|
||||
public string $newDescription = '';
|
||||
|
||||
public ?int $editingChannelId = null;
|
||||
|
||||
public string $editDisplayName = '';
|
||||
|
||||
public ?int $editLanguageId = null;
|
||||
|
||||
public string $editDescription = '';
|
||||
|
||||
public function toggle(int $channelId): void
|
||||
{
|
||||
$channel = PlatformChannel::findOrFail($channelId);
|
||||
|
|
@ -127,6 +135,45 @@ public function createChannel(CreateChannelAction $action): void
|
|||
$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
|
||||
{
|
||||
$this->managingChannelId = $channelId;
|
||||
|
|
@ -177,6 +224,9 @@ public function render(): View
|
|||
return view('livewire.channels', [
|
||||
'channels' => $channels,
|
||||
'managingChannel' => $managingChannel,
|
||||
'editingChannel' => $this->editingChannelId !== null
|
||||
? PlatformChannel::with('platformInstance')->find($this->editingChannelId)
|
||||
: null,
|
||||
'availableAccounts' => $availableAccounts,
|
||||
'platformInstances' => PlatformInstance::where('is_active', true)->orderBy('name')->get(),
|
||||
'languages' => Language::where('is_active', true)->orderBy('name')->get(),
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@
|
|||
use App\Dashboard\Stats\PublicationsPerChannel;
|
||||
use App\Dashboard\Stats\PublishSuccessRate;
|
||||
use App\Dashboard\Stats\SeriesResult;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Services\DashboardStatsService;
|
||||
use App\Support\DateRange;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Computed;
|
||||
|
|
@ -43,6 +45,8 @@ public function mount(): void
|
|||
'publications-per-channel',
|
||||
];
|
||||
|
||||
private const RECENT_ACTIVITY_LIMIT = 5;
|
||||
|
||||
public function applyPreset(string $preset): void
|
||||
{
|
||||
try {
|
||||
|
|
@ -179,6 +183,21 @@ private function breakdown(string $stat): 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>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ class Feeds extends Component
|
|||
|
||||
public string $newDescription = '';
|
||||
|
||||
public ?int $editingFeedId = null;
|
||||
|
||||
public string $editName = '';
|
||||
|
||||
public string $editDescription = '';
|
||||
|
||||
public function toggle(int $feedId): void
|
||||
{
|
||||
$feed = Feed::findOrFail($feedId);
|
||||
|
|
@ -67,6 +73,41 @@ public function createFeed(CreateFeedAction $action): void
|
|||
$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>>
|
||||
*/
|
||||
|
|
@ -86,6 +127,9 @@ public function render(): View
|
|||
'feeds' => $feeds,
|
||||
'providers' => $this->activeProviders(),
|
||||
'languages' => Language::where('is_active', true)->orderBy('name')->get(),
|
||||
'editingFeed' => $this->editingFeedId !== null
|
||||
? Feed::with('language')->find($this->editingFeedId)
|
||||
: null,
|
||||
])->layout('layouts.app');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
|
|
@ -77,4 +78,17 @@ public function scopeLatestFirst(Builder $query): Builder
|
|||
{
|
||||
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']]);
|
||||
}
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
* @property string $provider
|
||||
* @property int|null $language_id
|
||||
* @property Language|null $language
|
||||
* @property string $description
|
||||
* @property string|null $description
|
||||
* @property FeedColorEnum|null $color
|
||||
* @property array<string, mixed> $settings
|
||||
* @property bool $is_active
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@
|
|||
* @property PlatformInstance $platformInstance
|
||||
* @property int $channel_id
|
||||
* @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 bool $is_active
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@
|
|||
* @property int $article_id
|
||||
* @property ApprovalStatusEnum $approval_status
|
||||
* @property PublishStatusEnum $publish_status
|
||||
* @property int $publish_attempts
|
||||
* @property Carbon|null $next_attempt_at
|
||||
* @property string|null $publish_error
|
||||
* @property Carbon|null $validated_at
|
||||
* @property Carbon|null $decided_at
|
||||
* @property Carbon $created_at
|
||||
|
|
@ -39,8 +38,7 @@ class RouteArticle extends Model
|
|||
'article_id',
|
||||
'approval_status',
|
||||
'publish_status',
|
||||
'publish_attempts',
|
||||
'next_attempt_at',
|
||||
'publish_error',
|
||||
'validated_at',
|
||||
'decided_at',
|
||||
];
|
||||
|
|
@ -48,8 +46,6 @@ class RouteArticle extends Model
|
|||
protected $casts = [
|
||||
'approval_status' => ApprovalStatusEnum::class,
|
||||
'publish_status' => PublishStatusEnum::class,
|
||||
'publish_attempts' => 'integer',
|
||||
'next_attempt_at' => 'datetime',
|
||||
'validated_at' => 'datetime',
|
||||
'decided_at' => 'datetime',
|
||||
];
|
||||
|
|
@ -142,41 +138,39 @@ public function reject(): void
|
|||
);
|
||||
}
|
||||
|
||||
private const RETRY_BACKOFF_MINUTES = [5, 30, 120, 360];
|
||||
|
||||
public const MAX_PUBLISH_ATTEMPTS = 4;
|
||||
|
||||
public function recordPublishAttemptFailed(): void
|
||||
public function recordPublishFailed(string $reason): void
|
||||
{
|
||||
$attempts = $this->publish_attempts + 1;
|
||||
$backoff = self::RETRY_BACKOFF_MINUTES;
|
||||
|
||||
$this->update([
|
||||
'publish_attempts' => $attempts,
|
||||
'next_attempt_at' => now()->addMinutes($backoff[$attempts - 1] ?? end($backoff)),
|
||||
'publish_status' => PublishStatusEnum::ERROR,
|
||||
'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
|
||||
* @return Builder<RouteArticle>
|
||||
*/
|
||||
public function scopeDueForPublishing(Builder $query): Builder
|
||||
public function scopeFailed(Builder $query): Builder
|
||||
{
|
||||
return $query->where('publish_attempts', '<', self::MAX_PUBLISH_ATTEMPTS)
|
||||
->where(function (Builder $query) {
|
||||
$query->whereNull('next_attempt_at')
|
||||
->orWhere('next_attempt_at', '<=', now());
|
||||
});
|
||||
return $query->where('publish_status', PublishStatusEnum::ERROR);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
|
||||
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 $token;
|
||||
|
|
@ -83,6 +86,27 @@ public function post(string $endpoint, array $data = []): Response
|
|||
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
|
||||
{
|
||||
$this->token = $token;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Models\PlatformAccount;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Services\Auth\LemmyAuthService;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
|
||||
class LemmyPublisher
|
||||
|
|
@ -15,10 +16,13 @@ class LemmyPublisher
|
|||
|
||||
private PlatformAccount $account;
|
||||
|
||||
private ThumbnailUploader $thumbnailUploader;
|
||||
|
||||
public function __construct(PlatformAccount $account)
|
||||
{
|
||||
$this->api = new LemmyApiService($account->instance_url);
|
||||
$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);
|
||||
$token = $authService->getToken($this->account);
|
||||
|
||||
$thumbnail = $this->hostedThumbnail($extractedData, $channel, $article, $token);
|
||||
|
||||
try {
|
||||
return $this->createPost($token, $extractedData, $channel, $article);
|
||||
return $this->createPost($token, $extractedData, $channel, $article, $thumbnail);
|
||||
} catch (Exception $e) {
|
||||
// If the cached token was stale, refresh and retry once
|
||||
if (str_contains($e->getMessage(), 'not_logged_in') || str_contains($e->getMessage(), 'Unauthorized')) {
|
||||
$token = $authService->refreshToken($this->account);
|
||||
|
||||
return $this->createPost($token, $extractedData, $channel, $article);
|
||||
return $this->createPost($token, $extractedData, $channel, $article, $thumbnail);
|
||||
}
|
||||
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
|
||||
* @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;
|
||||
|
||||
|
|
@ -60,7 +93,7 @@ private function createPost(string $token, array $extractedData, PlatformChannel
|
|||
$extractedData['description'] ?? '',
|
||||
$channel->channel_id,
|
||||
$article->url,
|
||||
$extractedData['thumbnail'] ?? null,
|
||||
$thumbnail,
|
||||
$languageId
|
||||
);
|
||||
}
|
||||
|
|
|
|||
125
app/Modules/Lemmy/Services/ThumbnailUploader.php
Normal file
125
app/Modules/Lemmy/Services/ThumbnailUploader.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -12,18 +12,6 @@ parameters:
|
|||
count: 1
|
||||
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\.$#'
|
||||
identifier: property.notFound
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{{ $routeArticle->created_at->format('M d, Y H:i') }}
|
||||
</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 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) --}}
|
||||
@if ($tab === 'all')
|
||||
@if ($routeArticle->isApproved())
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
@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">
|
||||
<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>
|
||||
@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">No activity in this period.</p>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'p
|
|||
</span>
|
||||
@endif
|
||||
</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
|
||||
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' }}"
|
||||
|
|
@ -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">
|
||||
@if ($tab === 'pending')
|
||||
No pending articles
|
||||
@elseif ($tab === 'failed')
|
||||
No failed articles
|
||||
@else
|
||||
No articles found
|
||||
@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.
|
||||
@elseif ($tab === 'pending')
|
||||
All route articles have been reviewed.
|
||||
@elseif ($tab === 'failed')
|
||||
Nothing has failed to publish.
|
||||
@elseif ($search !== '')
|
||||
No results for "{{ $search }}".
|
||||
@elseif ($feedId !== null)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,18 @@ class="text-sm text-blue-500 hover:underline">
|
|||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center space-x-1">
|
||||
<button
|
||||
wire:click="openEditModal({{ $channel->id }})"
|
||||
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"
|
||||
aria-label="Edit {{ $channel->display_name }}"
|
||||
>
|
||||
<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.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' }}"
|
||||
|
|
@ -45,6 +57,7 @@ class="p-1 rounded-full {{ $channel->is_active ? 'bg-green-100 text-green-600 da
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($channel->description)
|
||||
<p class="mt-3 text-sm text-gray-500 line-clamp-2 dark:text-gray-400">
|
||||
|
|
@ -259,4 +272,82 @@ class="inline-flex justify-center rounded-md border border-transparent shadow-xs
|
|||
</form>
|
||||
</x-form-modal>
|
||||
@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>
|
||||
|
|
|
|||
|
|
@ -251,5 +251,17 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
|||
@endisland
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,18 @@ class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-me
|
|||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center space-x-1">
|
||||
<button
|
||||
wire:click="openEditModal({{ $feed->id }})"
|
||||
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"
|
||||
aria-label="Edit {{ $feed->name }}"
|
||||
>
|
||||
<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.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' }}"
|
||||
|
|
@ -48,6 +60,7 @@ class="p-1 rounded-full {{ $feed->is_active ? 'bg-green-100 text-green-600 dark:
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
|
|
@ -177,4 +190,67 @@ class="inline-flex justify-center rounded-md border border-transparent shadow-xs
|
|||
</form>
|
||||
</x-form-modal>
|
||||
@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>
|
||||
|
|
|
|||
23
resources/views/livewire/partials/activity-entry.blade.php
Normal file
23
resources/views/livewire/partials/activity-entry.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -99,6 +99,22 @@ public function test_stats_rejects_a_malformed_date(): void
|
|||
->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
|
||||
{
|
||||
$feed = Feed::factory()->create(['is_active' => true]);
|
||||
|
|
|
|||
|
|
@ -558,4 +558,93 @@ public function test_empty_state_on_all_tab(): void
|
|||
->call('setTab', 'all')
|
||||
->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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,4 +248,166 @@ public function test_toggle_flips_active_state(): void
|
|||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@
|
|||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Enums\ActivityTypeEnum;
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformChannel;
|
||||
use App\Models\RouteArticle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$fragments = $this->islandFragments(
|
||||
|
|
|
|||
|
|
@ -154,4 +154,134 @@ public function test_toggle_flips_active_state(): void
|
|||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -185,23 +185,25 @@ public function test_repeated_failures_exhaust_the_retry_attempts(): void
|
|||
$fetcher->shouldReceive('fetchArticleData')->andReturn([]);
|
||||
|
||||
$publishingService = Mockery::mock(ArticlePublishingService::class);
|
||||
$action = new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService);
|
||||
|
||||
for ($i = 0; $i < RouteArticle::MAX_PUBLISH_ATTEMPTS; $i++) {
|
||||
$action->execute($routeArticle);
|
||||
(new PublishRouteArticleAction($fetcher, $publishingService, new NotificationService))
|
||||
->execute($routeArticle);
|
||||
|
||||
$routeArticle->refresh();
|
||||
|
||||
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->publish_status);
|
||||
$this->assertNotNull($routeArticle->publish_error);
|
||||
$this->assertFalse(
|
||||
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
|
||||
);
|
||||
}
|
||||
|
||||
$this->assertSame(RouteArticle::MAX_PUBLISH_ATTEMPTS, $routeArticle->publish_attempts);
|
||||
$this->assertTrue($routeArticle->hasExhaustedPublishAttempts());
|
||||
}
|
||||
|
||||
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([
|
||||
'description' => 'Stored description',
|
||||
]);
|
||||
$routeArticle->update(['publish_attempts' => 2, 'next_attempt_at' => now()->subMinute()]);
|
||||
$routeArticle->recordPublishFailed('an earlier failure');
|
||||
|
||||
$fetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$fetcher->shouldNotReceive('fetchArticleData');
|
||||
|
|
@ -216,8 +218,8 @@ public function test_a_successful_publish_resets_earlier_failed_attempts(): void
|
|||
|
||||
$routeArticle->refresh();
|
||||
|
||||
$this->assertSame(0, $routeArticle->publish_attempts);
|
||||
$this->assertNull($routeArticle->next_attempt_at);
|
||||
$this->assertSame(PublishStatusEnum::PUBLISHED, $routeArticle->publish_status);
|
||||
$this->assertNull($routeArticle->publish_error);
|
||||
}
|
||||
|
||||
public function test_publish_fails_when_fallback_recovers_only_a_title(): void
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -515,10 +515,10 @@ public function test_handle_creates_notification_on_publish_exception(): void
|
|||
$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->update(['publish_attempts' => 1, 'next_attempt_at' => now()->addMinutes(5)]);
|
||||
$routeArticle->recordPublishFailed('couldnt_find_community');
|
||||
|
||||
$articleFetcherMock = Mockery::mock(ArticleFetcher::class);
|
||||
$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->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
|
||||
{
|
||||
$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
|
||||
public function test_handle_publishes_a_later_article_when_the_oldest_has_failed(): void
|
||||
{
|
||||
$blocked = $this->createApprovedRouteArticle(['title' => 'Blocked Article']);
|
||||
$blocked->update([
|
||||
'created_at' => now()->subDays(2),
|
||||
'publish_attempts' => 1,
|
||||
'next_attempt_at' => now()->addMinutes(5),
|
||||
]);
|
||||
$blocked->update(['created_at' => now()->subDays(2)]);
|
||||
$blocked->recordPublishFailed('couldnt_find_community');
|
||||
|
||||
$next = $this->createApprovedRouteArticle(['title' => 'Next Article']);
|
||||
$next->update(['created_at' => now()->subDay()]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/** @var RouteArticle $routeArticle */
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Models\RouteArticle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -10,67 +11,80 @@ class RouteArticleRetryTest extends TestCase
|
|||
{
|
||||
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 */
|
||||
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||
|
||||
$this->freezeTime(function () use ($routeArticle) {
|
||||
$routeArticle->recordPublishAttemptFailed();
|
||||
$routeArticle->recordPublishFailed('couldnt_find_community');
|
||||
|
||||
$this->assertSame(1, $routeArticle->publish_attempts);
|
||||
$this->assertSame(
|
||||
now()->addMinutes(5)->format('Y-m-d H:i:s'),
|
||||
$routeArticle->next_attempt_at->format('Y-m-d H:i:s')
|
||||
);
|
||||
});
|
||||
$routeArticle->refresh();
|
||||
|
||||
$this->assertSame(PublishStatusEnum::ERROR, $routeArticle->publish_status);
|
||||
$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 */
|
||||
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||
$routeArticle->recordPublishFailed('boom');
|
||||
|
||||
$this->freezeTime(function () use ($routeArticle) {
|
||||
foreach ([5, 30, 120, 360] as $expectedDelay) {
|
||||
$routeArticle->recordPublishAttemptFailed();
|
||||
$routeArticle->clearPublishFailure();
|
||||
|
||||
$this->assertSame(
|
||||
now()->addMinutes($expectedDelay)->format('Y-m-d H:i:s'),
|
||||
$routeArticle->next_attempt_at->format('Y-m-d H:i:s'),
|
||||
"Attempt {$routeArticle->publish_attempts} should wait {$expectedDelay} minutes"
|
||||
$routeArticle->refresh();
|
||||
|
||||
$this->assertSame(PublishStatusEnum::UNPUBLISHED, $routeArticle->publish_status);
|
||||
$this->assertNull($routeArticle->publish_error);
|
||||
}
|
||||
|
||||
public function test_a_failed_article_is_not_due_for_publishing(): void
|
||||
{
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||
$routeArticle->recordPublishFailed('boom');
|
||||
|
||||
$this->assertFalse(
|
||||
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_attempts_are_exhausted_after_the_configured_maximum(): void
|
||||
public function test_clearing_the_failure_returns_it_to_the_publish_queue(): void
|
||||
{
|
||||
/** @var RouteArticle $routeArticle */
|
||||
$routeArticle = RouteArticle::factory()->approved()->create();
|
||||
$routeArticle->recordPublishFailed('boom');
|
||||
$routeArticle->clearPublishFailure();
|
||||
|
||||
for ($i = 0; $i < RouteArticle::MAX_PUBLISH_ATTEMPTS - 1; $i++) {
|
||||
$routeArticle->recordPublishAttemptFailed();
|
||||
$this->assertFalse($routeArticle->hasExhaustedPublishAttempts());
|
||||
$this->assertTrue(
|
||||
RouteArticle::query()->dueForPublishing()->whereKey($routeArticle->getKey())->exists()
|
||||
);
|
||||
}
|
||||
|
||||
$routeArticle->recordPublishAttemptFailed();
|
||||
public function test_the_failed_scope_returns_only_failed_articles(): void
|
||||
{
|
||||
/** @var RouteArticle $failed */
|
||||
$failed = RouteArticle::factory()->approved()->create();
|
||||
$failed->recordPublishFailed('boom');
|
||||
|
||||
$this->assertTrue($routeArticle->hasExhaustedPublishAttempts());
|
||||
RouteArticle::factory()->approved()->create();
|
||||
|
||||
$ids = RouteArticle::failed()->pluck('id')->all();
|
||||
|
||||
$this->assertSame([$failed->id], $ids);
|
||||
}
|
||||
|
||||
public function test_a_successful_publish_clears_previous_attempts(): void
|
||||
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->recordPublishAttemptFailed();
|
||||
$routeArticle->recordPublishAttemptFailed();
|
||||
$routeArticle->recordPublishFailed('boom');
|
||||
|
||||
$routeArticle->clearPublishAttempts();
|
||||
$routeArticle->refresh();
|
||||
|
||||
$this->assertSame(0, $routeArticle->publish_attempts);
|
||||
$this->assertNull($routeArticle->next_attempt_at);
|
||||
$this->assertFalse($routeArticle->hasExhaustedPublishAttempts());
|
||||
$this->assertTrue($routeArticle->isApproved());
|
||||
$this->assertEquals($decidedAt, $routeArticle->decided_at);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
$reflection = new \ReflectionClass($object);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@
|
|||
use App\Models\PlatformChannel;
|
||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||
use App\Modules\Lemmy\Services\ThumbnailUploader;
|
||||
use App\Services\Auth\LemmyAuthService;
|
||||
use App\Services\Log\LogSaver;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
|
|
@ -25,6 +27,29 @@ protected function tearDown(): void
|
|||
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
|
||||
{
|
||||
$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
|
||||
$publisher = new LemmyPublisher($account);
|
||||
|
||||
$reflection = new \ReflectionClass($publisher);
|
||||
$apiProperty = $reflection->getProperty('api');
|
||||
$apiProperty->setAccessible(true);
|
||||
$apiProperty->setValue($publisher, $apiMock);
|
||||
$this->injectMocks($publisher, $apiMock);
|
||||
|
||||
$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
|
||||
$publisher = new LemmyPublisher($account);
|
||||
|
||||
$reflection = new \ReflectionClass($publisher);
|
||||
$apiProperty = $reflection->getProperty('api');
|
||||
$apiProperty->setAccessible(true);
|
||||
$apiProperty->setValue($publisher, $apiMock);
|
||||
$this->injectMocks($publisher, $apiMock);
|
||||
|
||||
$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
|
||||
$publisher = new LemmyPublisher($account);
|
||||
|
||||
$reflection = new \ReflectionClass($publisher);
|
||||
$apiProperty = $reflection->getProperty('api');
|
||||
$apiProperty->setAccessible(true);
|
||||
$apiProperty->setValue($publisher, $apiMock);
|
||||
$this->injectMocks($publisher, $apiMock);
|
||||
|
||||
$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
|
||||
$publisher = new LemmyPublisher($account);
|
||||
|
||||
$reflection = new \ReflectionClass($publisher);
|
||||
$apiProperty = $reflection->getProperty('api');
|
||||
$apiProperty->setAccessible(true);
|
||||
$apiProperty->setValue($publisher, $apiMock);
|
||||
$this->injectMocks($publisher, $apiMock);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$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
|
||||
$publisher = new LemmyPublisher($account);
|
||||
|
||||
$reflection = new \ReflectionClass($publisher);
|
||||
$apiProperty = $reflection->getProperty('api');
|
||||
$apiProperty->setAccessible(true);
|
||||
$apiProperty->setValue($publisher, $apiMock);
|
||||
$this->injectMocks($publisher, $apiMock);
|
||||
|
||||
$result = $publisher->publishToChannel($article, $extractedData, $channel);
|
||||
|
||||
$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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
176
tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php
Normal file
176
tests/Unit/Modules/Lemmy/Services/ThumbnailUploaderTest.php
Normal 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 '';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue