144 - Extract CreateRouteArticlesAction from ValidationService
This commit is contained in:
parent
d8b85f84ed
commit
dd8f799445
6 changed files with 305 additions and 77 deletions
79
app/Actions/CreateRouteArticlesAction.php
Normal file
79
app/Actions/CreateRouteArticlesAction.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
215
tests/Unit/Actions/CreateRouteArticlesActionTest.php
Normal file
215
tests/Unit/Actions/CreateRouteArticlesActionTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue