85 - Update ValidateArticleListener for per-route validation flow
This commit is contained in:
parent
e3ea02ae1c
commit
f449548123
3 changed files with 73 additions and 115 deletions
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue