85 - Refactor ValidationService to per-route keyword evaluation with ApprovalStatusEnum
This commit is contained in:
parent
2a5a8c788b
commit
e3ea02ae1c
7 changed files with 398 additions and 353 deletions
10
app/Enums/ApprovalStatusEnum.php
Normal file
10
app/Enums/ApprovalStatusEnum.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ApprovalStatusEnum: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case APPROVED = 'approved';
|
||||
case REJECTED = 'rejected';
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use Database\Factories\RouteArticleFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -13,7 +14,7 @@
|
|||
* @property int $feed_id
|
||||
* @property int $platform_channel_id
|
||||
* @property int $article_id
|
||||
* @property string $approval_status
|
||||
* @property ApprovalStatusEnum $approval_status
|
||||
* @property Carbon|null $validated_at
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
|
|
@ -32,6 +33,7 @@ class RouteArticle extends Model
|
|||
];
|
||||
|
||||
protected $casts = [
|
||||
'approval_status' => ApprovalStatusEnum::class,
|
||||
'validated_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
|
@ -70,26 +72,26 @@ public function platformChannel(): BelongsTo
|
|||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->approval_status === 'pending';
|
||||
return $this->approval_status === ApprovalStatusEnum::PENDING;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->approval_status === 'approved';
|
||||
return $this->approval_status === ApprovalStatusEnum::APPROVED;
|
||||
}
|
||||
|
||||
public function isRejected(): bool
|
||||
{
|
||||
return $this->approval_status === 'rejected';
|
||||
return $this->approval_status === ApprovalStatusEnum::REJECTED;
|
||||
}
|
||||
|
||||
public function approve(): void
|
||||
{
|
||||
$this->update(['approval_status' => 'approved']);
|
||||
$this->update(['approval_status' => ApprovalStatusEnum::APPROVED]);
|
||||
}
|
||||
|
||||
public function reject(): void
|
||||
{
|
||||
$this->update(['approval_status' => 'rejected']);
|
||||
$this->update(['approval_status' => ApprovalStatusEnum::REJECTED]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
namespace App\Services\Article;
|
||||
|
||||
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 ValidationService
|
||||
{
|
||||
|
|
@ -12,11 +18,10 @@ public function __construct(
|
|||
|
||||
public function validate(Article $article): Article
|
||||
{
|
||||
logger('Checking keywords for article: '.$article->id);
|
||||
logger('Validating article for routes: '.$article->id);
|
||||
|
||||
$articleData = $this->articleFetcher->fetchArticleData($article);
|
||||
|
||||
// Update article with fetched metadata (title, description)
|
||||
$updateData = [];
|
||||
|
||||
if (! empty($articleData)) {
|
||||
|
|
@ -31,51 +36,78 @@ public function validate(Article $article): Article
|
|||
'url' => $article->url,
|
||||
]);
|
||||
|
||||
$updateData['approval_status'] = 'rejected';
|
||||
$updateData['validated_at'] = now();
|
||||
$article->update($updateData);
|
||||
|
||||
return $article->refresh();
|
||||
}
|
||||
|
||||
// Validate content against keywords. If validation fails, reject.
|
||||
// If validation passes, leave approval_status as-is (pending) —
|
||||
// the listener decides whether to auto-approve based on settings.
|
||||
$validationResult = $this->validateByKeywords($articleData['full_article']);
|
||||
|
||||
if (! $validationResult) {
|
||||
$updateData['approval_status'] = 'rejected';
|
||||
}
|
||||
|
||||
$updateData['validated_at'] = now();
|
||||
$article->update($updateData);
|
||||
|
||||
$this->createRouteArticles($article, $articleData['full_article']);
|
||||
|
||||
return $article->refresh();
|
||||
}
|
||||
|
||||
private function validateByKeywords(string $full_article): bool
|
||||
private function createRouteArticles(Article $article, string $content): void
|
||||
{
|
||||
// Belgian news content keywords - broader set for Belgian news relevance
|
||||
$keywords = [
|
||||
// Political parties and leaders
|
||||
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
|
||||
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
|
||||
$activeRoutes = Route::where('feed_id', $article->feed_id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Belgian locations and institutions
|
||||
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
|
||||
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
|
||||
'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
|
||||
// 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');
|
||||
|
||||
// Common Belgian news topics
|
||||
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
|
||||
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police',
|
||||
];
|
||||
foreach ($activeRoutes as $route) {
|
||||
$routeKeywords = $keywordsByChannel->get($route->platform_channel_id, collect());
|
||||
$status = $this->evaluateKeywords($routeKeywords, $content);
|
||||
|
||||
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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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($full_article, $keyword) !== false) {
|
||||
return true;
|
||||
if (stripos($content, $keyword->keyword) !== false) {
|
||||
return ApprovalStatusEnum::PENDING;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
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 Database\Factories;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformChannel;
|
||||
|
|
@ -19,7 +20,7 @@ public function definition(): array
|
|||
'feed_id' => Feed::factory(),
|
||||
'platform_channel_id' => PlatformChannel::factory(),
|
||||
'article_id' => Article::factory(),
|
||||
'approval_status' => 'pending',
|
||||
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||
'validated_at' => null,
|
||||
];
|
||||
}
|
||||
|
|
@ -60,14 +61,14 @@ public function forRoute(Route $route): static
|
|||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'approval_status' => 'pending',
|
||||
'approval_status' => ApprovalStatusEnum::PENDING,
|
||||
]);
|
||||
}
|
||||
|
||||
public function approved(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'approval_status' => 'approved',
|
||||
'approval_status' => ApprovalStatusEnum::APPROVED,
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -75,7 +76,7 @@ public function approved(): static
|
|||
public function rejected(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'approval_status' => 'rejected',
|
||||
'approval_status' => ApprovalStatusEnum::REJECTED,
|
||||
'validated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformChannel;
|
||||
|
|
@ -39,7 +40,7 @@ public function test_route_article_has_default_pending_status(): void
|
|||
{
|
||||
$routeArticle = RouteArticle::factory()->create();
|
||||
|
||||
$this->assertEquals('pending', $routeArticle->approval_status);
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
|
||||
$this->assertTrue($routeArticle->isPending());
|
||||
$this->assertFalse($routeArticle->isApproved());
|
||||
$this->assertFalse($routeArticle->isRejected());
|
||||
|
|
@ -51,7 +52,7 @@ public function test_route_article_can_be_approved(): void
|
|||
|
||||
$routeArticle->approve();
|
||||
|
||||
$this->assertEquals('approved', $routeArticle->fresh()->approval_status);
|
||||
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status);
|
||||
}
|
||||
|
||||
public function test_route_article_can_be_rejected(): void
|
||||
|
|
@ -60,7 +61,7 @@ public function test_route_article_can_be_rejected(): void
|
|||
|
||||
$routeArticle->reject();
|
||||
|
||||
$this->assertEquals('rejected', $routeArticle->fresh()->approval_status);
|
||||
$this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status);
|
||||
}
|
||||
|
||||
public function test_article_has_many_route_articles(): void
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\Article\ValidationService;
|
||||
use Mockery;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
use Tests\Traits\CreatesArticleFetcher;
|
||||
|
||||
class ValidationServiceKeywordTest extends TestCase
|
||||
{
|
||||
use CreatesArticleFetcher;
|
||||
|
||||
private ValidationService $validationService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$articleFetcher = $this->createArticleFetcher();
|
||||
$this->validationService = new ValidationService($articleFetcher);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to access private validateByKeywords method
|
||||
*/
|
||||
private function getValidateByKeywordsMethod(): ReflectionMethod
|
||||
{
|
||||
$reflection = new ReflectionClass($this->validationService);
|
||||
$method = $reflection->getMethod('validateByKeywords');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method;
|
||||
}
|
||||
|
||||
public function test_validates_belgian_political_keywords(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$this->assertTrue($method->invoke($this->validationService, 'This article discusses N-VA party policies.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Bart De Wever made a statement today.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Frank Vandenbroucke announced new healthcare policies.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Alexander De Croo addressed the nation.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The Vooruit party proposed new legislation.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Open Vld supports the new budget.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'CD&V members voted on the proposal.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Vlaams Belang criticized the decision.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'PTB organized a protest yesterday.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'PVDA released a statement.'));
|
||||
}
|
||||
|
||||
public function test_validates_belgian_location_keywords(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$this->assertTrue($method->invoke($this->validationService, 'This event took place in Belgium.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The Belgian government announced new policies.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Flanders saw increased tourism this year.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The Flemish government supports this initiative.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Wallonia will receive additional funding.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Brussels hosted the international conference.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Antwerp Pride attracted thousands of participants.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Ghent University published the research.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Bruges tourism numbers increased.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Leuven students organized the protest.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Mechelen city council voted on the proposal.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Namur hosted the cultural event.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Liège airport saw increased traffic.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Charleroi industrial zone expanded.'));
|
||||
}
|
||||
|
||||
public function test_validates_government_keywords(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Parliament voted on the new legislation.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The government announced budget cuts.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The minister addressed concerns about healthcare.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'New policy changes will take effect next month.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The law was passed with majority support.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'New legislation affects education funding.'));
|
||||
}
|
||||
|
||||
public function test_validates_news_topic_keywords(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The economy showed signs of recovery.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Economic indicators improved this quarter.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Education reforms were announced today.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Healthcare workers received additional support.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Transport infrastructure will be upgraded.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Climate change policies were discussed.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Energy prices have increased significantly.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'European Union voted on trade agreements.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'EU sanctions were extended.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Migration policies need urgent review.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Security measures were enhanced.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Justice system reforms are underway.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Culture festivals received government funding.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Police reported 18 administrative detentions.'));
|
||||
}
|
||||
|
||||
public function test_case_insensitive_keyword_matching(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$this->assertTrue($method->invoke($this->validationService, 'This article mentions ANTWERP in capital letters.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'brussels is mentioned in lowercase.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'BeLgIuM is mentioned in mixed case.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The FLEMISH government announced policies.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'n-va party policies were discussed.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'EUROPEAN union directives apply.'));
|
||||
}
|
||||
|
||||
public function test_rejects_content_without_belgian_keywords(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$this->assertFalse($method->invoke($this->validationService, 'This article discusses random topics.'));
|
||||
$this->assertFalse($method->invoke($this->validationService, 'International news from other countries.'));
|
||||
$this->assertFalse($method->invoke($this->validationService, 'Technology updates and innovations.'));
|
||||
$this->assertFalse($method->invoke($this->validationService, 'Sports results from around the world.'));
|
||||
$this->assertFalse($method->invoke($this->validationService, 'Entertainment news and celebrity gossip.'));
|
||||
$this->assertFalse($method->invoke($this->validationService, 'Weather forecast for next week.'));
|
||||
$this->assertFalse($method->invoke($this->validationService, 'Stock market analysis and trends.'));
|
||||
}
|
||||
|
||||
public function test_keyword_matching_in_longer_text(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$longText = '
|
||||
This is a comprehensive article about various topics.
|
||||
It covers international relations, global economics, and regional policies.
|
||||
However, it specifically mentions that Antwerp hosted a major conference
|
||||
last week with participants from around the world. The event was
|
||||
considered highly successful and will likely be repeated next year.
|
||||
';
|
||||
|
||||
$this->assertTrue($method->invoke($this->validationService, $longText));
|
||||
|
||||
$longTextWithoutKeywords = '
|
||||
This is a comprehensive article about various topics.
|
||||
It covers international relations, global finance, and commercial matters.
|
||||
The conference was held in a major international city and attracted
|
||||
participants from around the world. The event was considered highly
|
||||
successful and will likely be repeated next year.
|
||||
';
|
||||
|
||||
$this->assertFalse($method->invoke($this->validationService, $longTextWithoutKeywords));
|
||||
}
|
||||
|
||||
public function test_empty_content_returns_false(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$this->assertFalse($method->invoke($this->validationService, ''));
|
||||
$this->assertFalse($method->invoke($this->validationService, ' '));
|
||||
$this->assertFalse($method->invoke($this->validationService, "\n\n\t"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test comprehensive keyword coverage to ensure all expected keywords work
|
||||
*/
|
||||
public function test_all_keywords_are_functional(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
$expectedKeywords = [
|
||||
// Political parties and leaders
|
||||
'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo',
|
||||
'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA',
|
||||
|
||||
// Belgian locations and institutions
|
||||
'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels',
|
||||
'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi',
|
||||
'parliament', 'government', 'minister', 'policy', 'law', 'legislation',
|
||||
|
||||
// Common Belgian news topics
|
||||
'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy',
|
||||
'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police',
|
||||
];
|
||||
|
||||
foreach ($expectedKeywords as $keyword) {
|
||||
$testContent = "This article contains the keyword: {$keyword}.";
|
||||
$result = $method->invoke($this->validationService, $testContent);
|
||||
|
||||
$this->assertTrue($result, "Keyword '{$keyword}' should match but didn't");
|
||||
}
|
||||
}
|
||||
|
||||
public function test_partial_keyword_matches_work(): void
|
||||
{
|
||||
$method = $this->getValidateByKeywordsMethod();
|
||||
|
||||
// Keywords should match when they appear as part of larger words or phrases
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Anti-government protesters gathered.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The policeman directed traffic.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Educational reforms are needed.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'Economic growth accelerated.'));
|
||||
$this->assertTrue($method->invoke($this->validationService, 'The European directive was implemented.'));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,26 +2,33 @@
|
|||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
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 App\Services\Article\ArticleFetcher;
|
||||
use App\Services\Article\ValidationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
use Tests\Traits\CreatesArticleFetcher;
|
||||
|
||||
class ValidationServiceTest extends TestCase
|
||||
{
|
||||
use CreatesArticleFetcher, RefreshDatabase;
|
||||
use RefreshDatabase;
|
||||
|
||||
private ValidationService $validationService;
|
||||
|
||||
private \Mockery\MockInterface $articleFetcher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$articleFetcher = $this->createArticleFetcher();
|
||||
$this->validationService = new ValidationService($articleFetcher);
|
||||
$this->articleFetcher = Mockery::mock(ArticleFetcher::class);
|
||||
$this->validationService = new ValidationService($this->articleFetcher);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
|
|
@ -30,133 +37,336 @@ protected function tearDown(): void
|
|||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_validate_returns_article_with_validation_status(): void
|
||||
private function mockFetchReturning(Article $article, ?string $content, ?string $title = 'Test Title', ?string $description = 'Test description'): void
|
||||
{
|
||||
// Mock HTTP requests
|
||||
Http::fake([
|
||||
'https://example.com/article' => Http::response('<html><body>Test content with Belgium news</body></html>', 200),
|
||||
]);
|
||||
$data = [];
|
||||
if ($title) {
|
||||
$data['title'] = $title;
|
||||
}
|
||||
if ($description) {
|
||||
$data['description'] = $description;
|
||||
}
|
||||
if ($content) {
|
||||
$data['full_article'] = $content;
|
||||
}
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$result = $this->validationService->validate($article);
|
||||
|
||||
$this->assertInstanceOf(Article::class, $result);
|
||||
$this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']);
|
||||
$this->articleFetcher
|
||||
->shouldReceive('fetchArticleData')
|
||||
->with($article)
|
||||
->once()
|
||||
->andReturn($data);
|
||||
}
|
||||
|
||||
public function test_validate_marks_article_invalid_when_missing_data(): void
|
||||
public function test_validate_sets_validated_at_on_article(): void
|
||||
{
|
||||
// Mock HTTP requests to return HTML without article content
|
||||
Http::fake([
|
||||
'https://invalid-url-without-parser.com/article' => Http::response('<html><body>Empty</body></html>', 200),
|
||||
]);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://invalid-url-without-parser.com/article',
|
||||
'approval_status' => 'pending',
|
||||
'platform_channel_id' => $route->platform_channel_id,
|
||||
'keyword' => 'Belgium',
|
||||
]);
|
||||
|
||||
$result = $this->validationService->validate($article);
|
||||
|
||||
$this->assertEquals('rejected', $result->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_with_supported_article_content(): void
|
||||
{
|
||||
// Mock HTTP requests
|
||||
Http::fake([
|
||||
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
|
||||
]);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$result = $this->validationService->validate($article);
|
||||
|
||||
// Since we can't fetch real content in tests, it should be marked rejected
|
||||
$this->assertEquals('rejected', $result->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_updates_article_in_database(): void
|
||||
{
|
||||
// Mock HTTP requests
|
||||
Http::fake([
|
||||
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
|
||||
]);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'pending',
|
||||
]);
|
||||
|
||||
$originalId = $article->id;
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about Belgium');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
// Check that the article was updated in the database
|
||||
$updatedArticle = Article::find($originalId);
|
||||
$this->assertContains($updatedArticle->approval_status, ['pending', 'approved', 'rejected']);
|
||||
$this->assertNotNull($article->fresh()->validated_at);
|
||||
}
|
||||
|
||||
public function test_validate_handles_article_with_existing_validation(): void
|
||||
public function test_validate_creates_route_articles_for_active_routes(): void
|
||||
{
|
||||
// Mock HTTP requests
|
||||
Http::fake([
|
||||
'https://example.com/article' => Http::response('<html><body>Article content</body></html>', 200),
|
||||
$feed = Feed::factory()->create();
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Some article content');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$this->assertCount(2, RouteArticle::where('article_id', $article->id)->get());
|
||||
}
|
||||
|
||||
public function test_validate_skips_inactive_routes(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
Route::factory()->inactive()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Some article content');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$this->assertCount(1, RouteArticle::where('article_id', $article->id)->get());
|
||||
}
|
||||
|
||||
public function test_validate_sets_pending_when_keywords_match(): 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',
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about Belgium politics');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_sets_rejected_when_no_keywords_match(): 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',
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about random topics and weather');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_sets_pending_when_route_has_no_keywords(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about random topics');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_different_routes_get_different_statuses(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$channel1 = PlatformChannel::factory()->create();
|
||||
$channel2 = PlatformChannel::factory()->create();
|
||||
|
||||
Route::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel1->id,
|
||||
]);
|
||||
Route::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel2->id,
|
||||
]);
|
||||
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel1->id,
|
||||
'keyword' => 'Belgium',
|
||||
]);
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $channel2->id,
|
||||
'keyword' => 'Technology',
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about Belgium');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$ra1 = RouteArticle::where('article_id', $article->id)
|
||||
->where('platform_channel_id', $channel1->id)->first();
|
||||
$ra2 = RouteArticle::where('article_id', $article->id)
|
||||
->where('platform_channel_id', $channel2->id)->first();
|
||||
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $ra1->approval_status);
|
||||
$this->assertEquals(ApprovalStatusEnum::REJECTED, $ra2->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_auto_approves_when_global_setting_off_and_keywords_match(): void
|
||||
{
|
||||
Setting::setBool('enable_publishing_approvals', false);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$article = Article::factory()->create([
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article',
|
||||
'approval_status' => 'approved',
|
||||
'platform_channel_id' => $route->platform_channel_id,
|
||||
'keyword' => 'Belgium',
|
||||
]);
|
||||
|
||||
$originalApprovalStatus = $article->approval_status;
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about Belgium');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_route_auto_approve_overrides_global_setting(): void
|
||||
{
|
||||
Setting::setBool('enable_publishing_approvals', true);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$route = Route::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'auto_approve' => true,
|
||||
]);
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $route->platform_channel_id,
|
||||
'keyword' => 'Belgium',
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about Belgium');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_route_auto_approve_false_overrides_global_off(): void
|
||||
{
|
||||
Setting::setBool('enable_publishing_approvals', false);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
$route = Route::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'auto_approve' => false,
|
||||
]);
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $route->platform_channel_id,
|
||||
'keyword' => 'Belgium',
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about Belgium');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_does_not_auto_approve_rejected_articles(): void
|
||||
{
|
||||
Setting::setBool('enable_publishing_approvals', false);
|
||||
|
||||
$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',
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Random content no match');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_creates_no_route_articles_when_content_fetch_fails(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, null);
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$this->assertCount(0, RouteArticle::where('article_id', $article->id)->get());
|
||||
$this->assertNotNull($article->fresh()->validated_at);
|
||||
}
|
||||
|
||||
public function test_validate_updates_article_metadata(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
$article = Article::factory()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'title' => 'Old Title',
|
||||
]);
|
||||
$this->mockFetchReturning($article, 'Content about Belgium', 'New Title', 'New description');
|
||||
|
||||
$result = $this->validationService->validate($article);
|
||||
|
||||
// Should re-validate - status may change based on content validation
|
||||
$this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']);
|
||||
$this->assertEquals('New Title', $result->title);
|
||||
$this->assertEquals('New description', $result->description);
|
||||
$this->assertEquals('Content about Belgium', $result->content);
|
||||
}
|
||||
|
||||
public function test_validate_keyword_checking_logic(): void
|
||||
public function test_validate_sets_validated_at_on_route_articles(): void
|
||||
{
|
||||
// Mock HTTP requests with content that contains Belgian keywords
|
||||
Http::fake([
|
||||
'https://example.com/article-about-bart-de-wever' => Http::response(
|
||||
'<html><body><article>Article about Bart De Wever and Belgian politics</article></body></html>',
|
||||
200
|
||||
),
|
||||
]);
|
||||
|
||||
$feed = Feed::factory()->create();
|
||||
Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
|
||||
// Create an article that would match the validation keywords if content was available
|
||||
$article = Article::factory()->create([
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Content about something');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertNotNull($routeArticle->validated_at);
|
||||
}
|
||||
|
||||
public function test_validate_keyword_matching_is_case_insensitive(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
Keyword::factory()->active()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'url' => 'https://example.com/article-about-bart-de-wever',
|
||||
'approval_status' => 'pending',
|
||||
'platform_channel_id' => $route->platform_channel_id,
|
||||
'keyword' => 'belgium',
|
||||
]);
|
||||
|
||||
$result = $this->validationService->validate($article);
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about BELGIUM politics');
|
||||
|
||||
// The service looks for keywords in the full_article content
|
||||
// Since we can't fetch real content, it will be marked rejected
|
||||
$this->assertEquals('rejected', $result->approval_status);
|
||||
$this->validationService->validate($article);
|
||||
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
|
||||
}
|
||||
|
||||
public function test_validate_only_uses_active_keywords(): void
|
||||
{
|
||||
$feed = Feed::factory()->create();
|
||||
$route = Route::factory()->active()->create(['feed_id' => $feed->id]);
|
||||
Keyword::factory()->inactive()->create([
|
||||
'feed_id' => $feed->id,
|
||||
'platform_channel_id' => $route->platform_channel_id,
|
||||
'keyword' => 'Belgium',
|
||||
]);
|
||||
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
$this->mockFetchReturning($article, 'Article about Belgium');
|
||||
|
||||
$this->validationService->validate($article);
|
||||
|
||||
// No active keywords = matches everything = pending
|
||||
$routeArticle = RouteArticle::where('article_id', $article->id)->first();
|
||||
$this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->approval_status);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue