From e3ea02ae1c85167f78f385525975b75c2d77bcc8 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 15:46:15 +0100 Subject: [PATCH] 85 - Refactor ValidationService to per-route keyword evaluation with ApprovalStatusEnum --- app/Enums/ApprovalStatusEnum.php | 10 + app/Models/RouteArticle.php | 14 +- app/Services/Article/ValidationService.php | 90 ++-- database/factories/RouteArticleFactory.php | 9 +- tests/Unit/Models/RouteArticleTest.php | 7 +- .../Services/ValidationServiceKeywordTest.php | 211 --------- tests/Unit/Services/ValidationServiceTest.php | 410 +++++++++++++----- 7 files changed, 398 insertions(+), 353 deletions(-) create mode 100644 app/Enums/ApprovalStatusEnum.php delete mode 100644 tests/Unit/Services/ValidationServiceKeywordTest.php diff --git a/app/Enums/ApprovalStatusEnum.php b/app/Enums/ApprovalStatusEnum.php new file mode 100644 index 0000000..5095c5d --- /dev/null +++ b/app/Enums/ApprovalStatusEnum.php @@ -0,0 +1,10 @@ + 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]); } } diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php index fb76392..aca2f8f 100644 --- a/app/Services/Article/ValidationService.php +++ b/app/Services/Article/ValidationService.php @@ -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 $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(); } } diff --git a/database/factories/RouteArticleFactory.php b/database/factories/RouteArticleFactory.php index 1b11673..0230d5a 100644 --- a/database/factories/RouteArticleFactory.php +++ b/database/factories/RouteArticleFactory.php @@ -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(), ]); } diff --git a/tests/Unit/Models/RouteArticleTest.php b/tests/Unit/Models/RouteArticleTest.php index 89e2471..4ccd826 100644 --- a/tests/Unit/Models/RouteArticleTest.php +++ b/tests/Unit/Models/RouteArticleTest.php @@ -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 diff --git a/tests/Unit/Services/ValidationServiceKeywordTest.php b/tests/Unit/Services/ValidationServiceKeywordTest.php deleted file mode 100644 index 711088f..0000000 --- a/tests/Unit/Services/ValidationServiceKeywordTest.php +++ /dev/null @@ -1,211 +0,0 @@ -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.')); - } -} diff --git a/tests/Unit/Services/ValidationServiceTest.php b/tests/Unit/Services/ValidationServiceTest.php index cd9a83a..03f69d9 100644 --- a/tests/Unit/Services/ValidationServiceTest.php +++ b/tests/Unit/Services/ValidationServiceTest.php @@ -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('Test content with Belgium news', 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('Empty', 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('Article content', 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('Article content', 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('Article content', 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( - '
Article about Bart De Wever and Belgian politics
', - 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); } }