diff --git a/backend/app/Facades/LogSaver.php b/backend/app/Facades/LogSaver.php new file mode 100644 index 0000000..661e618 --- /dev/null +++ b/backend/app/Facades/LogSaver.php @@ -0,0 +1,13 @@ +onQueue('feed-discovery'); } - public function handle(): void + public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void { - LogSaver::info('Starting feed article fetch', null, [ + $logSaver->info('Starting feed article fetch', null, [ 'feed_id' => $this->feed->id, 'feed_name' => $this->feed->name, 'feed_url' => $this->feed->url ]); - $articles = ArticleFetcher::getArticlesFromFeed($this->feed); + $articles = $articleFetcher->getArticlesFromFeed($this->feed); - LogSaver::info('Feed article fetch completed', null, [ + $logSaver->info('Feed article fetch completed', null, [ 'feed_id' => $this->feed->id, 'feed_name' => $this->feed->name, 'articles_count' => $articles->count() @@ -41,9 +41,11 @@ public function handle(): void public static function dispatchForAllActiveFeeds(): void { + $logSaver = app(LogSaver::class); + Feed::where('is_active', true) ->get() - ->each(function (Feed $feed, $index) { + ->each(function (Feed $feed, $index) use ($logSaver) { // Space jobs apart to avoid overwhelming feeds $delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES; @@ -51,7 +53,7 @@ public static function dispatchForAllActiveFeeds(): void ->delay(now()->addMinutes($delayMinutes)) ->onQueue('feed-discovery'); - LogSaver::info('Dispatched feed discovery job', null, [ + $logSaver->info('Dispatched feed discovery job', null, [ 'feed_id' => $feed->id, 'feed_name' => $feed->name, 'delay_minutes' => $delayMinutes diff --git a/backend/app/Jobs/ArticleDiscoveryJob.php b/backend/app/Jobs/ArticleDiscoveryJob.php index 5ea8476..c89894e 100644 --- a/backend/app/Jobs/ArticleDiscoveryJob.php +++ b/backend/app/Jobs/ArticleDiscoveryJob.php @@ -16,18 +16,18 @@ public function __construct() $this->onQueue('feed-discovery'); } - public function handle(): void + public function handle(LogSaver $logSaver): void { if (!Setting::isArticleProcessingEnabled()) { - LogSaver::info('Article processing is disabled. Article discovery skipped.'); + $logSaver->info('Article processing is disabled. Article discovery skipped.'); return; } - LogSaver::info('Starting article discovery for all active feeds'); + $logSaver->info('Starting article discovery for all active feeds'); ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - LogSaver::info('Article discovery jobs dispatched for all active feeds'); + $logSaver->info('Article discovery jobs dispatched for all active feeds'); } } diff --git a/backend/app/Jobs/PublishNextArticleJob.php b/backend/app/Jobs/PublishNextArticleJob.php index 1e6dde1..4e5fc0d 100644 --- a/backend/app/Jobs/PublishNextArticleJob.php +++ b/backend/app/Jobs/PublishNextArticleJob.php @@ -28,7 +28,7 @@ public function __construct() * Execute the job. * @throws PublishException */ - public function handle(): void + public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void { // Get the oldest approved article that hasn't been published yet $article = Article::where('approval_status', 'approved') @@ -48,10 +48,7 @@ public function handle(): void ]); // Fetch article data - $extractedData = ArticleFetcher::fetchArticleData($article); - - /** @var ArticlePublishingService $publishingService */ - $publishingService = resolve(ArticlePublishingService::class); + $extractedData = $articleFetcher->fetchArticleData($article); try { $publishingService->publishToRoutedChannels($article, $extractedData); diff --git a/backend/app/Jobs/SyncChannelPostsJob.php b/backend/app/Jobs/SyncChannelPostsJob.php index 5f40503..2f5bee6 100644 --- a/backend/app/Jobs/SyncChannelPostsJob.php +++ b/backend/app/Jobs/SyncChannelPostsJob.php @@ -27,32 +27,34 @@ public function __construct( public static function dispatchForAllActiveChannels(): void { + $logSaver = app(LogSaver::class); + PlatformChannel::with(['platformInstance', 'platformAccounts']) ->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY)) - ->whereHas('platformAccounts', fn ($query) => $query->where('is_active', true)) - ->where('is_active', true) + ->whereHas('platformAccounts', fn ($query) => $query->where('platform_accounts.is_active', true)) + ->where('platform_channels.is_active', true) ->get() - ->each(function (PlatformChannel $channel) { + ->each(function (PlatformChannel $channel) use ($logSaver) { self::dispatch($channel); - LogSaver::info('Dispatched sync job for channel', $channel); + $logSaver->info('Dispatched sync job for channel', $channel); }); } - public function handle(): void + public function handle(LogSaver $logSaver): void { - LogSaver::info('Starting channel posts sync job', $this->channel); + $logSaver->info('Starting channel posts sync job', $this->channel); match ($this->channel->platformInstance->platform) { - PlatformEnum::LEMMY => $this->syncLemmyChannelPosts(), + PlatformEnum::LEMMY => $this->syncLemmyChannelPosts($logSaver), }; - LogSaver::info('Channel posts sync job completed', $this->channel); + $logSaver->info('Channel posts sync job completed', $this->channel); } /** * @throws PlatformAuthException */ - private function syncLemmyChannelPosts(): void + private function syncLemmyChannelPosts(LogSaver $logSaver): void { try { /** @var Collection $accounts */ @@ -72,10 +74,10 @@ private function syncLemmyChannelPosts(): void $api->syncChannelPosts($token, $platformChannelId, $this->channel->name); - LogSaver::info('Channel posts synced successfully', $this->channel); + $logSaver->info('Channel posts synced successfully', $this->channel); } catch (Exception $e) { - LogSaver::error('Failed to sync channel posts', $this->channel, [ + $logSaver->error('Failed to sync channel posts', $this->channel, [ 'error' => $e->getMessage() ]); diff --git a/backend/app/Listeners/ValidateArticleListener.php b/backend/app/Listeners/ValidateArticleListener.php index 68290c8..9c5ebcf 100644 --- a/backend/app/Listeners/ValidateArticleListener.php +++ b/backend/app/Listeners/ValidateArticleListener.php @@ -12,7 +12,7 @@ class ValidateArticleListener implements ShouldQueue { public string $queue = 'default'; - public function handle(NewArticleFetched $event): void + public function handle(NewArticleFetched $event, ValidationService $validationService): void { $article = $event->article; @@ -25,7 +25,7 @@ public function handle(NewArticleFetched $event): void return; } - $article = ValidationService::validate($article); + $article = $validationService->validate($article); if ($article->isValid()) { // Double-check publication doesn't exist (race condition protection) diff --git a/backend/app/Services/Article/ArticleFetcher.php b/backend/app/Services/Article/ArticleFetcher.php index 5b1b87f..d14e7be 100644 --- a/backend/app/Services/Article/ArticleFetcher.php +++ b/backend/app/Services/Article/ArticleFetcher.php @@ -13,18 +13,22 @@ class ArticleFetcher { + public function __construct( + private LogSaver $logSaver + ) {} + /** * @return Collection */ - public static function getArticlesFromFeed(Feed $feed): Collection + public function getArticlesFromFeed(Feed $feed): Collection { if ($feed->type === 'rss') { - return self::getArticlesFromRssFeed($feed); + return $this->getArticlesFromRssFeed($feed); } elseif ($feed->type === 'website') { - return self::getArticlesFromWebsiteFeed($feed); + return $this->getArticlesFromWebsiteFeed($feed); } - LogSaver::warning("Unsupported feed type", null, [ + $this->logSaver->warning("Unsupported feed type", null, [ 'feed_id' => $feed->id, 'feed_type' => $feed->type ]); @@ -35,7 +39,7 @@ public static function getArticlesFromFeed(Feed $feed): Collection /** * @return Collection */ - private static function getArticlesFromRssFeed(Feed $feed): Collection + private function getArticlesFromRssFeed(Feed $feed): Collection { // TODO: Implement RSS feed parsing // For now, return empty collection @@ -45,14 +49,14 @@ private static function getArticlesFromRssFeed(Feed $feed): Collection /** * @return Collection */ - private static function getArticlesFromWebsiteFeed(Feed $feed): Collection + private function getArticlesFromWebsiteFeed(Feed $feed): Collection { try { // Try to get parser for this feed $parser = HomepageParserFactory::getParserForFeed($feed); if (! $parser) { - LogSaver::warning("No parser available for feed URL", null, [ + $this->logSaver->warning("No parser available for feed URL", null, [ 'feed_id' => $feed->id, 'feed_url' => $feed->url ]); @@ -64,10 +68,10 @@ private static function getArticlesFromWebsiteFeed(Feed $feed): Collection $urls = $parser->extractArticleUrls($html); return collect($urls) - ->map(fn (string $url) => self::saveArticle($url, $feed->id)); + ->map(fn (string $url) => $this->saveArticle($url, $feed->id)); } catch (Exception $e) { - LogSaver::error("Failed to fetch articles from website feed", null, [ + $this->logSaver->error("Failed to fetch articles from website feed", null, [ 'feed_id' => $feed->id, 'feed_url' => $feed->url, 'error' => $e->getMessage() @@ -80,7 +84,7 @@ private static function getArticlesFromWebsiteFeed(Feed $feed): Collection /** * @return array */ - public static function fetchArticleData(Article $article): array + public function fetchArticleData(Article $article): array { try { $html = HttpFetcher::fetchHtml($article->url); @@ -88,7 +92,7 @@ public static function fetchArticleData(Article $article): array return $parser->extractData($html); } catch (Exception $e) { - LogSaver::error('Exception while fetching article data', null, [ + $this->logSaver->error('Exception while fetching article data', null, [ 'url' => $article->url, 'error' => $e->getMessage() ]); @@ -97,7 +101,7 @@ public static function fetchArticleData(Article $article): array } } - private static function saveArticle(string $url, ?int $feedId = null): Article + private function saveArticle(string $url, ?int $feedId = null): Article { $existingArticle = Article::where('url', $url)->first(); @@ -106,7 +110,7 @@ private static function saveArticle(string $url, ?int $feedId = null): Article } // Extract a basic title from URL as fallback - $fallbackTitle = self::generateFallbackTitle($url); + $fallbackTitle = $this->generateFallbackTitle($url); try { return Article::create([ @@ -115,7 +119,7 @@ private static function saveArticle(string $url, ?int $feedId = null): Article 'title' => $fallbackTitle, ]); } catch (\Exception $e) { - LogSaver::error("Failed to create article - title validation failed", null, [ + $this->logSaver->error("Failed to create article - title validation failed", null, [ 'url' => $url, 'feed_id' => $feedId, 'error' => $e->getMessage(), @@ -125,7 +129,7 @@ private static function saveArticle(string $url, ?int $feedId = null): Article } } - private static function generateFallbackTitle(string $url): string + private function generateFallbackTitle(string $url): string { // Extract filename from URL as a basic fallback title $path = parse_url($url, PHP_URL_PATH); diff --git a/backend/app/Services/Article/ValidationService.php b/backend/app/Services/Article/ValidationService.php index e3c1e1b..271b8d7 100644 --- a/backend/app/Services/Article/ValidationService.php +++ b/backend/app/Services/Article/ValidationService.php @@ -6,11 +6,15 @@ class ValidationService { - public static function validate(Article $article): Article + public function __construct( + private ArticleFetcher $articleFetcher + ) {} + + public function validate(Article $article): Article { logger('Checking keywords for article: ' . $article->id); - $articleData = ArticleFetcher::fetchArticleData($article); + $articleData = $this->articleFetcher->fetchArticleData($article); // Update article with fetched metadata (title, description) $updateData = []; @@ -34,7 +38,7 @@ public static function validate(Article $article): Article } // Validate using extracted content (not stored) - $validationResult = self::validateByKeywords($articleData['full_article']); + $validationResult = $this->validateByKeywords($articleData['full_article']); $updateData['approval_status'] = $validationResult ? 'approved' : 'pending'; $article->update($updateData); @@ -42,7 +46,7 @@ public static function validate(Article $article): Article return $article->refresh(); } - private static function validateByKeywords(string $full_article): bool + private function validateByKeywords(string $full_article): bool { // Belgian news content keywords - broader set for Belgian news relevance $keywords = [ diff --git a/backend/app/Services/Log/LogSaver.php b/backend/app/Services/Log/LogSaver.php index 2592f82..5a72fb4 100644 --- a/backend/app/Services/Log/LogSaver.php +++ b/backend/app/Services/Log/LogSaver.php @@ -11,39 +11,39 @@ class LogSaver /** * @param array $context */ - public static function info(string $message, ?PlatformChannel $channel = null, array $context = []): void + public function info(string $message, ?PlatformChannel $channel = null, array $context = []): void { - self::log(LogLevelEnum::INFO, $message, $channel, $context); + $this->log(LogLevelEnum::INFO, $message, $channel, $context); } /** * @param array $context */ - public static function error(string $message, ?PlatformChannel $channel = null, array $context = []): void + public function error(string $message, ?PlatformChannel $channel = null, array $context = []): void { - self::log(LogLevelEnum::ERROR, $message, $channel, $context); + $this->log(LogLevelEnum::ERROR, $message, $channel, $context); } /** * @param array $context */ - public static function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void + public function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void { - self::log(LogLevelEnum::WARNING, $message, $channel, $context); + $this->log(LogLevelEnum::WARNING, $message, $channel, $context); } /** * @param array $context */ - public static function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void + public function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void { - self::log(LogLevelEnum::DEBUG, $message, $channel, $context); + $this->log(LogLevelEnum::DEBUG, $message, $channel, $context); } /** * @param array $context */ - private static function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void + private function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void { $logContext = $context; diff --git a/backend/app/Services/Publishing/ArticlePublishingService.php b/backend/app/Services/Publishing/ArticlePublishingService.php index 5b8bce7..26c12dc 100644 --- a/backend/app/Services/Publishing/ArticlePublishingService.php +++ b/backend/app/Services/Publishing/ArticlePublishingService.php @@ -17,6 +17,9 @@ class ArticlePublishingService { + public function __construct(private LogSaver $logSaver) + { + } /** * Factory seam to create publisher instances (helps testing without network calls) */ @@ -54,7 +57,7 @@ public function publishToRoutedChannels(Article $article, array $extractedData): $account = $channel->activePlatformAccounts()->first(); if (! $account) { - LogSaver::warning('No active account for channel', $channel, [ + $this->logSaver->warning('No active account for channel', $channel, [ 'article_id' => $article->id, 'route_priority' => $route->priority ]); @@ -123,13 +126,13 @@ private function publishToChannel(Article $article, array $extractedData, Platfo 'publication_data' => $postData, ]); - LogSaver::info('Published to channel via keyword-filtered routing', $channel, [ + $this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [ 'article_id' => $article->id ]); return $publication; } catch (Exception $e) { - LogSaver::warning('Failed to publish to channel', $channel, [ + $this->logSaver->warning('Failed to publish to channel', $channel, [ 'article_id' => $article->id, 'error' => $e->getMessage() ]); diff --git a/backend/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php b/backend/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php index afe9682..dc1b71f 100644 --- a/backend/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php +++ b/backend/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php @@ -35,30 +35,21 @@ public function test_command_returns_failure_exit_code_for_unsupported_platform( public function test_command_accepts_lemmy_platform_argument(): void { - // This test validates the command signature accepts the lemmy argument - // without actually executing the database-dependent logic + // Act - Test that the command accepts lemmy as a valid platform argument + $exitCode = $this->artisan('channel:sync lemmy'); - // Just test that the command can be called with lemmy argument - // The actual job dispatch is tested separately - try { - $this->artisan('channel:sync lemmy'); - } catch (\Exception $e) { - // Expected to fail due to database constraints in test environment - // but should not fail due to argument validation - $this->assertStringNotContainsString('No arguments expected', $e->getMessage()); - } + // Assert - Command should succeed (not fail with argument validation error) + $exitCode->assertSuccessful(); + $exitCode->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels'); } public function test_command_handles_default_platform(): void { - // This test validates the command signature works with default argument + // Act - Test that the command works with default platform (should be lemmy) + $exitCode = $this->artisan('channel:sync'); - try { - $this->artisan('channel:sync'); - } catch (\Exception $e) { - // Expected to fail due to database constraints in test environment - // but should not fail due to missing platform argument - $this->assertStringNotContainsString('Not enough arguments', $e->getMessage()); - } + // Assert - Command should succeed with default platform + $exitCode->assertSuccessful(); + $exitCode->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels'); } } \ No newline at end of file diff --git a/backend/tests/Feature/JobsAndEventsTest.php b/backend/tests/Feature/JobsAndEventsTest.php index 7ebe69e..7c0c4d6 100644 --- a/backend/tests/Feature/JobsAndEventsTest.php +++ b/backend/tests/Feature/JobsAndEventsTest.php @@ -19,6 +19,9 @@ use App\Models\Feed; use App\Models\Log; use App\Models\PlatformChannel; +use App\Services\Log\LogSaver; +use App\Services\Article\ArticleFetcher; +use App\Services\Article\ValidationService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Queue; @@ -28,14 +31,20 @@ class JobsAndEventsTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + } + public function test_article_discovery_job_processes_successfully(): void { Queue::fake(); $feed = Feed::factory()->create(['is_active' => true]); + $logSaver = app(LogSaver::class); $job = new ArticleDiscoveryJob(); - $job->handle(); + $job->handle($logSaver); // Should dispatch individual feed jobs Queue::assertPushed(ArticleDiscoveryForFeedJob::class); @@ -60,8 +69,10 @@ public function test_article_discovery_for_feed_job_processes_feed(): void $this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher); + $logSaver = app(LogSaver::class); + $articleFetcher = app(ArticleFetcher::class); $job = new ArticleDiscoveryForFeedJob($feed); - $job->handle(); + $job->handle($logSaver, $articleFetcher); // Should have articles in database (existing articles created by factory) $this->assertCount(2, Article::all()); @@ -173,18 +184,21 @@ public function test_validate_article_listener_processes_new_article(): void ]); // Mock ArticleFetcher to return valid article data - $mockFetcher = \Mockery::mock('alias:ArticleFetcher2'); + $mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class); $this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher); $mockFetcher->shouldReceive('fetchArticleData') ->with($article) ->andReturn([ - 'full_article' => 'Test article content' + 'title' => 'Belgian News', + 'description' => 'News from Belgium', + 'full_article' => 'This is a test article about Belgium and Belgian politics.' ]); + $validationService = app(ValidationService::class); $listener = new ValidateArticleListener(); $event = new NewArticleFetched($article); - $listener->handle($event); + $listener->handle($event, $validationService); $article->refresh(); $this->assertNotEquals('pending', $article->approval_status); diff --git a/backend/tests/Feature/ValidateArticleListenerTest.php b/backend/tests/Feature/ValidateArticleListenerTest.php index f235572..9256401 100644 --- a/backend/tests/Feature/ValidateArticleListenerTest.php +++ b/backend/tests/Feature/ValidateArticleListenerTest.php @@ -8,6 +8,7 @@ use App\Models\Article; use App\Models\ArticlePublication; use App\Models\Feed; +use App\Services\Article\ValidationService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; @@ -36,7 +37,8 @@ public function test_listener_validates_article_and_dispatches_ready_to_publish_ $listener = new ValidateArticleListener(); $event = new NewArticleFetched($article); - $listener->handle($event); + $validationService = app(ValidationService::class); + $listener->handle($event, $validationService); $article->refresh(); @@ -63,7 +65,8 @@ public function test_listener_skips_already_validated_articles(): void $listener = new ValidateArticleListener(); $event = new NewArticleFetched($article); - $listener->handle($event); + $validationService = app(ValidationService::class); + $listener->handle($event, $validationService); Event::assertNotDispatched(ArticleReadyToPublish::class); } @@ -90,7 +93,8 @@ public function test_listener_skips_articles_with_existing_publication(): void $listener = new ValidateArticleListener(); $event = new NewArticleFetched($article); - $listener->handle($event); + $validationService = app(ValidationService::class); + $listener->handle($event, $validationService); Event::assertNotDispatched(ArticleReadyToPublish::class); } @@ -114,7 +118,8 @@ public function test_listener_calls_validation_service(): void $listener = new ValidateArticleListener(); $event = new NewArticleFetched($article); - $listener->handle($event); + $validationService = app(ValidationService::class); + $listener->handle($event, $validationService); // Verify that the article was processed by ValidationService $article->refresh(); diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php index 0c17ed8..da6d365 100644 --- a/backend/tests/TestCase.php +++ b/backend/tests/TestCase.php @@ -14,6 +14,13 @@ abstract class TestCase extends BaseTestCase protected function setUp(): void { parent::setUp(); + + // Clean up any existing Mockery instances before each test + if (class_exists(Mockery::class)) { + Mockery::close(); + Mockery::globalHelpers(); + } + // Prevent any external HTTP requests during tests unless explicitly faked in a test Http::preventStrayRequests(); } diff --git a/backend/tests/Traits/CreatesArticleFetcher.php b/backend/tests/Traits/CreatesArticleFetcher.php new file mode 100644 index 0000000..dcd38eb --- /dev/null +++ b/backend/tests/Traits/CreatesArticleFetcher.php @@ -0,0 +1,36 @@ +shouldReceive('info')->zeroOrMoreTimes(); + $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); + $logSaver->shouldReceive('error')->zeroOrMoreTimes(); + $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); + } + + return new ArticleFetcher($logSaver); + } + + protected function createArticleFetcherWithMockedLogSaver(): array + { + $logSaver = Mockery::mock(LogSaver::class); + $logSaver->shouldReceive('info')->zeroOrMoreTimes(); + $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); + $logSaver->shouldReceive('error')->zeroOrMoreTimes(); + $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); + + $articleFetcher = new ArticleFetcher($logSaver); + + return [$articleFetcher, $logSaver]; + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Enums/LogLevelEnumTest.php b/backend/tests/Unit/Enums/LogLevelEnumTest.php new file mode 100644 index 0000000..88084f3 --- /dev/null +++ b/backend/tests/Unit/Enums/LogLevelEnumTest.php @@ -0,0 +1,108 @@ +assertEquals('debug', LogLevelEnum::DEBUG->value); + $this->assertEquals('info', LogLevelEnum::INFO->value); + $this->assertEquals('warning', LogLevelEnum::WARNING->value); + $this->assertEquals('error', LogLevelEnum::ERROR->value); + $this->assertEquals('critical', LogLevelEnum::CRITICAL->value); + } + + public function test_to_array_returns_all_enum_values(): void + { + $expected = ['debug', 'info', 'warning', 'error', 'critical']; + $actual = LogLevelEnum::toArray(); + + $this->assertEquals($expected, $actual); + $this->assertCount(5, $actual); + } + + public function test_enum_cases_exist(): void + { + $cases = LogLevelEnum::cases(); + + $this->assertCount(5, $cases); + $this->assertContains(LogLevelEnum::DEBUG, $cases); + $this->assertContains(LogLevelEnum::INFO, $cases); + $this->assertContains(LogLevelEnum::WARNING, $cases); + $this->assertContains(LogLevelEnum::ERROR, $cases); + $this->assertContains(LogLevelEnum::CRITICAL, $cases); + } + + public function test_enum_names_are_correct(): void + { + $this->assertEquals('DEBUG', LogLevelEnum::DEBUG->name); + $this->assertEquals('INFO', LogLevelEnum::INFO->name); + $this->assertEquals('WARNING', LogLevelEnum::WARNING->name); + $this->assertEquals('ERROR', LogLevelEnum::ERROR->name); + $this->assertEquals('CRITICAL', LogLevelEnum::CRITICAL->name); + } + + public function test_can_create_enum_from_string(): void + { + $this->assertEquals(LogLevelEnum::DEBUG, LogLevelEnum::from('debug')); + $this->assertEquals(LogLevelEnum::INFO, LogLevelEnum::from('info')); + $this->assertEquals(LogLevelEnum::WARNING, LogLevelEnum::from('warning')); + $this->assertEquals(LogLevelEnum::ERROR, LogLevelEnum::from('error')); + $this->assertEquals(LogLevelEnum::CRITICAL, LogLevelEnum::from('critical')); + } + + public function test_try_from_with_valid_values(): void + { + $this->assertEquals(LogLevelEnum::DEBUG, LogLevelEnum::tryFrom('debug')); + $this->assertEquals(LogLevelEnum::INFO, LogLevelEnum::tryFrom('info')); + $this->assertEquals(LogLevelEnum::WARNING, LogLevelEnum::tryFrom('warning')); + $this->assertEquals(LogLevelEnum::ERROR, LogLevelEnum::tryFrom('error')); + $this->assertEquals(LogLevelEnum::CRITICAL, LogLevelEnum::tryFrom('critical')); + } + + public function test_try_from_with_invalid_value_returns_null(): void + { + $this->assertNull(LogLevelEnum::tryFrom('invalid')); + $this->assertNull(LogLevelEnum::tryFrom('')); + $this->assertNull(LogLevelEnum::tryFrom('CRITICAL')); // case sensitive + } + + public function test_from_throws_exception_for_invalid_value(): void + { + $this->expectException(\ValueError::class); + LogLevelEnum::from('invalid'); + } + + public function test_enum_can_be_compared(): void + { + $debug1 = LogLevelEnum::DEBUG; + $debug2 = LogLevelEnum::DEBUG; + $info = LogLevelEnum::INFO; + + $this->assertTrue($debug1 === $debug2); + $this->assertFalse($debug1 === $info); + } + + public function test_enum_can_be_used_in_match_expression(): void + { + $getMessage = function (LogLevelEnum $level): string { + return match ($level) { + LogLevelEnum::DEBUG => 'Debug message', + LogLevelEnum::INFO => 'Info message', + LogLevelEnum::WARNING => 'Warning message', + LogLevelEnum::ERROR => 'Error message', + LogLevelEnum::CRITICAL => 'Critical message', + }; + }; + + $this->assertEquals('Debug message', $getMessage(LogLevelEnum::DEBUG)); + $this->assertEquals('Info message', $getMessage(LogLevelEnum::INFO)); + $this->assertEquals('Warning message', $getMessage(LogLevelEnum::WARNING)); + $this->assertEquals('Error message', $getMessage(LogLevelEnum::ERROR)); + $this->assertEquals('Critical message', $getMessage(LogLevelEnum::CRITICAL)); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Enums/PlatformEnumTest.php b/backend/tests/Unit/Enums/PlatformEnumTest.php new file mode 100644 index 0000000..ebd9348 --- /dev/null +++ b/backend/tests/Unit/Enums/PlatformEnumTest.php @@ -0,0 +1,89 @@ +assertEquals('lemmy', PlatformEnum::LEMMY->value); + } + + public function test_enum_cases_exist(): void + { + $cases = PlatformEnum::cases(); + + $this->assertCount(1, $cases); + $this->assertContains(PlatformEnum::LEMMY, $cases); + } + + public function test_enum_names_are_correct(): void + { + $this->assertEquals('LEMMY', PlatformEnum::LEMMY->name); + } + + public function test_can_create_enum_from_string(): void + { + $this->assertEquals(PlatformEnum::LEMMY, PlatformEnum::from('lemmy')); + } + + public function test_try_from_with_valid_values(): void + { + $this->assertEquals(PlatformEnum::LEMMY, PlatformEnum::tryFrom('lemmy')); + } + + public function test_try_from_with_invalid_value_returns_null(): void + { + $this->assertNull(PlatformEnum::tryFrom('reddit')); + $this->assertNull(PlatformEnum::tryFrom('mastodon')); + $this->assertNull(PlatformEnum::tryFrom('')); + $this->assertNull(PlatformEnum::tryFrom('LEMMY')); // case sensitive + } + + public function test_from_throws_exception_for_invalid_value(): void + { + $this->expectException(\ValueError::class); + PlatformEnum::from('reddit'); + } + + public function test_enum_can_be_compared(): void + { + $lemmy1 = PlatformEnum::LEMMY; + $lemmy2 = PlatformEnum::LEMMY; + + $this->assertTrue($lemmy1 === $lemmy2); + } + + public function test_enum_can_be_used_in_match_expression(): void + { + $getDescription = function (PlatformEnum $platform): string { + return match ($platform) { + PlatformEnum::LEMMY => 'Lemmy is a federated link aggregator', + }; + }; + + $this->assertEquals('Lemmy is a federated link aggregator', $getDescription(PlatformEnum::LEMMY)); + } + + public function test_enum_can_be_used_in_switch_statement(): void + { + $platform = PlatformEnum::LEMMY; + $result = ''; + + switch ($platform) { + case PlatformEnum::LEMMY: + $result = 'lemmy platform'; + break; + } + + $this->assertEquals('lemmy platform', $result); + } + + public function test_enum_value_is_string_backed(): void + { + $this->assertIsString(PlatformEnum::LEMMY->value); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php b/backend/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php new file mode 100644 index 0000000..e33e3a6 --- /dev/null +++ b/backend/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php @@ -0,0 +1,222 @@ +make(); + $job = new ArticleDiscoveryForFeedJob($feed); + + $this->assertEquals('feed-discovery', $job->queue); + } + + public function test_job_implements_should_queue(): void + { + $feed = Feed::factory()->make(); + $job = new ArticleDiscoveryForFeedJob($feed); + + $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); + } + + public function test_job_uses_queueable_trait(): void + { + $feed = Feed::factory()->make(); + $job = new ArticleDiscoveryForFeedJob($feed); + + $this->assertContains( + \Illuminate\Foundation\Queue\Queueable::class, + class_uses($job) + ); + } + + public function test_handle_fetches_articles_and_updates_feed(): void + { + // Arrange + $feed = Feed::factory()->create([ + 'name' => 'Test Feed', + 'url' => 'https://example.com/feed', + 'last_fetched_at' => null + ]); + + $mockArticles = collect(['article1', 'article2']); + + // Mock ArticleFetcher + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('getArticlesFromFeed') + ->once() + ->with($feed) + ->andReturn($mockArticles); + + // Mock LogSaver + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info') + ->with('Starting feed article fetch', null, [ + 'feed_id' => $feed->id, + 'feed_name' => $feed->name, + 'feed_url' => $feed->url + ]) + ->once(); + + $logSaverMock->shouldReceive('info') + ->with('Feed article fetch completed', null, [ + 'feed_id' => $feed->id, + 'feed_name' => $feed->name, + 'articles_count' => 2 + ]) + ->once(); + + $job = new ArticleDiscoveryForFeedJob($feed); + + // Act + $job->handle($logSaverMock, $articleFetcherMock); + + // Assert + $feed->refresh(); + $this->assertNotNull($feed->last_fetched_at); + $this->assertTrue($feed->last_fetched_at->greaterThan(now()->subMinute())); + } + + public function test_dispatch_for_all_active_feeds_dispatches_jobs_with_delay(): void + { + // Arrange + $feeds = Feed::factory()->count(3)->create(['is_active' => true]); + Feed::factory()->create(['is_active' => false]); // inactive feed should be ignored + + // Mock LogSaver + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info') + ->times(3) // Once for each active feed + ->with('Dispatched feed discovery job', null, Mockery::type('array')); + + $this->app->instance(LogSaver::class, $logSaverMock); + + // Act + ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); + + // Assert + Queue::assertPushed(ArticleDiscoveryForFeedJob::class, 3); + + // Verify jobs were dispatched (cannot access private $feed property in test) + } + + public function test_dispatch_for_all_active_feeds_applies_correct_delays(): void + { + // Arrange + Feed::factory()->count(2)->create(['is_active' => true]); + + // Mock LogSaver + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info')->times(2); + + $this->app->instance(LogSaver::class, $logSaverMock); + + // Act + ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); + + // Assert + Queue::assertPushed(ArticleDiscoveryForFeedJob::class, 2); + + // Verify jobs are pushed with delays + Queue::assertPushed(ArticleDiscoveryForFeedJob::class, function ($job) { + return $job->delay !== null; + }); + } + + public function test_dispatch_for_all_active_feeds_with_no_active_feeds(): void + { + // Arrange + Feed::factory()->count(2)->create(['is_active' => false]); + + // Act + ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); + + // Assert + Queue::assertNothingPushed(); + } + + public function test_feed_discovery_delay_constant_exists(): void + { + $reflection = new \ReflectionClass(ArticleDiscoveryForFeedJob::class); + $constant = $reflection->getConstant('FEED_DISCOVERY_DELAY_MINUTES'); + + $this->assertEquals(5, $constant); + } + + public function test_job_can_be_serialized(): void + { + $feed = Feed::factory()->create(['name' => 'Test Feed']); + $job = new ArticleDiscoveryForFeedJob($feed); + + $serialized = serialize($job); + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(ArticleDiscoveryForFeedJob::class, $unserialized); + $this->assertEquals($job->queue, $unserialized->queue); + // Note: Cannot test feed property directly as it's private + // but serialization/unserialization working proves the job structure is intact + } + + public function test_handle_logs_start_message_with_correct_context(): void + { + // Arrange + $feed = Feed::factory()->create([ + 'name' => 'Test Feed', + 'url' => 'https://example.com/feed' + ]); + + $mockArticles = collect([]); + + // Mock ArticleFetcher + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('getArticlesFromFeed') + ->once() + ->andReturn($mockArticles); + + // Mock LogSaver with specific expectations + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info') + ->with('Starting feed article fetch', null, [ + 'feed_id' => $feed->id, + 'feed_name' => 'Test Feed', + 'feed_url' => 'https://example.com/feed' + ]) + ->once(); + + $logSaverMock->shouldReceive('info') + ->with('Feed article fetch completed', null, Mockery::type('array')) + ->once(); + + $job = new ArticleDiscoveryForFeedJob($feed); + + // Act + $job->handle($logSaverMock, $articleFetcherMock); + + // Assert - Mockery expectations are verified in tearDown + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Jobs/ArticleDiscoveryJobTest.php b/backend/tests/Unit/Jobs/ArticleDiscoveryJobTest.php index 402c21c..4db26ae 100644 --- a/backend/tests/Unit/Jobs/ArticleDiscoveryJobTest.php +++ b/backend/tests/Unit/Jobs/ArticleDiscoveryJobTest.php @@ -2,18 +2,24 @@ namespace Tests\Unit\Jobs; -use App\Jobs\ArticleDiscoveryForFeedJob; use App\Jobs\ArticleDiscoveryJob; use App\Models\Setting; use App\Services\Log\LogSaver; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Queue; +use Mockery; use Tests\TestCase; class ArticleDiscoveryJobTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + Queue::fake(); + } + public function test_constructor_sets_correct_queue(): void { // Act @@ -26,13 +32,18 @@ public function test_constructor_sets_correct_queue(): void public function test_handle_skips_when_article_processing_disabled(): void { // Arrange - Queue::fake(); Setting::create(['key' => 'article_processing_enabled', 'value' => '0']); + // Mock LogSaver + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info') + ->once() + ->with('Article processing is disabled. Article discovery skipped.'); + $job = new ArticleDiscoveryJob(); // Act - $job->handle(); + $job->handle($logSaverMock); // Assert Queue::assertNothingPushed(); @@ -41,13 +52,21 @@ public function test_handle_skips_when_article_processing_disabled(): void public function test_handle_dispatches_jobs_when_article_processing_enabled(): void { // Arrange - Queue::fake(); Setting::create(['key' => 'article_processing_enabled', 'value' => '1']); + // Mock LogSaver + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info') + ->with('Starting article discovery for all active feeds') + ->once(); + $logSaverMock->shouldReceive('info') + ->with('Article discovery jobs dispatched for all active feeds') + ->once(); + $job = new ArticleDiscoveryJob(); // Act - $job->handle(); + $job->handle($logSaverMock); // Assert - This will test that the static method is called, but we can't easily verify // the job dispatch without mocking the static method @@ -57,12 +76,19 @@ public function test_handle_dispatches_jobs_when_article_processing_enabled(): v public function test_handle_with_default_article_processing_enabled(): void { // Arrange - No setting exists, should default to enabled - Queue::fake(); + // Mock LogSaver + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info') + ->with('Starting article discovery for all active feeds') + ->once(); + $logSaverMock->shouldReceive('info') + ->with('Article discovery jobs dispatched for all active feeds') + ->once(); $job = new ArticleDiscoveryJob(); // Act - $job->handle(); + $job->handle($logSaverMock); // Assert - Should complete without skipping $this->assertTrue(true); // Job completes without error @@ -92,16 +118,29 @@ public function test_handle_logs_appropriate_messages(): void { // This test verifies that the job calls the logging methods // The actual logging is tested in the LogSaver tests - + // Arrange - Queue::fake(); - + // Mock LogSaver + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info') + ->with('Starting article discovery for all active feeds') + ->once(); + $logSaverMock->shouldReceive('info') + ->with('Article discovery jobs dispatched for all active feeds') + ->once(); + $job = new ArticleDiscoveryJob(); // Act - Should not throw any exceptions - $job->handle(); + $job->handle($logSaverMock); // Assert - Job completes successfully $this->assertTrue(true); } -} \ No newline at end of file + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Jobs/PublishNextArticleJobTest.php b/backend/tests/Unit/Jobs/PublishNextArticleJobTest.php new file mode 100644 index 0000000..9e8f6cf --- /dev/null +++ b/backend/tests/Unit/Jobs/PublishNextArticleJobTest.php @@ -0,0 +1,296 @@ +assertEquals('publishing', $job->queue); + } + + public function test_job_implements_should_queue(): void + { + $job = new PublishNextArticleJob(); + + $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); + } + + public function test_job_implements_should_be_unique(): void + { + $job = new PublishNextArticleJob(); + + $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job); + } + + public function test_job_has_unique_for_property(): void + { + $job = new PublishNextArticleJob(); + + $this->assertEquals(300, $job->uniqueFor); + } + + public function test_job_uses_queueable_trait(): void + { + $job = new PublishNextArticleJob(); + + $this->assertContains( + \Illuminate\Foundation\Queue\Queueable::class, + class_uses($job) + ); + } + + public function test_handle_returns_early_when_no_approved_articles(): void + { + // Arrange - No articles exist + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + // No expectations as handle should return early + + $job = new PublishNextArticleJob(); + + // Act + $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); + $job->handle($articleFetcherMock, $publishingServiceMock); + + // Assert - Should complete without error + $this->assertTrue(true); + } + + public function test_handle_returns_early_when_no_unpublished_approved_articles(): void + { + // Arrange + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved' + ]); + + // Create a publication record to mark it as already published + ArticlePublication::factory()->create(['article_id' => $article->id]); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + // No expectations as handle should return early + + $job = new PublishNextArticleJob(); + + // Act + $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); + $job->handle($articleFetcherMock, $publishingServiceMock); + + // Assert - Should complete without error + $this->assertTrue(true); + } + + public function test_handle_skips_non_approved_articles(): void + { + // Arrange + $feed = Feed::factory()->create(); + Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'pending' + ]); + Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'rejected' + ]); + + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + // No expectations as handle should return early + + $job = new PublishNextArticleJob(); + + // Act + $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); + $job->handle($articleFetcherMock, $publishingServiceMock); + + // Assert - Should complete without error (no approved articles to process) + $this->assertTrue(true); + } + + public function test_handle_publishes_oldest_approved_article(): void + { + // Arrange + $feed = Feed::factory()->create(); + + // Create older article first + $olderArticle = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'created_at' => now()->subHours(2) + ]); + + // Create newer article + $newerArticle = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'created_at' => now()->subHour() + ]); + + $extractedData = ['title' => 'Test Article', 'content' => 'Test content']; + + // Mock ArticleFetcher + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->with(Mockery::on(function ($article) use ($olderArticle) { + return $article->id === $olderArticle->id; + })) + ->andReturn($extractedData); + + // Mock ArticlePublishingService + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->with( + Mockery::on(function ($article) use ($olderArticle) { + return $article->id === $olderArticle->id; + }), + $extractedData + ); + + $job = new PublishNextArticleJob(); + + // Act + $job->handle($articleFetcherMock, $publishingServiceMock); + + // Assert - Mockery expectations are verified in tearDown + $this->assertTrue(true); + } + + public function test_handle_throws_exception_on_publishing_failure(): void + { + // Arrange + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved' + ]); + + $extractedData = ['title' => 'Test Article']; + $publishException = new PublishException($article, null); + + // Mock ArticleFetcher + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->with(Mockery::type(Article::class)) + ->andReturn($extractedData); + + // Mock ArticlePublishingService to throw exception + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->andThrow($publishException); + + $job = new PublishNextArticleJob(); + + // Assert + $this->expectException(PublishException::class); + + // Act + $job->handle($articleFetcherMock, $publishingServiceMock); + } + + public function test_handle_logs_publishing_start(): void + { + // Arrange + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved', + 'title' => 'Test Article Title', + 'url' => 'https://example.com/article' + ]); + + $extractedData = ['title' => 'Test Article']; + + // Mock ArticleFetcher + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->andReturn($extractedData); + + // Mock ArticlePublishingService + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels')->once(); + + $job = new PublishNextArticleJob(); + + // Act + $job->handle($articleFetcherMock, $publishingServiceMock); + + // Assert - Verify the job completes (logging is verified by observing no exceptions) + $this->assertTrue(true); + } + + public function test_job_can_be_serialized(): void + { + $job = new PublishNextArticleJob(); + + $serialized = serialize($job); + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(PublishNextArticleJob::class, $unserialized); + $this->assertEquals($job->queue, $unserialized->queue); + $this->assertEquals($job->uniqueFor, $unserialized->uniqueFor); + } + + public function test_handle_fetches_article_data_before_publishing(): void + { + // Arrange + $feed = Feed::factory()->create(); + $article = Article::factory()->create([ + 'feed_id' => $feed->id, + 'approval_status' => 'approved' + ]); + + $extractedData = ['title' => 'Extracted Title', 'content' => 'Extracted Content']; + + // Mock ArticleFetcher with specific expectations + $articleFetcherMock = Mockery::mock(ArticleFetcher::class); + $articleFetcherMock->shouldReceive('fetchArticleData') + ->once() + ->with(Mockery::type(Article::class)) + ->andReturn($extractedData); + + // Mock publishing service to receive the extracted data + $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); + $publishingServiceMock->shouldReceive('publishToRoutedChannels') + ->once() + ->with(Mockery::type(Article::class), $extractedData); + + $job = new PublishNextArticleJob(); + + // Act + $job->handle($articleFetcherMock, $publishingServiceMock); + + // Assert - Mockery expectations verified in tearDown + $this->assertTrue(true); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Jobs/SyncChannelPostsJobTest.php b/backend/tests/Unit/Jobs/SyncChannelPostsJobTest.php index 1c3bc70..6b10a61 100644 --- a/backend/tests/Unit/Jobs/SyncChannelPostsJobTest.php +++ b/backend/tests/Unit/Jobs/SyncChannelPostsJobTest.php @@ -3,65 +3,168 @@ namespace Tests\Unit\Jobs; use App\Enums\PlatformEnum; +use App\Exceptions\PlatformAuthException; use App\Jobs\SyncChannelPostsJob; +use App\Models\PlatformAccount; use App\Models\PlatformChannel; +use App\Models\PlatformInstance; +use App\Modules\Lemmy\Services\LemmyApiService; +use App\Services\Log\LogSaver; +use Exception; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Queue; +use Mockery; use Tests\TestCase; class SyncChannelPostsJobTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + Queue::fake(); + Cache::flush(); + } + public function test_constructor_sets_correct_queue(): void { - // Arrange - $channel = new PlatformChannel(['name' => 'Test Channel']); - - // Act + $channel = PlatformChannel::factory()->make(); $job = new SyncChannelPostsJob($channel); - - // Assert + $this->assertEquals('sync', $job->queue); } - public function test_job_implements_required_interfaces(): void + public function test_job_implements_should_queue(): void { - // Arrange - $channel = new PlatformChannel(['name' => 'Test Channel']); + $channel = PlatformChannel::factory()->make(); $job = new SyncChannelPostsJob($channel); - - // Assert + $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); + } + + public function test_job_implements_should_be_unique(): void + { + $channel = PlatformChannel::factory()->make(); + $job = new SyncChannelPostsJob($channel); + $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job); } public function test_job_uses_queueable_trait(): void + { + $channel = PlatformChannel::factory()->make(); + $job = new SyncChannelPostsJob($channel); + + $this->assertContains( + \Illuminate\Foundation\Queue\Queueable::class, + class_uses($job) + ); + } + + public function test_dispatch_for_all_active_channels_dispatches_jobs(): void { // Arrange - $channel = new PlatformChannel(['name' => 'Test Channel']); + $platformInstance = PlatformInstance::factory()->create([ + 'platform' => PlatformEnum::LEMMY + ]); + + $account = PlatformAccount::factory()->create([ + 'instance_url' => $platformInstance->url, + 'is_active' => true + ]); + + $channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $platformInstance->id, + 'is_active' => true + ]); + + // Attach account to channel with active status + $channel->platformAccounts()->attach($account->id, [ + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now() + ]); + + // Mock LogSaver to avoid strict expectations + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info')->zeroOrMoreTimes(); + + $this->app->instance(LogSaver::class, $logSaverMock); + + // Act + SyncChannelPostsJob::dispatchForAllActiveChannels(); + + // Assert - At least one job should be dispatched + Queue::assertPushed(SyncChannelPostsJob::class); + } + + public function test_handle_logs_start_message(): void + { + // Arrange + $platformInstance = PlatformInstance::factory()->create([ + 'platform' => PlatformEnum::LEMMY, + 'url' => 'https://lemmy.example.com' + ]); + + $channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $platformInstance->id, + 'name' => 'testcommunity' + ]); + + // Mock LogSaver - only test that logging methods are called + $logSaverMock = Mockery::mock(LogSaver::class); + $logSaverMock->shouldReceive('info')->atLeast()->once(); + $logSaverMock->shouldReceive('error')->zeroOrMoreTimes(); + $job = new SyncChannelPostsJob($channel); - // Assert - $this->assertTrue(method_exists($job, 'onQueue')); - $this->assertTrue(method_exists($job, 'onConnection')); - $this->assertTrue(method_exists($job, 'delay')); + // Act - This will fail due to no active account, but we test the logging + try { + $job->handle($logSaverMock); + } catch (Exception $e) { + // Expected to fail, we're testing that logging is called + } + + // Assert - Test completes if no exceptions during setup + $this->assertTrue(true); + } + + public function test_job_can_be_serialized(): void + { + $platformInstance = PlatformInstance::factory()->create(); + $channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $platformInstance->id, + 'name' => 'Test Channel' + ]); + $job = new SyncChannelPostsJob($channel); + + $serialized = serialize($job); + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(SyncChannelPostsJob::class, $unserialized); + $this->assertEquals($job->queue, $unserialized->queue); + // Note: Cannot test channel property directly as it's private + // but serialization/unserialization working proves the job structure is intact } public function test_dispatch_for_all_active_channels_method_exists(): void { - // Assert - Test that the static method exists $this->assertTrue(method_exists(SyncChannelPostsJob::class, 'dispatchForAllActiveChannels')); } - public function test_job_has_correct_structure(): void + public function test_job_has_handle_method(): void { - // Arrange - $channel = new PlatformChannel(['name' => 'Test Channel']); + $channel = PlatformChannel::factory()->make(); $job = new SyncChannelPostsJob($channel); - - // Assert - Basic structure tests - $this->assertIsObject($job); + $this->assertTrue(method_exists($job, 'handle')); - $this->assertIsString($job->queue); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); } } \ No newline at end of file diff --git a/backend/tests/Unit/Services/ArticleFetcherTest.php b/backend/tests/Unit/Services/ArticleFetcherTest.php index fd5998c..c67c680 100644 --- a/backend/tests/Unit/Services/ArticleFetcherTest.php +++ b/backend/tests/Unit/Services/ArticleFetcherTest.php @@ -3,16 +3,18 @@ namespace Tests\Unit\Services; use App\Services\Article\ArticleFetcher; +use App\Services\Log\LogSaver; use App\Models\Feed; use App\Models\Article; use Tests\TestCase; +use Tests\Traits\CreatesArticleFetcher; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Mockery; class ArticleFetcherTest extends TestCase { - use RefreshDatabase; + use RefreshDatabase, CreatesArticleFetcher; protected function setUp(): void { @@ -22,16 +24,20 @@ protected function setUp(): void Http::fake([ '*' => Http::response('Mock HTML content', 200) ]); + + // Create ArticleFetcher only when needed - tests will create their own } public function test_get_articles_from_feed_returns_collection(): void { + $articleFetcher = $this->createArticleFetcher(); + $feed = Feed::factory()->create([ 'type' => 'rss', 'url' => 'https://example.com/feed.rss' ]); - $result = ArticleFetcher::getArticlesFromFeed($feed); + $result = $articleFetcher->getArticlesFromFeed($feed); $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); } @@ -43,7 +49,8 @@ public function test_get_articles_from_rss_feed_returns_empty_collection(): void 'url' => 'https://example.com/feed.rss' ]); - $result = ArticleFetcher::getArticlesFromFeed($feed); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->getArticlesFromFeed($feed); // RSS parsing is not implemented yet, should return empty collection $this->assertEmpty($result); @@ -56,7 +63,8 @@ public function test_get_articles_from_website_feed_handles_no_parser(): void 'url' => 'https://unsupported-site.com/' ]); - $result = ArticleFetcher::getArticlesFromFeed($feed); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->getArticlesFromFeed($feed); // Should return empty collection when no parser is available $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); @@ -70,7 +78,8 @@ public function test_get_articles_from_unsupported_feed_type(): void 'url' => 'https://unsupported-feed-type.com/feed' ]); - $result = ArticleFetcher::getArticlesFromFeed($feed); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->getArticlesFromFeed($feed); $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); $this->assertEmpty($result); @@ -82,7 +91,8 @@ public function test_fetch_article_data_returns_array(): void 'url' => 'https://example.com/article' ]); - $result = ArticleFetcher::fetchArticleData($article); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->fetchArticleData($article); $this->assertIsArray($result); // Will be empty array due to unsupported URL in test @@ -95,7 +105,8 @@ public function test_fetch_article_data_handles_invalid_url(): void 'url' => 'invalid-url' ]); - $result = ArticleFetcher::fetchArticleData($article); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->fetchArticleData($article); $this->assertIsArray($result); $this->assertEmpty($result); @@ -117,7 +128,8 @@ public function test_get_articles_from_feed_with_null_feed_type(): void $attributes['type'] = 'invalid_type'; $property->setValue($feed, $attributes); - $result = ArticleFetcher::getArticlesFromFeed($feed); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->getArticlesFromFeed($feed); $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); $this->assertEmpty($result); @@ -136,7 +148,8 @@ public function test_get_articles_from_website_feed_with_supported_parser(): voi ]); // Test actual behavior - VRT parser should be available - $result = ArticleFetcher::getArticlesFromFeed($feed); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->getArticlesFromFeed($feed); $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); // VRT parser will process the mocked HTML response @@ -151,7 +164,8 @@ public function test_get_articles_from_website_feed_handles_invalid_url(): void 'url' => 'https://invalid-domain-that-does-not-exist-12345.com/' ]); - $result = ArticleFetcher::getArticlesFromFeed($feed); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->getArticlesFromFeed($feed); $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); $this->assertEmpty($result); @@ -169,7 +183,8 @@ public function test_fetch_article_data_with_supported_parser(): void ]); // Test actual behavior - VRT parser should be available - $result = ArticleFetcher::fetchArticleData($article); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->fetchArticleData($article); $this->assertIsArray($result); // VRT parser will process the mocked HTML response @@ -181,7 +196,8 @@ public function test_fetch_article_data_handles_unsupported_domain(): void 'url' => 'https://unsupported-domain.com/article' ]); - $result = ArticleFetcher::fetchArticleData($article); + $articleFetcher = $this->createArticleFetcher(); + $result = $articleFetcher->fetchArticleData($article); $this->assertIsArray($result); $this->assertEmpty($result); @@ -195,12 +211,13 @@ public function test_save_article_creates_new_article_when_not_exists(): void // Ensure article doesn't exist $this->assertDatabaseMissing('articles', ['url' => $url]); + $articleFetcher = $this->createArticleFetcher(); // Use reflection to access private method for testing - $reflection = new \ReflectionClass(ArticleFetcher::class); + $reflection = new \ReflectionClass($articleFetcher); $saveArticleMethod = $reflection->getMethod('saveArticle'); $saveArticleMethod->setAccessible(true); - $article = $saveArticleMethod->invoke(null, $url, $feed->id); + $article = $saveArticleMethod->invoke($articleFetcher, $url, $feed->id); $this->assertInstanceOf(Article::class, $article); $this->assertEquals($url, $article->url); @@ -221,7 +238,8 @@ public function test_save_article_returns_existing_article_when_exists(): void $saveArticleMethod = $reflection->getMethod('saveArticle'); $saveArticleMethod->setAccessible(true); - $article = $saveArticleMethod->invoke(null, $existingArticle->url, $feed->id); + $articleFetcher = $this->createArticleFetcher(); + $article = $saveArticleMethod->invoke($articleFetcher, $existingArticle->url, $feed->id); $this->assertEquals($existingArticle->id, $article->id); $this->assertEquals($existingArticle->url, $article->url); @@ -239,7 +257,8 @@ public function test_save_article_without_feed_id(): void $saveArticleMethod = $reflection->getMethod('saveArticle'); $saveArticleMethod->setAccessible(true); - $article = $saveArticleMethod->invoke(null, $url, null); + $articleFetcher = $this->createArticleFetcher(); + $article = $saveArticleMethod->invoke($articleFetcher, $url, null); $this->assertInstanceOf(Article::class, $article); $this->assertEquals($url, $article->url); diff --git a/backend/tests/Unit/Services/Log/LogSaverTest.php b/backend/tests/Unit/Services/Log/LogSaverTest.php index af74dc2..d8dc159 100644 --- a/backend/tests/Unit/Services/Log/LogSaverTest.php +++ b/backend/tests/Unit/Services/Log/LogSaverTest.php @@ -14,13 +14,21 @@ class LogSaverTest extends TestCase { use RefreshDatabase; + + private LogSaver $logSaver; + + protected function setUp(): void + { + parent::setUp(); + $this->logSaver = new LogSaver(); + } public function test_info_creates_log_record_with_info_level(): void { $message = 'Test info message'; $context = ['key' => 'value']; - LogSaver::info($message, null, $context); + $this->logSaver->info($message, null, $context); $this->assertDatabaseHas('logs', [ 'level' => LogLevelEnum::INFO, @@ -36,7 +44,7 @@ public function test_error_creates_log_record_with_error_level(): void $message = 'Test error message'; $context = ['error_code' => 500]; - LogSaver::error($message, null, $context); + $this->logSaver->error($message, null, $context); $this->assertDatabaseHas('logs', [ 'level' => LogLevelEnum::ERROR, @@ -52,7 +60,7 @@ public function test_warning_creates_log_record_with_warning_level(): void $message = 'Test warning message'; $context = ['warning_type' => 'deprecation']; - LogSaver::warning($message, null, $context); + $this->logSaver->warning($message, null, $context); $this->assertDatabaseHas('logs', [ 'level' => LogLevelEnum::WARNING, @@ -68,7 +76,7 @@ public function test_debug_creates_log_record_with_debug_level(): void $message = 'Test debug message'; $context = ['debug_info' => 'trace']; - LogSaver::debug($message, null, $context); + $this->logSaver->debug($message, null, $context); $this->assertDatabaseHas('logs', [ 'level' => LogLevelEnum::DEBUG, @@ -94,7 +102,7 @@ public function test_log_with_channel_includes_channel_information_in_context(): $message = 'Test message with channel'; $originalContext = ['original_key' => 'original_value']; - LogSaver::info($message, $channel, $originalContext); + $this->logSaver->info($message, $channel, $originalContext); $log = Log::first(); @@ -115,7 +123,7 @@ public function test_log_without_channel_uses_original_context_only(): void $message = 'Test message without channel'; $context = ['test_key' => 'test_value']; - LogSaver::info($message, null, $context); + $this->logSaver->info($message, null, $context); $log = Log::first(); @@ -127,7 +135,7 @@ public function test_log_with_empty_context_creates_minimal_log(): void { $message = 'Simple message'; - LogSaver::info($message); + $this->logSaver->info($message); $this->assertDatabaseHas('logs', [ 'level' => LogLevelEnum::INFO, @@ -152,7 +160,7 @@ public function test_log_with_channel_but_empty_context_includes_only_channel_in $message = 'Message with channel but no context'; - LogSaver::warning($message, $channel); + $this->logSaver->warning($message, $channel); $log = Log::first(); @@ -185,7 +193,7 @@ public function test_context_merging_preserves_original_keys_and_adds_channel_in 'timestamp' => '2024-01-01 12:00:00' ]; - LogSaver::error('Context merge test', $channel, $originalContext); + $this->logSaver->error('Context merge test', $channel, $originalContext); $log = Log::first(); @@ -204,9 +212,9 @@ public function test_context_merging_preserves_original_keys_and_adds_channel_in public function test_multiple_logs_are_created_independently(): void { - LogSaver::info('First message', null, ['id' => 1]); - LogSaver::error('Second message', null, ['id' => 2]); - LogSaver::warning('Third message', null, ['id' => 3]); + $this->logSaver->info('First message', null, ['id' => 1]); + $this->logSaver->error('Second message', null, ['id' => 2]); + $this->logSaver->warning('Third message', null, ['id' => 3]); $this->assertDatabaseCount('logs', 3); @@ -237,7 +245,7 @@ public function test_log_with_complex_context_data(): void 'null_value' => null ]; - LogSaver::debug('Complex context test', null, $complexContext); + $this->logSaver->debug('Complex context test', null, $complexContext); $log = Log::first(); $this->assertEquals($complexContext, $log->context); @@ -249,10 +257,10 @@ public function test_each_log_level_method_delegates_to_private_log_method(): vo $context = ['test' => true]; // Test all four log level methods - LogSaver::info($message, null, $context); - LogSaver::error($message, null, $context); - LogSaver::warning($message, null, $context); - LogSaver::debug($message, null, $context); + $this->logSaver->info($message, null, $context); + $this->logSaver->error($message, null, $context); + $this->logSaver->warning($message, null, $context); + $this->logSaver->debug($message, null, $context); // Should have 4 log entries $this->assertDatabaseCount('logs', 4); diff --git a/backend/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/backend/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php index bcc877d..ed58673 100644 --- a/backend/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ b/backend/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php @@ -26,11 +26,17 @@ class ArticlePublishingServiceTest extends TestCase use RefreshDatabase; protected ArticlePublishingService $service; + protected LogSaver $logSaver; protected function setUp(): void { parent::setUp(); - $this->service = new ArticlePublishingService(); + $this->logSaver = Mockery::mock(LogSaver::class); + $this->logSaver->shouldReceive('info')->zeroOrMoreTimes(); + $this->logSaver->shouldReceive('warning')->zeroOrMoreTimes(); + $this->logSaver->shouldReceive('error')->zeroOrMoreTimes(); + $this->logSaver->shouldReceive('debug')->zeroOrMoreTimes(); + $this->service = new ArticlePublishingService($this->logSaver); } protected function tearDown(): void @@ -123,7 +129,7 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe $publisherDouble->shouldReceive('publishToChannel') ->once() ->andReturn(['post_view' => ['post' => ['id' => 123]]]); - $service = \Mockery::mock(ArticlePublishingService::class)->makePartial(); + $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); @@ -169,7 +175,7 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace $publisherDouble->shouldReceive('publishToChannel') ->once() ->andThrow(new Exception('network error')); - $service = \Mockery::mock(ArticlePublishingService::class)->makePartial(); + $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); @@ -223,7 +229,7 @@ public function test_publish_to_routed_channels_publishes_to_multiple_routes(): ->once()->andReturn(['post_view' => ['post' => ['id' => 100]]]); $publisherDouble->shouldReceive('publishToChannel') ->once()->andReturn(['post_view' => ['post' => ['id' => 200]]]); - $service = \Mockery::mock(ArticlePublishingService::class)->makePartial(); + $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); @@ -278,7 +284,7 @@ public function test_publish_to_routed_channels_filters_out_failed_publications( ->once()->andReturn(['post_view' => ['post' => ['id' => 300]]]); $publisherDouble->shouldReceive('publishToChannel') ->once()->andThrow(new Exception('failed')); - $service = \Mockery::mock(ArticlePublishingService::class)->makePartial(); + $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); $service->shouldAllowMockingProtectedMethods(); $service->shouldReceive('makePublisher')->andReturn($publisherDouble); diff --git a/backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php b/backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php index 5e82d07..0b98504 100644 --- a/backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php +++ b/backend/tests/Unit/Services/Publishing/KeywordFilteringTest.php @@ -7,8 +7,10 @@ use App\Models\Keyword; use App\Models\PlatformChannel; use App\Models\Route; +use App\Services\Log\LogSaver; use App\Services\Publishing\ArticlePublishingService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Mockery; use Tests\TestCase; class KeywordFilteringTest extends TestCase @@ -26,7 +28,12 @@ protected function setUp(): void { parent::setUp(); - $this->service = new ArticlePublishingService(); + $logSaver = Mockery::mock(LogSaver::class); + $logSaver->shouldReceive('info')->zeroOrMoreTimes(); + $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); + $logSaver->shouldReceive('error')->zeroOrMoreTimes(); + $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); + $this->service = new ArticlePublishingService($logSaver); $this->feed = Feed::factory()->create(); $this->channel1 = PlatformChannel::factory()->create(); $this->channel2 = PlatformChannel::factory()->create(); @@ -46,6 +53,12 @@ protected function setUp(): void 'priority' => 50 ]); } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } public function test_route_with_no_keywords_matches_all_articles(): void { diff --git a/backend/tests/Unit/Services/ValidationServiceKeywordTest.php b/backend/tests/Unit/Services/ValidationServiceKeywordTest.php index 88f9152..beaf87c 100644 --- a/backend/tests/Unit/Services/ValidationServiceKeywordTest.php +++ b/backend/tests/Unit/Services/ValidationServiceKeywordTest.php @@ -4,17 +4,36 @@ use App\Services\Article\ValidationService; use Tests\TestCase; +use Tests\Traits\CreatesArticleFetcher; use ReflectionClass; use ReflectionMethod; +use Mockery; 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(ValidationService::class); + $reflection = new ReflectionClass($this->validationService); $method = $reflection->getMethod('validateByKeywords'); $method->setAccessible(true); return $method; @@ -24,93 +43,93 @@ public function test_validates_belgian_political_keywords(): void { $method = $this->getValidateByKeywordsMethod(); - $this->assertTrue($method->invoke(null, 'This article discusses N-VA party policies.')); - $this->assertTrue($method->invoke(null, 'Bart De Wever made a statement today.')); - $this->assertTrue($method->invoke(null, 'Frank Vandenbroucke announced new healthcare policies.')); - $this->assertTrue($method->invoke(null, 'Alexander De Croo addressed the nation.')); - $this->assertTrue($method->invoke(null, 'The Vooruit party proposed new legislation.')); - $this->assertTrue($method->invoke(null, 'Open Vld supports the new budget.')); - $this->assertTrue($method->invoke(null, 'CD&V members voted on the proposal.')); - $this->assertTrue($method->invoke(null, 'Vlaams Belang criticized the decision.')); - $this->assertTrue($method->invoke(null, 'PTB organized a protest yesterday.')); - $this->assertTrue($method->invoke(null, 'PVDA released a statement.')); + $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(null, 'This event took place in Belgium.')); - $this->assertTrue($method->invoke(null, 'The Belgian government announced new policies.')); - $this->assertTrue($method->invoke(null, 'Flanders saw increased tourism this year.')); - $this->assertTrue($method->invoke(null, 'The Flemish government supports this initiative.')); - $this->assertTrue($method->invoke(null, 'Wallonia will receive additional funding.')); - $this->assertTrue($method->invoke(null, 'Brussels hosted the international conference.')); - $this->assertTrue($method->invoke(null, 'Antwerp Pride attracted thousands of participants.')); - $this->assertTrue($method->invoke(null, 'Ghent University published the research.')); - $this->assertTrue($method->invoke(null, 'Bruges tourism numbers increased.')); - $this->assertTrue($method->invoke(null, 'Leuven students organized the protest.')); - $this->assertTrue($method->invoke(null, 'Mechelen city council voted on the proposal.')); - $this->assertTrue($method->invoke(null, 'Namur hosted the cultural event.')); - $this->assertTrue($method->invoke(null, 'Liège airport saw increased traffic.')); - $this->assertTrue($method->invoke(null, 'Charleroi industrial zone expanded.')); + $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(null, 'Parliament voted on the new legislation.')); - $this->assertTrue($method->invoke(null, 'The government announced budget cuts.')); - $this->assertTrue($method->invoke(null, 'The minister addressed concerns about healthcare.')); - $this->assertTrue($method->invoke(null, 'New policy changes will take effect next month.')); - $this->assertTrue($method->invoke(null, 'The law was passed with majority support.')); - $this->assertTrue($method->invoke(null, 'New legislation affects education funding.')); + $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(null, 'The economy showed signs of recovery.')); - $this->assertTrue($method->invoke(null, 'Economic indicators improved this quarter.')); - $this->assertTrue($method->invoke(null, 'Education reforms were announced today.')); - $this->assertTrue($method->invoke(null, 'Healthcare workers received additional support.')); - $this->assertTrue($method->invoke(null, 'Transport infrastructure will be upgraded.')); - $this->assertTrue($method->invoke(null, 'Climate change policies were discussed.')); - $this->assertTrue($method->invoke(null, 'Energy prices have increased significantly.')); - $this->assertTrue($method->invoke(null, 'European Union voted on trade agreements.')); - $this->assertTrue($method->invoke(null, 'EU sanctions were extended.')); - $this->assertTrue($method->invoke(null, 'Migration policies need urgent review.')); - $this->assertTrue($method->invoke(null, 'Security measures were enhanced.')); - $this->assertTrue($method->invoke(null, 'Justice system reforms are underway.')); - $this->assertTrue($method->invoke(null, 'Culture festivals received government funding.')); - $this->assertTrue($method->invoke(null, 'Police reported 18 administrative detentions.')); + $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(null, 'This article mentions ANTWERP in capital letters.')); - $this->assertTrue($method->invoke(null, 'brussels is mentioned in lowercase.')); - $this->assertTrue($method->invoke(null, 'BeLgIuM is mentioned in mixed case.')); - $this->assertTrue($method->invoke(null, 'The FLEMISH government announced policies.')); - $this->assertTrue($method->invoke(null, 'n-va party policies were discussed.')); - $this->assertTrue($method->invoke(null, 'EUROPEAN union directives apply.')); + $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(null, 'This article discusses random topics.')); - $this->assertFalse($method->invoke(null, 'International news from other countries.')); - $this->assertFalse($method->invoke(null, 'Technology updates and innovations.')); - $this->assertFalse($method->invoke(null, 'Sports results from around the world.')); - $this->assertFalse($method->invoke(null, 'Entertainment news and celebrity gossip.')); - $this->assertFalse($method->invoke(null, 'Weather forecast for next week.')); - $this->assertFalse($method->invoke(null, 'Stock market analysis and trends.')); + $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 @@ -125,7 +144,7 @@ public function test_keyword_matching_in_longer_text(): void considered highly successful and will likely be repeated next year. '; - $this->assertTrue($method->invoke(null, $longText)); + $this->assertTrue($method->invoke($this->validationService, $longText)); $longTextWithoutKeywords = ' This is a comprehensive article about various topics. @@ -135,16 +154,16 @@ public function test_keyword_matching_in_longer_text(): void successful and will likely be repeated next year. '; - $this->assertFalse($method->invoke(null, $longTextWithoutKeywords)); + $this->assertFalse($method->invoke($this->validationService, $longTextWithoutKeywords)); } public function test_empty_content_returns_false(): void { $method = $this->getValidateByKeywordsMethod(); - $this->assertFalse($method->invoke(null, '')); - $this->assertFalse($method->invoke(null, ' ')); - $this->assertFalse($method->invoke(null, "\n\n\t")); + $this->assertFalse($method->invoke($this->validationService, '')); + $this->assertFalse($method->invoke($this->validationService, ' ')); + $this->assertFalse($method->invoke($this->validationService, "\n\n\t")); } /** @@ -171,7 +190,7 @@ public function test_all_keywords_are_functional(): void foreach ($expectedKeywords as $keyword) { $testContent = "This article contains the keyword: {$keyword}."; - $result = $method->invoke(null, $testContent); + $result = $method->invoke($this->validationService, $testContent); $this->assertTrue($result, "Keyword '{$keyword}' should match but didn't"); } @@ -182,10 +201,10 @@ 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(null, 'Anti-government protesters gathered.')); - $this->assertTrue($method->invoke(null, 'The policeman directed traffic.')); - $this->assertTrue($method->invoke(null, 'Educational reforms are needed.')); - $this->assertTrue($method->invoke(null, 'Economic growth accelerated.')); - $this->assertTrue($method->invoke(null, 'The European directive was implemented.')); + $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.')); } } \ No newline at end of file diff --git a/backend/tests/Unit/Services/ValidationServiceTest.php b/backend/tests/Unit/Services/ValidationServiceTest.php index 543b8c4..97df0b9 100644 --- a/backend/tests/Unit/Services/ValidationServiceTest.php +++ b/backend/tests/Unit/Services/ValidationServiceTest.php @@ -2,16 +2,35 @@ namespace Tests\Unit\Services; +use App\Services\Article\ArticleFetcher; use App\Services\Article\ValidationService; +use App\Services\Log\LogSaver; use App\Models\Article; use App\Models\Feed; use Tests\TestCase; +use Tests\Traits\CreatesArticleFetcher; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; +use Mockery; class ValidationServiceTest extends TestCase { - use RefreshDatabase; + use RefreshDatabase, 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(); + } public function test_validate_returns_article_with_validation_status(): void { @@ -27,7 +46,7 @@ public function test_validate_returns_article_with_validation_status(): void 'approval_status' => 'pending' ]); - $result = ValidationService::validate($article); + $result = $this->validationService->validate($article); $this->assertInstanceOf(Article::class, $result); $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); @@ -47,7 +66,7 @@ public function test_validate_marks_article_invalid_when_missing_data(): void 'approval_status' => 'pending' ]); - $result = ValidationService::validate($article); + $result = $this->validationService->validate($article); $this->assertEquals('rejected', $result->approval_status); } @@ -66,7 +85,7 @@ public function test_validate_with_supported_article_content(): void 'approval_status' => 'pending' ]); - $result = ValidationService::validate($article); + $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); @@ -88,7 +107,7 @@ public function test_validate_updates_article_in_database(): void $originalId = $article->id; - ValidationService::validate($article); + $this->validationService->validate($article); // Check that the article was updated in the database $updatedArticle = Article::find($originalId); @@ -111,7 +130,7 @@ public function test_validate_handles_article_with_existing_validation(): void $originalApprovalStatus = $article->approval_status; - $result = ValidationService::validate($article); + $result = $this->validationService->validate($article); // Should re-validate - status may change based on content validation $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); @@ -136,7 +155,7 @@ public function test_validate_keyword_checking_logic(): void 'approval_status' => 'pending' ]); - $result = ValidationService::validate($article); + $result = $this->validationService->validate($article); // The service looks for keywords in the full_article content // Since we can't fetch real content, it will be marked rejected