144 - Extract CreateRouteArticlesAction from ValidationService

This commit is contained in:
myrmidex 2026-08-13 23:16:43 +02:00
parent d8b85f84ed
commit dd8f799445
6 changed files with 305 additions and 77 deletions

View file

@ -0,0 +1,79 @@
<?php
namespace App\Actions;
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 CreateRouteArticlesAction
{
public function execute(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<int, Keyword> $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();
}
}

View file

@ -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<int, Keyword> $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();
}
}

View file

@ -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

View file

@ -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();

View file

@ -0,0 +1,215 @@
<?php
namespace Tests\Unit\Actions;
use App\Actions\CreateRouteArticlesAction;
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 Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CreateRouteArticlesActionTest extends TestCase
{
use RefreshDatabase;
private function action(): CreateRouteArticlesAction
{
return new CreateRouteArticlesAction;
}
private function route(bool $isActive = true, ?bool $autoApprove = null): Route
{
$feed = Feed::factory()->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());
}
}

View file

@ -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