From 4feab96765a1858449f907d2f9b70ef041c50fee Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 9 Mar 2026 02:21:24 +0100 Subject: [PATCH 01/20] 92 - Add in-app notification infrastructure with model, service, and bell component --- app/Enums/NotificationSeverityEnum.php | 10 ++ app/Enums/NotificationTypeEnum.php | 21 +++ app/Livewire/NotificationBell.php | 42 +++++ app/Models/Notification.php | 92 ++++++++++ .../Notification/NotificationService.php | 69 ++++++++ database/factories/NotificationFactory.php | 51 ++++++ ...1_01_000006_create_notifications_table.php | 33 ++++ resources/views/layouts/app.blade.php | 4 + .../livewire/notification-bell.blade.php | 90 ++++++++++ .../Feature/Livewire/NotificationBellTest.php | 85 +++++++++ tests/Feature/NotificationTest.php | 166 ++++++++++++++++++ 11 files changed, 663 insertions(+) create mode 100644 app/Enums/NotificationSeverityEnum.php create mode 100644 app/Enums/NotificationTypeEnum.php create mode 100644 app/Livewire/NotificationBell.php create mode 100644 app/Models/Notification.php create mode 100644 app/Services/Notification/NotificationService.php create mode 100644 database/factories/NotificationFactory.php create mode 100644 database/migrations/2024_01_01_000006_create_notifications_table.php create mode 100644 resources/views/livewire/notification-bell.blade.php create mode 100644 tests/Feature/Livewire/NotificationBellTest.php create mode 100644 tests/Feature/NotificationTest.php diff --git a/app/Enums/NotificationSeverityEnum.php b/app/Enums/NotificationSeverityEnum.php new file mode 100644 index 0000000..dbbefcb --- /dev/null +++ b/app/Enums/NotificationSeverityEnum.php @@ -0,0 +1,10 @@ + 'General', + self::FEED_STALE => 'Feed Stale', + self::PUBLISH_FAILED => 'Publish Failed', + self::CREDENTIAL_EXPIRED => 'Credential Expired', + }; + } +} diff --git a/app/Livewire/NotificationBell.php b/app/Livewire/NotificationBell.php new file mode 100644 index 0000000..6c82c08 --- /dev/null +++ b/app/Livewire/NotificationBell.php @@ -0,0 +1,42 @@ +markAsRead(); + } + + public function markAllAsRead(): void + { + Notification::markAllAsRead(); + } + + #[Computed] + public function unreadCount(): int + { + return Notification::unread()->count(); + } + + /** + * @return Collection + */ + #[Computed] + public function notifications(): Collection + { + return Notification::recent()->get(); + } + + public function render(): View + { + return view('livewire.notification-bell'); + } +} diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..f6d463e --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,92 @@ +|null $data + * @property string|null $notifiable_type + * @property int|null $notifiable_id + * @property Carbon|null $read_at + * @property Carbon $created_at + * @property Carbon $updated_at + */ +class Notification extends Model +{ + /** @use HasFactory */ + use HasFactory; + + protected $fillable = [ + 'type', + 'severity', + 'title', + 'message', + 'data', + 'notifiable_type', + 'notifiable_id', + 'read_at', + ]; + + protected $casts = [ + 'type' => NotificationTypeEnum::class, + 'severity' => NotificationSeverityEnum::class, + 'data' => 'array', + 'read_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * @return MorphTo + */ + public function notifiable(): MorphTo + { + return $this->morphTo(); + } + + public function isRead(): bool + { + return $this->read_at !== null; + } + + public function markAsRead(): void + { + $this->update(['read_at' => now()]); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeUnread(Builder $query): Builder + { + return $query->whereNull('read_at'); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeRecent(Builder $query): Builder + { + return $query->latest()->limit(50); + } + + public static function markAllAsRead(): void + { + static::unread()->update(['read_at' => now()]); + } +} diff --git a/app/Services/Notification/NotificationService.php b/app/Services/Notification/NotificationService.php new file mode 100644 index 0000000..4e0017b --- /dev/null +++ b/app/Services/Notification/NotificationService.php @@ -0,0 +1,69 @@ + $data + */ + public function send( + NotificationTypeEnum $type, + NotificationSeverityEnum $severity, + string $title, + string $message, + ?Model $notifiable = null, + array $data = [], + ): Notification { + return Notification::create([ + 'type' => $type, + 'severity' => $severity, + 'title' => $title, + 'message' => $message, + 'data' => $data ?: null, + 'notifiable_type' => $notifiable?->getMorphClass(), + 'notifiable_id' => $notifiable?->getKey(), + ]); + } + + /** + * @param array $data + */ + public function info( + string $title, + string $message, + ?Model $notifiable = null, + array $data = [], + ): Notification { + return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::INFO, $title, $message, $notifiable, $data); + } + + /** + * @param array $data + */ + public function warning( + string $title, + string $message, + ?Model $notifiable = null, + array $data = [], + ): Notification { + return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::WARNING, $title, $message, $notifiable, $data); + } + + /** + * @param array $data + */ + public function error( + string $title, + string $message, + ?Model $notifiable = null, + array $data = [], + ): Notification { + return $this->send(NotificationTypeEnum::GENERAL, NotificationSeverityEnum::ERROR, $title, $message, $notifiable, $data); + } +} diff --git a/database/factories/NotificationFactory.php b/database/factories/NotificationFactory.php new file mode 100644 index 0000000..a722e1a --- /dev/null +++ b/database/factories/NotificationFactory.php @@ -0,0 +1,51 @@ + + */ +class NotificationFactory extends Factory +{ + protected $model = Notification::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'type' => fake()->randomElement(NotificationTypeEnum::cases()), + 'severity' => fake()->randomElement(NotificationSeverityEnum::cases()), + 'title' => fake()->sentence(3), + 'message' => fake()->sentence(), + 'data' => null, + 'read_at' => null, + ]; + } + + public function read(): static + { + return $this->state(['read_at' => now()]); + } + + public function unread(): static + { + return $this->state(['read_at' => null]); + } + + public function severity(NotificationSeverityEnum $severity): static + { + return $this->state(['severity' => $severity]); + } + + public function type(NotificationTypeEnum $type): static + { + return $this->state(['type' => $type]); + } +} diff --git a/database/migrations/2024_01_01_000006_create_notifications_table.php b/database/migrations/2024_01_01_000006_create_notifications_table.php new file mode 100644 index 0000000..0e14bc7 --- /dev/null +++ b/database/migrations/2024_01_01_000006_create_notifications_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('type'); + $table->string('severity'); + $table->string('title'); + $table->text('message'); + $table->json('data')->nullable(); + $table->string('notifiable_type')->nullable(); + $table->unsignedBigInteger('notifiable_id')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + + $table->index('read_at'); + $table->index('created_at'); + $table->index(['notifiable_type', 'notifiable_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 8849a7d..78d305a 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -66,6 +66,9 @@ class="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100" +
+ +
@@ -98,6 +101,7 @@ class="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring

FFR

+
diff --git a/resources/views/livewire/notification-bell.blade.php b/resources/views/livewire/notification-bell.blade.php new file mode 100644 index 0000000..0df0c6f --- /dev/null +++ b/resources/views/livewire/notification-bell.blade.php @@ -0,0 +1,90 @@ +
+ + + +
diff --git a/tests/Feature/Livewire/NotificationBellTest.php b/tests/Feature/Livewire/NotificationBellTest.php new file mode 100644 index 0000000..e8913fd --- /dev/null +++ b/tests/Feature/Livewire/NotificationBellTest.php @@ -0,0 +1,85 @@ +assertStatus(200) + ->assertSee('Notifications'); + } + + public function test_it_shows_unread_count_badge(): void + { + Notification::factory()->count(3)->unread()->create(); + + Livewire::test(NotificationBell::class) + ->assertSee('3'); + } + + public function test_it_hides_badge_when_no_unread(): void + { + Notification::factory()->count(2)->read()->create(); + + Livewire::test(NotificationBell::class) + ->assertDontSee('Mark all as read'); + } + + public function test_it_shows_empty_state_when_no_notifications(): void + { + Livewire::test(NotificationBell::class) + ->assertSee('No notifications'); + } + + public function test_mark_as_read(): void + { + $notification = Notification::factory()->unread()->create(); + + Livewire::test(NotificationBell::class) + ->call('markAsRead', $notification->id); + + $this->assertTrue($notification->fresh()->isRead()); + } + + public function test_mark_all_as_read(): void + { + Notification::factory()->count(3)->unread()->create(); + + $this->assertEquals(3, Notification::unread()->count()); + + Livewire::test(NotificationBell::class) + ->call('markAllAsRead'); + + $this->assertEquals(0, Notification::unread()->count()); + } + + public function test_it_displays_notification_title_and_message(): void + { + Notification::factory()->create([ + 'title' => 'Test Notification Title', + 'message' => 'Test notification message body', + ]); + + Livewire::test(NotificationBell::class) + ->assertSee('Test Notification Title') + ->assertSee('Test notification message body'); + } + + public function test_it_caps_badge_at_99_plus(): void + { + Notification::factory()->count(100)->unread()->create(); + + Livewire::test(NotificationBell::class) + ->assertSee('99+'); + } +} diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php new file mode 100644 index 0000000..bf14d85 --- /dev/null +++ b/tests/Feature/NotificationTest.php @@ -0,0 +1,166 @@ +service = new NotificationService; + } + + public function test_send_creates_notification_with_all_fields(): void + { + $notification = $this->service->send( + NotificationTypeEnum::FEED_STALE, + NotificationSeverityEnum::WARNING, + 'Feed is stale', + 'Feed "VRT" has not returned new articles in 24 hours.', + data: ['hours_stale' => 24], + ); + + $this->assertDatabaseHas('notifications', [ + 'type' => 'feed_stale', + 'severity' => 'warning', + 'title' => 'Feed is stale', + ]); + $this->assertEquals(['hours_stale' => 24], $notification->data); + $this->assertNull($notification->read_at); + } + + public function test_info_convenience_method(): void + { + $notification = $this->service->info('Test title', 'Test message'); + + $this->assertEquals(NotificationTypeEnum::GENERAL, $notification->type); + $this->assertEquals(NotificationSeverityEnum::INFO, $notification->severity); + } + + public function test_warning_convenience_method(): void + { + $notification = $this->service->warning('Warning title', 'Warning message'); + + $this->assertEquals(NotificationSeverityEnum::WARNING, $notification->severity); + } + + public function test_error_convenience_method(): void + { + $notification = $this->service->error('Error title', 'Error message'); + + $this->assertEquals(NotificationSeverityEnum::ERROR, $notification->severity); + } + + public function test_mark_as_read(): void + { + $notification = Notification::factory()->unread()->create(); + + $this->assertFalse($notification->isRead()); + + $notification->markAsRead(); + + $this->assertTrue($notification->fresh()->isRead()); + $this->assertNotNull($notification->fresh()->read_at); + } + + public function test_mark_all_as_read(): void + { + Notification::factory()->count(3)->unread()->create(); + Notification::factory()->count(2)->read()->create(); + + $this->assertEquals(3, Notification::unread()->count()); + + Notification::markAllAsRead(); + + $this->assertEquals(0, Notification::unread()->count()); + } + + public function test_unread_scope(): void + { + Notification::factory()->count(2)->unread()->create(); + Notification::factory()->count(3)->read()->create(); + + $this->assertEquals(2, Notification::unread()->count()); + } + + public function test_recent_scope_limits_to_50(): void + { + Notification::factory()->count(60)->create(); + + $this->assertCount(50, Notification::recent()->get()); + } + + public function test_enums_cast_correctly(): void + { + $notification = Notification::factory()->create([ + 'type' => NotificationTypeEnum::PUBLISH_FAILED, + 'severity' => NotificationSeverityEnum::ERROR, + ]); + + $fresh = Notification::find($notification->id); + $this->assertInstanceOf(NotificationTypeEnum::class, $fresh->type); + $this->assertInstanceOf(NotificationSeverityEnum::class, $fresh->severity); + $this->assertEquals(NotificationTypeEnum::PUBLISH_FAILED, $fresh->type); + $this->assertEquals(NotificationSeverityEnum::ERROR, $fresh->severity); + } + + public function test_notifiable_morph_to_relationship(): void + { + $feed = Feed::factory()->create(); + + $notification = $this->service->send( + NotificationTypeEnum::FEED_STALE, + NotificationSeverityEnum::WARNING, + 'Feed stale', + 'No new articles', + notifiable: $feed, + ); + + $fresh = Notification::find($notification->id); + $this->assertInstanceOf(Feed::class, $fresh->notifiable); + $this->assertEquals($feed->id, $fresh->notifiable->id); + } + + public function test_notification_without_notifiable(): void + { + $notification = $this->service->info('General notice', 'Something happened'); + + $this->assertNull($notification->notifiable_type); + $this->assertNull($notification->notifiable_id); + $this->assertNull($notification->notifiable); + } + + public function test_notification_type_enum_labels(): void + { + $this->assertEquals('General', NotificationTypeEnum::GENERAL->label()); + $this->assertEquals('Feed Stale', NotificationTypeEnum::FEED_STALE->label()); + $this->assertEquals('Publish Failed', NotificationTypeEnum::PUBLISH_FAILED->label()); + $this->assertEquals('Credential Expired', NotificationTypeEnum::CREDENTIAL_EXPIRED->label()); + } + + public function test_data_stores_null_when_empty(): void + { + $notification = $this->service->info('Title', 'Message'); + + $this->assertNull($notification->data); + } + + public function test_data_stores_array_when_provided(): void + { + $notification = $this->service->info('Title', 'Message', data: ['key' => 'value']); + + $this->assertEquals(['key' => 'value'], $notification->data); + } +} -- 2.45.2 From 062b00d01c6bf01793c75ce64344785c29c85909 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 9 Mar 2026 17:32:55 +0100 Subject: [PATCH 02/20] 93 - Add feed staleness detection with configurable threshold and alerts --- app/Jobs/CheckFeedStalenessJob.php | 51 +++++++ app/Livewire/Settings.php | 13 ++ app/Models/Feed.php | 14 ++ app/Models/Setting.php | 10 ++ resources/views/livewire/settings.blade.php | 45 ++++++ routes/console.php | 7 + .../Jobs/CheckFeedStalenessJobTest.php | 133 ++++++++++++++++++ tests/Feature/Livewire/SettingsTest.php | 54 +++++++ tests/Unit/Models/FeedTest.php | 50 +++++++ tests/Unit/Models/SettingTest.php | 30 ++++ 10 files changed, 407 insertions(+) create mode 100644 app/Jobs/CheckFeedStalenessJob.php create mode 100644 tests/Feature/Jobs/CheckFeedStalenessJobTest.php create mode 100644 tests/Feature/Livewire/SettingsTest.php diff --git a/app/Jobs/CheckFeedStalenessJob.php b/app/Jobs/CheckFeedStalenessJob.php new file mode 100644 index 0000000..69cbc3f --- /dev/null +++ b/app/Jobs/CheckFeedStalenessJob.php @@ -0,0 +1,51 @@ +get(); + + foreach ($staleFeeds as $feed) { + $alreadyNotified = Notification::query() + ->where('type', NotificationTypeEnum::FEED_STALE) + ->where('notifiable_type', $feed->getMorphClass()) + ->where('notifiable_id', $feed->getKey()) + ->unread() + ->exists(); + + if ($alreadyNotified) { + continue; + } + + $notificationService->send( + type: NotificationTypeEnum::FEED_STALE, + severity: NotificationSeverityEnum::WARNING, + title: "Feed \"{$feed->name}\" is stale", + message: $feed->last_fetched_at + ? "Last fetched {$feed->last_fetched_at->diffForHumans()}. Threshold is {$thresholdHours} hours." + : "This feed has never been fetched. Threshold is {$thresholdHours} hours.", + notifiable: $feed, + ); + } + } +} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 9e89d9b..9c97752 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -13,6 +13,8 @@ class Settings extends Component public int $articlePublishingInterval = 5; + public int $feedStalenessThreshold = 48; + public ?string $successMessage = null; public ?string $errorMessage = null; @@ -22,6 +24,7 @@ public function mount(): void $this->articleProcessingEnabled = Setting::isArticleProcessingEnabled(); $this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled(); $this->articlePublishingInterval = Setting::getArticlePublishingInterval(); + $this->feedStalenessThreshold = Setting::getFeedStalenessThreshold(); } public function toggleArticleProcessing(): void @@ -48,6 +51,16 @@ public function updateArticlePublishingInterval(): void $this->showSuccess(); } + public function updateFeedStalenessThreshold(): void + { + $this->validate([ + 'feedStalenessThreshold' => 'required|integer|min:0', + ]); + + Setting::setFeedStalenessThreshold($this->feedStalenessThreshold); + $this->showSuccess(); + } + protected function showSuccess(): void { $this->successMessage = 'Settings updated successfully!'; diff --git a/app/Models/Feed.php b/app/Models/Feed.php index 6b34237..51537ae 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -3,6 +3,7 @@ namespace App\Models; use Database\Factories\FeedFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -86,6 +87,19 @@ public function getStatusAttribute(): string } } + /** + * @param Builder $query + * @return Builder + */ + public function scopeStale(Builder $query, int $thresholdHours): Builder + { + return $query->where('is_active', true) + ->where(function (Builder $query) use ($thresholdHours) { + $query->whereNull('last_fetched_at') + ->orWhere('last_fetched_at', '<', now()->subHours($thresholdHours)); + }); + } + /** * @return BelongsToMany */ diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 9f1cdfb..9206322 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -71,4 +71,14 @@ public static function setArticlePublishingInterval(int $minutes): void { static::set('article_publishing_interval', (string) $minutes); } + + public static function getFeedStalenessThreshold(): int + { + return (int) static::get('feed_staleness_threshold', 48); + } + + public static function setFeedStalenessThreshold(int $hours): void + { + static::set('feed_staleness_threshold', (string) $hours); + } } diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php index bd7a258..a2dc75d 100644 --- a/resources/views/livewire/settings.blade.php +++ b/resources/views/livewire/settings.blade.php @@ -104,6 +104,51 @@ class="flex-shrink-0"
+ +
+
+

+ + + + Feed Monitoring +

+

+ Configure alerts for feeds that stop returning articles +

+
+
+
+
+

+ Staleness Threshold (hours) +

+

+ Alert when a feed hasn't been fetched for this many hours. Set to 0 to disable. +

+
+
+ + +
+
+ @error('feedStalenessThreshold') +

{{ $message }}

+ @enderror +
+
+ @if ($successMessage)
diff --git a/routes/console.php b/routes/console.php index 83db0a3..98aac72 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ name('refresh-articles') ->withoutOverlapping() ->onOneServer(); + +Schedule::job(new CheckFeedStalenessJob) + ->hourly() + ->name('check-feed-staleness') + ->withoutOverlapping() + ->onOneServer(); diff --git a/tests/Feature/Jobs/CheckFeedStalenessJobTest.php b/tests/Feature/Jobs/CheckFeedStalenessJobTest.php new file mode 100644 index 0000000..4e5c0f2 --- /dev/null +++ b/tests/Feature/Jobs/CheckFeedStalenessJobTest.php @@ -0,0 +1,133 @@ +create([ + 'is_active' => true, + 'last_fetched_at' => now()->subHours(50), + ]); + + $this->dispatch(); + + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationTypeEnum::FEED_STALE->value, + 'severity' => NotificationSeverityEnum::WARNING->value, + 'notifiable_type' => $feed->getMorphClass(), + 'notifiable_id' => $feed->id, + ]); + } + + public function test_fresh_feed_does_not_create_notification(): void + { + Feed::factory()->create([ + 'is_active' => true, + 'last_fetched_at' => now()->subHours(10), + ]); + + $this->dispatch(); + + $this->assertDatabaseCount('notifications', 0); + } + + public function test_inactive_feed_does_not_create_notification(): void + { + Feed::factory()->create([ + 'is_active' => false, + 'last_fetched_at' => now()->subHours(100), + ]); + + $this->dispatch(); + + $this->assertDatabaseCount('notifications', 0); + } + + public function test_does_not_create_duplicate_notification_when_unread_exists(): void + { + $feed = Feed::factory()->create([ + 'is_active' => true, + 'last_fetched_at' => now()->subHours(50), + ]); + + Notification::factory() + ->type(NotificationTypeEnum::FEED_STALE) + ->unread() + ->create([ + 'notifiable_type' => $feed->getMorphClass(), + 'notifiable_id' => $feed->id, + ]); + + $this->dispatch(); + + $this->assertDatabaseCount('notifications', 1); + } + + public function test_creates_new_notification_when_previous_is_read(): void + { + $feed = Feed::factory()->create([ + 'is_active' => true, + 'last_fetched_at' => now()->subHours(50), + ]); + + Notification::factory() + ->type(NotificationTypeEnum::FEED_STALE) + ->read() + ->create([ + 'notifiable_type' => $feed->getMorphClass(), + 'notifiable_id' => $feed->id, + ]); + + $this->dispatch(); + + $this->assertDatabaseCount('notifications', 2); + } + + public function test_threshold_zero_disables_check(): void + { + Setting::setFeedStalenessThreshold(0); + + Feed::factory()->create([ + 'is_active' => true, + 'last_fetched_at' => now()->subHours(100), + ]); + + $this->dispatch(); + + $this->assertDatabaseCount('notifications', 0); + } + + public function test_never_fetched_feed_creates_notification(): void + { + $feed = Feed::factory()->create([ + 'is_active' => true, + 'last_fetched_at' => null, + ]); + + $this->dispatch(); + + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationTypeEnum::FEED_STALE->value, + 'notifiable_type' => $feed->getMorphClass(), + 'notifiable_id' => $feed->id, + ]); + } + + private function dispatch(): void + { + (new CheckFeedStalenessJob)->handle(app(\App\Services\Notification\NotificationService::class)); + } +} diff --git a/tests/Feature/Livewire/SettingsTest.php b/tests/Feature/Livewire/SettingsTest.php new file mode 100644 index 0000000..101673d --- /dev/null +++ b/tests/Feature/Livewire/SettingsTest.php @@ -0,0 +1,54 @@ +assertSet('feedStalenessThreshold', 72); + } + + public function test_mount_loads_default_feed_staleness_threshold(): void + { + Livewire::test(Settings::class) + ->assertSet('feedStalenessThreshold', 48); + } + + public function test_update_feed_staleness_threshold_saves_value(): void + { + Livewire::test(Settings::class) + ->set('feedStalenessThreshold', 24) + ->call('updateFeedStalenessThreshold') + ->assertHasNoErrors(); + + $this->assertSame(24, Setting::getFeedStalenessThreshold()); + } + + public function test_update_feed_staleness_threshold_validates_minimum(): void + { + Livewire::test(Settings::class) + ->set('feedStalenessThreshold', -1) + ->call('updateFeedStalenessThreshold') + ->assertHasErrors(['feedStalenessThreshold' => 'min']); + } + + public function test_update_feed_staleness_threshold_shows_success_message(): void + { + Livewire::test(Settings::class) + ->set('feedStalenessThreshold', 24) + ->call('updateFeedStalenessThreshold') + ->assertSet('successMessage', 'Settings updated successfully!'); + } +} diff --git a/tests/Unit/Models/FeedTest.php b/tests/Unit/Models/FeedTest.php index 6c4029f..05864cb 100644 --- a/tests/Unit/Models/FeedTest.php +++ b/tests/Unit/Models/FeedTest.php @@ -313,6 +313,56 @@ public function test_feed_settings_can_be_complex_structure(): void $this->assertTrue($feed->settings['schedule']['enabled']); } + public function test_scope_stale_includes_active_feeds_with_old_last_fetched_at(): void + { + $staleFeed = Feed::factory()->create([ + 'is_active' => true, + 'last_fetched_at' => now()->subHours(50), + ]); + + $staleFeeds = Feed::stale(48)->get(); + + $this->assertCount(1, $staleFeeds); + $this->assertTrue($staleFeeds->contains('id', $staleFeed->id)); + } + + public function test_scope_stale_includes_active_feeds_never_fetched(): void + { + $neverFetchedFeed = Feed::factory()->create([ + 'is_active' => true, + 'last_fetched_at' => null, + ]); + + $staleFeeds = Feed::stale(48)->get(); + + $this->assertCount(1, $staleFeeds); + $this->assertTrue($staleFeeds->contains('id', $neverFetchedFeed->id)); + } + + public function test_scope_stale_excludes_fresh_feeds(): void + { + Feed::factory()->create([ + 'is_active' => true, + 'last_fetched_at' => now()->subHours(10), + ]); + + $staleFeeds = Feed::stale(48)->get(); + + $this->assertCount(0, $staleFeeds); + } + + public function test_scope_stale_excludes_inactive_feeds(): void + { + Feed::factory()->create([ + 'is_active' => false, + 'last_fetched_at' => now()->subHours(100), + ]); + + $staleFeeds = Feed::stale(48)->get(); + + $this->assertCount(0, $staleFeeds); + } + public function test_feed_can_have_null_last_fetched_at(): void { $feed = Feed::factory()->create(['last_fetched_at' => null]); diff --git a/tests/Unit/Models/SettingTest.php b/tests/Unit/Models/SettingTest.php index 1165b81..19fc5a6 100644 --- a/tests/Unit/Models/SettingTest.php +++ b/tests/Unit/Models/SettingTest.php @@ -39,4 +39,34 @@ public function test_set_article_publishing_interval_zero(): void $this->assertSame(0, Setting::getArticlePublishingInterval()); } + + public function test_get_feed_staleness_threshold_returns_default_when_not_set(): void + { + $this->assertSame(48, Setting::getFeedStalenessThreshold()); + } + + public function test_get_feed_staleness_threshold_returns_stored_value(): void + { + Setting::set('feed_staleness_threshold', '72'); + + $this->assertSame(72, Setting::getFeedStalenessThreshold()); + } + + public function test_set_feed_staleness_threshold_persists_value(): void + { + Setting::setFeedStalenessThreshold(24); + + $this->assertSame(24, Setting::getFeedStalenessThreshold()); + $this->assertDatabaseHas('settings', [ + 'key' => 'feed_staleness_threshold', + 'value' => '24', + ]); + } + + public function test_set_feed_staleness_threshold_zero(): void + { + Setting::setFeedStalenessThreshold(0); + + $this->assertSame(0, Setting::getFeedStalenessThreshold()); + } } -- 2.45.2 From d21c0542505dad653610246cdbd3c1852038307d Mon Sep 17 00:00:00 2001 From: myrmidex Date: Mon, 9 Mar 2026 21:20:41 +0100 Subject: [PATCH 03/20] 94 - Add publishing failure notifications to job and listener --- app/Jobs/PublishNextArticleJob.php | 42 ++++-- .../PublishApprovedArticleListener.php | 22 ++- .../PublishApprovedArticleListenerTest.php | 123 +++++++++++++++++ tests/Unit/Jobs/PublishNextArticleJobTest.php | 130 +++++++++++++++--- 4 files changed, 288 insertions(+), 29 deletions(-) create mode 100644 tests/Feature/Listeners/PublishApprovedArticleListenerTest.php diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index 96c6cda..99239dd 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -3,12 +3,15 @@ namespace App\Jobs; use App\Enums\LogLevelEnum; +use App\Enums\NotificationSeverityEnum; +use App\Enums\NotificationTypeEnum; use App\Events\ActionPerformed; use App\Exceptions\PublishException; use App\Models\Article; use App\Models\ArticlePublication; use App\Models\Setting; use App\Services\Article\ArticleFetcher; +use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; @@ -33,7 +36,7 @@ public function __construct() * * @throws PublishException */ - public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void + public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService, NotificationService $notificationService): void { $interval = Setting::getArticlePublishingInterval(); @@ -62,22 +65,43 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService 'created_at' => $article->created_at, ]); - // Fetch article data - $extractedData = $articleFetcher->fetchArticleData($article); - try { - $publishingService->publishToRoutedChannels($article, $extractedData); + $extractedData = $articleFetcher->fetchArticleData($article); + $publications = $publishingService->publishToRoutedChannels($article, $extractedData); - ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [ - 'article_id' => $article->id, - 'title' => $article->title, - ]); + if ($publications->isNotEmpty()) { + ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [ + 'article_id' => $article->id, + 'title' => $article->title, + ]); + } else { + ActionPerformed::dispatch('No publications created for article', LogLevelEnum::WARNING, [ + 'article_id' => $article->id, + 'title' => $article->title, + ]); + + $notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::WARNING, + "Publish failed: {$article->title}", + 'No publications were created for this article. Check channel routing configuration.', + $article, + ); + } } catch (PublishException $e) { ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [ 'article_id' => $article->id, 'error' => $e->getMessage(), ]); + $notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::ERROR, + "Publish failed: {$article->title}", + $e->getMessage(), + $article, + ); + throw $e; } } diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php index f957809..aab3ddf 100644 --- a/app/Listeners/PublishApprovedArticleListener.php +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -3,9 +3,12 @@ namespace App\Listeners; use App\Enums\LogLevelEnum; +use App\Enums\NotificationSeverityEnum; +use App\Enums\NotificationTypeEnum; use App\Events\ActionPerformed; use App\Events\ArticleApproved; use App\Services\Article\ArticleFetcher; +use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; use Exception; use Illuminate\Contracts\Queue\ShouldQueue; @@ -16,7 +19,8 @@ class PublishApprovedArticleListener implements ShouldQueue public function __construct( private ArticleFetcher $articleFetcher, - private ArticlePublishingService $publishingService + private ArticlePublishingService $publishingService, + private NotificationService $notificationService, ) {} public function handle(ArticleApproved $event): void @@ -53,6 +57,14 @@ public function handle(ArticleApproved $event): void 'article_id' => $article->id, 'title' => $article->title, ]); + + $this->notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::WARNING, + "Publish failed: {$article->title}", + 'No publications were created for this article. Check channel routing configuration.', + $article, + ); } } catch (Exception $e) { $article->update(['publish_status' => 'error']); @@ -61,6 +73,14 @@ public function handle(ArticleApproved $event): void 'article_id' => $article->id, 'error' => $e->getMessage(), ]); + + $this->notificationService->send( + NotificationTypeEnum::PUBLISH_FAILED, + NotificationSeverityEnum::ERROR, + "Publish failed: {$article->title}", + $e->getMessage(), + $article, + ); } } } diff --git a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php new file mode 100644 index 0000000..cf1ffb1 --- /dev/null +++ b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php @@ -0,0 +1,123 @@ +create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'Test Article', + ]); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andThrow(new Exception('Connection refused')); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + + $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); + $listener->handle(new ArticleApproved($article)); + + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, + 'severity' => NotificationSeverityEnum::ERROR->value, + 'notifiable_type' => $article->getMorphClass(), + 'notifiable_id' => $article->id, + ]); + + $notification = Notification::first(); + $this->assertStringContainsString('Test Article', $notification->title); + $this->assertStringContainsString('Connection refused', $notification->message); + } + + public function test_no_publications_created_creates_warning_notification(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'Test Article', + ]); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->andReturn(new Collection); + + $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); + $listener->handle(new ArticleApproved($article)); + + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, + 'severity' => NotificationSeverityEnum::WARNING->value, + 'notifiable_type' => $article->getMorphClass(), + 'notifiable_id' => $article->id, + ]); + + $notification = Notification::first(); + $this->assertStringContainsString('Test Article', $notification->title); + } + + public function test_successful_publish_does_not_create_notification(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'Test Article', + ]); + + $extractedData = ['title' => 'Test Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->andReturn(new Collection(['publication'])); + + $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); + $listener->handle(new ArticleApproved($article)); + + $this->assertDatabaseCount('notifications', 0); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 46cdf3c..3d77b50 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -2,15 +2,20 @@ namespace Tests\Unit\Jobs; +use App\Enums\NotificationSeverityEnum; +use App\Enums\NotificationTypeEnum; use App\Exceptions\PublishException; use App\Jobs\PublishNextArticleJob; use App\Models\Article; use App\Models\ArticlePublication; use App\Models\Feed; +use App\Models\Notification; use App\Models\Setting; use App\Services\Article\ArticleFetcher; +use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; use Mockery; use Tests\TestCase; @@ -18,9 +23,12 @@ class PublishNextArticleJobTest extends TestCase { use RefreshDatabase; + private NotificationService $notificationService; + protected function setUp(): void { parent::setUp(); + $this->notificationService = new NotificationService; } public function test_constructor_sets_correct_queue(): void @@ -71,7 +79,7 @@ public function test_handle_returns_early_when_no_approved_articles(): void // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error $this->assertTrue(true); @@ -96,7 +104,7 @@ public function test_handle_returns_early_when_no_unpublished_approved_articles( // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error $this->assertTrue(true); @@ -122,7 +130,7 @@ public function test_handle_skips_non_approved_articles(): void // Act $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Should complete without error (no approved articles to process) $this->assertTrue(true); @@ -167,12 +175,13 @@ public function test_handle_publishes_oldest_approved_article(): void return $article->id === $olderArticle->id; }), $extractedData - ); + ) + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Mockery expectations are verified in tearDown $this->assertTrue(true); @@ -209,7 +218,7 @@ public function test_handle_throws_exception_on_publishing_failure(): void $this->expectException(PublishException::class); // Act - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); } public function test_handle_logs_publishing_start(): void @@ -233,12 +242,14 @@ public function test_handle_logs_publishing_start(): void // Mock ArticlePublishingService $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels')->once(); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Verify the job completes (logging is verified by observing no exceptions) $this->assertTrue(true); @@ -278,12 +289,13 @@ public function test_handle_fetches_article_data_before_publishing(): void $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') ->once() - ->with(Mockery::type(Article::class), $extractedData); + ->with(Mockery::type(Article::class), $extractedData) + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; // Act - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); // Assert - Mockery expectations verified in tearDown $this->assertTrue(true); @@ -311,7 +323,7 @@ public function test_handle_skips_publishing_when_last_publication_within_interv $publishingServiceMock->shouldNotReceive('publishToRoutedChannels'); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } @@ -339,10 +351,11 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once(); + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } @@ -370,10 +383,11 @@ public function test_handle_publishes_when_interval_is_zero(): void $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once(); + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } @@ -401,10 +415,11 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval( $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once(); + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } @@ -428,14 +443,91 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once(); + ->once() + ->andReturn(new Collection(['publication'])); $job = new PublishNextArticleJob; - $job->handle($articleFetcherMock, $publishingServiceMock); + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); $this->assertTrue(true); } + public function test_handle_creates_warning_notification_when_no_publications_created(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'No Route Article', + ]); + + $extractedData = ['title' => 'No Route Article']; + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->andReturn(new Collection); + + $job = new PublishNextArticleJob; + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); + + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, + 'severity' => NotificationSeverityEnum::WARNING->value, + 'notifiable_type' => $article->getMorphClass(), + 'notifiable_id' => $article->id, + ]); + + $notification = Notification::first(); + $this->assertStringContainsString('No Route Article', $notification->title); + } + + public function test_handle_creates_notification_on_publish_exception(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'Failing Article', + ]); + + $extractedData = ['title' => 'Failing Article']; + $publishException = new PublishException($article, null); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->andThrow($publishException); + + $job = new PublishNextArticleJob; + + try { + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); + } catch (PublishException) { + // Expected + } + + $this->assertDatabaseHas('notifications', [ + 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, + 'severity' => NotificationSeverityEnum::ERROR->value, + 'notifiable_type' => $article->getMorphClass(), + 'notifiable_id' => $article->id, + ]); + + $notification = Notification::first(); + $this->assertStringContainsString('Failing Article', $notification->title); + } + protected function tearDown(): void { Mockery::close(); -- 2.45.2 From b832d6d8500c01e9d5064fc9aaac87be8d577fb0 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 15:24:03 +0100 Subject: [PATCH 04/20] 85 - Add route_articles table, model, and factory for per-route approval --- app/Models/Article.php | 9 ++ app/Models/Route.php | 9 ++ app/Models/RouteArticle.php | 95 ++++++++++++ database/factories/RouteArticleFactory.php | 82 ++++++++++ ..._01_000007_create_route_articles_table.php | 34 +++++ tests/Unit/Models/RouteArticleTest.php | 140 ++++++++++++++++++ 6 files changed, 369 insertions(+) create mode 100644 app/Models/RouteArticle.php create mode 100644 database/factories/RouteArticleFactory.php create mode 100644 database/migrations/2024_01_01_000007_create_route_articles_table.php create mode 100644 tests/Unit/Models/RouteArticleTest.php diff --git a/app/Models/Article.php b/app/Models/Article.php index 59477d9..96b3f74 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Carbon; @@ -137,6 +138,14 @@ public function feed(): BelongsTo return $this->belongsTo(Feed::class); } + /** + * @return HasMany + */ + public function routeArticles(): HasMany + { + return $this->hasMany(RouteArticle::class); + } + public function dispatchFetchedEvent(): void { event(new NewArticleFetched($this)); diff --git a/app/Models/Route.php b/app/Models/Route.php index 1125bf5..c78016c 100644 --- a/app/Models/Route.php +++ b/app/Models/Route.php @@ -64,4 +64,13 @@ public function keywords(): HasMany return $this->hasMany(Keyword::class, 'feed_id', 'feed_id') ->where('platform_channel_id', $this->platform_channel_id); } + + /** + * @return HasMany + */ + public function routeArticles(): HasMany + { + return $this->hasMany(RouteArticle::class, 'feed_id', 'feed_id') + ->where('platform_channel_id', $this->platform_channel_id); + } } diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php new file mode 100644 index 0000000..52106df --- /dev/null +++ b/app/Models/RouteArticle.php @@ -0,0 +1,95 @@ + */ + use HasFactory; + + protected $fillable = [ + 'feed_id', + 'platform_channel_id', + 'article_id', + 'approval_status', + 'validated_at', + ]; + + protected $casts = [ + 'validated_at' => 'datetime', + ]; + + /** + * @return BelongsTo + */ + public function route(): BelongsTo + { + return $this->belongsTo(Route::class, 'feed_id', 'feed_id') + ->where('platform_channel_id', $this->platform_channel_id); + } + + /** + * @return BelongsTo + */ + public function article(): BelongsTo + { + return $this->belongsTo(Article::class); + } + + /** + * @return BelongsTo + */ + public function feed(): BelongsTo + { + return $this->belongsTo(Feed::class); + } + + /** + * @return BelongsTo + */ + public function platformChannel(): BelongsTo + { + return $this->belongsTo(PlatformChannel::class); + } + + public function isPending(): bool + { + return $this->approval_status === 'pending'; + } + + public function isApproved(): bool + { + return $this->approval_status === 'approved'; + } + + public function isRejected(): bool + { + return $this->approval_status === 'rejected'; + } + + public function approve(): void + { + $this->update(['approval_status' => 'approved']); + } + + public function reject(): void + { + $this->update(['approval_status' => 'rejected']); + } +} diff --git a/database/factories/RouteArticleFactory.php b/database/factories/RouteArticleFactory.php new file mode 100644 index 0000000..1b11673 --- /dev/null +++ b/database/factories/RouteArticleFactory.php @@ -0,0 +1,82 @@ + Feed::factory(), + 'platform_channel_id' => PlatformChannel::factory(), + 'article_id' => Article::factory(), + 'approval_status' => 'pending', + 'validated_at' => null, + ]; + } + + public function configure(): static + { + return $this->afterMaking(function (RouteArticle $routeArticle) { + // Ensure a route exists for this feed+channel combination + Route::firstOrCreate( + [ + 'feed_id' => $routeArticle->feed_id, + 'platform_channel_id' => $routeArticle->platform_channel_id, + ], + [ + 'is_active' => true, + 'priority' => 50, + ] + ); + + // Ensure the article belongs to the same feed + if ($routeArticle->article_id) { + $article = Article::find($routeArticle->article_id); + if ($article && $article->feed_id !== $routeArticle->feed_id) { + $article->update(['feed_id' => $routeArticle->feed_id]); + } + } + }); + } + + public function forRoute(Route $route): static + { + return $this->state(fn (array $attributes) => [ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + ]); + } + + public function pending(): static + { + return $this->state(fn (array $attributes) => [ + 'approval_status' => 'pending', + ]); + } + + public function approved(): static + { + return $this->state(fn (array $attributes) => [ + 'approval_status' => 'approved', + 'validated_at' => now(), + ]); + } + + public function rejected(): static + { + return $this->state(fn (array $attributes) => [ + 'approval_status' => 'rejected', + 'validated_at' => now(), + ]); + } +} diff --git a/database/migrations/2024_01_01_000007_create_route_articles_table.php b/database/migrations/2024_01_01_000007_create_route_articles_table.php new file mode 100644 index 0000000..d32a0ad --- /dev/null +++ b/database/migrations/2024_01_01_000007_create_route_articles_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('feed_id'); + $table->unsignedBigInteger('platform_channel_id'); + $table->foreignId('article_id')->constrained()->onDelete('cascade'); + $table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->timestamp('validated_at')->nullable(); + $table->timestamps(); + + $table->foreign(['feed_id', 'platform_channel_id']) + ->references(['feed_id', 'platform_channel_id']) + ->on('routes') + ->onDelete('cascade'); + + $table->unique(['feed_id', 'platform_channel_id', 'article_id'], 'route_articles_unique'); + $table->index(['approval_status', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('route_articles'); + } +}; diff --git a/tests/Unit/Models/RouteArticleTest.php b/tests/Unit/Models/RouteArticleTest.php new file mode 100644 index 0000000..89e2471 --- /dev/null +++ b/tests/Unit/Models/RouteArticleTest.php @@ -0,0 +1,140 @@ +create(); + + $this->assertInstanceOf(Article::class, $routeArticle->article); + } + + public function test_route_article_belongs_to_feed(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $this->assertInstanceOf(Feed::class, $routeArticle->feed); + } + + public function test_route_article_belongs_to_platform_channel(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $this->assertInstanceOf(PlatformChannel::class, $routeArticle->platformChannel); + } + + public function test_route_article_has_default_pending_status(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $this->assertEquals('pending', $routeArticle->approval_status); + $this->assertTrue($routeArticle->isPending()); + $this->assertFalse($routeArticle->isApproved()); + $this->assertFalse($routeArticle->isRejected()); + } + + public function test_route_article_can_be_approved(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $routeArticle->approve(); + + $this->assertEquals('approved', $routeArticle->fresh()->approval_status); + } + + public function test_route_article_can_be_rejected(): void + { + $routeArticle = RouteArticle::factory()->create(); + + $routeArticle->reject(); + + $this->assertEquals('rejected', $routeArticle->fresh()->approval_status); + } + + public function test_article_has_many_route_articles(): void + { + $route1 = Route::factory()->active()->create(); + $route2 = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route1->feed_id]); + + RouteArticle::factory()->forRoute($route1)->create(['article_id' => $article->id]); + RouteArticle::factory()->forRoute($route2)->create(['article_id' => $article->id]); + + $this->assertCount(2, $article->routeArticles); + } + + public function test_route_has_many_route_articles(): void + { + $route = Route::factory()->active()->create(); + $article1 = Article::factory()->create(['feed_id' => $route->feed_id]); + $article2 = Article::factory()->create(['feed_id' => $route->feed_id]); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article1->id]); + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article2->id]); + + $this->assertCount(2, $route->routeArticles); + } + + public function test_unique_constraint_prevents_duplicate_route_articles(): void + { + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + $this->expectException(\Illuminate\Database\QueryException::class); + + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + } + + public function test_route_article_cascade_deletes_when_article_deleted(): void + { + $routeArticle = RouteArticle::factory()->create(); + $articleId = $routeArticle->article_id; + + Article::destroy($articleId); + + $this->assertDatabaseMissing('route_articles', ['article_id' => $articleId]); + } + + public function test_route_article_cascade_deletes_when_route_deleted(): void + { + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + Route::where('feed_id', $route->feed_id) + ->where('platform_channel_id', $route->platform_channel_id) + ->delete(); + + $this->assertDatabaseMissing('route_articles', [ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + ]); + } + + public function test_route_article_belongs_to_route(): void + { + $route = Route::factory()->active()->create(); + $article = Article::factory()->create(['feed_id' => $route->feed_id]); + $routeArticle = RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); + + $loadedRoute = $routeArticle->route; + + $this->assertInstanceOf(Route::class, $loadedRoute); + $this->assertEquals($route->feed_id, $loadedRoute->feed_id); + $this->assertEquals($route->platform_channel_id, $loadedRoute->platform_channel_id); + } +} -- 2.45.2 From 2a5a8c788b781efa7553683d8eadd875084752e3 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 15:28:01 +0100 Subject: [PATCH 05/20] 85 - Add auto_approve column to routes table --- app/Models/Route.php | 3 +++ ...00008_add_auto_approve_to_routes_table.php | 22 +++++++++++++++++++ tests/Unit/Models/RouteTest.php | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2024_01_01_000008_add_auto_approve_to_routes_table.php diff --git a/app/Models/Route.php b/app/Models/Route.php index c78016c..5c566e1 100644 --- a/app/Models/Route.php +++ b/app/Models/Route.php @@ -14,6 +14,7 @@ * @property int $platform_channel_id * @property bool $is_active * @property int $priority + * @property bool|null $auto_approve * @property Carbon $created_at * @property Carbon $updated_at */ @@ -34,10 +35,12 @@ class Route extends Model 'platform_channel_id', 'is_active', 'priority', + 'auto_approve', ]; protected $casts = [ 'is_active' => 'boolean', + 'auto_approve' => 'boolean', ]; /** diff --git a/database/migrations/2024_01_01_000008_add_auto_approve_to_routes_table.php b/database/migrations/2024_01_01_000008_add_auto_approve_to_routes_table.php new file mode 100644 index 0000000..1cb1b9a --- /dev/null +++ b/database/migrations/2024_01_01_000008_add_auto_approve_to_routes_table.php @@ -0,0 +1,22 @@ +boolean('auto_approve')->nullable()->after('priority'); + }); + } + + public function down(): void + { + Schema::table('routes', function (Blueprint $table) { + $table->dropColumn('auto_approve'); + }); + } +}; diff --git a/tests/Unit/Models/RouteTest.php b/tests/Unit/Models/RouteTest.php index 1903e43..e559410 100644 --- a/tests/Unit/Models/RouteTest.php +++ b/tests/Unit/Models/RouteTest.php @@ -15,7 +15,7 @@ class RouteTest extends TestCase public function test_fillable_fields(): void { - $fillableFields = ['feed_id', 'platform_channel_id', 'is_active', 'priority']; + $fillableFields = ['feed_id', 'platform_channel_id', 'is_active', 'priority', 'auto_approve']; $route = new Route; $this->assertEquals($fillableFields, $route->getFillable()); -- 2.45.2 From e3ea02ae1c85167f78f385525975b75c2d77bcc8 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 15:46:15 +0100 Subject: [PATCH 06/20] 85 - Refactor ValidationService to per-route keyword evaluation with ApprovalStatusEnum --- app/Enums/ApprovalStatusEnum.php | 10 + app/Models/RouteArticle.php | 14 +- app/Services/Article/ValidationService.php | 90 ++-- database/factories/RouteArticleFactory.php | 9 +- tests/Unit/Models/RouteArticleTest.php | 7 +- .../Services/ValidationServiceKeywordTest.php | 211 --------- tests/Unit/Services/ValidationServiceTest.php | 410 +++++++++++++----- 7 files changed, 398 insertions(+), 353 deletions(-) create mode 100644 app/Enums/ApprovalStatusEnum.php delete mode 100644 tests/Unit/Services/ValidationServiceKeywordTest.php diff --git a/app/Enums/ApprovalStatusEnum.php b/app/Enums/ApprovalStatusEnum.php new file mode 100644 index 0000000..5095c5d --- /dev/null +++ b/app/Enums/ApprovalStatusEnum.php @@ -0,0 +1,10 @@ + ApprovalStatusEnum::class, 'validated_at' => 'datetime', ]; @@ -70,26 +72,26 @@ public function platformChannel(): BelongsTo public function isPending(): bool { - return $this->approval_status === 'pending'; + return $this->approval_status === ApprovalStatusEnum::PENDING; } public function isApproved(): bool { - return $this->approval_status === 'approved'; + return $this->approval_status === ApprovalStatusEnum::APPROVED; } public function isRejected(): bool { - return $this->approval_status === 'rejected'; + return $this->approval_status === ApprovalStatusEnum::REJECTED; } public function approve(): void { - $this->update(['approval_status' => 'approved']); + $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]); } public function reject(): void { - $this->update(['approval_status' => 'rejected']); + $this->update(['approval_status' => ApprovalStatusEnum::REJECTED]); } } diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php index fb76392..aca2f8f 100644 --- a/app/Services/Article/ValidationService.php +++ b/app/Services/Article/ValidationService.php @@ -2,7 +2,13 @@ namespace App\Services\Article; +use App\Enums\ApprovalStatusEnum; use App\Models\Article; +use App\Models\Keyword; +use App\Models\Route; +use App\Models\RouteArticle; +use App\Models\Setting; +use Illuminate\Support\Collection; class ValidationService { @@ -12,11 +18,10 @@ public function __construct( public function validate(Article $article): Article { - logger('Checking keywords for article: '.$article->id); + logger('Validating article for routes: '.$article->id); $articleData = $this->articleFetcher->fetchArticleData($article); - // Update article with fetched metadata (title, description) $updateData = []; if (! empty($articleData)) { @@ -31,51 +36,78 @@ public function validate(Article $article): Article 'url' => $article->url, ]); - $updateData['approval_status'] = 'rejected'; + $updateData['validated_at'] = now(); $article->update($updateData); return $article->refresh(); } - // Validate content against keywords. If validation fails, reject. - // If validation passes, leave approval_status as-is (pending) — - // the listener decides whether to auto-approve based on settings. - $validationResult = $this->validateByKeywords($articleData['full_article']); - - if (! $validationResult) { - $updateData['approval_status'] = 'rejected'; - } - $updateData['validated_at'] = now(); $article->update($updateData); + $this->createRouteArticles($article, $articleData['full_article']); + return $article->refresh(); } - private function validateByKeywords(string $full_article): bool + private function createRouteArticles(Article $article, string $content): void { - // Belgian news content keywords - broader set for Belgian news relevance - $keywords = [ - // Political parties and leaders - 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', - 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', + $activeRoutes = Route::where('feed_id', $article->feed_id) + ->where('is_active', true) + ->get(); - // Belgian locations and institutions - 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', - 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', - 'parliament', 'government', 'minister', 'policy', 'law', 'legislation', + // Batch-load all active keywords for this feed, grouped by channel + $keywordsByChannel = Keyword::where('feed_id', $article->feed_id) + ->where('is_active', true) + ->get() + ->groupBy('platform_channel_id'); - // Common Belgian news topics - 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', - 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police', - ]; + foreach ($activeRoutes as $route) { + $routeKeywords = $keywordsByChannel->get($route->platform_channel_id, collect()); + $status = $this->evaluateKeywords($routeKeywords, $content); + + if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) { + $status = ApprovalStatusEnum::APPROVED; + } + + RouteArticle::firstOrCreate( + [ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'article_id' => $article->id, + ], + [ + 'approval_status' => $status, + 'validated_at' => now(), + ] + ); + } + } + + /** + * @param Collection $keywords + */ + private function evaluateKeywords(Collection $keywords, string $content): ApprovalStatusEnum + { + if ($keywords->isEmpty()) { + return ApprovalStatusEnum::PENDING; + } foreach ($keywords as $keyword) { - if (stripos($full_article, $keyword) !== false) { - return true; + if (stripos($content, $keyword->keyword) !== false) { + return ApprovalStatusEnum::PENDING; } } - return false; + return ApprovalStatusEnum::REJECTED; + } + + private function shouldAutoApprove(Route $route): bool + { + if ($route->auto_approve !== null) { + return $route->auto_approve; + } + + return ! Setting::isPublishingApprovalsEnabled(); } } diff --git a/database/factories/RouteArticleFactory.php b/database/factories/RouteArticleFactory.php index 1b11673..0230d5a 100644 --- a/database/factories/RouteArticleFactory.php +++ b/database/factories/RouteArticleFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\ApprovalStatusEnum; use App\Models\Article; use App\Models\Feed; use App\Models\PlatformChannel; @@ -19,7 +20,7 @@ public function definition(): array 'feed_id' => Feed::factory(), 'platform_channel_id' => PlatformChannel::factory(), 'article_id' => Article::factory(), - 'approval_status' => 'pending', + 'approval_status' => ApprovalStatusEnum::PENDING, 'validated_at' => null, ]; } @@ -60,14 +61,14 @@ public function forRoute(Route $route): static public function pending(): static { return $this->state(fn (array $attributes) => [ - 'approval_status' => 'pending', + 'approval_status' => ApprovalStatusEnum::PENDING, ]); } public function approved(): static { return $this->state(fn (array $attributes) => [ - 'approval_status' => 'approved', + 'approval_status' => ApprovalStatusEnum::APPROVED, 'validated_at' => now(), ]); } @@ -75,7 +76,7 @@ public function approved(): static public function rejected(): static { return $this->state(fn (array $attributes) => [ - 'approval_status' => 'rejected', + 'approval_status' => ApprovalStatusEnum::REJECTED, 'validated_at' => now(), ]); } diff --git a/tests/Unit/Models/RouteArticleTest.php b/tests/Unit/Models/RouteArticleTest.php index 89e2471..4ccd826 100644 --- a/tests/Unit/Models/RouteArticleTest.php +++ b/tests/Unit/Models/RouteArticleTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Models; +use App\Enums\ApprovalStatusEnum; use App\Models\Article; use App\Models\Feed; use App\Models\PlatformChannel; @@ -39,7 +40,7 @@ public function test_route_article_has_default_pending_status(): void { $routeArticle = RouteArticle::factory()->create(); - $this->assertEquals('pending', $routeArticle->approval_status); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); $this->assertTrue($routeArticle->isPending()); $this->assertFalse($routeArticle->isApproved()); $this->assertFalse($routeArticle->isRejected()); @@ -51,7 +52,7 @@ public function test_route_article_can_be_approved(): void $routeArticle->approve(); - $this->assertEquals('approved', $routeArticle->fresh()->approval_status); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status); } public function test_route_article_can_be_rejected(): void @@ -60,7 +61,7 @@ public function test_route_article_can_be_rejected(): void $routeArticle->reject(); - $this->assertEquals('rejected', $routeArticle->fresh()->approval_status); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status); } public function test_article_has_many_route_articles(): void diff --git a/tests/Unit/Services/ValidationServiceKeywordTest.php b/tests/Unit/Services/ValidationServiceKeywordTest.php deleted file mode 100644 index 711088f..0000000 --- a/tests/Unit/Services/ValidationServiceKeywordTest.php +++ /dev/null @@ -1,211 +0,0 @@ -createArticleFetcher(); - $this->validationService = new ValidationService($articleFetcher); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * Helper method to access private validateByKeywords method - */ - private function getValidateByKeywordsMethod(): ReflectionMethod - { - $reflection = new ReflectionClass($this->validationService); - $method = $reflection->getMethod('validateByKeywords'); - $method->setAccessible(true); - - return $method; - } - - public function test_validates_belgian_political_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This article discusses N-VA party policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Bart De Wever made a statement today.')); - $this->assertTrue($method->invoke($this->validationService, 'Frank Vandenbroucke announced new healthcare policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Alexander De Croo addressed the nation.')); - $this->assertTrue($method->invoke($this->validationService, 'The Vooruit party proposed new legislation.')); - $this->assertTrue($method->invoke($this->validationService, 'Open Vld supports the new budget.')); - $this->assertTrue($method->invoke($this->validationService, 'CD&V members voted on the proposal.')); - $this->assertTrue($method->invoke($this->validationService, 'Vlaams Belang criticized the decision.')); - $this->assertTrue($method->invoke($this->validationService, 'PTB organized a protest yesterday.')); - $this->assertTrue($method->invoke($this->validationService, 'PVDA released a statement.')); - } - - public function test_validates_belgian_location_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This event took place in Belgium.')); - $this->assertTrue($method->invoke($this->validationService, 'The Belgian government announced new policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Flanders saw increased tourism this year.')); - $this->assertTrue($method->invoke($this->validationService, 'The Flemish government supports this initiative.')); - $this->assertTrue($method->invoke($this->validationService, 'Wallonia will receive additional funding.')); - $this->assertTrue($method->invoke($this->validationService, 'Brussels hosted the international conference.')); - $this->assertTrue($method->invoke($this->validationService, 'Antwerp Pride attracted thousands of participants.')); - $this->assertTrue($method->invoke($this->validationService, 'Ghent University published the research.')); - $this->assertTrue($method->invoke($this->validationService, 'Bruges tourism numbers increased.')); - $this->assertTrue($method->invoke($this->validationService, 'Leuven students organized the protest.')); - $this->assertTrue($method->invoke($this->validationService, 'Mechelen city council voted on the proposal.')); - $this->assertTrue($method->invoke($this->validationService, 'Namur hosted the cultural event.')); - $this->assertTrue($method->invoke($this->validationService, 'Liège airport saw increased traffic.')); - $this->assertTrue($method->invoke($this->validationService, 'Charleroi industrial zone expanded.')); - } - - public function test_validates_government_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'Parliament voted on the new legislation.')); - $this->assertTrue($method->invoke($this->validationService, 'The government announced budget cuts.')); - $this->assertTrue($method->invoke($this->validationService, 'The minister addressed concerns about healthcare.')); - $this->assertTrue($method->invoke($this->validationService, 'New policy changes will take effect next month.')); - $this->assertTrue($method->invoke($this->validationService, 'The law was passed with majority support.')); - $this->assertTrue($method->invoke($this->validationService, 'New legislation affects education funding.')); - } - - public function test_validates_news_topic_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'The economy showed signs of recovery.')); - $this->assertTrue($method->invoke($this->validationService, 'Economic indicators improved this quarter.')); - $this->assertTrue($method->invoke($this->validationService, 'Education reforms were announced today.')); - $this->assertTrue($method->invoke($this->validationService, 'Healthcare workers received additional support.')); - $this->assertTrue($method->invoke($this->validationService, 'Transport infrastructure will be upgraded.')); - $this->assertTrue($method->invoke($this->validationService, 'Climate change policies were discussed.')); - $this->assertTrue($method->invoke($this->validationService, 'Energy prices have increased significantly.')); - $this->assertTrue($method->invoke($this->validationService, 'European Union voted on trade agreements.')); - $this->assertTrue($method->invoke($this->validationService, 'EU sanctions were extended.')); - $this->assertTrue($method->invoke($this->validationService, 'Migration policies need urgent review.')); - $this->assertTrue($method->invoke($this->validationService, 'Security measures were enhanced.')); - $this->assertTrue($method->invoke($this->validationService, 'Justice system reforms are underway.')); - $this->assertTrue($method->invoke($this->validationService, 'Culture festivals received government funding.')); - $this->assertTrue($method->invoke($this->validationService, 'Police reported 18 administrative detentions.')); - } - - public function test_case_insensitive_keyword_matching(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This article mentions ANTWERP in capital letters.')); - $this->assertTrue($method->invoke($this->validationService, 'brussels is mentioned in lowercase.')); - $this->assertTrue($method->invoke($this->validationService, 'BeLgIuM is mentioned in mixed case.')); - $this->assertTrue($method->invoke($this->validationService, 'The FLEMISH government announced policies.')); - $this->assertTrue($method->invoke($this->validationService, 'n-va party policies were discussed.')); - $this->assertTrue($method->invoke($this->validationService, 'EUROPEAN union directives apply.')); - } - - public function test_rejects_content_without_belgian_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertFalse($method->invoke($this->validationService, 'This article discusses random topics.')); - $this->assertFalse($method->invoke($this->validationService, 'International news from other countries.')); - $this->assertFalse($method->invoke($this->validationService, 'Technology updates and innovations.')); - $this->assertFalse($method->invoke($this->validationService, 'Sports results from around the world.')); - $this->assertFalse($method->invoke($this->validationService, 'Entertainment news and celebrity gossip.')); - $this->assertFalse($method->invoke($this->validationService, 'Weather forecast for next week.')); - $this->assertFalse($method->invoke($this->validationService, 'Stock market analysis and trends.')); - } - - public function test_keyword_matching_in_longer_text(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $longText = ' - This is a comprehensive article about various topics. - It covers international relations, global economics, and regional policies. - However, it specifically mentions that Antwerp hosted a major conference - last week with participants from around the world. The event was - considered highly successful and will likely be repeated next year. - '; - - $this->assertTrue($method->invoke($this->validationService, $longText)); - - $longTextWithoutKeywords = ' - This is a comprehensive article about various topics. - It covers international relations, global finance, and commercial matters. - The conference was held in a major international city and attracted - participants from around the world. The event was considered highly - successful and will likely be repeated next year. - '; - - $this->assertFalse($method->invoke($this->validationService, $longTextWithoutKeywords)); - } - - public function test_empty_content_returns_false(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertFalse($method->invoke($this->validationService, '')); - $this->assertFalse($method->invoke($this->validationService, ' ')); - $this->assertFalse($method->invoke($this->validationService, "\n\n\t")); - } - - /** - * Test comprehensive keyword coverage to ensure all expected keywords work - */ - public function test_all_keywords_are_functional(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $expectedKeywords = [ - // Political parties and leaders - 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', - 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', - - // Belgian locations and institutions - 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', - 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', - 'parliament', 'government', 'minister', 'policy', 'law', 'legislation', - - // Common Belgian news topics - 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', - 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police', - ]; - - foreach ($expectedKeywords as $keyword) { - $testContent = "This article contains the keyword: {$keyword}."; - $result = $method->invoke($this->validationService, $testContent); - - $this->assertTrue($result, "Keyword '{$keyword}' should match but didn't"); - } - } - - public function test_partial_keyword_matches_work(): void - { - $method = $this->getValidateByKeywordsMethod(); - - // Keywords should match when they appear as part of larger words or phrases - $this->assertTrue($method->invoke($this->validationService, 'Anti-government protesters gathered.')); - $this->assertTrue($method->invoke($this->validationService, 'The policeman directed traffic.')); - $this->assertTrue($method->invoke($this->validationService, 'Educational reforms are needed.')); - $this->assertTrue($method->invoke($this->validationService, 'Economic growth accelerated.')); - $this->assertTrue($method->invoke($this->validationService, 'The European directive was implemented.')); - } -} diff --git a/tests/Unit/Services/ValidationServiceTest.php b/tests/Unit/Services/ValidationServiceTest.php index cd9a83a..03f69d9 100644 --- a/tests/Unit/Services/ValidationServiceTest.php +++ b/tests/Unit/Services/ValidationServiceTest.php @@ -2,26 +2,33 @@ namespace Tests\Unit\Services; +use App\Enums\ApprovalStatusEnum; use App\Models\Article; use App\Models\Feed; +use App\Models\Keyword; +use App\Models\PlatformChannel; +use App\Models\Route; +use App\Models\RouteArticle; +use App\Models\Setting; +use App\Services\Article\ArticleFetcher; use App\Services\Article\ValidationService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; use Mockery; use Tests\TestCase; -use Tests\Traits\CreatesArticleFetcher; class ValidationServiceTest extends TestCase { - use CreatesArticleFetcher, RefreshDatabase; + use RefreshDatabase; private ValidationService $validationService; + private \Mockery\MockInterface $articleFetcher; + protected function setUp(): void { parent::setUp(); - $articleFetcher = $this->createArticleFetcher(); - $this->validationService = new ValidationService($articleFetcher); + $this->articleFetcher = Mockery::mock(ArticleFetcher::class); + $this->validationService = new ValidationService($this->articleFetcher); } protected function tearDown(): void @@ -30,133 +37,336 @@ protected function tearDown(): void parent::tearDown(); } - public function test_validate_returns_article_with_validation_status(): void + private function mockFetchReturning(Article $article, ?string $content, ?string $title = 'Test Title', ?string $description = 'Test description'): void { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Test content with Belgium news', 200), - ]); + $data = []; + if ($title) { + $data['title'] = $title; + } + if ($description) { + $data['description'] = $description; + } + if ($content) { + $data['full_article'] = $content; + } - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - $result = $this->validationService->validate($article); - - $this->assertInstanceOf(Article::class, $result); - $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); + $this->articleFetcher + ->shouldReceive('fetchArticleData') + ->with($article) + ->once() + ->andReturn($data); } - public function test_validate_marks_article_invalid_when_missing_data(): void + public function test_validate_sets_validated_at_on_article(): void { - // Mock HTTP requests to return HTML without article content - Http::fake([ - 'https://invalid-url-without-parser.com/article' => Http::response('Empty', 200), - ]); - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ 'feed_id' => $feed->id, - 'url' => 'https://invalid-url-without-parser.com/article', - 'approval_status' => 'pending', + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', ]); - $result = $this->validationService->validate($article); - - $this->assertEquals('rejected', $result->approval_status); - } - - public function test_validate_with_supported_article_content(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200), - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - $result = $this->validationService->validate($article); - - // Since we can't fetch real content in tests, it should be marked rejected - $this->assertEquals('rejected', $result->approval_status); - } - - public function test_validate_updates_article_in_database(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200), - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - $originalId = $article->id; + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); $this->validationService->validate($article); - // Check that the article was updated in the database - $updatedArticle = Article::find($originalId); - $this->assertContains($updatedArticle->approval_status, ['pending', 'approved', 'rejected']); + $this->assertNotNull($article->fresh()->validated_at); } - public function test_validate_handles_article_with_existing_validation(): void + public function test_validate_creates_route_articles_for_active_routes(): void { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200), + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + Route::factory()->active()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Some article content'); + + $this->validationService->validate($article); + + $this->assertCount(2, RouteArticle::where('article_id', $article->id)->get()); + } + + public function test_validate_skips_inactive_routes(): void + { + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + Route::factory()->inactive()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Some article content'); + + $this->validationService->validate($article); + + $this->assertCount(1, RouteArticle::where('article_id', $article->id)->get()); + } + + public function test_validate_sets_pending_when_keywords_match(): void + { + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', ]); + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium politics'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + } + + public function test_validate_sets_rejected_when_no_keywords_match(): void + { + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about random topics and weather'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status); + } + + public function test_validate_sets_pending_when_route_has_no_keywords(): void + { + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about random topics'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + } + + public function test_validate_different_routes_get_different_statuses(): void + { + $feed = Feed::factory()->create(); + $channel1 = PlatformChannel::factory()->create(); + $channel2 = PlatformChannel::factory()->create(); + + Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel1->id, + ]); + Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel2->id, + ]); + + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel1->id, + 'keyword' => 'Belgium', + ]); + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel2->id, + 'keyword' => 'Technology', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + $ra1 = RouteArticle::where('article_id', $article->id) + ->where('platform_channel_id', $channel1->id)->first(); + $ra2 = RouteArticle::where('article_id', $article->id) + ->where('platform_channel_id', $channel2->id)->first(); + + $this->assertEquals(ApprovalStatusEnum::PENDING, $ra1->approval_status); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $ra2->approval_status); + } + + public function test_validate_auto_approves_when_global_setting_off_and_keywords_match(): void + { + Setting::setBool('enable_publishing_approvals', false); + $feed = Feed::factory()->create(); - $article = Article::factory()->create([ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'approved', + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', ]); - $originalApprovalStatus = $article->approval_status; + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status); + } + + public function test_validate_route_auto_approve_overrides_global_setting(): void + { + Setting::setBool('enable_publishing_approvals', true); + + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'auto_approve' => true, + ]); + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status); + } + + public function test_validate_route_auto_approve_false_overrides_global_off(): void + { + Setting::setBool('enable_publishing_approvals', false); + + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'auto_approve' => false, + ]); + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + } + + public function test_validate_does_not_auto_approve_rejected_articles(): void + { + Setting::setBool('enable_publishing_approvals', false); + + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Random content no match'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status); + } + + public function test_validate_creates_no_route_articles_when_content_fetch_fails(): void + { + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, null); + + $this->validationService->validate($article); + + $this->assertCount(0, RouteArticle::where('article_id', $article->id)->get()); + $this->assertNotNull($article->fresh()->validated_at); + } + + public function test_validate_updates_article_metadata(): void + { + $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'title' => 'Old Title', + ]); + $this->mockFetchReturning($article, 'Content about Belgium', 'New Title', 'New description'); $result = $this->validationService->validate($article); - // Should re-validate - status may change based on content validation - $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); + $this->assertEquals('New Title', $result->title); + $this->assertEquals('New description', $result->description); + $this->assertEquals('Content about Belgium', $result->content); } - public function test_validate_keyword_checking_logic(): void + public function test_validate_sets_validated_at_on_route_articles(): void { - // Mock HTTP requests with content that contains Belgian keywords - Http::fake([ - 'https://example.com/article-about-bart-de-wever' => Http::response( - '
Article about Bart De Wever and Belgian politics
', - 200 - ), - ]); - $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); - // Create an article that would match the validation keywords if content was available - $article = Article::factory()->create([ + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Content about something'); + + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertNotNull($routeArticle->validated_at); + } + + public function test_validate_keyword_matching_is_case_insensitive(): void + { + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ 'feed_id' => $feed->id, - 'url' => 'https://example.com/article-about-bart-de-wever', - 'approval_status' => 'pending', + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'belgium', ]); - $result = $this->validationService->validate($article); + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about BELGIUM politics'); - // The service looks for keywords in the full_article content - // Since we can't fetch real content, it will be marked rejected - $this->assertEquals('rejected', $result->approval_status); + $this->validationService->validate($article); + + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); + } + + public function test_validate_only_uses_active_keywords(): void + { + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->inactive()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + $this->mockFetchReturning($article, 'Article about Belgium'); + + $this->validationService->validate($article); + + // No active keywords = matches everything = pending + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); } } -- 2.45.2 From f4495481237075cdd16e9c79db4a6a4dbbfa1536 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 15:51:33 +0100 Subject: [PATCH 07/20] 85 - Update ValidateArticleListener for per-route validation flow --- app/Listeners/ValidateArticleListener.php | 23 +--- tests/Feature/JobsAndEventsTest.php | 51 ++------ tests/Feature/ValidateArticleListenerTest.php | 114 +++++++++--------- 3 files changed, 73 insertions(+), 115 deletions(-) diff --git a/app/Listeners/ValidateArticleListener.php b/app/Listeners/ValidateArticleListener.php index 0ef3d9b..27d4431 100644 --- a/app/Listeners/ValidateArticleListener.php +++ b/app/Listeners/ValidateArticleListener.php @@ -5,7 +5,6 @@ use App\Enums\LogLevelEnum; use App\Events\ActionPerformed; use App\Events\NewArticleFetched; -use App\Models\Setting; use App\Services\Article\ValidationService; use Exception; use Illuminate\Contracts\Queue\ShouldQueue; @@ -26,38 +25,18 @@ public function handle(NewArticleFetched $event): void return; } - // Only validate articles that are still pending - if (! $article->isPending()) { - return; - } - // Skip if already has publication (prevents duplicate processing) if ($article->articlePublication()->exists()) { return; } try { - $article = $this->validationService->validate($article); + $this->validationService->validate($article); } catch (Exception $e) { ActionPerformed::dispatch('Article validation failed', LogLevelEnum::ERROR, [ 'article_id' => $article->id, 'error' => $e->getMessage(), ]); - - return; - } - - if ($article->isValid()) { - // Double-check publication doesn't exist (race condition protection) - if ($article->articlePublication()->exists()) { - return; - } - - // If approvals are enabled, article waits for manual approval. - // If approvals are disabled, auto-approve and publish. - if (! Setting::isPublishingApprovalsEnabled()) { - $article->approve(); - } } } } diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index ed85e4e..c398cbc 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -175,12 +175,17 @@ public function test_exception_logged_event_is_dispatched(): void public function test_validate_article_listener_processes_new_article(): void { - Event::fake([ArticleApproved::class]); - // Disable approvals so listener auto-approves valid articles Setting::setBool('enable_publishing_approvals', false); $feed = Feed::factory()->create(); + $route = \App\Models\Route::factory()->active()->create(['feed_id' => $feed->id]); + \App\Models\Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', + ]); + $article = Article::factory()->create([ 'feed_id' => $feed->id, 'approval_status' => 'pending', @@ -203,45 +208,13 @@ public function test_validate_article_listener_processes_new_article(): void $listener->handle($event); $article->refresh(); - $this->assertEquals('approved', $article->approval_status); - Event::assertDispatched(ArticleApproved::class); + $this->assertNotNull($article->validated_at); + + $routeArticle = \App\Models\RouteArticle::where('article_id', $article->id)->first(); + $this->assertNotNull($routeArticle); + $this->assertEquals(\App\Enums\ApprovalStatusEnum::APPROVED, $routeArticle->approval_status); } - // Test removed - PublishApprovedArticle and ArticleReadyToPublish classes no longer exist - // public function test_publish_approved_article_listener_queues_job(): void - // { - // Event::fake(); - - // $article = Article::factory()->create([ - // 'approval_status' => 'approved', - // 'approval_status' => 'approved', - // ]); - - // $listener = new PublishApprovedArticle(); - // $event = new ArticleApproved($article); - - // $listener->handle($event); - - // Event::assertDispatched(ArticleReadyToPublish::class); - // } - - // Test removed - PublishArticle and ArticleReadyToPublish classes no longer exist - // public function test_publish_article_listener_queues_publish_job(): void - // { - // Queue::fake(); - - // $article = Article::factory()->create([ - // 'approval_status' => 'approved', - // ]); - - // $listener = new PublishArticle(); - // $event = new ArticleReadyToPublish($article); - - // $listener->handle($event); - - // Queue::assertPushed(PublishNextArticleJob::class); - // } - public function test_log_exception_to_database_listener_creates_log(): void { $log = Log::factory()->create([ diff --git a/tests/Feature/ValidateArticleListenerTest.php b/tests/Feature/ValidateArticleListenerTest.php index cf7366f..a5412ca 100644 --- a/tests/Feature/ValidateArticleListenerTest.php +++ b/tests/Feature/ValidateArticleListenerTest.php @@ -2,81 +2,95 @@ namespace Tests\Feature; -use App\Events\ArticleApproved; +use App\Enums\ApprovalStatusEnum; use App\Events\NewArticleFetched; use App\Listeners\ValidateArticleListener; use App\Models\Article; use App\Models\ArticlePublication; use App\Models\Feed; -use App\Services\Article\ValidationService; +use App\Models\Keyword; +use App\Models\Route; +use App\Models\RouteArticle; +use App\Services\Article\ArticleFetcher; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\Http; +use Mockery; use Tests\TestCase; class ValidateArticleListenerTest extends TestCase { use RefreshDatabase; - public function test_listener_validates_article_and_dispatches_ready_to_publish_event(): void + private function createListenerWithMockedFetcher(?string $content = 'Some article content'): ValidateArticleListener { - Event::fake([ArticleApproved::class]); + $articleFetcher = Mockery::mock(ArticleFetcher::class); + $articleFetcher->shouldReceive('fetchArticleData')->andReturn( + $content ? [ + 'title' => 'Test Title', + 'description' => 'Test description', + 'full_article' => $content, + ] : [] + ); - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200), + return new ValidateArticleListener( + new \App\Services\Article\ValidationService($articleFetcher) + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_listener_validates_article_and_creates_route_articles(): void + { + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'Belgium', ]); - $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', 'approval_status' => 'pending', ]); - $listener = app(ValidateArticleListener::class); - $event = new NewArticleFetched($article); - - $listener->handle($event); + $listener = $this->createListenerWithMockedFetcher('Article about Belgium'); + $listener->handle(new NewArticleFetched($article)); $article->refresh(); + $this->assertNotNull($article->validated_at); - if ($article->isValid()) { - Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) { - return $event->article->id === $article->id; - }); - } else { - Event::assertNotDispatched(ArticleApproved::class); - } + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); + $this->assertNotNull($routeArticle); + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); } public function test_listener_skips_already_validated_articles(): void { - Event::fake([ArticleApproved::class]); - $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'approved', + 'validated_at' => now(), ]); - $listener = app(ValidateArticleListener::class); - $event = new NewArticleFetched($article); + $listener = $this->createListenerWithMockedFetcher(); + $listener->handle(new NewArticleFetched($article)); - $listener->handle($event); - - Event::assertNotDispatched(ArticleApproved::class); + $this->assertCount(0, RouteArticle::where('article_id', $article->id)->get()); } public function test_listener_skips_articles_with_existing_publication(): void { - Event::fake([ArticleApproved::class]); - $feed = Feed::factory()->create(); + Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', 'approval_status' => 'pending', ]); @@ -88,38 +102,30 @@ public function test_listener_skips_articles_with_existing_publication(): void 'published_by' => 'test-user', ]); - $listener = app(ValidateArticleListener::class); - $event = new NewArticleFetched($article); + $listener = $this->createListenerWithMockedFetcher(); + $listener->handle(new NewArticleFetched($article)); - $listener->handle($event); - - Event::assertNotDispatched(ArticleApproved::class); + $this->assertCount(0, RouteArticle::where('article_id', $article->id)->get()); } - public function test_listener_calls_validation_service(): void + public function test_listener_handles_validation_errors_gracefully(): void { - Event::fake([ArticleApproved::class]); + $articleFetcher = Mockery::mock(ArticleFetcher::class); + $articleFetcher->shouldReceive('fetchArticleData')->andThrow(new \Exception('Fetch failed')); - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200), - ]); + $listener = new ValidateArticleListener( + new \App\Services\Article\ValidationService($articleFetcher) + ); $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', 'approval_status' => 'pending', ]); - $listener = app(ValidateArticleListener::class); - $event = new NewArticleFetched($article); + $listener->handle(new NewArticleFetched($article)); - $listener->handle($event); - - // Verify that the article was processed by ValidationService - $article->refresh(); - $this->assertNotEquals('pending', $article->approval_status, 'Article should have been validated'); - $this->assertContains($article->approval_status, ['approved', 'rejected'], 'Article should have validation result'); + $this->assertNull($article->fresh()->validated_at); + $this->assertCount(0, RouteArticle::where('article_id', $article->id)->get()); } } -- 2.45.2 From 0c35af440371ae91c6490925730f14305ca26cb2 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 16:05:31 +0100 Subject: [PATCH 08/20] 85 - Update publishing pipeline to use route_articles for per-route publishing --- app/Jobs/PublishNextArticleJob.php | 28 +- app/Models/Article.php | 9 + .../Publishing/ArticlePublishingService.php | 113 ++++--- tests/Unit/Jobs/PublishNextArticleJobTest.php | 318 +++++------------- .../Publishing/KeywordFilteringTest.php | 281 ---------------- 5 files changed, 183 insertions(+), 566 deletions(-) delete mode 100644 tests/Unit/Services/Publishing/KeywordFilteringTest.php diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index 99239dd..c0fd39d 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -2,13 +2,14 @@ namespace App\Jobs; +use App\Enums\ApprovalStatusEnum; use App\Enums\LogLevelEnum; use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationTypeEnum; use App\Events\ActionPerformed; use App\Exceptions\PublishException; -use App\Models\Article; use App\Models\ArticlePublication; +use App\Models\RouteArticle; use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Notification\NotificationService; @@ -48,34 +49,39 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService } } - // Get the oldest approved article that hasn't been published yet - $article = Article::where('approval_status', 'approved') - ->whereDoesntHave('articlePublication') - ->oldest('created_at') + // Get the oldest approved route_article that hasn't been published to its channel yet + $routeArticle = RouteArticle::where('approval_status', ApprovalStatusEnum::APPROVED) + ->whereDoesntHave('article.articlePublications', function ($query) { + $query->whereColumn('article_publications.platform_channel_id', 'route_articles.platform_channel_id'); + }) + ->oldest('route_articles.created_at') + ->with(['article', 'platformChannel.platformInstance', 'platformChannel.activePlatformAccounts']) ->first(); - if (! $article) { + if (! $routeArticle) { return; } + $article = $routeArticle->article; + ActionPerformed::dispatch('Publishing next article from scheduled job', LogLevelEnum::INFO, [ 'article_id' => $article->id, 'title' => $article->title, 'url' => $article->url, - 'created_at' => $article->created_at, + 'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id, ]); try { $extractedData = $articleFetcher->fetchArticleData($article); - $publications = $publishingService->publishToRoutedChannels($article, $extractedData); + $publication = $publishingService->publishRouteArticle($routeArticle, $extractedData); - if ($publications->isNotEmpty()) { + if ($publication) { ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [ 'article_id' => $article->id, 'title' => $article->title, ]); } else { - ActionPerformed::dispatch('No publications created for article', LogLevelEnum::WARNING, [ + ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [ 'article_id' => $article->id, 'title' => $article->title, ]); @@ -84,7 +90,7 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService NotificationTypeEnum::PUBLISH_FAILED, NotificationSeverityEnum::WARNING, "Publish failed: {$article->title}", - 'No publications were created for this article. Check channel routing configuration.', + 'No publication was created for this article. Check channel routing configuration.', $article, ); } diff --git a/app/Models/Article.php b/app/Models/Article.php index 96b3f74..bb834a0 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -30,6 +30,7 @@ * @property Carbon $created_at * @property Carbon $updated_at * @property ArticlePublication|null $articlePublication + * @property \Illuminate\Support\HigherOrderCollectionProxy|mixed $routeArticles */ class Article extends Model { @@ -130,6 +131,14 @@ public function articlePublication(): HasOne return $this->hasOne(ArticlePublication::class); } + /** + * @return HasMany + */ + public function articlePublications(): HasMany + { + return $this->hasMany(ArticlePublication::class); + } + /** * @return BelongsTo */ diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php index 7634137..1d8d271 100644 --- a/app/Services/Publishing/ArticlePublishingService.php +++ b/app/Services/Publishing/ArticlePublishingService.php @@ -6,9 +6,11 @@ use App\Exceptions\PublishException; use App\Models\Article; use App\Models\ArticlePublication; +use App\Models\Keyword; use App\Models\PlatformChannel; use App\Models\PlatformChannelPost; use App\Models\Route; +use App\Models\RouteArticle; use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use Exception; @@ -28,6 +30,42 @@ protected function makePublisher(mixed $account): LemmyPublisher } /** + * Publish an article to the channel specified by a route_article record. + * + * @param array $extractedData + * + * @throws PublishException + */ + public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication + { + $article = $routeArticle->article; + $channel = $routeArticle->platformChannel; + + if (! $channel) { + throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('ROUTE_ARTICLE_MISSING_CHANNEL')); + } + + if (! $channel->relationLoaded('platformInstance')) { + $channel->load(['platformInstance', 'activePlatformAccounts']); + } + + $account = $channel->activePlatformAccounts()->first(); + + if (! $account) { + $this->logSaver->warning('No active account for channel', $channel, [ + 'article_id' => $article->id, + 'route_article_id' => $routeArticle->id, + ]); + + return null; + } + + return $this->publishToChannel($article, $extractedData, $channel, $account); + } + + /** + * @deprecated Use publishRouteArticle() instead. Kept for PublishApprovedArticleListener compatibility. + * * @param array $extractedData * @return Collection * @@ -41,21 +79,37 @@ public function publishToRoutedChannels(Article $article, array $extractedData): $feed = $article->feed; - // Get active routes with keywords instead of just channels $activeRoutes = Route::where('feed_id', $feed->id) ->where('is_active', true) - ->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords']) - ->orderBy('priority', 'desc') ->get(); - // Filter routes based on keyword matches - $matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) { - return $this->routeMatchesArticle($route, $extractedData); + $keywordsByChannel = Keyword::where('feed_id', $feed->id) + ->where('is_active', true) + ->get() + ->groupBy('platform_channel_id'); + + $matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData, $keywordsByChannel) { + $keywords = $keywordsByChannel->get($route->platform_channel_id, collect()); + if ($keywords->isEmpty()) { + return true; + } + + $articleContent = ($extractedData['full_article'] ?? ''). + ' '.($extractedData['title'] ?? ''). + ' '.($extractedData['description'] ?? ''); + + foreach ($keywords as $keyword) { + if (stripos($articleContent, $keyword->keyword) !== false) { + return true; + } + } + + return false; }); return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) { - $channel = $route->platformChannel; - $account = $channel->activePlatformAccounts()->first(); + $channel = PlatformChannel::with(['platformInstance', 'activePlatformAccounts'])->find($route->platform_channel_id); + $account = $channel?->activePlatformAccounts()->first(); if (! $account) { $this->logSaver->warning('No active account for channel', $channel, [ @@ -67,46 +121,7 @@ public function publishToRoutedChannels(Article $article, array $extractedData): } return $this->publishToChannel($article, $extractedData, $channel, $account); - }) - ->filter(); - } - - /** - * Check if a route matches an article based on keywords - * - * @param array $extractedData - */ - private function routeMatchesArticle(Route $route, array $extractedData): bool - { - // Get active keywords for this route - $activeKeywords = $route->keywords->where('is_active', true); - - // If no keywords are defined for this route, the route matches any article - if ($activeKeywords->isEmpty()) { - return true; - } - - // Get article content for keyword matching - $articleContent = ''; - if (isset($extractedData['full_article'])) { - $articleContent = $extractedData['full_article']; - } - if (isset($extractedData['title'])) { - $articleContent .= ' '.$extractedData['title']; - } - if (isset($extractedData['description'])) { - $articleContent .= ' '.$extractedData['description']; - } - - // Check if any of the route's keywords match the article content - foreach ($activeKeywords as $keywordModel) { - $keyword = $keywordModel->keyword; - if (stripos($articleContent, $keyword) !== false) { - return true; - } - } - - return false; + })->filter(); } /** @@ -145,7 +160,7 @@ private function publishToChannel(Article $article, array $extractedData, Platfo 'publication_data' => $postData, ]); - $this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [ + $this->logSaver->info('Published to channel', $channel, [ 'article_id' => $article->id, ]); diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 3d77b50..e37079a 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -10,12 +10,14 @@ use App\Models\ArticlePublication; use App\Models\Feed; use App\Models\Notification; +use App\Models\Route; +use App\Models\RouteArticle; use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; +use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Collection; use Mockery; use Tests\TestCase; @@ -31,6 +33,17 @@ protected function setUp(): void $this->notificationService = new NotificationService; } + private function createApprovedRouteArticle(array $articleOverrides = [], array $routeOverrides = []): RouteArticle + { + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides)); + $article = Article::factory()->create(array_merge(['feed_id' => $feed->id], $articleOverrides)); + + return RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + } + public function test_constructor_sets_correct_queue(): void { $job = new PublishNextArticleJob; @@ -63,253 +76,125 @@ public function test_job_uses_queueable_trait(): void { $job = new PublishNextArticleJob; - $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, - class_uses($job) - ); + $this->assertContains(Queueable::class, class_uses($job)); } - public function test_handle_returns_early_when_no_approved_articles(): void + public function test_handle_returns_early_when_no_approved_route_articles(): void { - // Arrange - No articles exist $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $job = new PublishNextArticleJob; - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); - // Assert - Should complete without error $this->assertTrue(true); } - public function test_handle_returns_early_when_no_unpublished_approved_articles(): void + public function test_handle_returns_early_when_no_unpublished_approved_route_articles(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); + $routeArticle = $this->createApprovedRouteArticle(); - // Create a publication record to mark it as already published - ArticlePublication::factory()->create(['article_id' => $article->id]); - - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early - - $job = new PublishNextArticleJob; - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); - - // Assert - Should complete without error - $this->assertTrue(true); - } - - public function test_handle_skips_non_approved_articles(): void - { - // Arrange - $feed = Feed::factory()->create(); - Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'rejected', + // Mark the article as already published to this channel + ArticlePublication::factory()->create([ + 'article_id' => $routeArticle->article_id, + 'platform_channel_id' => $routeArticle->platform_channel_id, ]); $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $job = new PublishNextArticleJob; - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); - // Assert - Should complete without error (no approved articles to process) $this->assertTrue(true); } - public function test_handle_publishes_oldest_approved_article(): void + public function test_handle_skips_non_approved_route_articles(): void { - // Arrange $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create(['feed_id' => $feed->id]); - // Create older article first - $olderArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', + RouteArticle::factory()->forRoute($route)->pending()->create(['article_id' => $article->id]); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + + $job = new PublishNextArticleJob; + $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); + + $this->assertTrue(true); + } + + public function test_handle_publishes_oldest_approved_route_article(): void + { + $feed = Feed::factory()->create(); + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + + $olderArticle = Article::factory()->create(['feed_id' => $feed->id]); + $newerArticle = Article::factory()->create(['feed_id' => $feed->id]); + + RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $olderArticle->id, 'created_at' => now()->subHours(2), ]); - - // Create newer article - $newerArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', + RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $newerArticle->id, 'created_at' => now()->subHour(), ]); $extractedData = ['title' => 'Test Article', 'content' => 'Test content']; - // Mock ArticleFetcher $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock->shouldReceive('fetchArticleData') ->once() - ->with(Mockery::on(function ($article) use ($olderArticle) { - return $article->id === $olderArticle->id; - })) + ->with(Mockery::on(fn ($article) => $article->id === $olderArticle->id)) ->andReturn($extractedData); - // Mock ArticlePublishingService $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() ->with( - Mockery::on(function ($article) use ($olderArticle) { - return $article->id === $olderArticle->id; - }), + Mockery::on(fn ($ra) => $ra->article_id === $olderArticle->id), $extractedData ) - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; - - // Act $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); - // Assert - Mockery expectations are verified in tearDown $this->assertTrue(true); } public function test_handle_throws_exception_on_publishing_failure(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); + $routeArticle = $this->createApprovedRouteArticle(); + $article = $routeArticle->article; $extractedData = ['title' => 'Test Article']; $publishException = new PublishException($article, null); - // Mock ArticleFetcher $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock->shouldReceive('fetchArticleData') ->once() - ->with(Mockery::type(Article::class)) ->andReturn($extractedData); - // Mock ArticlePublishingService to throw exception $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() ->andThrow($publishException); $job = new PublishNextArticleJob; - // Assert $this->expectException(PublishException::class); - // Act $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); } - public function test_handle_logs_publishing_start(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'title' => 'Test Article Title', - 'url' => 'https://example.com/article', - ]); - - $extractedData = ['title' => 'Test Article']; - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->andReturn($extractedData); - - // Mock ArticlePublishingService - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->andReturn(new Collection(['publication'])); - - $job = new PublishNextArticleJob; - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); - - // Assert - Verify the job completes (logging is verified by observing no exceptions) - $this->assertTrue(true); - } - - public function test_job_can_be_serialized(): void - { - $job = new PublishNextArticleJob; - - $serialized = serialize($job); - $unserialized = unserialize($serialized); - - $this->assertInstanceOf(PublishNextArticleJob::class, $unserialized); - $this->assertEquals($job->queue, $unserialized->queue); - $this->assertEquals($job->uniqueFor, $unserialized->uniqueFor); - } - - public function test_handle_fetches_article_data_before_publishing(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = ['title' => 'Extracted Title', 'content' => 'Extracted Content']; - - // Mock ArticleFetcher with specific expectations - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->with(Mockery::type(Article::class)) - ->andReturn($extractedData); - - // Mock publishing service to receive the extracted data - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->with(Mockery::type(Article::class), $extractedData) - ->andReturn(new Collection(['publication'])); - - $job = new PublishNextArticleJob; - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); - - // Assert - Mockery expectations verified in tearDown - $this->assertTrue(true); - } - public function test_handle_skips_publishing_when_last_publication_within_interval(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); + $this->createApprovedRouteArticle(); - // Last publication was 3 minutes ago, interval is 10 minutes ArticlePublication::factory()->create([ 'published_at' => now()->subMinutes(3), ]); @@ -318,9 +203,8 @@ public function test_handle_skips_publishing_when_last_publication_within_interv $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - // Neither should be called $articleFetcherMock->shouldNotReceive('fetchArticleData'); - $publishingServiceMock->shouldNotReceive('publishToRoutedChannels'); + $publishingServiceMock->shouldNotReceive('publishRouteArticle'); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -330,13 +214,8 @@ public function test_handle_skips_publishing_when_last_publication_within_interv public function test_handle_publishes_when_last_publication_beyond_interval(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); + $this->createApprovedRouteArticle(); - // Last publication was 15 minutes ago, interval is 10 minutes ArticlePublication::factory()->create([ 'published_at' => now()->subMinutes(15), ]); @@ -350,9 +229,9 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -362,13 +241,8 @@ public function test_handle_publishes_when_last_publication_beyond_interval(): v public function test_handle_publishes_when_interval_is_zero(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); + $this->createApprovedRouteArticle(); - // Last publication was just now, but interval is 0 ArticlePublication::factory()->create([ 'published_at' => now(), ]); @@ -382,9 +256,9 @@ public function test_handle_publishes_when_interval_is_zero(): void ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->andReturn(new Collection(['publication'])); + $publishingServiceMock->shouldReceive('publishRouteArticle') + ->once() + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -394,13 +268,8 @@ public function test_handle_publishes_when_interval_is_zero(): void public function test_handle_publishes_when_last_publication_exactly_at_interval(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); + $this->createApprovedRouteArticle(); - // Last publication was exactly 10 minutes ago, interval is 10 minutes — should publish ArticlePublication::factory()->create([ 'published_at' => now()->subMinutes(10), ]); @@ -414,9 +283,9 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval( ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -426,11 +295,7 @@ public function test_handle_publishes_when_last_publication_exactly_at_interval( public function test_handle_publishes_when_no_previous_publications_exist(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); + $this->createApprovedRouteArticle(); Setting::setArticlePublishingInterval(10); @@ -442,9 +307,9 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -452,14 +317,9 @@ public function test_handle_publishes_when_no_previous_publications_exist(): voi $this->assertTrue(true); } - public function test_handle_creates_warning_notification_when_no_publications_created(): void + public function test_handle_creates_warning_notification_when_no_publication_created(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'title' => 'No Route Article', - ]); + $routeArticle = $this->createApprovedRouteArticle(['title' => 'No Route Article']); $extractedData = ['title' => 'No Route Article']; @@ -469,9 +329,9 @@ public function test_handle_creates_warning_notification_when_no_publications_cr ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection); + ->andReturn(null); $job = new PublishNextArticleJob; $job->handle($articleFetcherMock, $publishingServiceMock, $this->notificationService); @@ -479,8 +339,8 @@ public function test_handle_creates_warning_notification_when_no_publications_cr $this->assertDatabaseHas('notifications', [ 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'severity' => NotificationSeverityEnum::WARNING->value, - 'notifiable_type' => $article->getMorphClass(), - 'notifiable_id' => $article->id, + 'notifiable_type' => $routeArticle->article->getMorphClass(), + 'notifiable_id' => $routeArticle->article_id, ]); $notification = Notification::first(); @@ -489,12 +349,8 @@ public function test_handle_creates_warning_notification_when_no_publications_cr public function test_handle_creates_notification_on_publish_exception(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'title' => 'Failing Article', - ]); + $routeArticle = $this->createApprovedRouteArticle(['title' => 'Failing Article']); + $article = $routeArticle->article; $extractedData = ['title' => 'Failing Article']; $publishException = new PublishException($article, null); @@ -505,7 +361,7 @@ public function test_handle_creates_notification_on_publish_exception(): void ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() ->andThrow($publishException); @@ -528,6 +384,18 @@ public function test_handle_creates_notification_on_publish_exception(): void $this->assertStringContainsString('Failing Article', $notification->title); } + public function test_job_can_be_serialized(): void + { + $job = new PublishNextArticleJob; + + $serialized = serialize($job); + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(PublishNextArticleJob::class, $unserialized); + $this->assertEquals($job->queue, $unserialized->queue); + $this->assertEquals($job->uniqueFor, $unserialized->uniqueFor); + } + protected function tearDown(): void { Mockery::close(); diff --git a/tests/Unit/Services/Publishing/KeywordFilteringTest.php b/tests/Unit/Services/Publishing/KeywordFilteringTest.php deleted file mode 100644 index 7a0b80c..0000000 --- a/tests/Unit/Services/Publishing/KeywordFilteringTest.php +++ /dev/null @@ -1,281 +0,0 @@ -shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - $this->service = new ArticlePublishingService($logSaver); - $this->feed = Feed::factory()->create(); - $this->channel1 = PlatformChannel::factory()->create(); - $this->channel2 = PlatformChannel::factory()->create(); - - // Create routes - $this->route1 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - $this->route2 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_route_with_no_keywords_matches_all_articles(): void - { - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Some random article', - 'description' => 'This is about something', - 'full_article' => 'The content talks about various topics', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route with no keywords should match any article'); - } - - public function test_route_with_keywords_matches_article_containing_keyword(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => true, - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route should match article containing keyword "Belgium"'); - } - - public function test_route_with_keywords_does_not_match_article_without_keywords(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'sports', - 'is_active' => true, - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'football', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'Economic news update', - 'description' => 'Markets are doing well', - 'full_article' => 'The economy is showing strong growth this quarter...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertFalse($result, 'Route should not match article without any keywords'); - } - - public function test_inactive_keywords_are_ignored(): void - { - // Add active and inactive keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => false, // Inactive - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true, // Active - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedDataWithInactiveKeyword = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...', - ]; - - $extractedDataWithActiveKeyword = [ - 'title' => 'Political changes ahead', - 'description' => 'Politics is changing', - 'full_article' => 'The political landscape is shifting...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithInactiveKeyword]); - $result2 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithActiveKeyword]); - - $this->assertFalse($result1, 'Route should not match article with inactive keyword'); - $this->assertTrue($result2, 'Route should match article with active keyword'); - } - - public function test_keyword_matching_is_case_insensitive(): void - { - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'BELGIUM', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'belgium news', - 'description' => 'About Belgium', - 'full_article' => 'News from belgium today...', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Keyword matching should be case insensitive'); - } - - public function test_keywords_match_in_title_description_and_content(): void - { - $keywordInTitle = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'title-word', - 'is_active' => true, - ]); - - $keywordInDescription = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'keyword' => 'desc-word', - 'is_active' => true, - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved', - ]); - - $extractedData = [ - 'title' => 'This contains title-word', - 'description' => 'This has desc-word in it', - 'full_article' => 'The content has no special words', - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - $result2 = $method->invokeArgs($this->service, [$this->route2, $extractedData]); - - $this->assertTrue($result1, 'Should match keyword in title'); - $this->assertTrue($result2, 'Should match keyword in description'); - } -} -- 2.45.2 From d0985fc57de9367cf072c8aba0f8cb4ecc1bcd09 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 16:09:48 +0100 Subject: [PATCH 09/20] 85 - Simplify Articles page to read-only feed log --- app/Livewire/Articles.php | 22 +---- resources/views/livewire/articles.blade.php | 89 +++------------------ 2 files changed, 10 insertions(+), 101 deletions(-) diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index 8d2de63..a70dbf2 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -4,7 +4,6 @@ use App\Jobs\ArticleDiscoveryJob; use App\Models\Article; -use App\Models\Setting; use Livewire\Component; use Livewire\WithPagination; @@ -14,22 +13,6 @@ class Articles extends Component public bool $isRefreshing = false; - public function approve(int $articleId): void - { - $article = Article::findOrFail($articleId); - $article->approve(); - - $this->dispatch('article-updated'); - } - - public function reject(int $articleId): void - { - $article = Article::findOrFail($articleId); - $article->reject(); - - $this->dispatch('article-updated'); - } - public function refresh(): void { $this->isRefreshing = true; @@ -41,15 +24,12 @@ public function refresh(): void public function render(): \Illuminate\Contracts\View\View { - $articles = Article::with(['feed', 'articlePublication']) + $articles = Article::with('feed') ->orderBy('created_at', 'desc') ->paginate(15); - $approvalsEnabled = Setting::isPublishingApprovalsEnabled(); - return view('livewire.articles', [ 'articles' => $articles, - 'approvalsEnabled' => $approvalsEnabled, ])->layout('layouts.app'); } } diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php index 3acec05..29a218f 100644 --- a/resources/views/livewire/articles.blade.php +++ b/resources/views/livewire/articles.blade.php @@ -3,17 +3,8 @@

Articles

- Manage and review articles from your feeds + Articles fetched from your feeds

- @if ($approvalsEnabled) -
- - - - - Approval system enabled -
- @endif
- @if ($article->is_published) - - - - - Published - - @elseif ($article->publish_status === 'error') - - - - - Publish Error - - @elseif ($article->publish_status === 'publishing') - - - - - Publishing... - - @elseif ($article->approval_status === 'approved') - - - - - Approved - - @elseif ($article->approval_status === 'rejected') - - - - - Rejected - - @else - - - - - Pending - - @endif - @if ($article->url)
- - @if ($article->approval_status === 'pending' && $approvalsEnabled) -
- - -
- @endif @empty
-- 2.45.2 From f3406b171397f7009fb0518b33df9626ee2510dc Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 16:23:46 +0100 Subject: [PATCH 10/20] 85 - Remove approval_status from Article, migrate to route_articles --- .../Controllers/Api/V1/ArticlesController.php | 34 ---- app/Http/Resources/ArticleResource.php | 2 - .../PublishApprovedArticleListener.php | 5 - app/Models/Article.php | 58 ------ .../Publishing/ArticlePublishingService.php | 4 - database/factories/ArticleFactory.php | 1 - ...eywords_and_approval_to_route_articles.php | 61 ++++++ routes/api.php | 2 - tests/Feature/DatabaseIntegrationTest.php | 2 - .../Api/V1/ArticlesControllerTest.php | 54 ----- tests/Feature/JobsAndEventsTest.php | 1 - .../PublishApprovedArticleListenerTest.php | 6 +- tests/Feature/ValidateArticleListenerTest.php | 3 - tests/Unit/Models/ArticleTest.php | 189 +++--------------- .../ArticlePublishingServiceTest.php | 34 +--- 15 files changed, 100 insertions(+), 356 deletions(-) create mode 100644 database/migrations/2024_01_01_000009_migrate_keywords_and_approval_to_route_articles.php diff --git a/app/Http/Controllers/Api/V1/ArticlesController.php b/app/Http/Controllers/Api/V1/ArticlesController.php index 7fa1d97..e565749 100644 --- a/app/Http/Controllers/Api/V1/ArticlesController.php +++ b/app/Http/Controllers/Api/V1/ArticlesController.php @@ -40,40 +40,6 @@ public function index(Request $request): JsonResponse ]); } - /** - * Approve an article - */ - public function approve(Article $article): JsonResponse - { - try { - $article->approve('manual'); - - return $this->sendResponse( - new ArticleResource($article->fresh(['feed', 'articlePublication'])), - 'Article approved and queued for publishing.' - ); - } catch (Exception $e) { - return $this->sendError('Failed to approve article: '.$e->getMessage(), [], 500); - } - } - - /** - * Reject an article - */ - public function reject(Article $article): JsonResponse - { - try { - $article->reject('manual'); - - return $this->sendResponse( - new ArticleResource($article->fresh(['feed', 'articlePublication'])), - 'Article rejected.' - ); - } catch (Exception $e) { - return $this->sendError('Failed to reject article: '.$e->getMessage(), [], 500); - } - } - /** * Manually refresh articles from all active feeds */ diff --git a/app/Http/Resources/ArticleResource.php b/app/Http/Resources/ArticleResource.php index 36dbbbf..086b1eb 100644 --- a/app/Http/Resources/ArticleResource.php +++ b/app/Http/Resources/ArticleResource.php @@ -21,8 +21,6 @@ public function toArray(Request $request): array 'url' => $this->url, 'title' => $this->title, 'description' => $this->description, - 'is_valid' => $this->is_valid, - 'approval_status' => $this->approval_status, 'publish_status' => $this->publish_status, 'validated_at' => $this->validated_at?->toISOString(), 'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null, diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php index aab3ddf..0bb3243 100644 --- a/app/Listeners/PublishApprovedArticleListener.php +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -32,11 +32,6 @@ public function handle(ArticleApproved $event): void return; } - // Skip if not approved (safety check) - if (! $article->isApproved()) { - return; - } - $article->update(['publish_status' => 'publishing']); try { diff --git a/app/Models/Article.php b/app/Models/Article.php index bb834a0..6156321 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -2,7 +2,6 @@ namespace App\Models; -use App\Events\ArticleApproved; use App\Events\NewArticleFetched; use Database\Factories\ArticleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -23,14 +22,11 @@ * @property string $url * @property string $title * @property string|null $description - * @property string $approval_status * @property string $publish_status - * @property bool|null $is_valid * @property Carbon|null $validated_at * @property Carbon $created_at * @property Carbon $updated_at * @property ArticlePublication|null $articlePublication - * @property \Illuminate\Support\HigherOrderCollectionProxy|mixed $routeArticles */ class Article extends Model { @@ -46,7 +42,6 @@ class Article extends Model 'image_url', 'published_at', 'author', - 'approval_status', 'validated_at', 'publish_status', ]; @@ -57,7 +52,6 @@ class Article extends Model public function casts(): array { return [ - 'approval_status' => 'string', 'publish_status' => 'string', 'published_at' => 'datetime', 'validated_at' => 'datetime', @@ -66,58 +60,6 @@ public function casts(): array ]; } - public function isValid(): bool - { - return $this->validated_at !== null && ! $this->isRejected(); - } - - public function isApproved(): bool - { - return $this->approval_status === 'approved'; - } - - public function isPending(): bool - { - return $this->approval_status === 'pending'; - } - - public function isRejected(): bool - { - return $this->approval_status === 'rejected'; - } - - public function approve(?string $approvedBy = null): void - { - $this->update([ - 'approval_status' => 'approved', - ]); - - // Fire event to trigger publishing - event(new ArticleApproved($this)); - } - - public function reject(?string $rejectedBy = null): void - { - $this->update([ - 'approval_status' => 'rejected', - ]); - } - - public function canBePublished(): bool - { - if (! $this->isValid()) { - return false; - } - - // If approval system is disabled, auto-approve valid articles - if (! \App\Models\Setting::isPublishingApprovalsEnabled()) { - return true; - } - - // If approval system is enabled, only approved articles can be published - return $this->isApproved(); - } - public function getIsPublishedAttribute(): bool { return $this->articlePublication()->exists(); diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php index 1d8d271..bac0db9 100644 --- a/app/Services/Publishing/ArticlePublishingService.php +++ b/app/Services/Publishing/ArticlePublishingService.php @@ -73,10 +73,6 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted */ public function publishToRoutedChannels(Article $article, array $extractedData): Collection { - if (! $article->isValid()) { - throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE')); - } - $feed = $article->feed; $activeRoutes = Route::where('feed_id', $feed->id) diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index f35645d..f236a63 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -25,7 +25,6 @@ public function definition(): array 'image_url' => $this->faker->optional()->imageUrl(), 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), 'author' => $this->faker->optional()->name(), - 'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']), 'publish_status' => 'unpublished', ]; } diff --git a/database/migrations/2024_01_01_000009_migrate_keywords_and_approval_to_route_articles.php b/database/migrations/2024_01_01_000009_migrate_keywords_and_approval_to_route_articles.php new file mode 100644 index 0000000..307dc0f --- /dev/null +++ b/database/migrations/2024_01_01_000009_migrate_keywords_and_approval_to_route_articles.php @@ -0,0 +1,61 @@ +whereIn('approval_status', ['approved', 'rejected']) + ->whereNotNull('validated_at') + ->get(); + + foreach ($validatedArticles as $article) { + $routes = DB::table('routes') + ->where('feed_id', $article->feed_id) + ->where('is_active', true) + ->get(); + + foreach ($routes as $route) { + $exists = DB::table('route_articles') + ->where('feed_id', $route->feed_id) + ->where('platform_channel_id', $route->platform_channel_id) + ->where('article_id', $article->id) + ->exists(); + + if ($exists) { + continue; + } + + DB::table('route_articles')->insert([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'article_id' => $article->id, + 'approval_status' => $article->approval_status, + 'validated_at' => $article->validated_at, + 'created_at' => $article->created_at, + 'updated_at' => now(), + ]); + } + } + + // Remove approval_status column from articles + Schema::table('articles', function (Blueprint $table) { + $table->dropIndex(['published_at', 'approval_status']); + $table->dropColumn('approval_status'); + }); + } + + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending')->after('feed_id'); + $table->index(['published_at', 'approval_status']); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 7e12a04..0ab6dfc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -47,8 +47,6 @@ // Articles Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index'); - Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('api.articles.approve'); - Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('api.articles.reject'); Route::post('/articles/refresh', [ArticlesController::class, 'refresh'])->name('api.articles.refresh'); // Platform Accounts diff --git a/tests/Feature/DatabaseIntegrationTest.php b/tests/Feature/DatabaseIntegrationTest.php index e7c9a1d..982ec93 100644 --- a/tests/Feature/DatabaseIntegrationTest.php +++ b/tests/Feature/DatabaseIntegrationTest.php @@ -134,14 +134,12 @@ public function test_article_model_creates_successfully(): void 'feed_id' => $feed->id, 'title' => 'Test Article', 'url' => 'https://example.com/article', - 'approval_status' => 'pending', ]); $this->assertDatabaseHas('articles', [ 'feed_id' => $feed->id, 'title' => 'Test Article', 'url' => 'https://example.com/article', - 'approval_status' => 'pending', ]); $this->assertEquals($feed->id, $article->feed->id); diff --git a/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php index 9b16f24..ced053d 100644 --- a/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php +++ b/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php @@ -102,60 +102,6 @@ public function test_index_orders_articles_by_created_at_desc(): void $this->assertEquals('First Article', $articles[1]['title']); } - public function test_approve_article_successfully(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - - $response = $this->postJson("/api/v1/articles/{$article->id}/approve"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Article approved and queued for publishing.', - ]); - - $article->refresh(); - $this->assertEquals('approved', $article->approval_status); - } - - public function test_approve_nonexistent_article_returns_404(): void - { - $response = $this->postJson('/api/v1/articles/999/approve'); - - $response->assertStatus(404); - } - - public function test_reject_article_successfully(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - - $response = $this->postJson("/api/v1/articles/{$article->id}/reject"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Article rejected.', - ]); - - $article->refresh(); - $this->assertEquals('rejected', $article->approval_status); - } - - public function test_reject_nonexistent_article_returns_404(): void - { - $response = $this->postJson('/api/v1/articles/999/reject'); - - $response->assertStatus(404); - } - public function test_index_includes_settings(): void { $response = $this->getJson('/api/v1/articles'); diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index c398cbc..aa76731 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -188,7 +188,6 @@ public function test_validate_article_listener_processes_new_article(): void $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'pending', ]); // Mock ArticleFetcher to return valid article data diff --git a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php index cf1ffb1..b23ac11 100644 --- a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php +++ b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php @@ -27,7 +27,7 @@ public function test_exception_during_publishing_creates_error_notification(): v $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 'title' => 'Test Article', ]); @@ -58,7 +58,7 @@ public function test_no_publications_created_creates_warning_notification(): voi $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 'title' => 'Test Article', ]); @@ -93,7 +93,7 @@ public function test_successful_publish_does_not_create_notification(): void $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 'title' => 'Test Article', ]); diff --git a/tests/Feature/ValidateArticleListenerTest.php b/tests/Feature/ValidateArticleListenerTest.php index a5412ca..88a67e1 100644 --- a/tests/Feature/ValidateArticleListenerTest.php +++ b/tests/Feature/ValidateArticleListenerTest.php @@ -54,7 +54,6 @@ public function test_listener_validates_article_and_creates_route_articles(): vo $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'pending', ]); $listener = $this->createListenerWithMockedFetcher('Article about Belgium'); @@ -91,7 +90,6 @@ public function test_listener_skips_articles_with_existing_publication(): void $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'pending', ]); ArticlePublication::create([ @@ -120,7 +118,6 @@ public function test_listener_handles_validation_errors_gracefully(): void $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'pending', ]); $listener->handle(new NewArticleFetched($article)); diff --git a/tests/Unit/Models/ArticleTest.php b/tests/Unit/Models/ArticleTest.php index ed9cd0d..ead0e8b 100644 --- a/tests/Unit/Models/ArticleTest.php +++ b/tests/Unit/Models/ArticleTest.php @@ -2,11 +2,9 @@ namespace Tests\Unit\Models; -use App\Events\ArticleApproved; use App\Events\NewArticleFetched; use App\Models\Article; use App\Models\Feed; -use App\Models\Setting; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; @@ -20,169 +18,9 @@ protected function setUp(): void { parent::setUp(); - // Mock HTTP requests to prevent external calls Http::fake([ '*' => Http::response('', 500), ]); - - // Don't fake events globally - let individual tests control this - } - - public function test_is_valid_returns_false_when_approval_status_is_pending(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'pending', - ]); - - $this->assertFalse($article->isValid()); - } - - public function test_is_valid_returns_false_when_approval_status_is_rejected(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'rejected', - ]); - - $this->assertFalse($article->isValid()); - } - - public function test_is_valid_returns_true_when_validated_and_not_rejected(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'approved', - 'validated_at' => now(), - ]); - - $this->assertTrue($article->isValid()); - } - - public function test_is_valid_returns_false_when_not_validated(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'approved', - 'validated_at' => null, - ]); - - $this->assertFalse($article->isValid()); - } - - public function test_is_approved_returns_true_for_approved_status(): void - { - $article = Article::factory()->make(['approval_status' => 'approved']); - - $this->assertTrue($article->isApproved()); - } - - public function test_is_approved_returns_false_for_non_approved_status(): void - { - $article = Article::factory()->make(['approval_status' => 'pending']); - - $this->assertFalse($article->isApproved()); - } - - public function test_is_pending_returns_true_for_pending_status(): void - { - $article = Article::factory()->make(['approval_status' => 'pending']); - - $this->assertTrue($article->isPending()); - } - - public function test_is_rejected_returns_true_for_rejected_status(): void - { - $article = Article::factory()->make(['approval_status' => 'rejected']); - - $this->assertTrue($article->isRejected()); - } - - public function test_approve_updates_status_and_triggers_event(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - - Event::fake(); - - $article->approve('test_user'); - - $article->refresh(); - $this->assertEquals('approved', $article->approval_status); - - Event::assertDispatched(ArticleApproved::class, function ($event) use ($article) { - return $event->article->id === $article->id; - }); - } - - public function test_approve_without_approved_by_parameter(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - - Event::fake(); - - $article->approve(); - - $article->refresh(); - $this->assertEquals('approved', $article->approval_status); - } - - public function test_reject_updates_status(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - - $article->reject('test_user'); - - $article->refresh(); - $this->assertEquals('rejected', $article->approval_status); - } - - public function test_can_be_published_returns_false_for_invalid_article(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'rejected', // rejected = not valid - ]); - - $this->assertFalse($article->canBePublished()); - } - - public function test_can_be_published_requires_approval_when_approvals_enabled(): void - { - // Create a setting that enables approvals - Setting::create(['key' => 'enable_publishing_approvals', 'value' => '1']); - - $pendingArticle = Article::factory()->make([ - 'approval_status' => 'pending', - 'validated_at' => now(), - ]); - - $approvedArticle = Article::factory()->make([ - 'approval_status' => 'approved', - 'validated_at' => now(), - ]); - - $this->assertFalse($pendingArticle->canBePublished()); - $this->assertTrue($approvedArticle->canBePublished()); - } - - public function test_can_be_published_returns_true_when_approvals_disabled(): void - { - // Make sure approvals are disabled (default behavior) - Setting::where('key', 'enable_publishing_approvals')->delete(); - - $article = Article::factory()->make([ - 'approval_status' => 'approved', - 'validated_at' => now(), - ]); - - $this->assertTrue($article->canBePublished()); } public function test_feed_relationship(): void @@ -194,6 +32,14 @@ public function test_feed_relationship(): void $this->assertEquals($feed->id, $article->feed->id); } + public function test_route_articles_relationship(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create(['feed_id' => $feed->id]); + + $this->assertCount(0, $article->routeArticles); + } + public function test_dispatch_fetched_event_fires_new_article_fetched_event(): void { Event::fake([NewArticleFetched::class]); @@ -207,4 +53,23 @@ public function test_dispatch_fetched_event_fires_new_article_fetched_event(): v return $event->article->id === $article->id; }); } + + public function test_is_published_attribute(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create(['feed_id' => $feed->id]); + + $this->assertFalse($article->is_published); + } + + public function test_validated_at_is_cast_to_datetime(): void + { + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'validated_at' => now(), + ]); + + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $article->validated_at); + } } diff --git a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php index 4302a83..40e2c8d 100644 --- a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php @@ -3,7 +3,6 @@ namespace Tests\Unit\Services\Publishing; use App\Enums\PlatformEnum; -use App\Exceptions\PublishException; use App\Models\Article; use App\Models\Feed; use App\Models\PlatformAccount; @@ -45,23 +44,12 @@ protected function tearDown(): void parent::tearDown(); } - public function test_publish_to_routed_channels_throws_exception_for_invalid_article(): void - { - $article = Article::factory()->create(['approval_status' => 'rejected']); - $extractedData = ['title' => 'Test Title']; - - $this->expectException(PublishException::class); - $this->expectExceptionMessage('CANNOT_PUBLISH_INVALID_ARTICLE'); - - $this->service->publishToRoutedChannels($article, $extractedData); - } - public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void { $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 'validated_at' => now(), ]); $extractedData = ['title' => 'Test Title']; @@ -78,7 +66,7 @@ public function test_publish_to_routed_channels_skips_routes_without_active_acco $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 'validated_at' => now(), ]); @@ -106,8 +94,7 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe { // Arrange $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', - 'validated_at' => now()]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); $platformInstance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); @@ -153,8 +140,7 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace { // Arrange $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', - 'validated_at' => now()]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); $platformInstance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); @@ -195,8 +181,7 @@ public function test_publish_to_routed_channels_publishes_to_multiple_routes(): { // Arrange $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', - 'validated_at' => now()]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); $platformInstance = PlatformInstance::factory()->create(); $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); @@ -251,8 +236,7 @@ public function test_publish_to_routed_channels_filters_out_failed_publications( { // Arrange $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved', - 'validated_at' => now()]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); $platformInstance = PlatformInstance::factory()->create(); $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); @@ -309,7 +293,7 @@ public function test_publish_skips_duplicate_when_url_already_posted_to_channel( $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 'validated_at' => now(), 'url' => 'https://example.com/article-1', ]); @@ -361,7 +345,7 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 'validated_at' => now(), 'url' => 'https://example.com/article-new-url', 'title' => 'Breaking News: Something Happened', @@ -414,7 +398,7 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void $feed = Feed::factory()->create(); $article = Article::factory()->create([ 'feed_id' => $feed->id, - 'approval_status' => 'approved', + 'validated_at' => now(), 'url' => 'https://example.com/unique-article', ]); -- 2.45.2 From 5e571babdaeae567cfef0a95cc3feee1eb7f4409 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 16:35:12 +0100 Subject: [PATCH 11/20] 85 - Fix keyword matching to include title and description, add PHPStan type annotations --- app/Services/Article/ValidationService.php | 5 ++++- tests/Feature/JobsAndEventsTest.php | 1 + tests/Feature/ValidateArticleListenerTest.php | 1 + tests/Unit/Jobs/PublishNextArticleJobTest.php | 12 +++++++++++- tests/Unit/Models/RouteArticleTest.php | 15 ++++++++++++++- tests/Unit/Services/ValidationServiceTest.php | 9 +++++++++ 6 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php index aca2f8f..431417b 100644 --- a/app/Services/Article/ValidationService.php +++ b/app/Services/Article/ValidationService.php @@ -62,9 +62,12 @@ private function createRouteArticles(Article $article, string $content): void ->get() ->groupBy('platform_channel_id'); + // Match keywords against full article content, title, and description + $searchableContent = $content.' '.$article->title.' '.$article->description; + foreach ($activeRoutes as $route) { $routeKeywords = $keywordsByChannel->get($route->platform_channel_id, collect()); - $status = $this->evaluateKeywords($routeKeywords, $content); + $status = $this->evaluateKeywords($routeKeywords, $searchableContent); if ($status === ApprovalStatusEnum::PENDING && $this->shouldAutoApprove($route)) { $status = ApprovalStatusEnum::APPROVED; diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index aa76731..9c2e3ef 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -179,6 +179,7 @@ public function test_validate_article_listener_processes_new_article(): void Setting::setBool('enable_publishing_approvals', false); $feed = Feed::factory()->create(); + /** @var \App\Models\Route $route */ $route = \App\Models\Route::factory()->active()->create(['feed_id' => $feed->id]); \App\Models\Keyword::factory()->active()->create([ 'feed_id' => $feed->id, diff --git a/tests/Feature/ValidateArticleListenerTest.php b/tests/Feature/ValidateArticleListenerTest.php index 88a67e1..57529a6 100644 --- a/tests/Feature/ValidateArticleListenerTest.php +++ b/tests/Feature/ValidateArticleListenerTest.php @@ -45,6 +45,7 @@ protected function tearDown(): void public function test_listener_validates_article_and_creates_route_articles(): void { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); Keyword::factory()->active()->create([ 'feed_id' => $feed->id, diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index e37079a..694789a 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -33,15 +33,23 @@ protected function setUp(): void $this->notificationService = new NotificationService; } + /** + * @param array $articleOverrides + * @param array $routeOverrides + */ private function createApprovedRouteArticle(array $articleOverrides = [], array $routeOverrides = []): RouteArticle { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(array_merge(['feed_id' => $feed->id], $routeOverrides)); $article = Article::factory()->create(array_merge(['feed_id' => $feed->id], $articleOverrides)); - return RouteArticle::factory()->forRoute($route)->approved()->create([ + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ 'article_id' => $article->id, ]); + + return $routeArticle; } public function test_constructor_sets_correct_queue(): void @@ -112,6 +120,7 @@ public function test_handle_returns_early_when_no_unpublished_approved_route_art public function test_handle_skips_non_approved_route_articles(): void { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); $article = Article::factory()->create(['feed_id' => $feed->id]); @@ -129,6 +138,7 @@ public function test_handle_skips_non_approved_route_articles(): void public function test_handle_publishes_oldest_approved_route_article(): void { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); $olderArticle = Article::factory()->create(['feed_id' => $feed->id]); diff --git a/tests/Unit/Models/RouteArticleTest.php b/tests/Unit/Models/RouteArticleTest.php index 4ccd826..f5229e9 100644 --- a/tests/Unit/Models/RouteArticleTest.php +++ b/tests/Unit/Models/RouteArticleTest.php @@ -17,6 +17,7 @@ class RouteArticleTest extends TestCase public function test_route_article_belongs_to_article(): void { + /** @var RouteArticle $routeArticle */ $routeArticle = RouteArticle::factory()->create(); $this->assertInstanceOf(Article::class, $routeArticle->article); @@ -24,6 +25,7 @@ public function test_route_article_belongs_to_article(): void public function test_route_article_belongs_to_feed(): void { + /** @var RouteArticle $routeArticle */ $routeArticle = RouteArticle::factory()->create(); $this->assertInstanceOf(Feed::class, $routeArticle->feed); @@ -31,6 +33,7 @@ public function test_route_article_belongs_to_feed(): void public function test_route_article_belongs_to_platform_channel(): void { + /** @var RouteArticle $routeArticle */ $routeArticle = RouteArticle::factory()->create(); $this->assertInstanceOf(PlatformChannel::class, $routeArticle->platformChannel); @@ -38,6 +41,7 @@ public function test_route_article_belongs_to_platform_channel(): void public function test_route_article_has_default_pending_status(): void { + /** @var RouteArticle $routeArticle */ $routeArticle = RouteArticle::factory()->create(); $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status); @@ -48,15 +52,16 @@ public function test_route_article_has_default_pending_status(): void public function test_route_article_can_be_approved(): void { + /** @var RouteArticle $routeArticle */ $routeArticle = RouteArticle::factory()->create(); $routeArticle->approve(); - $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status); } public function test_route_article_can_be_rejected(): void { + /** @var RouteArticle $routeArticle */ $routeArticle = RouteArticle::factory()->create(); $routeArticle->reject(); @@ -66,7 +71,9 @@ public function test_route_article_can_be_rejected(): void public function test_article_has_many_route_articles(): void { + /** @var Route $route1 */ $route1 = Route::factory()->active()->create(); + /** @var Route $route2 */ $route2 = Route::factory()->active()->create(); $article = Article::factory()->create(['feed_id' => $route1->feed_id]); @@ -78,6 +85,7 @@ public function test_article_has_many_route_articles(): void public function test_route_has_many_route_articles(): void { + /** @var Route $route */ $route = Route::factory()->active()->create(); $article1 = Article::factory()->create(['feed_id' => $route->feed_id]); $article2 = Article::factory()->create(['feed_id' => $route->feed_id]); @@ -90,6 +98,7 @@ public function test_route_has_many_route_articles(): void public function test_unique_constraint_prevents_duplicate_route_articles(): void { + /** @var Route $route */ $route = Route::factory()->active()->create(); $article = Article::factory()->create(['feed_id' => $route->feed_id]); @@ -102,6 +111,7 @@ public function test_unique_constraint_prevents_duplicate_route_articles(): void public function test_route_article_cascade_deletes_when_article_deleted(): void { + /** @var RouteArticle $routeArticle */ $routeArticle = RouteArticle::factory()->create(); $articleId = $routeArticle->article_id; @@ -112,6 +122,7 @@ public function test_route_article_cascade_deletes_when_article_deleted(): void public function test_route_article_cascade_deletes_when_route_deleted(): void { + /** @var Route $route */ $route = Route::factory()->active()->create(); $article = Article::factory()->create(['feed_id' => $route->feed_id]); RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); @@ -128,8 +139,10 @@ public function test_route_article_cascade_deletes_when_route_deleted(): void public function test_route_article_belongs_to_route(): void { + /** @var Route $route */ $route = Route::factory()->active()->create(); $article = Article::factory()->create(['feed_id' => $route->feed_id]); + /** @var RouteArticle $routeArticle */ $routeArticle = RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); $loadedRoute = $routeArticle->route; diff --git a/tests/Unit/Services/ValidationServiceTest.php b/tests/Unit/Services/ValidationServiceTest.php index 03f69d9..4d216d0 100644 --- a/tests/Unit/Services/ValidationServiceTest.php +++ b/tests/Unit/Services/ValidationServiceTest.php @@ -60,6 +60,7 @@ private function mockFetchReturning(Article $article, ?string $content, ?string public function test_validate_sets_validated_at_on_article(): void { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); Keyword::factory()->active()->create([ 'feed_id' => $feed->id, @@ -106,6 +107,7 @@ public function test_validate_skips_inactive_routes(): void public function test_validate_sets_pending_when_keywords_match(): void { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); Keyword::factory()->active()->create([ 'feed_id' => $feed->id, @@ -125,6 +127,7 @@ public function test_validate_sets_pending_when_keywords_match(): void public function test_validate_sets_rejected_when_no_keywords_match(): void { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); Keyword::factory()->active()->create([ 'feed_id' => $feed->id, @@ -200,6 +203,7 @@ public function test_validate_auto_approves_when_global_setting_off_and_keywords Setting::setBool('enable_publishing_approvals', false); $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); Keyword::factory()->active()->create([ 'feed_id' => $feed->id, @@ -221,6 +225,7 @@ public function test_validate_route_auto_approve_overrides_global_setting(): voi Setting::setBool('enable_publishing_approvals', true); $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create([ 'feed_id' => $feed->id, 'auto_approve' => true, @@ -245,6 +250,7 @@ public function test_validate_route_auto_approve_false_overrides_global_off(): v Setting::setBool('enable_publishing_approvals', false); $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create([ 'feed_id' => $feed->id, 'auto_approve' => false, @@ -269,6 +275,7 @@ public function test_validate_does_not_auto_approve_rejected_articles(): void Setting::setBool('enable_publishing_approvals', false); $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); Keyword::factory()->active()->create([ 'feed_id' => $feed->id, @@ -334,6 +341,7 @@ public function test_validate_sets_validated_at_on_route_articles(): void public function test_validate_keyword_matching_is_case_insensitive(): void { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); Keyword::factory()->active()->create([ 'feed_id' => $feed->id, @@ -353,6 +361,7 @@ public function test_validate_keyword_matching_is_case_insensitive(): void public function test_validate_only_uses_active_keywords(): void { $feed = Feed::factory()->create(); + /** @var Route $route */ $route = Route::factory()->active()->create(['feed_id' => $feed->id]); Keyword::factory()->inactive()->create([ 'feed_id' => $feed->id, -- 2.45.2 From 2b74f24356229f1844b70226138e797dcd35b81b Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 17:00:56 +0100 Subject: [PATCH 12/20] 85 - Replace ArticleApproved with RouteArticleApproved event and update publishing listener --- ...eApproved.php => RouteArticleApproved.php} | 9 +- .../PublishApprovedArticleListener.php | 30 +- app/Models/RouteArticle.php | 3 + app/Providers/AppServiceProvider.php | 2 +- .../Publishing/ArticlePublishingService.php | 60 --- tests/Feature/JobsAndEventsTest.php | 40 +- .../PublishApprovedArticleListenerTest.php | 84 ++-- .../ArticlePublishingServiceTest.php | 376 ++++-------------- 8 files changed, 155 insertions(+), 449 deletions(-) rename app/Events/{ArticleApproved.php => RouteArticleApproved.php} (56%) diff --git a/app/Events/ArticleApproved.php b/app/Events/RouteArticleApproved.php similarity index 56% rename from app/Events/ArticleApproved.php rename to app/Events/RouteArticleApproved.php index 1e9ec9b..7f1e1f6 100644 --- a/app/Events/ArticleApproved.php +++ b/app/Events/RouteArticleApproved.php @@ -2,16 +2,13 @@ namespace App\Events; -use App\Models\Article; +use App\Models\RouteArticle; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class ArticleApproved +class RouteArticleApproved { use Dispatchable, SerializesModels; - public function __construct(public Article $article) - { - // - } + public function __construct(public RouteArticle $routeArticle) {} } diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php index 0bb3243..ccd7764 100644 --- a/app/Listeners/PublishApprovedArticleListener.php +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -6,7 +6,7 @@ use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationTypeEnum; use App\Events\ActionPerformed; -use App\Events\ArticleApproved; +use App\Events\RouteArticleApproved; use App\Services\Article\ArticleFetcher; use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; @@ -23,32 +23,30 @@ public function __construct( private NotificationService $notificationService, ) {} - public function handle(ArticleApproved $event): void + public function handle(RouteArticleApproved $event): void { - $article = $event->article->fresh(); + $routeArticle = $event->routeArticle; + $article = $routeArticle->article; - // Skip if already published - if ($article->articlePublication()->exists()) { + // Skip if already published to this channel + if ($article->articlePublications() + ->where('platform_channel_id', $routeArticle->platform_channel_id) + ->exists() + ) { return; } - $article->update(['publish_status' => 'publishing']); - try { $extractedData = $this->articleFetcher->fetchArticleData($article); - $publications = $this->publishingService->publishToRoutedChannels($article, $extractedData); - - if ($publications->isNotEmpty()) { - $article->update(['publish_status' => 'published']); + $publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData); + if ($publication) { ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [ 'article_id' => $article->id, 'title' => $article->title, ]); } else { - $article->update(['publish_status' => 'error']); - - ActionPerformed::dispatch('No publications created for approved article', LogLevelEnum::WARNING, [ + ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [ 'article_id' => $article->id, 'title' => $article->title, ]); @@ -57,13 +55,11 @@ public function handle(ArticleApproved $event): void NotificationTypeEnum::PUBLISH_FAILED, NotificationSeverityEnum::WARNING, "Publish failed: {$article->title}", - 'No publications were created for this article. Check channel routing configuration.', + 'No publication was created for this article. Check channel routing configuration.', $article, ); } } catch (Exception $e) { - $article->update(['publish_status' => 'error']); - ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [ 'article_id' => $article->id, 'error' => $e->getMessage(), diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php index 605f153..8b7c4ac 100644 --- a/app/Models/RouteArticle.php +++ b/app/Models/RouteArticle.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ApprovalStatusEnum; +use App\Events\RouteArticleApproved; use Database\Factories\RouteArticleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -88,6 +89,8 @@ public function isRejected(): bool public function approve(): void { $this->update(['approval_status' => ApprovalStatusEnum::APPROVED]); + + event(new RouteArticleApproved($this)); } public function reject(): void diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2a25ea8..c9b6f5d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -36,7 +36,7 @@ public function boot(): void ); Event::listen( - \App\Events\ArticleApproved::class, + \App\Events\RouteArticleApproved::class, \App\Listeners\PublishApprovedArticleListener::class, ); diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php index bac0db9..73079e1 100644 --- a/app/Services/Publishing/ArticlePublishingService.php +++ b/app/Services/Publishing/ArticlePublishingService.php @@ -6,15 +6,12 @@ use App\Exceptions\PublishException; use App\Models\Article; use App\Models\ArticlePublication; -use App\Models\Keyword; use App\Models\PlatformChannel; use App\Models\PlatformChannelPost; -use App\Models\Route; use App\Models\RouteArticle; use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use Exception; -use Illuminate\Support\Collection; use RuntimeException; class ArticlePublishingService @@ -63,63 +60,6 @@ public function publishRouteArticle(RouteArticle $routeArticle, array $extracted return $this->publishToChannel($article, $extractedData, $channel, $account); } - /** - * @deprecated Use publishRouteArticle() instead. Kept for PublishApprovedArticleListener compatibility. - * - * @param array $extractedData - * @return Collection - * - * @throws PublishException - */ - public function publishToRoutedChannels(Article $article, array $extractedData): Collection - { - $feed = $article->feed; - - $activeRoutes = Route::where('feed_id', $feed->id) - ->where('is_active', true) - ->get(); - - $keywordsByChannel = Keyword::where('feed_id', $feed->id) - ->where('is_active', true) - ->get() - ->groupBy('platform_channel_id'); - - $matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData, $keywordsByChannel) { - $keywords = $keywordsByChannel->get($route->platform_channel_id, collect()); - if ($keywords->isEmpty()) { - return true; - } - - $articleContent = ($extractedData['full_article'] ?? ''). - ' '.($extractedData['title'] ?? ''). - ' '.($extractedData['description'] ?? ''); - - foreach ($keywords as $keyword) { - if (stripos($articleContent, $keyword->keyword) !== false) { - return true; - } - } - - return false; - }); - - return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) { - $channel = PlatformChannel::with(['platformInstance', 'activePlatformAccounts'])->find($route->platform_channel_id); - $account = $channel?->activePlatformAccounts()->first(); - - if (! $account) { - $this->logSaver->warning('No active account for channel', $channel, [ - 'article_id' => $article->id, - 'route_priority' => $route->priority, - ]); - - return null; - } - - return $this->publishToChannel($article, $extractedData, $channel, $account); - })->filter(); - } - /** * @param array $extractedData */ diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index 9c2e3ef..e1337ac 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -3,8 +3,6 @@ namespace Tests\Feature; use App\Events\ActionPerformed; -use App\Events\ArticleApproved; -// use App\Events\ArticleReadyToPublish; // Class no longer exists use App\Events\ExceptionLogged; use App\Events\ExceptionOccurred; use App\Events\NewArticleFetched; @@ -13,8 +11,6 @@ use App\Jobs\PublishNextArticleJob; use App\Jobs\SyncChannelPostsJob; use App\Listeners\LogExceptionToDatabase; -// use App\Listeners\PublishApprovedArticle; // Class no longer exists -// use App\Listeners\PublishArticle; // Class no longer exists use App\Listeners\ValidateArticleListener; use App\Models\Article; use App\Models\Feed; @@ -116,33 +112,6 @@ public function test_new_article_fetched_event_is_dispatched(): void }); } - public function test_article_approved_event_is_dispatched(): void - { - Event::fake(); - - $article = Article::factory()->create(); - - event(new ArticleApproved($article)); - - Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) { - return $event->article->id === $article->id; - }); - } - - // Test removed - ArticleReadyToPublish class no longer exists - // public function test_article_ready_to_publish_event_is_dispatched(): void - // { - // Event::fake(); - - // $article = Article::factory()->create(); - - // event(new ArticleReadyToPublish($article)); - - // Event::assertDispatched(ArticleReadyToPublish::class, function (ArticleReadyToPublish $event) use ($article) { - // return $event->article->id === $article->id; - // }); - // } - public function test_exception_occurred_event_is_dispatched(): void { Event::fake(); @@ -248,13 +217,8 @@ public function test_event_listener_registration_works(): void $listeners = Event::getListeners(NewArticleFetched::class); $this->assertNotEmpty($listeners); - // ArticleApproved event exists but has no listeners after publishing redesign - // $listeners = Event::getListeners(ArticleApproved::class); - // $this->assertNotEmpty($listeners); - - // ArticleReadyToPublish no longer exists - removed this check - // $listeners = Event::getListeners(ArticleReadyToPublish::class); - // $this->assertNotEmpty($listeners); + $listeners = Event::getListeners(\App\Events\RouteArticleApproved::class); + $this->assertNotEmpty($listeners); $listeners = Event::getListeners(ExceptionOccurred::class); $this->assertNotEmpty($listeners); diff --git a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php index b23ac11..fa0bb85 100644 --- a/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php +++ b/tests/Feature/Listeners/PublishApprovedArticleListenerTest.php @@ -4,17 +4,19 @@ use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationTypeEnum; -use App\Events\ArticleApproved; +use App\Events\RouteArticleApproved; use App\Listeners\PublishApprovedArticleListener; use App\Models\Article; +use App\Models\ArticlePublication; use App\Models\Feed; use App\Models\Notification; +use App\Models\Route; +use App\Models\RouteArticle; use App\Services\Article\ArticleFetcher; use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; use Exception; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Collection; use Mockery; use Tests\TestCase; @@ -22,15 +24,28 @@ class PublishApprovedArticleListenerTest extends TestCase { use RefreshDatabase; - public function test_exception_during_publishing_creates_error_notification(): void + private function createApprovedRouteArticle(string $title = 'Test Article'): RouteArticle { $feed = Feed::factory()->create(); + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); $article = Article::factory()->create([ 'feed_id' => $feed->id, - - 'title' => 'Test Article', + 'title' => $title, ]); + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + + return $routeArticle; + } + + public function test_exception_during_publishing_creates_error_notification(): void + { + $routeArticle = $this->createApprovedRouteArticle(); + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); $articleFetcherMock->shouldReceive('fetchArticleData') ->once() @@ -39,13 +54,13 @@ public function test_exception_during_publishing_creates_error_notification(): v $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); - $listener->handle(new ArticleApproved($article)); + $listener->handle(new RouteArticleApproved($routeArticle)); $this->assertDatabaseHas('notifications', [ 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'severity' => NotificationSeverityEnum::ERROR->value, - 'notifiable_type' => $article->getMorphClass(), - 'notifiable_id' => $article->id, + 'notifiable_type' => $routeArticle->article->getMorphClass(), + 'notifiable_id' => $routeArticle->article_id, ]); $notification = Notification::first(); @@ -53,14 +68,9 @@ public function test_exception_during_publishing_creates_error_notification(): v $this->assertStringContainsString('Connection refused', $notification->message); } - public function test_no_publications_created_creates_warning_notification(): void + public function test_no_publication_created_creates_warning_notification(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'title' => 'Test Article', - ]); + $routeArticle = $this->createApprovedRouteArticle(); $extractedData = ['title' => 'Test Article']; @@ -70,18 +80,18 @@ public function test_no_publications_created_creates_warning_notification(): voi ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection); + ->andReturn(null); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); - $listener->handle(new ArticleApproved($article)); + $listener->handle(new RouteArticleApproved($routeArticle)); $this->assertDatabaseHas('notifications', [ 'type' => NotificationTypeEnum::PUBLISH_FAILED->value, 'severity' => NotificationSeverityEnum::WARNING->value, - 'notifiable_type' => $article->getMorphClass(), - 'notifiable_id' => $article->id, + 'notifiable_type' => $routeArticle->article->getMorphClass(), + 'notifiable_id' => $routeArticle->article_id, ]); $notification = Notification::first(); @@ -90,12 +100,7 @@ public function test_no_publications_created_creates_warning_notification(): voi public function test_successful_publish_does_not_create_notification(): void { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'title' => 'Test Article', - ]); + $routeArticle = $this->createApprovedRouteArticle(); $extractedData = ['title' => 'Test Article']; @@ -105,16 +110,37 @@ public function test_successful_publish_does_not_create_notification(): void ->andReturn($extractedData); $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') + $publishingServiceMock->shouldReceive('publishRouteArticle') ->once() - ->andReturn(new Collection(['publication'])); + ->andReturn(ArticlePublication::factory()->make()); $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); - $listener->handle(new ArticleApproved($article)); + $listener->handle(new RouteArticleApproved($routeArticle)); $this->assertDatabaseCount('notifications', 0); } + public function test_skips_already_published_to_channel(): void + { + $routeArticle = $this->createApprovedRouteArticle(); + + ArticlePublication::factory()->create([ + 'article_id' => $routeArticle->article_id, + 'platform_channel_id' => $routeArticle->platform_channel_id, + ]); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldNotReceive('fetchArticleData'); + + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldNotReceive('publishRouteArticle'); + + $listener = new PublishApprovedArticleListener($articleFetcherMock, $publishingServiceMock, new NotificationService); + $listener->handle(new RouteArticleApproved($routeArticle)); + + $this->assertTrue(true); + } + protected function tearDown(): void { Mockery::close(); diff --git a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php index 40e2c8d..61a8d17 100644 --- a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php @@ -10,11 +10,11 @@ use App\Models\PlatformChannelPost; use App\Models\PlatformInstance; use App\Models\Route; +use App\Models\RouteArticle; use App\Modules\Lemmy\Services\LemmyPublisher; use App\Services\Log\LogSaver; use App\Services\Publishing\ArticlePublishingService; use Exception; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; @@ -44,90 +44,77 @@ protected function tearDown(): void parent::tearDown(); } - public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void + /** + * @return array{RouteArticle, PlatformChannel, PlatformAccount, Article} + */ + private function createRouteArticleWithAccount(): array { $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'validated_at' => now(), - ]); - $extractedData = ['title' => 'Test Title']; - - $result = $this->service->publishToRoutedChannels($article, $extractedData); - - $this->assertInstanceOf(EloquentCollection::class, $result); - $this->assertTrue($result->isEmpty()); - } - - public function test_publish_to_routed_channels_skips_routes_without_active_accounts(): void - { - // Arrange: valid article - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'validated_at' => now(), - ]); - - // Create a route with a channel but no active accounts - $channel = PlatformChannel::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Don't create any platform accounts for the channel - - // Act - $result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']); - - // Assert - $this->assertTrue($result->isEmpty()); - $this->assertDatabaseCount('article_publications', 0); - } - - public function test_publish_to_routed_channels_successfully_publishes_to_channel(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); - $platformInstance = PlatformInstance::factory()->create(); $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); $account = PlatformAccount::factory()->create(); - // Create route - Route::create([ + /** @var Route $route */ + $route = Route::factory()->active()->create([ 'feed_id' => $feed->id, 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, ]); - // Attach account to channel as active $channel->platformAccounts()->attach($account->id, [ 'is_active' => true, 'priority' => 50, ]); - // Mock publisher via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $article = Article::factory()->create(['feed_id' => $feed->id]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + + return [$routeArticle, $channel, $account, $article]; + } + + public function test_publish_route_article_returns_null_when_no_active_account(): void + { + $feed = Feed::factory()->create(); + $channel = PlatformChannel::factory()->create(); + + /** @var Route $route */ + $route = Route::factory()->active()->create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + ]); + + $article = Article::factory()->create(['feed_id' => $feed->id]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->approved()->create([ + 'article_id' => $article->id, + ]); + + $result = $this->service->publishRouteArticle($routeArticle, ['title' => 'Test']); + + $this->assertNull($result); + $this->assertDatabaseCount('article_publications', 0); + } + + public function test_publish_route_article_successfully_publishes(): void + { + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); + + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andReturn(['post_view' => ['post' => ['id' => 123]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); - // Assert - $this->assertCount(1, $result); + $this->assertNotNull($result); $this->assertDatabaseHas('article_publications', [ 'article_id' => $article->id, 'platform_channel_id' => $channel->id, @@ -136,236 +123,55 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe ]); } - public function test_publish_to_routed_channels_handles_publishing_failure_gracefully(): void + public function test_publish_route_article_handles_publishing_failure_gracefully(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); + [$routeArticle] = $this->createRouteArticleWithAccount(); - $platformInstance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - // Create route - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach account to channel as active - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - // Publisher throws an exception via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andThrow(new Exception('network error')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Hello']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } - public function test_publish_to_routed_channels_publishes_to_multiple_routes(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 100]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 200]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(2, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 100]); - $this->assertDatabaseHas('article_publications', ['post_id' => 200]); - } - - public function test_publish_to_routed_channels_filters_out_failed_publications(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'validated_at' => now()]); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100, - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50, - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 300]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andThrow(new Exception('failed')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(1, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 300]); - $this->assertDatabaseCount('article_publications', 1); - } - public function test_publish_skips_duplicate_when_url_already_posted_to_channel(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); - 'validated_at' => now(), - 'url' => 'https://example.com/article-1', - ]); - - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); - - // Simulate the URL already being posted to this channel (synced from Lemmy) + // Simulate the URL already being posted to this channel PlatformChannelPost::storePost( PlatformEnum::LEMMY, (string) $channel->channel_id, $channel->name, '999', - 'https://example.com/article-1', + $article->url, 'Different Title', ); - // Publisher should never be called - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Some Title']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Some Title']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_skips_duplicate_when_title_already_posted_to_channel(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'validated_at' => now(), - 'url' => 'https://example.com/article-new-url', - 'title' => 'Breaking News: Something Happened', - ]); - - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Simulate the same title already posted with a different URL PlatformChannelPost::storePost( @@ -374,50 +180,25 @@ public function test_publish_skips_duplicate_when_title_already_posted_to_channe $channel->name, '888', 'https://example.com/different-url', - 'Breaking News: Something Happened', + 'Breaking News', ); - // Publisher should never be called - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldNotReceive('publishToChannel'); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Breaking News: Something Happened']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Breaking News']); - // Assert - $this->assertTrue($result->isEmpty()); + $this->assertNull($result); $this->assertDatabaseCount('article_publications', 0); } public function test_publish_proceeds_when_no_duplicate_exists(): void { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - - 'validated_at' => now(), - 'url' => 'https://example.com/unique-article', - ]); - - $platformInstance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50, - ]); - - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50, - ]); + [$routeArticle, $channel, $account, $article] = $this->createRouteArticleWithAccount(); // Existing post in the channel has a completely different URL and title PlatformChannelPost::storePost( @@ -429,19 +210,18 @@ public function test_publish_proceeds_when_no_duplicate_exists(): void 'Totally Different Title', ); - $publisherDouble = \Mockery::mock(LemmyPublisher::class); + $publisherDouble = Mockery::mock(LemmyPublisher::class); $publisherDouble->shouldReceive('publishToChannel') ->once() ->andReturn(['post_view' => ['post' => ['id' => 456]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); + + $service = Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Unique Title']); + $result = $service->publishRouteArticle($routeArticle, ['title' => 'Unique Title']); - // Assert - $this->assertCount(1, $result); + $this->assertNotNull($result); $this->assertDatabaseHas('article_publications', [ 'article_id' => $article->id, 'post_id' => 456, -- 2.45.2 From 9fb373d1396f9ecab83b64f4f34be08bfe701ed3 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 17:20:15 +0100 Subject: [PATCH 13/20] 98 - Add RouteArticle API endpoints for approve, reject, restore, and clear --- .../Api/V1/RouteArticlesController.php | 101 +++++++ app/Http/Resources/RouteArticleResource.php | 37 +++ routes/api.php | 8 + .../Api/V1/RouteArticlesControllerTest.php | 253 ++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/RouteArticlesController.php create mode 100644 app/Http/Resources/RouteArticleResource.php create mode 100644 tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php diff --git a/app/Http/Controllers/Api/V1/RouteArticlesController.php b/app/Http/Controllers/Api/V1/RouteArticlesController.php new file mode 100644 index 0000000..b41763b --- /dev/null +++ b/app/Http/Controllers/Api/V1/RouteArticlesController.php @@ -0,0 +1,101 @@ +get('per_page', 15), 100); + + $query = RouteArticle::with(['article.feed', 'feed', 'platformChannel']) + ->orderBy('created_at', 'desc'); + + if ($request->has('status')) { + $status = ApprovalStatusEnum::tryFrom($request->get('status')); + if ($status) { + $query->where('approval_status', $status); + } + } + + $routeArticles = $query->paginate($perPage); + + return $this->sendResponse([ + 'route_articles' => RouteArticleResource::collection($routeArticles->items()), + 'pagination' => [ + 'current_page' => $routeArticles->currentPage(), + 'last_page' => $routeArticles->lastPage(), + 'per_page' => $routeArticles->perPage(), + 'total' => $routeArticles->total(), + 'from' => $routeArticles->firstItem(), + 'to' => $routeArticles->lastItem(), + ], + ]); + } + + public function approve(RouteArticle $routeArticle): JsonResponse + { + try { + $routeArticle->approve(); + + return $this->sendResponse( + new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])), + 'Route article approved and queued for publishing.' + ); + } catch (Exception $e) { + return $this->sendError('Failed to approve route article: '.$e->getMessage(), [], 500); + } + } + + public function reject(RouteArticle $routeArticle): JsonResponse + { + try { + $routeArticle->reject(); + + return $this->sendResponse( + new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])), + 'Route article rejected.' + ); + } catch (Exception $e) { + return $this->sendError('Failed to reject route article: '.$e->getMessage(), [], 500); + } + } + + public function restore(RouteArticle $routeArticle): JsonResponse + { + try { + $routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]); + + return $this->sendResponse( + new RouteArticleResource($routeArticle->fresh(['article.feed', 'feed', 'platformChannel'])), + 'Route article restored to pending.' + ); + } catch (Exception $e) { + return $this->sendError('Failed to restore route article: '.$e->getMessage(), [], 500); + } + } + + public function clear(): JsonResponse + { + try { + $count = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); + + RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING) + ->update(['approval_status' => ApprovalStatusEnum::REJECTED]); + + return $this->sendResponse( + ['rejected_count' => $count], + "Rejected {$count} pending route articles." + ); + } catch (Exception $e) { + return $this->sendError('Failed to clear pending route articles: '.$e->getMessage(), [], 500); + } + } +} diff --git a/app/Http/Resources/RouteArticleResource.php b/app/Http/Resources/RouteArticleResource.php new file mode 100644 index 0000000..2bb350e --- /dev/null +++ b/app/Http/Resources/RouteArticleResource.php @@ -0,0 +1,37 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'feed_id' => $this->feed_id, + 'platform_channel_id' => $this->platform_channel_id, + 'article_id' => $this->article_id, + 'approval_status' => $this->approval_status->value, + 'validated_at' => $this->validated_at?->toISOString(), + 'created_at' => $this->created_at->toISOString(), + 'updated_at' => $this->updated_at->toISOString(), + 'article' => [ + 'id' => $this->article->id, + 'title' => $this->article->title, + 'url' => $this->article->url, + 'description' => $this->article->description, + 'feed_name' => $this->article->feed->name, + ], + 'route_name' => $this->feed->name.' → '.$this->platformChannel->name, + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 0ab6dfc..38a35e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\V1\OnboardingController; use App\Http\Controllers\Api\V1\PlatformAccountsController; use App\Http\Controllers\Api\V1\PlatformChannelsController; +use App\Http\Controllers\Api\V1\RouteArticlesController; use App\Http\Controllers\Api\V1\RoutingController; use App\Http\Controllers\Api\V1\SettingsController; use Illuminate\Support\Facades\Route; @@ -102,6 +103,13 @@ Route::delete('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'destroy'])->name('api.keywords.destroy'); Route::post('/routing/{feed}/{channel}/keywords/{keyword}/toggle', [KeywordsController::class, 'toggle'])->name('api.keywords.toggle'); + // Route Articles + Route::get('/route-articles', [RouteArticlesController::class, 'index'])->name('api.route-articles.index'); + Route::post('/route-articles/clear', [RouteArticlesController::class, 'clear'])->name('api.route-articles.clear'); + Route::post('/route-articles/{routeArticle}/approve', [RouteArticlesController::class, 'approve'])->name('api.route-articles.approve'); + Route::post('/route-articles/{routeArticle}/reject', [RouteArticlesController::class, 'reject'])->name('api.route-articles.reject'); + Route::post('/route-articles/{routeArticle}/restore', [RouteArticlesController::class, 'restore'])->name('api.route-articles.restore'); + // Settings Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index'); Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update'); diff --git a/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php new file mode 100644 index 0000000..715dc20 --- /dev/null +++ b/tests/Feature/Http/Controllers/Api/V1/RouteArticlesControllerTest.php @@ -0,0 +1,253 @@ +create(); + + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create(['feed_id' => $feed->id]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->create([ + 'article_id' => $article->id, + 'approval_status' => $status, + 'validated_at' => now(), + ]); + + return $routeArticle; + } + + public function test_index_returns_successful_response(): void + { + $response = $this->getJson('/api/v1/route-articles'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'success', + 'data' => [ + 'route_articles', + 'pagination' => [ + 'current_page', + 'last_page', + 'per_page', + 'total', + 'from', + 'to', + ], + ], + 'message', + ]); + } + + public function test_index_returns_route_articles_with_pagination(): void + { + $feed = Feed::factory()->create(); + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + + for ($i = 0; $i < 20; $i++) { + $article = Article::factory()->create(['feed_id' => $feed->id]); + RouteArticle::factory()->forRoute($route)->create([ + 'article_id' => $article->id, + 'validated_at' => now(), + ]); + } + + $response = $this->getJson('/api/v1/route-articles?per_page=10'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'pagination' => [ + 'per_page' => 10, + 'total' => 20, + 'last_page' => 2, + ], + ], + ]); + + $this->assertCount(10, $response->json('data.route_articles')); + } + + public function test_index_filters_by_status(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::APPROVED); + $this->createRouteArticle(ApprovalStatusEnum::REJECTED); + + $response = $this->getJson('/api/v1/route-articles?status=pending'); + + $response->assertStatus(200); + $this->assertCount(2, $response->json('data.route_articles')); + } + + public function test_index_returns_all_when_no_status_filter(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::APPROVED); + $this->createRouteArticle(ApprovalStatusEnum::REJECTED); + + $response = $this->getJson('/api/v1/route-articles'); + + $response->assertStatus(200); + $this->assertCount(3, $response->json('data.route_articles')); + } + + public function test_index_includes_article_and_route_data(): void + { + $routeArticle = $this->createRouteArticle(); + + $response = $this->getJson('/api/v1/route-articles'); + + $response->assertStatus(200); + + $data = $response->json('data.route_articles.0'); + $this->assertArrayHasKey('article', $data); + $this->assertArrayHasKey('title', $data['article']); + $this->assertArrayHasKey('url', $data['article']); + $this->assertArrayHasKey('route_name', $data); + $this->assertArrayHasKey('approval_status', $data); + } + + public function test_approve_route_article_successfully(): void + { + Event::fake([RouteArticleApproved::class]); + + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + + $response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/approve"); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Route article approved and queued for publishing.', + ]); + + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status); + + Event::assertDispatched(RouteArticleApproved::class, function ($event) use ($routeArticle) { + return $event->routeArticle->id === $routeArticle->id; + }); + } + + public function test_approve_nonexistent_route_article_returns_404(): void + { + $response = $this->postJson('/api/v1/route-articles/999/approve'); + + $response->assertStatus(404); + } + + public function test_reject_route_article_successfully(): void + { + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + + $response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/reject"); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Route article rejected.', + ]); + + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status); + } + + public function test_reject_nonexistent_route_article_returns_404(): void + { + $response = $this->postJson('/api/v1/route-articles/999/reject'); + + $response->assertStatus(404); + } + + public function test_restore_route_article_successfully(): void + { + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::REJECTED); + + $response = $this->postJson("/api/v1/route-articles/{$routeArticle->id}/restore"); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Route article restored to pending.', + ]); + + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->fresh()->approval_status); + } + + public function test_restore_nonexistent_route_article_returns_404(): void + { + $response = $this->postJson('/api/v1/route-articles/999/restore'); + + $response->assertStatus(404); + } + + public function test_clear_rejects_all_pending_route_articles(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::APPROVED); + + $response = $this->postJson('/api/v1/route-articles/clear'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'rejected_count' => 3, + ], + ]); + + $this->assertEquals(0, RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count()); + $this->assertEquals(1, RouteArticle::where('approval_status', ApprovalStatusEnum::APPROVED)->count()); + $this->assertEquals(3, RouteArticle::where('approval_status', ApprovalStatusEnum::REJECTED)->count()); + } + + public function test_clear_returns_zero_when_no_pending(): void + { + $this->createRouteArticle(ApprovalStatusEnum::APPROVED); + + $response = $this->postJson('/api/v1/route-articles/clear'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'rejected_count' => 0, + ], + ]); + } + + public function test_index_respects_per_page_limit(): void + { + $response = $this->getJson('/api/v1/route-articles?per_page=150'); + + $response->assertStatus(200) + ->assertJson([ + 'data' => [ + 'pagination' => [ + 'per_page' => 100, + ], + ], + ]); + } +} -- 2.45.2 From e7acbb6882f6bf740c08a36383b55cccb0e7a955 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 17:31:47 +0100 Subject: [PATCH 14/20] 99 - Move publish_status from Article to RouteArticle with PublishStatusEnum --- app/Enums/PublishStatusEnum.php | 11 +++++++ app/Http/Resources/ArticleResource.php | 1 - app/Http/Resources/RouteArticleResource.php | 1 + app/Jobs/PublishNextArticleJob.php | 9 +++++ .../PublishApprovedArticleListener.php | 9 +++++ app/Models/Article.php | 3 -- app/Models/RouteArticle.php | 4 +++ database/factories/ArticleFactory.php | 1 - ..._move_publish_status_to_route_articles.php | 33 +++++++++++++++++++ 9 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 app/Enums/PublishStatusEnum.php create mode 100644 database/migrations/2024_01_01_000010_move_publish_status_to_route_articles.php diff --git a/app/Enums/PublishStatusEnum.php b/app/Enums/PublishStatusEnum.php new file mode 100644 index 0000000..03260e3 --- /dev/null +++ b/app/Enums/PublishStatusEnum.php @@ -0,0 +1,11 @@ + $this->url, 'title' => $this->title, 'description' => $this->description, - 'publish_status' => $this->publish_status, 'validated_at' => $this->validated_at?->toISOString(), 'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null, 'created_at' => $this->created_at->toISOString(), diff --git a/app/Http/Resources/RouteArticleResource.php b/app/Http/Resources/RouteArticleResource.php index 2bb350e..73d9520 100644 --- a/app/Http/Resources/RouteArticleResource.php +++ b/app/Http/Resources/RouteArticleResource.php @@ -21,6 +21,7 @@ public function toArray(Request $request): array 'platform_channel_id' => $this->platform_channel_id, 'article_id' => $this->article_id, 'approval_status' => $this->approval_status->value, + 'publish_status' => $this->publish_status->value, 'validated_at' => $this->validated_at?->toISOString(), 'created_at' => $this->created_at->toISOString(), 'updated_at' => $this->updated_at->toISOString(), diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php index c0fd39d..fd9a811 100644 --- a/app/Jobs/PublishNextArticleJob.php +++ b/app/Jobs/PublishNextArticleJob.php @@ -6,6 +6,7 @@ use App\Enums\LogLevelEnum; use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationTypeEnum; +use App\Enums\PublishStatusEnum; use App\Events\ActionPerformed; use App\Exceptions\PublishException; use App\Models\ArticlePublication; @@ -71,16 +72,22 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService 'route' => $routeArticle->feed_id.'-'.$routeArticle->platform_channel_id, ]); + $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]); + try { $extractedData = $articleFetcher->fetchArticleData($article); $publication = $publishingService->publishRouteArticle($routeArticle, $extractedData); if ($publication) { + $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]); + ActionPerformed::dispatch('Successfully published article', LogLevelEnum::INFO, [ 'article_id' => $article->id, 'title' => $article->title, ]); } else { + $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); + ActionPerformed::dispatch('No publication created for article', LogLevelEnum::WARNING, [ 'article_id' => $article->id, 'title' => $article->title, @@ -95,6 +102,8 @@ public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService ); } } catch (PublishException $e) { + $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); + ActionPerformed::dispatch('Failed to publish article', LogLevelEnum::ERROR, [ 'article_id' => $article->id, 'error' => $e->getMessage(), diff --git a/app/Listeners/PublishApprovedArticleListener.php b/app/Listeners/PublishApprovedArticleListener.php index ccd7764..b08e3b7 100644 --- a/app/Listeners/PublishApprovedArticleListener.php +++ b/app/Listeners/PublishApprovedArticleListener.php @@ -5,6 +5,7 @@ use App\Enums\LogLevelEnum; use App\Enums\NotificationSeverityEnum; use App\Enums\NotificationTypeEnum; +use App\Enums\PublishStatusEnum; use App\Events\ActionPerformed; use App\Events\RouteArticleApproved; use App\Services\Article\ArticleFetcher; @@ -36,16 +37,22 @@ public function handle(RouteArticleApproved $event): void return; } + $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHING]); + try { $extractedData = $this->articleFetcher->fetchArticleData($article); $publication = $this->publishingService->publishRouteArticle($routeArticle, $extractedData); if ($publication) { + $routeArticle->update(['publish_status' => PublishStatusEnum::PUBLISHED]); + ActionPerformed::dispatch('Published approved article', LogLevelEnum::INFO, [ 'article_id' => $article->id, 'title' => $article->title, ]); } else { + $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); + ActionPerformed::dispatch('No publication created for approved article', LogLevelEnum::WARNING, [ 'article_id' => $article->id, 'title' => $article->title, @@ -60,6 +67,8 @@ public function handle(RouteArticleApproved $event): void ); } } catch (Exception $e) { + $routeArticle->update(['publish_status' => PublishStatusEnum::ERROR]); + ActionPerformed::dispatch('Failed to publish approved article', LogLevelEnum::ERROR, [ 'article_id' => $article->id, 'error' => $e->getMessage(), diff --git a/app/Models/Article.php b/app/Models/Article.php index 6156321..dfd03ea 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -22,7 +22,6 @@ * @property string $url * @property string $title * @property string|null $description - * @property string $publish_status * @property Carbon|null $validated_at * @property Carbon $created_at * @property Carbon $updated_at @@ -43,7 +42,6 @@ class Article extends Model 'published_at', 'author', 'validated_at', - 'publish_status', ]; /** @@ -52,7 +50,6 @@ class Article extends Model public function casts(): array { return [ - 'publish_status' => 'string', 'published_at' => 'datetime', 'validated_at' => 'datetime', 'created_at' => 'datetime', diff --git a/app/Models/RouteArticle.php b/app/Models/RouteArticle.php index 8b7c4ac..7e71b52 100644 --- a/app/Models/RouteArticle.php +++ b/app/Models/RouteArticle.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\ApprovalStatusEnum; +use App\Enums\PublishStatusEnum; use App\Events\RouteArticleApproved; use Database\Factories\RouteArticleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -16,6 +17,7 @@ * @property int $platform_channel_id * @property int $article_id * @property ApprovalStatusEnum $approval_status + * @property PublishStatusEnum $publish_status * @property Carbon|null $validated_at * @property Carbon $created_at * @property Carbon $updated_at @@ -30,11 +32,13 @@ class RouteArticle extends Model 'platform_channel_id', 'article_id', 'approval_status', + 'publish_status', 'validated_at', ]; protected $casts = [ 'approval_status' => ApprovalStatusEnum::class, + 'publish_status' => PublishStatusEnum::class, 'validated_at' => 'datetime', ]; diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index f236a63..d5a57bb 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -25,7 +25,6 @@ public function definition(): array 'image_url' => $this->faker->optional()->imageUrl(), 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), 'author' => $this->faker->optional()->name(), - 'publish_status' => 'unpublished', ]; } } diff --git a/database/migrations/2024_01_01_000010_move_publish_status_to_route_articles.php b/database/migrations/2024_01_01_000010_move_publish_status_to_route_articles.php new file mode 100644 index 0000000..97c0828 --- /dev/null +++ b/database/migrations/2024_01_01_000010_move_publish_status_to_route_articles.php @@ -0,0 +1,33 @@ +enum('publish_status', ['unpublished', 'publishing', 'published', 'error']) + ->default('unpublished') + ->after('approval_status'); + }); + + Schema::table('articles', function (Blueprint $table) { + $table->dropColumn('publish_status'); + }); + } + + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->enum('publish_status', ['unpublished', 'publishing', 'published', 'error']) + ->default('unpublished'); + }); + + Schema::table('route_articles', function (Blueprint $table) { + $table->dropColumn('publish_status'); + }); + } +}; -- 2.45.2 From 943015805156d8392b247139d8bee407ebce4e71 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 17:39:38 +0100 Subject: [PATCH 15/20] 96 - Rework Articles page into route_article triage UI with tabs and actions --- app/Livewire/Articles.php | 63 ++++++- resources/views/livewire/articles.blade.php | 166 +++++++++++++++--- tests/Feature/Livewire/ArticlesTest.php | 184 ++++++++++++++++++++ 3 files changed, 380 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/Livewire/ArticlesTest.php diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index a70dbf2..8c5d11f 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -2,8 +2,9 @@ namespace App\Livewire; +use App\Enums\ApprovalStatusEnum; use App\Jobs\ArticleDiscoveryJob; -use App\Models\Article; +use App\Models\RouteArticle; use Livewire\Component; use Livewire\WithPagination; @@ -11,8 +12,46 @@ class Articles extends Component { use WithPagination; + public string $tab = 'pending'; + + public string $search = ''; + public bool $isRefreshing = false; + public function setTab(string $tab): void + { + $this->tab = $tab; + $this->search = ''; + $this->resetPage(); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function approve(int $routeArticleId): void + { + RouteArticle::findOrFail($routeArticleId)->approve(); + } + + public function reject(int $routeArticleId): void + { + RouteArticle::findOrFail($routeArticleId)->reject(); + } + + public function restore(int $routeArticleId): void + { + $routeArticle = RouteArticle::findOrFail($routeArticleId); + $routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]); + } + + public function clear(): void + { + RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING) + ->update(['approval_status' => ApprovalStatusEnum::REJECTED]); + } + public function refresh(): void { $this->isRefreshing = true; @@ -24,12 +63,26 @@ public function refresh(): void public function render(): \Illuminate\Contracts\View\View { - $articles = Article::with('feed') - ->orderBy('created_at', 'desc') - ->paginate(15); + $query = RouteArticle::with(['article.feed', 'feed', 'platformChannel']) + ->orderBy('created_at', 'desc'); + + if ($this->tab === 'pending') { + $query->where('approval_status', ApprovalStatusEnum::PENDING); + } elseif ($this->search !== '') { + $search = $this->search; + $query->whereHas('article', function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + $routeArticles = $query->paginate(15); + + $pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); return view('livewire.articles', [ - 'articles' => $articles, + 'routeArticles' => $routeArticles, + 'pendingCount' => $pendingCount, ])->layout('layouts.app'); } } diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php index 29a218f..1ad79b9 100644 --- a/resources/views/livewire/articles.blade.php +++ b/resources/views/livewire/articles.blade.php @@ -1,9 +1,9 @@
-
+

Articles

- Articles fetched from your feeds + Review and manage article routing

+ + +
+ + {{-- Tab actions --}} +
+ @if ($tab === 'pending' && $pendingCount > 0) + + @elseif ($tab === 'all') +
+ +
+ @else +
+ @endif +
+ + {{-- Route articles list --}} +
+ @forelse ($routeArticles as $routeArticle) +
-

- {{ $article->title ?? 'Untitled Article' }} +

+ {{ $routeArticle->article->title ?? 'Untitled Article' }}

-

- {{ $article->description ?? 'No description available' }} +

+ {{ $routeArticle->article->description ?? 'No description available' }}

-
- Feed: {{ $article->feed?->name ?? 'Unknown' }} - - {{ $article->created_at->format('M d, Y H:i') }} - @if ($article->validated_at) - - Validated - @else - - Not validated - @endif +
+ + + + + {{ $routeArticle->feed?->name ?? 'Unknown' }} → {{ $routeArticle->platformChannel?->name ?? 'Unknown' }} + + {{ $routeArticle->created_at->format('M d, Y H:i') }}
-
diff --git a/tests/Feature/Livewire/ArticlesTest.php b/tests/Feature/Livewire/ArticlesTest.php new file mode 100644 index 0000000..0b7752a --- /dev/null +++ b/tests/Feature/Livewire/ArticlesTest.php @@ -0,0 +1,184 @@ +create(); + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'title' => $title]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->create([ + 'article_id' => $article->id, + 'approval_status' => $status, + 'validated_at' => now(), + ]); + + return $routeArticle; + } + + public function test_renders_successfully(): void + { + Livewire::test(Articles::class) + ->assertStatus(200); + } + + public function test_defaults_to_pending_tab(): void + { + Livewire::test(Articles::class) + ->assertSet('tab', 'pending'); + } + + public function test_pending_tab_shows_only_pending_route_articles(): void + { + $pending = $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Pending Article'); + $approved = $this->createRouteArticle(ApprovalStatusEnum::APPROVED, 'Approved Article'); + $rejected = $this->createRouteArticle(ApprovalStatusEnum::REJECTED, 'Rejected Article'); + + Livewire::test(Articles::class) + ->assertSee('Pending Article') + ->assertDontSee('Approved Article') + ->assertDontSee('Rejected Article'); + } + + public function test_all_tab_shows_all_route_articles(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Pending Article'); + $this->createRouteArticle(ApprovalStatusEnum::APPROVED, 'Approved Article'); + $this->createRouteArticle(ApprovalStatusEnum::REJECTED, 'Rejected Article'); + + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->assertSee('Pending Article') + ->assertSee('Approved Article') + ->assertSee('Rejected Article'); + } + + public function test_all_tab_search_filters_by_title(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Belgian Politics Update'); + $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Weather Forecast Today'); + + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->set('search', 'Belgian') + ->assertSee('Belgian Politics Update') + ->assertDontSee('Weather Forecast Today'); + } + + public function test_approve_changes_status_and_dispatches_event(): void + { + Event::fake([RouteArticleApproved::class]); + + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + + Livewire::test(Articles::class) + ->call('approve', $routeArticle->id); + + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status); + + Event::assertDispatched(RouteArticleApproved::class); + } + + public function test_reject_changes_status(): void + { + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + + Livewire::test(Articles::class) + ->call('reject', $routeArticle->id); + + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status); + } + + public function test_restore_changes_status_back_to_pending(): void + { + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::REJECTED); + + Livewire::test(Articles::class) + ->call('restore', $routeArticle->id); + + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->fresh()->approval_status); + } + + public function test_clear_rejects_all_pending_route_articles(): void + { + $pending1 = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $pending2 = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $approved = $this->createRouteArticle(ApprovalStatusEnum::APPROVED); + + Livewire::test(Articles::class) + ->call('clear'); + + $this->assertEquals(ApprovalStatusEnum::REJECTED, $pending1->fresh()->approval_status); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $pending2->fresh()->approval_status); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $approved->fresh()->approval_status); + } + + public function test_pending_count_badge_shows_correct_count(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::APPROVED); + + Livewire::test(Articles::class) + ->assertSeeInOrder(['Pending', '2']); + } + + public function test_switching_tabs_resets_search(): void + { + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->set('search', 'something') + ->call('setTab', 'pending') + ->assertSet('search', '') + ->assertSet('tab', 'pending'); + } + + public function test_shows_route_name_in_listing(): void + { + $feed = Feed::factory()->create(['name' => 'VRT News']); + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'title' => 'Test']); + + RouteArticle::factory()->forRoute($route)->create([ + 'article_id' => $article->id, + 'approval_status' => ApprovalStatusEnum::PENDING, + 'validated_at' => now(), + ]); + + Livewire::test(Articles::class) + ->assertSee('VRT News'); + } + + public function test_empty_state_on_pending_tab(): void + { + Livewire::test(Articles::class) + ->assertSee('No pending articles'); + } + + public function test_empty_state_on_all_tab(): void + { + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->assertSee('No route articles have been created yet.'); + } +} -- 2.45.2 From bab2557e85377af37b51e1862fe6d5050375cadc Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 17:51:35 +0100 Subject: [PATCH 16/20] 97 - Add auto_approve toggle to Route edit modal --- app/Livewire/Routes.php | 14 +- resources/views/livewire/routes.blade.php | 27 ++++ .../Livewire/RoutesAutoApproveTest.php | 130 ++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Livewire/RoutesAutoApproveTest.php diff --git a/app/Livewire/Routes.php b/app/Livewire/Routes.php index 485a06b..1578e43 100644 --- a/app/Livewire/Routes.php +++ b/app/Livewire/Routes.php @@ -26,6 +26,8 @@ class Routes extends Component // Edit form public int $editPriority = 50; + public string $editAutoApprove = ''; + // Keyword management public string $newKeyword = ''; @@ -81,6 +83,7 @@ public function openEditModal(int $feedId, int $channelId): void $this->editingFeedId = $feedId; $this->editingChannelId = $channelId; $this->editPriority = $route->priority; + $this->editAutoApprove = $route->auto_approve === null ? '' : ($route->auto_approve ? '1' : '0'); $this->newKeyword = ''; $this->showKeywordInput = false; } @@ -101,9 +104,18 @@ public function updateRoute(): void 'editPriority' => 'required|integer|min:0', ]); + $autoApprove = match ($this->editAutoApprove) { + '1' => true, + '0' => false, + default => null, + }; + Route::where('feed_id', $this->editingFeedId) ->where('platform_channel_id', $this->editingChannelId) - ->update(['priority' => $this->editPriority]); + ->update([ + 'priority' => $this->editPriority, + 'auto_approve' => $autoApprove, + ]); $this->closeEditModal(); } diff --git a/resources/views/livewire/routes.blade.php b/resources/views/livewire/routes.blade.php index 63ef1ec..5ab5cc2 100644 --- a/resources/views/livewire/routes.blade.php +++ b/resources/views/livewire/routes.blade.php @@ -50,6 +50,13 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font {{ $route->platformChannel?->platformInstance?->platform?->channelLabel() ?? 'Channel' }}: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }} Created: {{ $route->created_at->format('M d, Y') }} + @if ($route->auto_approve === true) + + Auto-approve: On + @elseif ($route->auto_approve === false) + + Auto-approve: Off + @endif
@if ($route->platformChannel?->description)

@@ -265,6 +272,26 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none foc

Higher priority routes are processed first

+ +
+ +
+ + + +
+

Override global approval setting for this route

+
+
diff --git a/tests/Feature/Livewire/RoutesAutoApproveTest.php b/tests/Feature/Livewire/RoutesAutoApproveTest.php new file mode 100644 index 0000000..b23ced8 --- /dev/null +++ b/tests/Feature/Livewire/RoutesAutoApproveTest.php @@ -0,0 +1,130 @@ +create(); + $channel = PlatformChannel::factory()->create(); + + return Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'is_active' => true, + 'priority' => 50, + 'auto_approve' => $autoApprove, + ]); + } + + public function test_edit_modal_loads_auto_approve_null(): void + { + $route = $this->createRoute(null); + + Livewire::test(Routes::class) + ->call('openEditModal', $route->feed_id, $route->platform_channel_id) + ->assertSet('editAutoApprove', ''); + } + + public function test_edit_modal_loads_auto_approve_true(): void + { + $route = $this->createRoute(true); + + Livewire::test(Routes::class) + ->call('openEditModal', $route->feed_id, $route->platform_channel_id) + ->assertSet('editAutoApprove', '1'); + } + + public function test_edit_modal_loads_auto_approve_false(): void + { + $route = $this->createRoute(false); + + Livewire::test(Routes::class) + ->call('openEditModal', $route->feed_id, $route->platform_channel_id) + ->assertSet('editAutoApprove', '0'); + } + + public function test_update_route_sets_auto_approve_to_true(): void + { + $route = $this->createRoute(null); + + Livewire::test(Routes::class) + ->call('openEditModal', $route->feed_id, $route->platform_channel_id) + ->set('editAutoApprove', '1') + ->call('updateRoute'); + + $updated = Route::where('feed_id', $route->feed_id) + ->where('platform_channel_id', $route->platform_channel_id) + ->first(); + + $this->assertTrue($updated->auto_approve); + } + + public function test_update_route_sets_auto_approve_to_false(): void + { + $route = $this->createRoute(null); + + Livewire::test(Routes::class) + ->call('openEditModal', $route->feed_id, $route->platform_channel_id) + ->set('editAutoApprove', '0') + ->call('updateRoute'); + + $updated = Route::where('feed_id', $route->feed_id) + ->where('platform_channel_id', $route->platform_channel_id) + ->first(); + + $this->assertFalse($updated->auto_approve); + } + + public function test_update_route_sets_auto_approve_to_null(): void + { + $route = $this->createRoute(true); + + Livewire::test(Routes::class) + ->call('openEditModal', $route->feed_id, $route->platform_channel_id) + ->set('editAutoApprove', '') + ->call('updateRoute'); + + $updated = Route::where('feed_id', $route->feed_id) + ->where('platform_channel_id', $route->platform_channel_id) + ->first(); + + $this->assertNull($updated->auto_approve); + } + + public function test_route_card_shows_auto_approve_on_badge(): void + { + $this->createRoute(true); + + Livewire::test(Routes::class) + ->assertSee('Auto-approve: On'); + } + + public function test_route_card_shows_auto_approve_off_badge(): void + { + $this->createRoute(false); + + Livewire::test(Routes::class) + ->assertSee('Auto-approve: Off'); + } + + public function test_route_card_hides_badge_when_using_global_setting(): void + { + $this->createRoute(null); + + Livewire::test(Routes::class) + ->assertDontSee('Auto-approve: On') + ->assertDontSee('Auto-approve: Off'); + } +} -- 2.45.2 From cc94ba8e55b6282650072afdcd0075ae7df06c53 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 18:09:54 +0100 Subject: [PATCH 17/20] 89 - Add article cleanup job with 30-day retention policy --- app/Jobs/CleanupArticlesJob.php | 25 +++++ routes/console.php | 7 ++ tests/Unit/Jobs/CleanupArticlesJobTest.php | 111 +++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 app/Jobs/CleanupArticlesJob.php create mode 100644 tests/Unit/Jobs/CleanupArticlesJobTest.php diff --git a/app/Jobs/CleanupArticlesJob.php b/app/Jobs/CleanupArticlesJob.php new file mode 100644 index 0000000..0799064 --- /dev/null +++ b/app/Jobs/CleanupArticlesJob.php @@ -0,0 +1,25 @@ +subDays(self::RETENTION_DAYS)) + ->whereDoesntHave('routeArticles', fn ($q) => $q->whereIn('approval_status', [ + ApprovalStatusEnum::PENDING, + ApprovalStatusEnum::APPROVED, + ])) + ->delete(); + } +} diff --git a/routes/console.php b/routes/console.php index 98aac72..31b4712 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,6 +2,7 @@ use App\Jobs\ArticleDiscoveryJob; use App\Jobs\CheckFeedStalenessJob; +use App\Jobs\CleanupArticlesJob; use App\Jobs\PublishNextArticleJob; use App\Jobs\SyncChannelPostsJob; use Illuminate\Support\Facades\Schedule; @@ -27,3 +28,9 @@ ->name('check-feed-staleness') ->withoutOverlapping() ->onOneServer(); + +Schedule::job(new CleanupArticlesJob) + ->daily() + ->name('cleanup-old-articles') + ->withoutOverlapping() + ->onOneServer(); diff --git a/tests/Unit/Jobs/CleanupArticlesJobTest.php b/tests/Unit/Jobs/CleanupArticlesJobTest.php new file mode 100644 index 0000000..8e58f0d --- /dev/null +++ b/tests/Unit/Jobs/CleanupArticlesJobTest.php @@ -0,0 +1,111 @@ +for(Feed::factory())->create([ + 'created_at' => now()->subDays(31), + ]); + $recent = Article::factory()->for(Feed::factory())->create([ + 'created_at' => now()->subDays(10), + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseMissing('articles', ['id' => $old->id]); + $this->assertDatabaseHas('articles', ['id' => $recent->id]); + } + + public function test_preserves_old_articles_with_pending_route_articles(): void + { + $route = Route::factory()->create(); + $article = Article::factory()->for($route->feed)->create([ + 'created_at' => now()->subDays(31), + ]); + RouteArticle::factory()->for($article)->create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'approval_status' => ApprovalStatusEnum::PENDING, + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseHas('articles', ['id' => $article->id]); + } + + public function test_preserves_old_articles_with_approved_route_articles(): void + { + $route = Route::factory()->create(); + $article = Article::factory()->for($route->feed)->create([ + 'created_at' => now()->subDays(31), + ]); + RouteArticle::factory()->for($article)->create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'approval_status' => ApprovalStatusEnum::APPROVED, + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseHas('articles', ['id' => $article->id]); + } + + public function test_deletes_old_articles_with_only_rejected_route_articles(): void + { + $route = Route::factory()->create(); + $article = Article::factory()->for($route->feed)->create([ + 'created_at' => now()->subDays(31), + ]); + RouteArticle::factory()->for($article)->create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'approval_status' => ApprovalStatusEnum::REJECTED, + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseMissing('articles', ['id' => $article->id]); + } + + public function test_cascade_deletes_route_articles(): void + { + $route = Route::factory()->create(); + $article = Article::factory()->for($route->feed)->create([ + 'created_at' => now()->subDays(31), + ]); + $routeArticle = RouteArticle::factory()->for($article)->create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'approval_status' => ApprovalStatusEnum::REJECTED, + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseMissing('route_articles', ['id' => $routeArticle->id]); + } + + public function test_preserves_article_at_exact_retention_boundary(): void + { + $boundary = Article::factory()->for(Feed::factory())->create([ + 'created_at' => now()->subDays(30), + ]); + + (new CleanupArticlesJob)->handle(); + + $this->assertDatabaseHas('articles', ['id' => $boundary->id]); + } +} -- 2.45.2 From 273fca76e39691e387a0807049ee36c8f77274ab Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 18:42:41 +0100 Subject: [PATCH 18/20] Fix CI lint by using ZTS PHP to match dev container --- .forgejo/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 8b4c936..69284f8 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -18,6 +18,7 @@ jobs: uses: https://github.com/shivammathur/setup-php@v2 with: php-version: '8.3' + phpts: zts extensions: pdo_sqlite, mbstring, xml, dom coverage: pcov -- 2.45.2 From dfb87f4152088e360fa00b2ca6a1b2a5ac337d11 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 19:48:48 +0100 Subject: [PATCH 19/20] Debug CI Pint environment --- .forgejo/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 69284f8..3d6d45e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -35,8 +35,13 @@ jobs: - name: Prepare environment run: cp .env.testing .env - - name: Lint - run: vendor/bin/pint --test + - name: Debug Pint + run: | + php -v + vendor/bin/pint --version + vendor/bin/pint + git diff --stat + git diff | head -200 - name: Static analysis run: vendor/bin/phpstan analyse -- 2.45.2 From d2919758f5b7e013fa4130bf4d14784bcd3807e1 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 20:01:25 +0100 Subject: [PATCH 20/20] Fix Pint 1.29.0 lint issues and update CI workflow --- .forgejo/workflows/ci.yml | 10 ++---- .../Auth/NewPasswordController.php | 3 +- .../Auth/PasswordResetLinkController.php | 3 +- .../Auth/RegisteredUserController.php | 3 +- app/Http/Middleware/HandleAppearance.php | 2 +- app/Http/Requests/Auth/LoginRequest.php | 7 ++-- app/Http/Requests/ProfileUpdateRequest.php | 3 +- .../Resources/ArticlePublicationResource.php | 3 +- app/Http/Resources/ArticleResource.php | 3 +- app/Http/Resources/FeedResource.php | 3 +- .../Resources/PlatformAccountResource.php | 5 +-- .../Resources/PlatformChannelResource.php | 3 +- .../Resources/PlatformInstanceResource.php | 3 +- app/Http/Resources/RouteArticleResource.php | 3 +- app/Http/Resources/RouteResource.php | 3 +- app/Livewire/Articles.php | 3 +- app/Livewire/Channels.php | 3 +- app/Livewire/Dashboard.php | 3 +- app/Livewire/Feeds.php | 3 +- app/Livewire/Onboarding.php | 6 ++-- app/Livewire/Routes.php | 3 +- app/Livewire/Settings.php | 3 +- app/Models/ArticlePublication.php | 7 ++-- app/Models/PlatformChannelPost.php | 3 +- app/Models/User.php | 3 +- app/Providers/AppServiceProvider.php | 12 ++++--- app/Services/Article/ArticleFetcher.php | 2 +- app/Services/Http/HttpFetcher.php | 3 +- bootstrap/app.php | 12 ++++--- bootstrap/providers.php | 7 ++-- config/auth.php | 4 ++- config/feed.php | 22 +++++++++---- config/sanctum.php | 9 ++++-- database/factories/ArticleFactory.php | 6 ++-- database/factories/KeywordFactory.php | 10 +++--- database/factories/UserFactory.php | 3 +- phpstan-baseline.neon | 24 ++++++++++++++ tests/Feature/DatabaseIntegrationTest.php | 6 ++-- .../Jobs/CheckFeedStalenessJobTest.php | 3 +- tests/Feature/JobsAndEventsTest.php | 32 +++++++++++-------- tests/Feature/ValidateArticleListenerTest.php | 5 +-- tests/Traits/CreatesArticleFetcher.php | 3 +- .../CreatePlatformAccountActionTest.php | 3 +- tests/Unit/Events/ActionPerformedTest.php | 3 +- .../RoutingMismatchExceptionTest.php | 3 +- .../Jobs/ArticleDiscoveryForFeedJobTest.php | 6 ++-- tests/Unit/Jobs/ArticleDiscoveryJobTest.php | 3 +- tests/Unit/Jobs/PublishNextArticleJobTest.php | 6 ++-- tests/Unit/Jobs/SyncChannelPostsJobTest.php | 9 ++++-- tests/Unit/Models/ArticlePublicationTest.php | 13 ++++---- tests/Unit/Models/ArticleTest.php | 3 +- tests/Unit/Models/FeedTest.php | 7 ++-- tests/Unit/Models/KeywordTest.php | 8 +++-- tests/Unit/Models/LanguageTest.php | 5 +-- tests/Unit/Models/PlatformAccountTest.php | 7 ++-- tests/Unit/Models/PlatformChannelTest.php | 5 +-- tests/Unit/Models/PlatformInstanceTest.php | 5 +-- tests/Unit/Models/RouteArticleTest.php | 3 +- tests/Unit/Services/ArticleFetcherRssTest.php | 5 +-- tests/Unit/Services/ArticleFetcherTest.php | 13 ++++---- tests/Unit/Services/Http/HttpFetcherTest.php | 4 ++- tests/Unit/Services/ValidationServiceTest.php | 3 +- 62 files changed, 239 insertions(+), 132 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 3d6d45e..8b4c936 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: uses: https://github.com/shivammathur/setup-php@v2 with: php-version: '8.3' - phpts: zts extensions: pdo_sqlite, mbstring, xml, dom coverage: pcov @@ -35,13 +34,8 @@ jobs: - name: Prepare environment run: cp .env.testing .env - - name: Debug Pint - run: | - php -v - vendor/bin/pint --version - vendor/bin/pint - git diff --stat - git diff | head -200 + - name: Lint + run: vendor/bin/pint --test - name: Static analysis run: vendor/bin/phpstan analyse diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php index e8368bd..fc16e14 100644 --- a/app/Http/Controllers/Auth/NewPasswordController.php +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; use Illuminate\Validation\Rules; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; class NewPasswordController extends Controller @@ -26,7 +27,7 @@ public function create(Request $request): View /** * Handle an incoming new password request. * - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public function store(Request $request): RedirectResponse { diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php index bf1ebfa..1bc9c11 100644 --- a/app/Http/Controllers/Auth/PasswordResetLinkController.php +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -6,6 +6,7 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Password; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; class PasswordResetLinkController extends Controller @@ -21,7 +22,7 @@ public function create(): View /** * Handle an incoming password reset link request. * - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public function store(Request $request): RedirectResponse { diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 0739e2e..44a3930 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rules; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; class RegisteredUserController extends Controller @@ -25,7 +26,7 @@ public function create(): View /** * Handle an incoming registration request. * - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public function store(Request $request): RedirectResponse { diff --git a/app/Http/Middleware/HandleAppearance.php b/app/Http/Middleware/HandleAppearance.php index f1a02bb..1a01684 100644 --- a/app/Http/Middleware/HandleAppearance.php +++ b/app/Http/Middleware/HandleAppearance.php @@ -12,7 +12,7 @@ class HandleAppearance /** * Handle an incoming request. * - * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + * @param Closure(Request): (Response) $next */ public function handle(Request $request, Closure $next): Response { diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 2574642..711e0a1 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Auth; use Illuminate\Auth\Events\Lockout; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; @@ -22,7 +23,7 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -35,7 +36,7 @@ public function rules(): array /** * Attempt to authenticate the request's credentials. * - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public function authenticate(): void { @@ -55,7 +56,7 @@ public function authenticate(): void /** * Ensure the login request is not rate limited. * - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public function ensureIsNotRateLimited(): void { diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/ProfileUpdateRequest.php index 3622a8f..e2202dd 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/ProfileUpdateRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Models\User; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,7 +12,7 @@ class ProfileUpdateRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { diff --git a/app/Http/Resources/ArticlePublicationResource.php b/app/Http/Resources/ArticlePublicationResource.php index 9640f57..2e28574 100644 --- a/app/Http/Resources/ArticlePublicationResource.php +++ b/app/Http/Resources/ArticlePublicationResource.php @@ -2,11 +2,12 @@ namespace App\Http\Resources; +use App\Models\ArticlePublication; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Models\ArticlePublication + * @mixin ArticlePublication */ class ArticlePublicationResource extends JsonResource { diff --git a/app/Http/Resources/ArticleResource.php b/app/Http/Resources/ArticleResource.php index 79da658..79760b5 100644 --- a/app/Http/Resources/ArticleResource.php +++ b/app/Http/Resources/ArticleResource.php @@ -2,11 +2,12 @@ namespace App\Http\Resources; +use App\Models\Article; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Models\Article + * @mixin Article */ class ArticleResource extends JsonResource { diff --git a/app/Http/Resources/FeedResource.php b/app/Http/Resources/FeedResource.php index 1c50a90..b2cc7d2 100644 --- a/app/Http/Resources/FeedResource.php +++ b/app/Http/Resources/FeedResource.php @@ -2,11 +2,12 @@ namespace App\Http\Resources; +use App\Models\Feed; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Models\Feed + * @mixin Feed */ class FeedResource extends JsonResource { diff --git a/app/Http/Resources/PlatformAccountResource.php b/app/Http/Resources/PlatformAccountResource.php index 6aa9c04..46d7cc6 100644 --- a/app/Http/Resources/PlatformAccountResource.php +++ b/app/Http/Resources/PlatformAccountResource.php @@ -2,14 +2,15 @@ namespace App\Http\Resources; +use App\Models\PlatformAccount; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Models\PlatformAccount + * @mixin PlatformAccount */ /** - * @mixin \App\Models\PlatformAccount + * @mixin PlatformAccount */ class PlatformAccountResource extends JsonResource { diff --git a/app/Http/Resources/PlatformChannelResource.php b/app/Http/Resources/PlatformChannelResource.php index c0d3338..f3b7f27 100644 --- a/app/Http/Resources/PlatformChannelResource.php +++ b/app/Http/Resources/PlatformChannelResource.php @@ -2,11 +2,12 @@ namespace App\Http\Resources; +use App\Models\PlatformChannel; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Models\PlatformChannel + * @mixin PlatformChannel */ class PlatformChannelResource extends JsonResource { diff --git a/app/Http/Resources/PlatformInstanceResource.php b/app/Http/Resources/PlatformInstanceResource.php index c95f1f4..45ffa16 100644 --- a/app/Http/Resources/PlatformInstanceResource.php +++ b/app/Http/Resources/PlatformInstanceResource.php @@ -2,11 +2,12 @@ namespace App\Http\Resources; +use App\Models\PlatformInstance; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Models\PlatformInstance + * @mixin PlatformInstance */ class PlatformInstanceResource extends JsonResource { diff --git a/app/Http/Resources/RouteArticleResource.php b/app/Http/Resources/RouteArticleResource.php index 73d9520..c56869b 100644 --- a/app/Http/Resources/RouteArticleResource.php +++ b/app/Http/Resources/RouteArticleResource.php @@ -2,11 +2,12 @@ namespace App\Http\Resources; +use App\Models\RouteArticle; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Models\RouteArticle + * @mixin RouteArticle */ class RouteArticleResource extends JsonResource { diff --git a/app/Http/Resources/RouteResource.php b/app/Http/Resources/RouteResource.php index f294f1a..e8d89cb 100644 --- a/app/Http/Resources/RouteResource.php +++ b/app/Http/Resources/RouteResource.php @@ -2,11 +2,12 @@ namespace App\Http\Resources; +use App\Models\Route; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; /** - * @mixin \App\Models\Route + * @mixin Route */ class RouteResource extends JsonResource { diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index 8c5d11f..2d26538 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -5,6 +5,7 @@ use App\Enums\ApprovalStatusEnum; use App\Jobs\ArticleDiscoveryJob; use App\Models\RouteArticle; +use Illuminate\Contracts\View\View; use Livewire\Component; use Livewire\WithPagination; @@ -61,7 +62,7 @@ public function refresh(): void $this->dispatch('refresh-started'); } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { $query = RouteArticle::with(['article.feed', 'feed', 'platformChannel']) ->orderBy('created_at', 'desc'); diff --git a/app/Livewire/Channels.php b/app/Livewire/Channels.php index a4aff29..49ea79c 100644 --- a/app/Livewire/Channels.php +++ b/app/Livewire/Channels.php @@ -4,6 +4,7 @@ use App\Models\PlatformAccount; use App\Models\PlatformChannel; +use Illuminate\Contracts\View\View; use Livewire\Component; class Channels extends Component @@ -51,7 +52,7 @@ public function detachAccount(int $channelId, int $accountId): void $channel->platformAccounts()->detach($accountId); } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { $channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get(); $allAccounts = PlatformAccount::where('is_active', true)->get(); diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 0ffb4e9..c65b6f8 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -3,6 +3,7 @@ namespace App\Livewire; use App\Services\DashboardStatsService; +use Illuminate\Contracts\View\View; use Livewire\Component; class Dashboard extends Component @@ -19,7 +20,7 @@ public function setPeriod(string $period): void $this->period = $period; } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { $service = app(DashboardStatsService::class); diff --git a/app/Livewire/Feeds.php b/app/Livewire/Feeds.php index 89f8b42..b1f86be 100644 --- a/app/Livewire/Feeds.php +++ b/app/Livewire/Feeds.php @@ -3,6 +3,7 @@ namespace App\Livewire; use App\Models\Feed; +use Illuminate\Contracts\View\View; use Livewire\Component; class Feeds extends Component @@ -14,7 +15,7 @@ public function toggle(int $feedId): void $feed->save(); } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { $feeds = Feed::orderBy('name')->get(); diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php index 9c17fcf..919514e 100644 --- a/app/Livewire/Onboarding.php +++ b/app/Livewire/Onboarding.php @@ -18,7 +18,9 @@ use App\Models\Setting; use App\Services\OnboardingService; use Exception; +use Illuminate\Contracts\View\View; use InvalidArgumentException; +use Livewire\Attributes\Locked; use Livewire\Component; use RuntimeException; @@ -68,7 +70,7 @@ class Onboarding extends Component public bool $isLoading = false; - #[\Livewire\Attributes\Locked] + #[Locked] public ?int $previousChannelLanguageId = null; protected CreatePlatformAccountAction $createPlatformAccountAction; @@ -406,7 +408,7 @@ public function getChannelLanguage(): ?Language return Language::find($this->channelLanguageId); } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { // For channel step: only show languages that have providers $availableCodes = $this->getAvailableLanguageCodes(); diff --git a/app/Livewire/Routes.php b/app/Livewire/Routes.php index 1578e43..64ee812 100644 --- a/app/Livewire/Routes.php +++ b/app/Livewire/Routes.php @@ -6,6 +6,7 @@ use App\Models\Keyword; use App\Models\PlatformChannel; use App\Models\Route; +use Illuminate\Contracts\View\View; use Livewire\Component; class Routes extends Component @@ -171,7 +172,7 @@ public function deleteKeyword(int $keywordId): void Keyword::destroy($keywordId); } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { $routes = Route::with(['feed', 'platformChannel.platformInstance']) ->orderBy('priority', 'desc') diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php index 9c97752..693db05 100644 --- a/app/Livewire/Settings.php +++ b/app/Livewire/Settings.php @@ -3,6 +3,7 @@ namespace App\Livewire; use App\Models\Setting; +use Illuminate\Contracts\View\View; use Livewire\Component; class Settings extends Component @@ -76,7 +77,7 @@ public function clearMessages(): void $this->errorMessage = null; } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { return view('livewire.settings')->layout('layouts.app'); } diff --git a/app/Models/ArticlePublication.php b/app/Models/ArticlePublication.php index ee29b72..163f2e8 100644 --- a/app/Models/ArticlePublication.php +++ b/app/Models/ArticlePublication.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; /** * @property int $id @@ -15,9 +16,9 @@ * @property string $platform * @property string $published_by * @property array|null $publication_data - * @property \Illuminate\Support\Carbon $published_at - * @property \Illuminate\Support\Carbon $created_at - * @property \Illuminate\Support\Carbon $updated_at + * @property Carbon $published_at + * @property Carbon $created_at + * @property Carbon $updated_at * * @method static create(array $array) */ diff --git a/app/Models/PlatformChannelPost.php b/app/Models/PlatformChannelPost.php index b2a8652..a411b24 100644 --- a/app/Models/PlatformChannelPost.php +++ b/app/Models/PlatformChannelPost.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\PlatformEnum; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,7 +13,7 @@ */ class PlatformChannelPost extends Model { - /** @use HasFactory<\Illuminate\Database\Eloquent\Factories\Factory> */ + /** @use HasFactory> */ use HasFactory; protected $fillable = [ diff --git a/app/Models/User.php b/app/Models/User.php index 91135d7..c016ca6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -10,7 +11,7 @@ class User extends Authenticatable { - /** @use HasFactory<\Database\Factories\UserFactory> */ + /** @use HasFactory */ use HasApiTokens, HasFactory, Notifiable; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c9b6f5d..573fa39 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,8 +5,12 @@ use App\Enums\LogLevelEnum; use App\Events\ActionPerformed; use App\Events\ExceptionOccurred; +use App\Events\NewArticleFetched; +use App\Events\RouteArticleApproved; use App\Listeners\LogActionListener; use App\Listeners\LogExceptionToDatabase; +use App\Listeners\PublishApprovedArticleListener; +use App\Listeners\ValidateArticleListener; use Error; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Support\Facades\Event; @@ -31,13 +35,13 @@ public function boot(): void ); Event::listen( - \App\Events\NewArticleFetched::class, - \App\Listeners\ValidateArticleListener::class, + NewArticleFetched::class, + ValidateArticleListener::class, ); Event::listen( - \App\Events\RouteArticleApproved::class, - \App\Listeners\PublishApprovedArticleListener::class, + RouteArticleApproved::class, + PublishApprovedArticleListener::class, ); app()->make(ExceptionHandler::class) diff --git a/app/Services/Article/ArticleFetcher.php b/app/Services/Article/ArticleFetcher.php index 46435a8..e655e8d 100644 --- a/app/Services/Article/ArticleFetcher.php +++ b/app/Services/Article/ArticleFetcher.php @@ -155,7 +155,7 @@ private function saveArticle(string $url, ?int $feedId = null): Article } return $article; - } catch (\Exception $e) { + } catch (Exception $e) { $this->logSaver->error('Failed to create article', null, [ 'url' => $url, 'feed_id' => $feedId, diff --git a/app/Services/Http/HttpFetcher.php b/app/Services/Http/HttpFetcher.php index 1caa6a6..670b0de 100644 --- a/app/Services/Http/HttpFetcher.php +++ b/app/Services/Http/HttpFetcher.php @@ -3,6 +3,7 @@ namespace App\Services\Http; use Exception; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; class HttpFetcher @@ -48,7 +49,7 @@ public static function fetchMultipleUrls(array $urls): array ->reject(fn ($response, $index) => $response instanceof Exception) ->map(function ($response, $index) use ($urls) { $url = $urls[$index]; - /** @var \Illuminate\Http\Client\Response $response */ + /** @var Response $response */ try { if ($response->successful()) { return [ diff --git a/bootstrap/app.php b/bootstrap/app.php index 5db8c44..0ba51af 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,7 @@ withExceptions(function (Exceptions $exceptions) { $exceptions->reportable(function (Throwable $e) { $level = match (true) { - $e instanceof Error => \App\Enums\LogLevelEnum::CRITICAL, - $e instanceof RuntimeException => \App\Enums\LogLevelEnum::ERROR, - $e instanceof InvalidArgumentException => \App\Enums\LogLevelEnum::WARNING, - default => \App\Enums\LogLevelEnum::ERROR, + $e instanceof Error => LogLevelEnum::CRITICAL, + $e instanceof RuntimeException => LogLevelEnum::ERROR, + $e instanceof InvalidArgumentException => LogLevelEnum::WARNING, + default => LogLevelEnum::ERROR, }; - App\Events\ExceptionOccurred::dispatch( + ExceptionOccurred::dispatch( $e, $level, $e->getMessage(), diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 4e3b440..2345aa6 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,6 +1,9 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/config/feed.php b/config/feed.php index 73227fe..4215761 100644 --- a/config/feed.php +++ b/config/feed.php @@ -1,5 +1,13 @@ ['url' => 'https://www.vrt.be/vrtnws/nl/'], ], 'parsers' => [ - 'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class, - 'article' => \App\Services\Parsers\VrtArticleParser::class, - 'article_page' => \App\Services\Parsers\VrtArticlePageParser::class, + 'homepage' => VrtHomepageParserAdapter::class, + 'article' => VrtArticleParser::class, + 'article_page' => VrtArticlePageParser::class, ], ], 'belga' => [ @@ -39,8 +47,8 @@ 'en' => ['url' => 'https://www.belganewsagency.eu/feed'], ], 'parsers' => [ - 'article' => \App\Services\Parsers\BelgaArticleParser::class, - 'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class, + 'article' => BelgaArticleParser::class, + 'article_page' => BelgaArticlePageParser::class, ], ], 'guardian' => [ @@ -53,8 +61,8 @@ 'en' => ['url' => 'https://www.theguardian.com/international/rss'], ], 'parsers' => [ - 'article' => \App\Services\Parsers\GuardianArticleParser::class, - 'article_page' => \App\Services\Parsers\GuardianArticlePageParser::class, + 'article' => GuardianArticleParser::class, + 'article_page' => GuardianArticlePageParser::class, ], ], ], diff --git a/config/sanctum.php b/config/sanctum.php index 44527d6..cde73cf 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -1,5 +1,8 @@ [ - 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, - 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, - 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, ], ]; diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index d5a57bb..3e15408 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -2,10 +2,12 @@ namespace Database\Factories; +use App\Models\Article; +use App\Models\Feed; use Illuminate\Database\Eloquent\Factories\Factory; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Article> + * @extends Factory
*/ class ArticleFactory extends Factory { @@ -17,7 +19,7 @@ class ArticleFactory extends Factory public function definition(): array { return [ - 'feed_id' => \App\Models\Feed::factory(), + 'feed_id' => Feed::factory(), 'url' => $this->faker->url(), 'title' => $this->faker->sentence(), 'description' => $this->faker->paragraph(), diff --git a/database/factories/KeywordFactory.php b/database/factories/KeywordFactory.php index db36fd1..c807a17 100644 --- a/database/factories/KeywordFactory.php +++ b/database/factories/KeywordFactory.php @@ -2,7 +2,9 @@ namespace Database\Factories; +use App\Models\Feed; use App\Models\Keyword; +use App\Models\PlatformChannel; use Illuminate\Database\Eloquent\Factories\Factory; class KeywordFactory extends Factory @@ -12,8 +14,8 @@ class KeywordFactory extends Factory public function definition(): array { return [ - 'feed_id' => \App\Models\Feed::factory(), - 'platform_channel_id' => \App\Models\PlatformChannel::factory(), + 'feed_id' => Feed::factory(), + 'platform_channel_id' => PlatformChannel::factory(), 'keyword' => $this->faker->word(), 'is_active' => $this->faker->boolean(70), // 70% chance of being active 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), @@ -21,14 +23,14 @@ public function definition(): array ]; } - public function forFeed(\App\Models\Feed $feed): static + public function forFeed(Feed $feed): static { return $this->state(fn (array $attributes) => [ 'feed_id' => $feed->id, ]); } - public function forChannel(\App\Models\PlatformChannel $channel): static + public function forChannel(PlatformChannel $channel): static { return $this->state(fn (array $attributes) => [ 'platform_channel_id' => $channel->id, diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 584104c..c4ceb07 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,12 +2,13 @@ namespace Database\Factories; +use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User> + * @extends Factory */ class UserFactory extends Factory { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fb3f0fd..15c4715 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,6 +54,30 @@ parameters: count: 1 path: tests/Unit/Enums/PlatformEnumTest.php + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$feed\.$#' + identifier: property.notFound + count: 4 + path: tests/Unit/Jobs/CleanupArticlesJobTest.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$feed_id\.$#' + identifier: property.notFound + count: 4 + path: tests/Unit/Jobs/CleanupArticlesJobTest.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#' + identifier: property.notFound + count: 1 + path: tests/Unit/Jobs/CleanupArticlesJobTest.php + + - + message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$platform_channel_id\.$#' + identifier: property.notFound + count: 4 + path: tests/Unit/Jobs/CleanupArticlesJobTest.php + - message: '#^Access to an undefined property App\\Models\\PlatformChannel\:\:\$pivot\.$#' identifier: property.notFound diff --git a/tests/Feature/DatabaseIntegrationTest.php b/tests/Feature/DatabaseIntegrationTest.php index 982ec93..2eebb44 100644 --- a/tests/Feature/DatabaseIntegrationTest.php +++ b/tests/Feature/DatabaseIntegrationTest.php @@ -16,6 +16,8 @@ use App\Models\Route; use App\Models\Setting; use App\Models\User; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -332,7 +334,7 @@ public function test_model_soft_deletes_work_correctly(): void public function test_database_constraints_are_enforced(): void { // Test foreign key constraints - $this->expectException(\Illuminate\Database\QueryException::class); + $this->expectException(QueryException::class); // Try to create article with non-existent feed_id Article::factory()->create(['feed_id' => 99999]); @@ -355,7 +357,7 @@ public function test_all_factories_work_correctly(): void foreach ($models as $model) { $this->assertNotNull($model); - $this->assertInstanceOf(\Illuminate\Database\Eloquent\Model::class, $model); + $this->assertInstanceOf(Model::class, $model); } } } diff --git a/tests/Feature/Jobs/CheckFeedStalenessJobTest.php b/tests/Feature/Jobs/CheckFeedStalenessJobTest.php index 4e5c0f2..9788fd9 100644 --- a/tests/Feature/Jobs/CheckFeedStalenessJobTest.php +++ b/tests/Feature/Jobs/CheckFeedStalenessJobTest.php @@ -8,6 +8,7 @@ use App\Models\Feed; use App\Models\Notification; use App\Models\Setting; +use App\Services\Notification\NotificationService; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -128,6 +129,6 @@ public function test_never_fetched_feed_creates_notification(): void private function dispatch(): void { - (new CheckFeedStalenessJob)->handle(app(\App\Services\Notification\NotificationService::class)); + (new CheckFeedStalenessJob)->handle(app(NotificationService::class)); } } diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php index e1337ac..b5c793e 100644 --- a/tests/Feature/JobsAndEventsTest.php +++ b/tests/Feature/JobsAndEventsTest.php @@ -2,10 +2,13 @@ namespace Tests\Feature; +use App\Enums\ApprovalStatusEnum; +use App\Enums\LogLevelEnum; use App\Events\ActionPerformed; use App\Events\ExceptionLogged; use App\Events\ExceptionOccurred; use App\Events\NewArticleFetched; +use App\Events\RouteArticleApproved; use App\Jobs\ArticleDiscoveryForFeedJob; use App\Jobs\ArticleDiscoveryJob; use App\Jobs\PublishNextArticleJob; @@ -14,8 +17,11 @@ use App\Listeners\ValidateArticleListener; use App\Models\Article; use App\Models\Feed; +use App\Models\Keyword; use App\Models\Log; use App\Models\PlatformChannel; +use App\Models\Route; +use App\Models\RouteArticle; use App\Models\Setting; use App\Services\Article\ArticleFetcher; use App\Services\Log\LogSaver; @@ -57,14 +63,14 @@ public function test_article_discovery_for_feed_job_processes_feed(): void ]); // Mock the ArticleFetcher service in the container - $mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class); + $mockFetcher = \Mockery::mock(ArticleFetcher::class); $article1 = Article::factory()->create(['url' => 'https://example.com/article1', 'feed_id' => $feed->id]); $article2 = Article::factory()->create(['url' => 'https://example.com/article2', 'feed_id' => $feed->id]); $mockFetcher->shouldReceive('getArticlesFromFeed') ->with($feed) ->andReturn(collect([$article1, $article2])); - $this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher); + $this->app->instance(ArticleFetcher::class, $mockFetcher); $logSaver = app(LogSaver::class); $articleFetcher = app(ArticleFetcher::class); @@ -118,7 +124,7 @@ public function test_exception_occurred_event_is_dispatched(): void $exception = new \Exception('Test exception'); - event(new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception', ['context' => 'test'])); + event(new ExceptionOccurred($exception, LogLevelEnum::ERROR, 'Test exception', ['context' => 'test'])); Event::assertDispatched(ExceptionOccurred::class, function (ExceptionOccurred $event) { return $event->exception->getMessage() === 'Test exception'; @@ -148,9 +154,9 @@ public function test_validate_article_listener_processes_new_article(): void Setting::setBool('enable_publishing_approvals', false); $feed = Feed::factory()->create(); - /** @var \App\Models\Route $route */ - $route = \App\Models\Route::factory()->active()->create(['feed_id' => $feed->id]); - \App\Models\Keyword::factory()->active()->create([ + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + Keyword::factory()->active()->create([ 'feed_id' => $feed->id, 'platform_channel_id' => $route->platform_channel_id, 'keyword' => 'Belgium', @@ -161,8 +167,8 @@ public function test_validate_article_listener_processes_new_article(): void ]); // Mock ArticleFetcher to return valid article data - $mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class); - $this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher); + $mockFetcher = \Mockery::mock(ArticleFetcher::class); + $this->app->instance(ArticleFetcher::class, $mockFetcher); $mockFetcher->shouldReceive('fetchArticleData') ->with($article) ->andReturn([ @@ -179,9 +185,9 @@ public function test_validate_article_listener_processes_new_article(): void $article->refresh(); $this->assertNotNull($article->validated_at); - $routeArticle = \App\Models\RouteArticle::where('article_id', $article->id)->first(); + $routeArticle = RouteArticle::where('article_id', $article->id)->first(); $this->assertNotNull($routeArticle); - $this->assertEquals(\App\Enums\ApprovalStatusEnum::APPROVED, $routeArticle->approval_status); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status); } public function test_log_exception_to_database_listener_creates_log(): void @@ -194,7 +200,7 @@ public function test_log_exception_to_database_listener_creates_log(): void $listener = new LogExceptionToDatabase; $exception = new \Exception('Test exception message'); - $event = new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception message'); + $event = new ExceptionOccurred($exception, LogLevelEnum::ERROR, 'Test exception message'); $listener->handle($event); @@ -205,7 +211,7 @@ public function test_log_exception_to_database_listener_creates_log(): void $savedLog = Log::where('message', 'Test exception message')->first(); $this->assertNotNull($savedLog); - $this->assertEquals(\App\Enums\LogLevelEnum::ERROR, $savedLog->level); + $this->assertEquals(LogLevelEnum::ERROR, $savedLog->level); } public function test_event_listener_registration_works(): void @@ -217,7 +223,7 @@ public function test_event_listener_registration_works(): void $listeners = Event::getListeners(NewArticleFetched::class); $this->assertNotEmpty($listeners); - $listeners = Event::getListeners(\App\Events\RouteArticleApproved::class); + $listeners = Event::getListeners(RouteArticleApproved::class); $this->assertNotEmpty($listeners); $listeners = Event::getListeners(ExceptionOccurred::class); diff --git a/tests/Feature/ValidateArticleListenerTest.php b/tests/Feature/ValidateArticleListenerTest.php index 57529a6..5743555 100644 --- a/tests/Feature/ValidateArticleListenerTest.php +++ b/tests/Feature/ValidateArticleListenerTest.php @@ -12,6 +12,7 @@ use App\Models\Route; use App\Models\RouteArticle; use App\Services\Article\ArticleFetcher; +use App\Services\Article\ValidationService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; @@ -32,7 +33,7 @@ private function createListenerWithMockedFetcher(?string $content = 'Some articl ); return new ValidateArticleListener( - new \App\Services\Article\ValidationService($articleFetcher) + new ValidationService($articleFetcher) ); } @@ -113,7 +114,7 @@ public function test_listener_handles_validation_errors_gracefully(): void $articleFetcher->shouldReceive('fetchArticleData')->andThrow(new \Exception('Fetch failed')); $listener = new ValidateArticleListener( - new \App\Services\Article\ValidationService($articleFetcher) + new ValidationService($articleFetcher) ); $feed = Feed::factory()->create(); diff --git a/tests/Traits/CreatesArticleFetcher.php b/tests/Traits/CreatesArticleFetcher.php index ad2f985..adeda40 100644 --- a/tests/Traits/CreatesArticleFetcher.php +++ b/tests/Traits/CreatesArticleFetcher.php @@ -5,6 +5,7 @@ use App\Services\Article\ArticleFetcher; use App\Services\Log\LogSaver; use Mockery; +use Mockery\MockInterface; trait CreatesArticleFetcher { @@ -21,7 +22,7 @@ protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetc return new ArticleFetcher($logSaver); } - /** @return array{ArticleFetcher, \Mockery\MockInterface} */ + /** @return array{ArticleFetcher, MockInterface} */ protected function createArticleFetcherWithMockedLogSaver(): array { $logSaver = Mockery::mock(LogSaver::class); diff --git a/tests/Unit/Actions/CreatePlatformAccountActionTest.php b/tests/Unit/Actions/CreatePlatformAccountActionTest.php index 051c522..2c0b817 100644 --- a/tests/Unit/Actions/CreatePlatformAccountActionTest.php +++ b/tests/Unit/Actions/CreatePlatformAccountActionTest.php @@ -10,6 +10,7 @@ use App\Services\Auth\LemmyAuthService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; +use Mockery\MockInterface; use Tests\TestCase; class CreatePlatformAccountActionTest extends TestCase @@ -18,7 +19,7 @@ class CreatePlatformAccountActionTest extends TestCase private CreatePlatformAccountAction $action; - /** @var LemmyAuthService&\Mockery\MockInterface */ + /** @var LemmyAuthService&MockInterface */ private LemmyAuthService $lemmyAuthService; protected function setUp(): void diff --git a/tests/Unit/Events/ActionPerformedTest.php b/tests/Unit/Events/ActionPerformedTest.php index 5091bbf..7e8fb41 100644 --- a/tests/Unit/Events/ActionPerformedTest.php +++ b/tests/Unit/Events/ActionPerformedTest.php @@ -5,6 +5,7 @@ use App\Enums\LogLevelEnum; use App\Events\ActionPerformed; use Illuminate\Foundation\Events\Dispatchable; +use Illuminate\Queue\SerializesModels; use Tests\TestCase; class ActionPerformedTest extends TestCase @@ -41,7 +42,7 @@ public function test_event_uses_dispatchable_trait(): void public function test_event_does_not_use_serializes_models_trait(): void { $this->assertNotContains( - \Illuminate\Queue\SerializesModels::class, + SerializesModels::class, class_uses(ActionPerformed::class), ); } diff --git a/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php b/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php index b88968d..5ddd4f8 100644 --- a/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php +++ b/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Exceptions; +use App\Exceptions\RoutingException; use App\Exceptions\RoutingMismatchException; use App\Models\Feed; use App\Models\Language; @@ -52,7 +53,7 @@ public function test_exception_extends_routing_exception(): void $exception = new RoutingMismatchException($feed, $channel); // Assert - $this->assertInstanceOf(\App\Exceptions\RoutingException::class, $exception); + $this->assertInstanceOf(RoutingException::class, $exception); } public function test_exception_with_different_languages(): void diff --git a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php index c1c5579..847c426 100644 --- a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php +++ b/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php @@ -6,6 +6,8 @@ use App\Models\Feed; use App\Services\Article\ArticleFetcher; use App\Services\Log\LogSaver; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Mockery; @@ -34,7 +36,7 @@ public function test_job_implements_should_queue(): void $feed = Feed::factory()->make(); $job = new ArticleDiscoveryForFeedJob($feed); - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); + $this->assertInstanceOf(ShouldQueue::class, $job); } public function test_job_uses_queueable_trait(): void @@ -43,7 +45,7 @@ public function test_job_uses_queueable_trait(): void $job = new ArticleDiscoveryForFeedJob($feed); $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, + Queueable::class, class_uses($job) ); } diff --git a/tests/Unit/Jobs/ArticleDiscoveryJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryJobTest.php index d9546d0..3b27bfd 100644 --- a/tests/Unit/Jobs/ArticleDiscoveryJobTest.php +++ b/tests/Unit/Jobs/ArticleDiscoveryJobTest.php @@ -5,6 +5,7 @@ use App\Jobs\ArticleDiscoveryJob; use App\Models\Setting; use App\Services\Log\LogSaver; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; use Mockery; @@ -100,7 +101,7 @@ public function test_job_implements_should_queue(): void $job = new ArticleDiscoveryJob; // Assert - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); + $this->assertInstanceOf(ShouldQueue::class, $job); } public function test_job_uses_queueable_trait(): void diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php index 694789a..6614595 100644 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ b/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -16,6 +16,8 @@ use App\Services\Article\ArticleFetcher; use App\Services\Notification\NotificationService; use App\Services\Publishing\ArticlePublishingService; +use Illuminate\Contracts\Queue\ShouldBeUnique; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; @@ -63,14 +65,14 @@ public function test_job_implements_should_queue(): void { $job = new PublishNextArticleJob; - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); + $this->assertInstanceOf(ShouldQueue::class, $job); } public function test_job_implements_should_be_unique(): void { $job = new PublishNextArticleJob; - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job); + $this->assertInstanceOf(ShouldBeUnique::class, $job); } public function test_job_has_unique_for_property(): void diff --git a/tests/Unit/Jobs/SyncChannelPostsJobTest.php b/tests/Unit/Jobs/SyncChannelPostsJobTest.php index 7286a55..d439bff 100644 --- a/tests/Unit/Jobs/SyncChannelPostsJobTest.php +++ b/tests/Unit/Jobs/SyncChannelPostsJobTest.php @@ -9,6 +9,9 @@ use App\Models\PlatformInstance; use App\Services\Log\LogSaver; use Exception; +use Illuminate\Contracts\Queue\ShouldBeUnique; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; @@ -39,7 +42,7 @@ public function test_job_implements_should_queue(): void $channel = PlatformChannel::factory()->make(); $job = new SyncChannelPostsJob($channel); - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); + $this->assertInstanceOf(ShouldQueue::class, $job); } public function test_job_implements_should_be_unique(): void @@ -47,7 +50,7 @@ public function test_job_implements_should_be_unique(): void $channel = PlatformChannel::factory()->make(); $job = new SyncChannelPostsJob($channel); - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job); + $this->assertInstanceOf(ShouldBeUnique::class, $job); } public function test_job_uses_queueable_trait(): void @@ -56,7 +59,7 @@ public function test_job_uses_queueable_trait(): void $job = new SyncChannelPostsJob($channel); $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, + Queueable::class, class_uses($job) ); } diff --git a/tests/Unit/Models/ArticlePublicationTest.php b/tests/Unit/Models/ArticlePublicationTest.php index 3c7e69c..1949811 100644 --- a/tests/Unit/Models/ArticlePublicationTest.php +++ b/tests/Unit/Models/ArticlePublicationTest.php @@ -5,6 +5,7 @@ use App\Models\Article; use App\Models\ArticlePublication; use App\Models\PlatformChannel; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -33,7 +34,7 @@ public function test_casts_published_at_to_datetime(): void /** @var ArticlePublication $publication */ $publication = ArticlePublication::factory()->create(['published_at' => $timestamp]); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); + $this->assertInstanceOf(Carbon::class, $publication->published_at); $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s')); } @@ -76,7 +77,7 @@ public function test_publication_creation_with_factory(): void $this->assertNotNull($publication->article_id); $this->assertNotNull($publication->platform_channel_id); $this->assertIsString($publication->post_id); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); + $this->assertInstanceOf(Carbon::class, $publication->published_at); $this->assertIsString($publication->published_by); } @@ -111,7 +112,7 @@ public function test_publication_factory_recently_published_state(): void /** @var ArticlePublication $publication */ $publication = ArticlePublication::factory()->recentlyPublished()->create(); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); + $this->assertInstanceOf(Carbon::class, $publication->published_at); $this->assertTrue($publication->published_at->isAfter(now()->subDay())); $this->assertTrue($publication->published_at->isBefore(now()->addMinute())); } @@ -203,7 +204,7 @@ public function test_publication_with_specific_published_at(): void /** @var ArticlePublication $publication */ $publication = ArticlePublication::factory()->create(['published_at' => $timestamp]); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); + $this->assertInstanceOf(Carbon::class, $publication->published_at); $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s')); } @@ -230,8 +231,8 @@ public function test_publication_timestamps(): void $this->assertNotNull($publication->created_at); $this->assertNotNull($publication->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->updated_at); + $this->assertInstanceOf(Carbon::class, $publication->created_at); + $this->assertInstanceOf(Carbon::class, $publication->updated_at); } public function test_multiple_publications_for_same_article(): void diff --git a/tests/Unit/Models/ArticleTest.php b/tests/Unit/Models/ArticleTest.php index ead0e8b..0f2a538 100644 --- a/tests/Unit/Models/ArticleTest.php +++ b/tests/Unit/Models/ArticleTest.php @@ -6,6 +6,7 @@ use App\Models\Article; use App\Models\Feed; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Tests\TestCase; @@ -70,6 +71,6 @@ public function test_validated_at_is_cast_to_datetime(): void 'validated_at' => now(), ]); - $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $article->validated_at); + $this->assertInstanceOf(Carbon::class, $article->validated_at); } } diff --git a/tests/Unit/Models/FeedTest.php b/tests/Unit/Models/FeedTest.php index 05864cb..274659c 100644 --- a/tests/Unit/Models/FeedTest.php +++ b/tests/Unit/Models/FeedTest.php @@ -7,6 +7,7 @@ use App\Models\Language; use App\Models\PlatformChannel; use App\Models\Route; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -51,7 +52,7 @@ public function test_casts_last_fetched_at_to_datetime(): void $timestamp = now()->subHours(2); $feed = Feed::factory()->create(['last_fetched_at' => $timestamp]); - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->last_fetched_at); + $this->assertInstanceOf(Carbon::class, $feed->last_fetched_at); $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $feed->last_fetched_at->format('Y-m-d H:i:s')); } @@ -376,7 +377,7 @@ public function test_feed_timestamps(): void $this->assertNotNull($feed->created_at); $this->assertNotNull($feed->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->updated_at); + $this->assertInstanceOf(Carbon::class, $feed->created_at); + $this->assertInstanceOf(Carbon::class, $feed->updated_at); } } diff --git a/tests/Unit/Models/KeywordTest.php b/tests/Unit/Models/KeywordTest.php index d1fbd13..d729ad2 100644 --- a/tests/Unit/Models/KeywordTest.php +++ b/tests/Unit/Models/KeywordTest.php @@ -5,6 +5,8 @@ use App\Models\Feed; use App\Models\Keyword; use App\Models\PlatformChannel; +use Carbon\Carbon; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -213,7 +215,7 @@ public function test_keyword_uniqueness_constraint(): void ]); // Attempt to create duplicate should fail - $this->expectException(\Illuminate\Database\QueryException::class); + $this->expectException(QueryException::class); Keyword::create([ 'feed_id' => $feed->id, @@ -257,8 +259,8 @@ public function test_keyword_timestamps(): void $this->assertNotNull($keyword->created_at); $this->assertNotNull($keyword->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $keyword->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $keyword->updated_at); + $this->assertInstanceOf(Carbon::class, $keyword->created_at); + $this->assertInstanceOf(Carbon::class, $keyword->updated_at); } public function test_keyword_default_active_state(): void diff --git a/tests/Unit/Models/LanguageTest.php b/tests/Unit/Models/LanguageTest.php index 3017be0..d18baf0 100644 --- a/tests/Unit/Models/LanguageTest.php +++ b/tests/Unit/Models/LanguageTest.php @@ -6,6 +6,7 @@ use App\Models\Language; use App\Models\PlatformChannel; use App\Models\PlatformInstance; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -193,8 +194,8 @@ public function test_language_timestamps(): void $this->assertNotNull($language->created_at); $this->assertNotNull($language->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $language->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $language->updated_at); + $this->assertInstanceOf(Carbon::class, $language->created_at); + $this->assertInstanceOf(Carbon::class, $language->updated_at); } public function test_language_can_have_multiple_platform_instances(): void diff --git a/tests/Unit/Models/PlatformAccountTest.php b/tests/Unit/Models/PlatformAccountTest.php index c3e351e..ff58aca 100644 --- a/tests/Unit/Models/PlatformAccountTest.php +++ b/tests/Unit/Models/PlatformAccountTest.php @@ -5,6 +5,7 @@ use App\Enums\PlatformEnum; use App\Models\PlatformAccount; use App\Models\PlatformChannel; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -65,7 +66,7 @@ public function test_casts_last_tested_at_to_datetime(): void $timestamp = now()->subHours(2); $account = PlatformAccount::factory()->create(['last_tested_at' => $timestamp]); - $this->assertInstanceOf(\Carbon\Carbon::class, $account->last_tested_at); + $this->assertInstanceOf(Carbon::class, $account->last_tested_at); $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $account->last_tested_at->format('Y-m-d H:i:s')); } @@ -366,8 +367,8 @@ public function test_account_timestamps(): void $this->assertNotNull($account->created_at); $this->assertNotNull($account->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $account->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $account->updated_at); + $this->assertInstanceOf(Carbon::class, $account->created_at); + $this->assertInstanceOf(Carbon::class, $account->updated_at); } public function test_account_can_have_multiple_channels_with_different_priorities(): void diff --git a/tests/Unit/Models/PlatformChannelTest.php b/tests/Unit/Models/PlatformChannelTest.php index 9d08a2e..f6e81ba 100644 --- a/tests/Unit/Models/PlatformChannelTest.php +++ b/tests/Unit/Models/PlatformChannelTest.php @@ -8,6 +8,7 @@ use App\Models\PlatformChannel; use App\Models\PlatformInstance; use App\Models\Route; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -305,8 +306,8 @@ public function test_channel_timestamps(): void $this->assertNotNull($channel->created_at); $this->assertNotNull($channel->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $channel->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $channel->updated_at); + $this->assertInstanceOf(Carbon::class, $channel->created_at); + $this->assertInstanceOf(Carbon::class, $channel->updated_at); } public function test_channel_can_have_multiple_accounts_with_different_priorities(): void diff --git a/tests/Unit/Models/PlatformInstanceTest.php b/tests/Unit/Models/PlatformInstanceTest.php index 296515f..7ec68d3 100644 --- a/tests/Unit/Models/PlatformInstanceTest.php +++ b/tests/Unit/Models/PlatformInstanceTest.php @@ -6,6 +6,7 @@ use App\Models\Language; use App\Models\PlatformChannel; use App\Models\PlatformInstance; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -250,8 +251,8 @@ public function test_instance_timestamps(): void $this->assertNotNull($instance->created_at); $this->assertNotNull($instance->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $instance->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $instance->updated_at); + $this->assertInstanceOf(Carbon::class, $instance->created_at); + $this->assertInstanceOf(Carbon::class, $instance->updated_at); } public function test_instance_can_have_multiple_languages(): void diff --git a/tests/Unit/Models/RouteArticleTest.php b/tests/Unit/Models/RouteArticleTest.php index f5229e9..97b656e 100644 --- a/tests/Unit/Models/RouteArticleTest.php +++ b/tests/Unit/Models/RouteArticleTest.php @@ -8,6 +8,7 @@ use App\Models\PlatformChannel; use App\Models\Route; use App\Models\RouteArticle; +use Illuminate\Database\QueryException; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -104,7 +105,7 @@ public function test_unique_constraint_prevents_duplicate_route_articles(): void RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); - $this->expectException(\Illuminate\Database\QueryException::class); + $this->expectException(QueryException::class); RouteArticle::factory()->forRoute($route)->create(['article_id' => $article->id]); } diff --git a/tests/Unit/Services/ArticleFetcherRssTest.php b/tests/Unit/Services/ArticleFetcherRssTest.php index 425ae23..3f15dd6 100644 --- a/tests/Unit/Services/ArticleFetcherRssTest.php +++ b/tests/Unit/Services/ArticleFetcherRssTest.php @@ -5,6 +5,7 @@ use App\Models\Article; use App\Models\Feed; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Mockery; use Tests\TestCase; @@ -56,7 +57,7 @@ public function test_get_articles_from_rss_feed_returns_collection(): void $fetcher = $this->createArticleFetcher(); $result = $fetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); } public function test_get_articles_from_rss_feed_creates_articles(): void @@ -118,7 +119,7 @@ public function test_get_articles_from_rss_feed_handles_invalid_xml(): void $fetcher = $this->createArticleFetcher(); $result = $fetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } diff --git a/tests/Unit/Services/ArticleFetcherTest.php b/tests/Unit/Services/ArticleFetcherTest.php index 180b099..f281bea 100644 --- a/tests/Unit/Services/ArticleFetcherTest.php +++ b/tests/Unit/Services/ArticleFetcherTest.php @@ -6,6 +6,7 @@ use App\Models\Feed; use App\Services\Article\ArticleFetcher; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Mockery; use Tests\TestCase; @@ -38,7 +39,7 @@ public function test_get_articles_from_feed_returns_collection(): void $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); } public function test_get_articles_from_rss_feed_returns_empty_collection(): void @@ -66,7 +67,7 @@ public function test_get_articles_from_website_feed_handles_no_parser(): void $result = $articleFetcher->getArticlesFromFeed($feed); // Should return empty collection when no parser is available - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } @@ -80,7 +81,7 @@ public function test_get_articles_from_unsupported_feed_type(): void $articleFetcher = $this->createArticleFetcher(); $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } @@ -130,7 +131,7 @@ public function test_get_articles_from_feed_with_null_feed_type(): void $articleFetcher = $this->createArticleFetcher(); $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } @@ -150,7 +151,7 @@ public function test_get_articles_from_website_feed_with_supported_parser(): voi $articleFetcher = $this->createArticleFetcher(); $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); // VRT parser will process the mocked HTML response } @@ -166,7 +167,7 @@ public function test_get_articles_from_website_feed_handles_invalid_url(): void $articleFetcher = $this->createArticleFetcher(); $result = $articleFetcher->getArticlesFromFeed($feed); - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + $this->assertInstanceOf(Collection::class, $result); $this->assertEmpty($result); } diff --git a/tests/Unit/Services/Http/HttpFetcherTest.php b/tests/Unit/Services/Http/HttpFetcherTest.php index 7f6c8c9..ae2266b 100644 --- a/tests/Unit/Services/Http/HttpFetcherTest.php +++ b/tests/Unit/Services/Http/HttpFetcherTest.php @@ -4,6 +4,8 @@ use App\Services\Http\HttpFetcher; use Exception; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Psr7\Request; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -154,7 +156,7 @@ public function test_fetch_multiple_urls_returns_empty_array_on_exception(): voi $urls = ['https://example.com']; Http::fake(function () { - throw new \GuzzleHttp\Exception\ConnectException('Pool request failed', new \GuzzleHttp\Psr7\Request('GET', 'https://example.com')); + throw new ConnectException('Pool request failed', new Request('GET', 'https://example.com')); }); $results = HttpFetcher::fetchMultipleUrls($urls); diff --git a/tests/Unit/Services/ValidationServiceTest.php b/tests/Unit/Services/ValidationServiceTest.php index 4d216d0..5133193 100644 --- a/tests/Unit/Services/ValidationServiceTest.php +++ b/tests/Unit/Services/ValidationServiceTest.php @@ -14,6 +14,7 @@ use App\Services\Article\ValidationService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; +use Mockery\MockInterface; use Tests\TestCase; class ValidationServiceTest extends TestCase @@ -22,7 +23,7 @@ class ValidationServiceTest extends TestCase private ValidationService $validationService; - private \Mockery\MockInterface $articleFetcher; + private MockInterface $articleFetcher; protected function setUp(): void { -- 2.45.2