Release v1.3.0 #100

Merged
myrmidex merged 20 commits from release/v1.3.0 into main 2026-03-18 20:30:29 +01:00
3 changed files with 73 additions and 115 deletions
Showing only changes of commit f449548123 - Show all commits

View file

@ -5,7 +5,6 @@
use App\Enums\LogLevelEnum; use App\Enums\LogLevelEnum;
use App\Events\ActionPerformed; use App\Events\ActionPerformed;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Models\Setting;
use App\Services\Article\ValidationService; use App\Services\Article\ValidationService;
use Exception; use Exception;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -26,38 +25,18 @@ public function handle(NewArticleFetched $event): void
return; return;
} }
// Only validate articles that are still pending
if (! $article->isPending()) {
return;
}
// Skip if already has publication (prevents duplicate processing) // Skip if already has publication (prevents duplicate processing)
if ($article->articlePublication()->exists()) { if ($article->articlePublication()->exists()) {
return; return;
} }
try { try {
$article = $this->validationService->validate($article); $this->validationService->validate($article);
} catch (Exception $e) { } catch (Exception $e) {
ActionPerformed::dispatch('Article validation failed', LogLevelEnum::ERROR, [ ActionPerformed::dispatch('Article validation failed', LogLevelEnum::ERROR, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage(), '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();
}
} }
} }
} }

View file

@ -175,12 +175,17 @@ public function test_exception_logged_event_is_dispatched(): void
public function test_validate_article_listener_processes_new_article(): void public function test_validate_article_listener_processes_new_article(): void
{ {
Event::fake([ArticleApproved::class]);
// Disable approvals so listener auto-approves valid articles // Disable approvals so listener auto-approves valid articles
Setting::setBool('enable_publishing_approvals', false); Setting::setBool('enable_publishing_approvals', false);
$feed = Feed::factory()->create(); $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([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'approval_status' => 'pending', 'approval_status' => 'pending',
@ -203,45 +208,13 @@ public function test_validate_article_listener_processes_new_article(): void
$listener->handle($event); $listener->handle($event);
$article->refresh(); $article->refresh();
$this->assertEquals('approved', $article->approval_status); $this->assertNotNull($article->validated_at);
Event::assertDispatched(ArticleApproved::class);
$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 public function test_log_exception_to_database_listener_creates_log(): void
{ {
$log = Log::factory()->create([ $log = Log::factory()->create([

View file

@ -2,81 +2,95 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Events\ArticleApproved; use App\Enums\ApprovalStatusEnum;
use App\Events\NewArticleFetched; use App\Events\NewArticleFetched;
use App\Listeners\ValidateArticleListener; use App\Listeners\ValidateArticleListener;
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\Feed; 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\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Mockery;
use Illuminate\Support\Facades\Http;
use Tests\TestCase; use Tests\TestCase;
class ValidateArticleListenerTest extends TestCase class ValidateArticleListenerTest extends TestCase
{ {
use RefreshDatabase; 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 return new ValidateArticleListener(
Http::fake([ new \App\Services\Article\ValidationService($articleFetcher)
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200), );
}
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([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'approval_status' => 'pending', 'approval_status' => 'pending',
]); ]);
$listener = app(ValidateArticleListener::class); $listener = $this->createListenerWithMockedFetcher('Article about Belgium');
$event = new NewArticleFetched($article); $listener->handle(new NewArticleFetched($article));
$listener->handle($event);
$article->refresh(); $article->refresh();
$this->assertNotNull($article->validated_at);
if ($article->isValid()) { $routeArticle = RouteArticle::where('article_id', $article->id)->first();
Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) { $this->assertNotNull($routeArticle);
return $event->article->id === $article->id; $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
});
} else {
Event::assertNotDispatched(ArticleApproved::class);
}
} }
public function test_listener_skips_already_validated_articles(): void public function test_listener_skips_already_validated_articles(): void
{ {
Event::fake([ArticleApproved::class]);
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'url' => 'https://example.com/article', 'validated_at' => now(),
'approval_status' => 'approved',
]); ]);
$listener = app(ValidateArticleListener::class); $listener = $this->createListenerWithMockedFetcher();
$event = new NewArticleFetched($article); $listener->handle(new NewArticleFetched($article));
$listener->handle($event); $this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
Event::assertNotDispatched(ArticleApproved::class);
} }
public function test_listener_skips_articles_with_existing_publication(): void public function test_listener_skips_articles_with_existing_publication(): void
{ {
Event::fake([ArticleApproved::class]);
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
Route::factory()->active()->create(['feed_id' => $feed->id]);
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'approval_status' => 'pending', 'approval_status' => 'pending',
]); ]);
@ -88,38 +102,30 @@ public function test_listener_skips_articles_with_existing_publication(): void
'published_by' => 'test-user', 'published_by' => 'test-user',
]); ]);
$listener = app(ValidateArticleListener::class); $listener = $this->createListenerWithMockedFetcher();
$event = new NewArticleFetched($article); $listener->handle(new NewArticleFetched($article));
$listener->handle($event); $this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
Event::assertNotDispatched(ArticleApproved::class);
} }
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 $listener = new ValidateArticleListener(
Http::fake([ new \App\Services\Article\ValidationService($articleFetcher)
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200), );
]);
$feed = Feed::factory()->create(); $feed = Feed::factory()->create();
$article = Article::factory()->create([ $article = Article::factory()->create([
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'approval_status' => 'pending', 'approval_status' => 'pending',
]); ]);
$listener = app(ValidateArticleListener::class); $listener->handle(new NewArticleFetched($article));
$event = new NewArticleFetched($article);
$listener->handle($event); $this->assertNull($article->fresh()->validated_at);
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
// 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');
} }
} }