From dd8f7994456916c00e522c43e4ecc0c2c6381596 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Thu, 13 Aug 2026 23:16:43 +0200 Subject: [PATCH] 144 - Extract CreateRouteArticlesAction from ValidationService --- app/Actions/CreateRouteArticlesAction.php | 79 +++++++ app/Services/Article/ValidationService.php | 77 +------ .../RouteArticleDecisionStampingTest.php | 3 +- tests/Feature/ValidateArticleListenerTest.php | 5 +- .../Actions/CreateRouteArticlesActionTest.php | 215 ++++++++++++++++++ tests/Unit/Services/ValidationServiceTest.php | 3 +- 6 files changed, 305 insertions(+), 77 deletions(-) create mode 100644 app/Actions/CreateRouteArticlesAction.php create mode 100644 tests/Unit/Actions/CreateRouteArticlesActionTest.php diff --git a/app/Actions/CreateRouteArticlesAction.php b/app/Actions/CreateRouteArticlesAction.php new file mode 100644 index 00000000..b7ecbd34 --- /dev/null +++ b/app/Actions/CreateRouteArticlesAction.php @@ -0,0 +1,79 @@ +feed_id) + ->where('is_active', true) + ->get(); + + // 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'); + + // 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, $searchableContent); + + 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(), + 'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(), + ] + ); + } + } + + /** + * @param Collection $keywords + */ + private function evaluateKeywords(Collection $keywords, string $content): ApprovalStatusEnum + { + if ($keywords->isEmpty()) { + return ApprovalStatusEnum::PENDING; + } + + foreach ($keywords as $keyword) { + if (stripos($content, $keyword->keyword) !== false) { + return ApprovalStatusEnum::PENDING; + } + } + + return ApprovalStatusEnum::REJECTED; + } + + private function shouldAutoApprove(Route $route): bool + { + if ($route->auto_approve !== null) { + return $route->auto_approve; + } + + return ! Setting::isPublishingApprovalsEnabled(); + } +} diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php index 1387ce92..2557f437 100644 --- a/app/Services/Article/ValidationService.php +++ b/app/Services/Article/ValidationService.php @@ -2,18 +2,14 @@ namespace App\Services\Article; -use App\Enums\ApprovalStatusEnum; +use App\Actions\CreateRouteArticlesAction; 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 { public function __construct( - private ArticleFetcher $articleFetcher + private ArticleFetcher $articleFetcher, + private CreateRouteArticlesAction $createRouteArticles, ) {} public function validate(Article $article): Article @@ -46,73 +42,8 @@ public function validate(Article $article): Article $updateData['validated_at'] = now(); $article->update($updateData); - $this->createRouteArticles($article, $articleData['full_article']); + $this->createRouteArticles->execute($article, $articleData['full_article']); return $article->refresh(); } - - private function createRouteArticles(Article $article, string $content): void - { - $activeRoutes = Route::where('feed_id', $article->feed_id) - ->where('is_active', true) - ->get(); - - // 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'); - - // 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, $searchableContent); - - 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(), - 'decided_at' => $status === ApprovalStatusEnum::PENDING ? null : now(), - ] - ); - } - } - - /** - * @param Collection $keywords - */ - private function evaluateKeywords(Collection $keywords, string $content): ApprovalStatusEnum - { - if ($keywords->isEmpty()) { - return ApprovalStatusEnum::PENDING; - } - - foreach ($keywords as $keyword) { - if (stripos($content, $keyword->keyword) !== false) { - return ApprovalStatusEnum::PENDING; - } - } - - return ApprovalStatusEnum::REJECTED; - } - - private function shouldAutoApprove(Route $route): bool - { - if ($route->auto_approve !== null) { - return $route->auto_approve; - } - - return ! Setting::isPublishingApprovalsEnabled(); - } } diff --git a/tests/Feature/RouteArticleDecisionStampingTest.php b/tests/Feature/RouteArticleDecisionStampingTest.php index 8fffadc2..eefd1963 100644 --- a/tests/Feature/RouteArticleDecisionStampingTest.php +++ b/tests/Feature/RouteArticleDecisionStampingTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Actions\CreateRouteArticlesAction; use App\Enums\ApprovalStatusEnum; use App\Livewire\Articles; use App\Models\Article; @@ -134,7 +135,7 @@ private function validate(Article $article): void 'full_article' => 'Body text', ]); - (new ValidationService($fetcher))->validate($article); + (new ValidationService($fetcher, new CreateRouteArticlesAction))->validate($article); } private function articleOnRouteWithoutKeywords(bool $autoApprove): Article diff --git a/tests/Feature/ValidateArticleListenerTest.php b/tests/Feature/ValidateArticleListenerTest.php index 5743555d..39ace545 100644 --- a/tests/Feature/ValidateArticleListenerTest.php +++ b/tests/Feature/ValidateArticleListenerTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use App\Actions\CreateRouteArticlesAction; use App\Enums\ApprovalStatusEnum; use App\Events\NewArticleFetched; use App\Listeners\ValidateArticleListener; @@ -33,7 +34,7 @@ private function createListenerWithMockedFetcher(?string $content = 'Some articl ); return new ValidateArticleListener( - new ValidationService($articleFetcher) + new ValidationService($articleFetcher, new CreateRouteArticlesAction) ); } @@ -114,7 +115,7 @@ public function test_listener_handles_validation_errors_gracefully(): void $articleFetcher->shouldReceive('fetchArticleData')->andThrow(new \Exception('Fetch failed')); $listener = new ValidateArticleListener( - new ValidationService($articleFetcher) + new ValidationService($articleFetcher, new CreateRouteArticlesAction) ); $feed = Feed::factory()->create(); diff --git a/tests/Unit/Actions/CreateRouteArticlesActionTest.php b/tests/Unit/Actions/CreateRouteArticlesActionTest.php new file mode 100644 index 00000000..9e8fdeea --- /dev/null +++ b/tests/Unit/Actions/CreateRouteArticlesActionTest.php @@ -0,0 +1,215 @@ +create(); + $channel = PlatformChannel::factory()->create(); + + return Route::create([ + 'feed_id' => $feed->id, + 'platform_channel_id' => $channel->id, + 'priority' => 50, + 'is_active' => $isActive, + 'auto_approve' => $autoApprove, + ]); + } + + private function articleFor(Route $route): Article + { + return Article::factory()->create([ + 'feed_id' => $route->feed_id, + 'title' => 'A title', + 'description' => 'A description', + ]); + } + + public function test_it_creates_a_route_article_per_active_route(): void + { + $route = $this->route(); + $article = $this->articleFor($route); + + $this->action()->execute($article, 'body content'); + + $this->assertDatabaseHas('route_articles', [ + 'article_id' => $article->id, + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + ]); + } + + public function test_it_skips_inactive_routes(): void + { + $route = $this->route(isActive: false); + $article = $this->articleFor($route); + + $this->action()->execute($article, 'body content'); + + $this->assertSame(0, RouteArticle::count()); + } + + public function test_a_route_without_keywords_is_pending(): void + { + Setting::setBool('enable_publishing_approvals', true); + $route = $this->route(); + $article = $this->articleFor($route); + + $this->action()->execute($article, 'body content'); + + $this->assertSame(ApprovalStatusEnum::PENDING, RouteArticle::first()->approval_status); + } + + public function test_a_matching_keyword_leaves_it_pending(): void + { + Setting::setBool('enable_publishing_approvals', true); + $route = $this->route(); + $article = $this->articleFor($route); + + Keyword::create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'brussels', + 'is_active' => true, + ]); + + $this->action()->execute($article, 'news from Brussels today'); + + $this->assertSame(ApprovalStatusEnum::PENDING, RouteArticle::first()->approval_status); + } + + public function test_a_non_matching_keyword_rejects(): void + { + $route = $this->route(); + $article = $this->articleFor($route); + + Keyword::create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'antwerp', + 'is_active' => true, + ]); + + $this->action()->execute($article, 'news from Brussels today'); + + $this->assertSame(ApprovalStatusEnum::REJECTED, RouteArticle::first()->approval_status); + } + + public function test_keyword_matching_is_case_insensitive(): void + { + Setting::setBool('enable_publishing_approvals', true); + $route = $this->route(); + $article = $this->articleFor($route); + + Keyword::create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'BRUSSELS', + 'is_active' => true, + ]); + + $this->action()->execute($article, 'news from brussels today'); + + $this->assertSame(ApprovalStatusEnum::PENDING, RouteArticle::first()->approval_status); + } + + public function test_inactive_keywords_are_ignored(): void + { + $route = $this->route(); + $article = $this->articleFor($route); + + Keyword::create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'antwerp', + 'is_active' => false, + ]); + + Setting::setBool('enable_publishing_approvals', true); + + $this->action()->execute($article, 'news from Brussels today'); + + $this->assertSame(ApprovalStatusEnum::PENDING, RouteArticle::first()->approval_status); + } + + public function test_the_route_auto_approve_flag_overrides_the_global_setting(): void + { + Setting::setBool('enable_publishing_approvals', true); + $route = $this->route(autoApprove: true); + $article = $this->articleFor($route); + + $this->action()->execute($article, 'body content'); + + $this->assertSame(ApprovalStatusEnum::APPROVED, RouteArticle::first()->approval_status); + } + + public function test_a_rejected_article_is_never_auto_approved(): void + { + $route = $this->route(autoApprove: true); + $article = $this->articleFor($route); + + Keyword::create([ + 'feed_id' => $route->feed_id, + 'platform_channel_id' => $route->platform_channel_id, + 'keyword' => 'antwerp', + 'is_active' => true, + ]); + + $this->action()->execute($article, 'news from Brussels today'); + + $this->assertSame(ApprovalStatusEnum::REJECTED, RouteArticle::first()->approval_status); + } + + public function test_an_approved_route_article_is_stamped_with_a_decision_time(): void + { + $route = $this->route(autoApprove: true); + $article = $this->articleFor($route); + + $this->action()->execute($article, 'body content'); + + $this->assertNotNull(RouteArticle::first()->decided_at); + } + + public function test_a_pending_route_article_has_no_decision_time(): void + { + Setting::setBool('enable_publishing_approvals', true); + $route = $this->route(); + $article = $this->articleFor($route); + + $this->action()->execute($article, 'body content'); + + $this->assertNull(RouteArticle::first()->decided_at); + } + + public function test_running_twice_does_not_duplicate_route_articles(): void + { + $route = $this->route(); + $article = $this->articleFor($route); + + $this->action()->execute($article, 'body content'); + $this->action()->execute($article, 'body content'); + + $this->assertSame(1, RouteArticle::count()); + } +} diff --git a/tests/Unit/Services/ValidationServiceTest.php b/tests/Unit/Services/ValidationServiceTest.php index d9f31af8..6c501d6c 100644 --- a/tests/Unit/Services/ValidationServiceTest.php +++ b/tests/Unit/Services/ValidationServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Services; +use App\Actions\CreateRouteArticlesAction; use App\Enums\ApprovalStatusEnum; use App\Models\Article; use App\Models\Feed; @@ -29,7 +30,7 @@ protected function setUp(): void { parent::setUp(); $this->articleFetcher = Mockery::mock(ArticleFetcher::class); - $this->validationService = new ValidationService($this->articleFetcher); + $this->validationService = new ValidationService($this->articleFetcher, new CreateRouteArticlesAction); } protected function tearDown(): void