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\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();
}
}
}
}

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
{
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([

View file

@ -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('<html><body>Article content</body></html>', 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('<html><body>Article content</body></html>', 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());
}
}