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;
|
namespace App\Services\Article;
|
||||||
|
|
||||||
use App\Enums\ApprovalStatusEnum;
|
use App\Actions\CreateRouteArticlesAction;
|
||||||
use App\Models\Article;
|
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
|
class ValidationService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ArticleFetcher $articleFetcher
|
private ArticleFetcher $articleFetcher,
|
||||||
|
private CreateRouteArticlesAction $createRouteArticles,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function validate(Article $article): Article
|
public function validate(Article $article): Article
|
||||||
|
|
@ -46,73 +42,8 @@ public function validate(Article $article): Article
|
||||||
$updateData['validated_at'] = now();
|
$updateData['validated_at'] = now();
|
||||||
$article->update($updateData);
|
$article->update($updateData);
|
||||||
|
|
||||||
$this->createRouteArticles($article, $articleData['full_article']);
|
$this->createRouteArticles->execute($article, $articleData['full_article']);
|
||||||
|
|
||||||
return $article->refresh();
|
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;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Actions\CreateRouteArticlesAction;
|
||||||
use App\Enums\ApprovalStatusEnum;
|
use App\Enums\ApprovalStatusEnum;
|
||||||
use App\Livewire\Articles;
|
use App\Livewire\Articles;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
|
|
@ -134,7 +135,7 @@ private function validate(Article $article): void
|
||||||
'full_article' => 'Body text',
|
'full_article' => 'Body text',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
(new ValidationService($fetcher))->validate($article);
|
(new ValidationService($fetcher, new CreateRouteArticlesAction))->validate($article);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function articleOnRouteWithoutKeywords(bool $autoApprove): Article
|
private function articleOnRouteWithoutKeywords(bool $autoApprove): Article
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Actions\CreateRouteArticlesAction;
|
||||||
use App\Enums\ApprovalStatusEnum;
|
use App\Enums\ApprovalStatusEnum;
|
||||||
use App\Events\NewArticleFetched;
|
use App\Events\NewArticleFetched;
|
||||||
use App\Listeners\ValidateArticleListener;
|
use App\Listeners\ValidateArticleListener;
|
||||||
|
|
@ -33,7 +34,7 @@ private function createListenerWithMockedFetcher(?string $content = 'Some articl
|
||||||
);
|
);
|
||||||
|
|
||||||
return new ValidateArticleListener(
|
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'));
|
$articleFetcher->shouldReceive('fetchArticleData')->andThrow(new \Exception('Fetch failed'));
|
||||||
|
|
||||||
$listener = new ValidateArticleListener(
|
$listener = new ValidateArticleListener(
|
||||||
new ValidationService($articleFetcher)
|
new ValidationService($articleFetcher, new CreateRouteArticlesAction)
|
||||||
);
|
);
|
||||||
|
|
||||||
$feed = Feed::factory()->create();
|
$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;
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use App\Actions\CreateRouteArticlesAction;
|
||||||
use App\Enums\ApprovalStatusEnum;
|
use App\Enums\ApprovalStatusEnum;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
|
|
@ -29,7 +30,7 @@ protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
$this->articleFetcher = Mockery::mock(ArticleFetcher::class);
|
$this->articleFetcher = Mockery::mock(ArticleFetcher::class);
|
||||||
$this->validationService = new ValidationService($this->articleFetcher);
|
$this->validationService = new ValidationService($this->articleFetcher, new CreateRouteArticlesAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue