Fix failing tests

This commit is contained in:
myrmidex 2025-08-15 02:50:42 +02:00
parent 65cb836b51
commit 0de5ea795d
27 changed files with 1257 additions and 238 deletions

View file

@ -0,0 +1,13 @@
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
class LogSaver extends Facade
{
protected static function getFacadeAccessor()
{
return \App\Services\Log\LogSaver::class;
}
}

View file

@ -20,17 +20,17 @@ public function __construct(
$this->onQueue('feed-discovery'); $this->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_id' => $this->feed->id,
'feed_name' => $this->feed->name, 'feed_name' => $this->feed->name,
'feed_url' => $this->feed->url '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_id' => $this->feed->id,
'feed_name' => $this->feed->name, 'feed_name' => $this->feed->name,
'articles_count' => $articles->count() 'articles_count' => $articles->count()
@ -41,9 +41,11 @@ public function handle(): void
public static function dispatchForAllActiveFeeds(): void public static function dispatchForAllActiveFeeds(): void
{ {
$logSaver = app(LogSaver::class);
Feed::where('is_active', true) Feed::where('is_active', true)
->get() ->get()
->each(function (Feed $feed, $index) { ->each(function (Feed $feed, $index) use ($logSaver) {
// Space jobs apart to avoid overwhelming feeds // Space jobs apart to avoid overwhelming feeds
$delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES; $delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES;
@ -51,7 +53,7 @@ public static function dispatchForAllActiveFeeds(): void
->delay(now()->addMinutes($delayMinutes)) ->delay(now()->addMinutes($delayMinutes))
->onQueue('feed-discovery'); ->onQueue('feed-discovery');
LogSaver::info('Dispatched feed discovery job', null, [ $logSaver->info('Dispatched feed discovery job', null, [
'feed_id' => $feed->id, 'feed_id' => $feed->id,
'feed_name' => $feed->name, 'feed_name' => $feed->name,
'delay_minutes' => $delayMinutes 'delay_minutes' => $delayMinutes

View file

@ -16,18 +16,18 @@ public function __construct()
$this->onQueue('feed-discovery'); $this->onQueue('feed-discovery');
} }
public function handle(): void public function handle(LogSaver $logSaver): void
{ {
if (!Setting::isArticleProcessingEnabled()) { if (!Setting::isArticleProcessingEnabled()) {
LogSaver::info('Article processing is disabled. Article discovery skipped.'); $logSaver->info('Article processing is disabled. Article discovery skipped.');
return; return;
} }
LogSaver::info('Starting article discovery for all active feeds'); $logSaver->info('Starting article discovery for all active feeds');
ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds();
LogSaver::info('Article discovery jobs dispatched for all active feeds'); $logSaver->info('Article discovery jobs dispatched for all active feeds');
} }
} }

View file

@ -28,7 +28,7 @@ public function __construct()
* Execute the job. * Execute the job.
* @throws PublishException * @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 // Get the oldest approved article that hasn't been published yet
$article = Article::where('approval_status', 'approved') $article = Article::where('approval_status', 'approved')
@ -48,10 +48,7 @@ public function handle(): void
]); ]);
// Fetch article data // Fetch article data
$extractedData = ArticleFetcher::fetchArticleData($article); $extractedData = $articleFetcher->fetchArticleData($article);
/** @var ArticlePublishingService $publishingService */
$publishingService = resolve(ArticlePublishingService::class);
try { try {
$publishingService->publishToRoutedChannels($article, $extractedData); $publishingService->publishToRoutedChannels($article, $extractedData);

View file

@ -27,32 +27,34 @@ public function __construct(
public static function dispatchForAllActiveChannels(): void public static function dispatchForAllActiveChannels(): void
{ {
$logSaver = app(LogSaver::class);
PlatformChannel::with(['platformInstance', 'platformAccounts']) PlatformChannel::with(['platformInstance', 'platformAccounts'])
->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY)) ->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY))
->whereHas('platformAccounts', fn ($query) => $query->where('is_active', true)) ->whereHas('platformAccounts', fn ($query) => $query->where('platform_accounts.is_active', true))
->where('is_active', true) ->where('platform_channels.is_active', true)
->get() ->get()
->each(function (PlatformChannel $channel) { ->each(function (PlatformChannel $channel) use ($logSaver) {
self::dispatch($channel); 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) { 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 * @throws PlatformAuthException
*/ */
private function syncLemmyChannelPosts(): void private function syncLemmyChannelPosts(LogSaver $logSaver): void
{ {
try { try {
/** @var Collection<int, PlatformAccount> $accounts */ /** @var Collection<int, PlatformAccount> $accounts */
@ -72,10 +74,10 @@ private function syncLemmyChannelPosts(): void
$api->syncChannelPosts($token, $platformChannelId, $this->channel->name); $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) { } catch (Exception $e) {
LogSaver::error('Failed to sync channel posts', $this->channel, [ $logSaver->error('Failed to sync channel posts', $this->channel, [
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);

View file

@ -12,7 +12,7 @@ class ValidateArticleListener implements ShouldQueue
{ {
public string $queue = 'default'; public string $queue = 'default';
public function handle(NewArticleFetched $event): void public function handle(NewArticleFetched $event, ValidationService $validationService): void
{ {
$article = $event->article; $article = $event->article;
@ -25,7 +25,7 @@ public function handle(NewArticleFetched $event): void
return; return;
} }
$article = ValidationService::validate($article); $article = $validationService->validate($article);
if ($article->isValid()) { if ($article->isValid()) {
// Double-check publication doesn't exist (race condition protection) // Double-check publication doesn't exist (race condition protection)

View file

@ -13,18 +13,22 @@
class ArticleFetcher class ArticleFetcher
{ {
public function __construct(
private LogSaver $logSaver
) {}
/** /**
* @return Collection<int, Article> * @return Collection<int, Article>
*/ */
public static function getArticlesFromFeed(Feed $feed): Collection public function getArticlesFromFeed(Feed $feed): Collection
{ {
if ($feed->type === 'rss') { if ($feed->type === 'rss') {
return self::getArticlesFromRssFeed($feed); return $this->getArticlesFromRssFeed($feed);
} elseif ($feed->type === 'website') { } 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_id' => $feed->id,
'feed_type' => $feed->type 'feed_type' => $feed->type
]); ]);
@ -35,7 +39,7 @@ public static function getArticlesFromFeed(Feed $feed): Collection
/** /**
* @return Collection<int, Article> * @return Collection<int, Article>
*/ */
private static function getArticlesFromRssFeed(Feed $feed): Collection private function getArticlesFromRssFeed(Feed $feed): Collection
{ {
// TODO: Implement RSS feed parsing // TODO: Implement RSS feed parsing
// For now, return empty collection // For now, return empty collection
@ -45,14 +49,14 @@ private static function getArticlesFromRssFeed(Feed $feed): Collection
/** /**
* @return Collection<int, Article> * @return Collection<int, Article>
*/ */
private static function getArticlesFromWebsiteFeed(Feed $feed): Collection private function getArticlesFromWebsiteFeed(Feed $feed): Collection
{ {
try { try {
// Try to get parser for this feed // Try to get parser for this feed
$parser = HomepageParserFactory::getParserForFeed($feed); $parser = HomepageParserFactory::getParserForFeed($feed);
if (! $parser) { 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_id' => $feed->id,
'feed_url' => $feed->url 'feed_url' => $feed->url
]); ]);
@ -64,10 +68,10 @@ private static function getArticlesFromWebsiteFeed(Feed $feed): Collection
$urls = $parser->extractArticleUrls($html); $urls = $parser->extractArticleUrls($html);
return collect($urls) return collect($urls)
->map(fn (string $url) => self::saveArticle($url, $feed->id)); ->map(fn (string $url) => $this->saveArticle($url, $feed->id));
} catch (Exception $e) { } 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_id' => $feed->id,
'feed_url' => $feed->url, 'feed_url' => $feed->url,
'error' => $e->getMessage() 'error' => $e->getMessage()
@ -80,7 +84,7 @@ private static function getArticlesFromWebsiteFeed(Feed $feed): Collection
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public static function fetchArticleData(Article $article): array public function fetchArticleData(Article $article): array
{ {
try { try {
$html = HttpFetcher::fetchHtml($article->url); $html = HttpFetcher::fetchHtml($article->url);
@ -88,7 +92,7 @@ public static function fetchArticleData(Article $article): array
return $parser->extractData($html); return $parser->extractData($html);
} catch (Exception $e) { } catch (Exception $e) {
LogSaver::error('Exception while fetching article data', null, [ $this->logSaver->error('Exception while fetching article data', null, [
'url' => $article->url, 'url' => $article->url,
'error' => $e->getMessage() '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(); $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 // Extract a basic title from URL as fallback
$fallbackTitle = self::generateFallbackTitle($url); $fallbackTitle = $this->generateFallbackTitle($url);
try { try {
return Article::create([ return Article::create([
@ -115,7 +119,7 @@ private static function saveArticle(string $url, ?int $feedId = null): Article
'title' => $fallbackTitle, 'title' => $fallbackTitle,
]); ]);
} catch (\Exception $e) { } 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, 'url' => $url,
'feed_id' => $feedId, 'feed_id' => $feedId,
'error' => $e->getMessage(), '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 // Extract filename from URL as a basic fallback title
$path = parse_url($url, PHP_URL_PATH); $path = parse_url($url, PHP_URL_PATH);

View file

@ -6,11 +6,15 @@
class ValidationService 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); logger('Checking keywords for article: ' . $article->id);
$articleData = ArticleFetcher::fetchArticleData($article); $articleData = $this->articleFetcher->fetchArticleData($article);
// Update article with fetched metadata (title, description) // Update article with fetched metadata (title, description)
$updateData = []; $updateData = [];
@ -34,7 +38,7 @@ public static function validate(Article $article): Article
} }
// Validate using extracted content (not stored) // Validate using extracted content (not stored)
$validationResult = self::validateByKeywords($articleData['full_article']); $validationResult = $this->validateByKeywords($articleData['full_article']);
$updateData['approval_status'] = $validationResult ? 'approved' : 'pending'; $updateData['approval_status'] = $validationResult ? 'approved' : 'pending';
$article->update($updateData); $article->update($updateData);
@ -42,7 +46,7 @@ public static function validate(Article $article): Article
return $article->refresh(); 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 // Belgian news content keywords - broader set for Belgian news relevance
$keywords = [ $keywords = [

View file

@ -11,39 +11,39 @@ class LogSaver
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $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<string, mixed> $context * @param array<string, mixed> $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<string, mixed> $context * @param array<string, mixed> $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<string, mixed> $context * @param array<string, mixed> $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<string, mixed> $context * @param array<string, mixed> $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; $logContext = $context;

View file

@ -17,6 +17,9 @@
class ArticlePublishingService class ArticlePublishingService
{ {
public function __construct(private LogSaver $logSaver)
{
}
/** /**
* Factory seam to create publisher instances (helps testing without network calls) * 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(); $account = $channel->activePlatformAccounts()->first();
if (! $account) { if (! $account) {
LogSaver::warning('No active account for channel', $channel, [ $this->logSaver->warning('No active account for channel', $channel, [
'article_id' => $article->id, 'article_id' => $article->id,
'route_priority' => $route->priority 'route_priority' => $route->priority
]); ]);
@ -123,13 +126,13 @@ private function publishToChannel(Article $article, array $extractedData, Platfo
'publication_data' => $postData, '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 'article_id' => $article->id
]); ]);
return $publication; return $publication;
} catch (Exception $e) { } catch (Exception $e) {
LogSaver::warning('Failed to publish to channel', $channel, [ $this->logSaver->warning('Failed to publish to channel', $channel, [
'article_id' => $article->id, 'article_id' => $article->id,
'error' => $e->getMessage() 'error' => $e->getMessage()
]); ]);

View file

@ -35,30 +35,21 @@ public function test_command_returns_failure_exit_code_for_unsupported_platform(
public function test_command_accepts_lemmy_platform_argument(): void public function test_command_accepts_lemmy_platform_argument(): void
{ {
// This test validates the command signature accepts the lemmy argument // Act - Test that the command accepts lemmy as a valid platform argument
// without actually executing the database-dependent logic $exitCode = $this->artisan('channel:sync lemmy');
// Just test that the command can be called with lemmy argument // Assert - Command should succeed (not fail with argument validation error)
// The actual job dispatch is tested separately $exitCode->assertSuccessful();
try { $exitCode->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels');
$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());
}
} }
public function test_command_handles_default_platform(): void 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 { // Assert - Command should succeed with default platform
$this->artisan('channel:sync'); $exitCode->assertSuccessful();
} catch (\Exception $e) { $exitCode->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels');
// 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());
}
} }
} }

View file

@ -19,6 +19,9 @@
use App\Models\Feed; use App\Models\Feed;
use App\Models\Log; use App\Models\Log;
use App\Models\PlatformChannel; 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\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
@ -28,14 +31,20 @@ class JobsAndEventsTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
}
public function test_article_discovery_job_processes_successfully(): void public function test_article_discovery_job_processes_successfully(): void
{ {
Queue::fake(); Queue::fake();
$feed = Feed::factory()->create(['is_active' => true]); $feed = Feed::factory()->create(['is_active' => true]);
$logSaver = app(LogSaver::class);
$job = new ArticleDiscoveryJob(); $job = new ArticleDiscoveryJob();
$job->handle(); $job->handle($logSaver);
// Should dispatch individual feed jobs // Should dispatch individual feed jobs
Queue::assertPushed(ArticleDiscoveryForFeedJob::class); 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); $this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher);
$logSaver = app(LogSaver::class);
$articleFetcher = app(ArticleFetcher::class);
$job = new ArticleDiscoveryForFeedJob($feed); $job = new ArticleDiscoveryForFeedJob($feed);
$job->handle(); $job->handle($logSaver, $articleFetcher);
// Should have articles in database (existing articles created by factory) // Should have articles in database (existing articles created by factory)
$this->assertCount(2, Article::all()); $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 // 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); $this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher);
$mockFetcher->shouldReceive('fetchArticleData') $mockFetcher->shouldReceive('fetchArticleData')
->with($article) ->with($article)
->andReturn([ ->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(); $listener = new ValidateArticleListener();
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$listener->handle($event); $listener->handle($event, $validationService);
$article->refresh(); $article->refresh();
$this->assertNotEquals('pending', $article->approval_status); $this->assertNotEquals('pending', $article->approval_status);

View file

@ -8,6 +8,7 @@
use App\Models\Article; use App\Models\Article;
use App\Models\ArticlePublication; use App\Models\ArticlePublication;
use App\Models\Feed; use App\Models\Feed;
use App\Services\Article\ValidationService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -36,7 +37,8 @@ public function test_listener_validates_article_and_dispatches_ready_to_publish_
$listener = new ValidateArticleListener(); $listener = new ValidateArticleListener();
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$listener->handle($event); $validationService = app(ValidationService::class);
$listener->handle($event, $validationService);
$article->refresh(); $article->refresh();
@ -63,7 +65,8 @@ public function test_listener_skips_already_validated_articles(): void
$listener = new ValidateArticleListener(); $listener = new ValidateArticleListener();
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$listener->handle($event); $validationService = app(ValidationService::class);
$listener->handle($event, $validationService);
Event::assertNotDispatched(ArticleReadyToPublish::class); Event::assertNotDispatched(ArticleReadyToPublish::class);
} }
@ -90,7 +93,8 @@ public function test_listener_skips_articles_with_existing_publication(): void
$listener = new ValidateArticleListener(); $listener = new ValidateArticleListener();
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$listener->handle($event); $validationService = app(ValidationService::class);
$listener->handle($event, $validationService);
Event::assertNotDispatched(ArticleReadyToPublish::class); Event::assertNotDispatched(ArticleReadyToPublish::class);
} }
@ -114,7 +118,8 @@ public function test_listener_calls_validation_service(): void
$listener = new ValidateArticleListener(); $listener = new ValidateArticleListener();
$event = new NewArticleFetched($article); $event = new NewArticleFetched($article);
$listener->handle($event); $validationService = app(ValidationService::class);
$listener->handle($event, $validationService);
// Verify that the article was processed by ValidationService // Verify that the article was processed by ValidationService
$article->refresh(); $article->refresh();

View file

@ -14,6 +14,13 @@ abstract class TestCase extends BaseTestCase
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); 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 // Prevent any external HTTP requests during tests unless explicitly faked in a test
Http::preventStrayRequests(); Http::preventStrayRequests();
} }

View file

@ -0,0 +1,36 @@
<?php
namespace Tests\Traits;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver;
use Mockery;
trait CreatesArticleFetcher
{
protected function createArticleFetcher(?LogSaver $logSaver = null): ArticleFetcher
{
if (!$logSaver) {
$logSaver = Mockery::mock(LogSaver::class);
$logSaver->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];
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace Tests\Unit\Enums;
use App\Enums\LogLevelEnum;
use Tests\TestCase;
class LogLevelEnumTest extends TestCase
{
public function test_enum_cases_have_correct_values(): void
{
$this->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));
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace Tests\Unit\Enums;
use App\Enums\PlatformEnum;
use Tests\TestCase;
class PlatformEnumTest extends TestCase
{
public function test_enum_cases_have_correct_values(): void
{
$this->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);
}
}

View file

@ -0,0 +1,222 @@
<?php
namespace Tests\Unit\Jobs;
use App\Jobs\ArticleDiscoveryForFeedJob;
use App\Models\Feed;
use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Mockery;
use Tests\TestCase;
class ArticleDiscoveryForFeedJobTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
}
public function test_constructor_sets_correct_queue(): void
{
$feed = Feed::factory()->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();
}
}

View file

@ -2,18 +2,24 @@
namespace Tests\Unit\Jobs; namespace Tests\Unit\Jobs;
use App\Jobs\ArticleDiscoveryForFeedJob;
use App\Jobs\ArticleDiscoveryJob; use App\Jobs\ArticleDiscoveryJob;
use App\Models\Setting; use App\Models\Setting;
use App\Services\Log\LogSaver; use App\Services\Log\LogSaver;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Mockery;
use Tests\TestCase; use Tests\TestCase;
class ArticleDiscoveryJobTest extends TestCase class ArticleDiscoveryJobTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
}
public function test_constructor_sets_correct_queue(): void public function test_constructor_sets_correct_queue(): void
{ {
// Act // Act
@ -26,13 +32,18 @@ public function test_constructor_sets_correct_queue(): void
public function test_handle_skips_when_article_processing_disabled(): void public function test_handle_skips_when_article_processing_disabled(): void
{ {
// Arrange // Arrange
Queue::fake();
Setting::create(['key' => 'article_processing_enabled', 'value' => '0']); 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(); $job = new ArticleDiscoveryJob();
// Act // Act
$job->handle(); $job->handle($logSaverMock);
// Assert // Assert
Queue::assertNothingPushed(); 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 public function test_handle_dispatches_jobs_when_article_processing_enabled(): void
{ {
// Arrange // Arrange
Queue::fake();
Setting::create(['key' => 'article_processing_enabled', 'value' => '1']); 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(); $job = new ArticleDiscoveryJob();
// Act // Act
$job->handle(); $job->handle($logSaverMock);
// Assert - This will test that the static method is called, but we can't easily verify // Assert - This will test that the static method is called, but we can't easily verify
// the job dispatch without mocking the static method // 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 public function test_handle_with_default_article_processing_enabled(): void
{ {
// Arrange - No setting exists, should default to enabled // 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(); $job = new ArticleDiscoveryJob();
// Act // Act
$job->handle(); $job->handle($logSaverMock);
// Assert - Should complete without skipping // Assert - Should complete without skipping
$this->assertTrue(true); // Job completes without error $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 // This test verifies that the job calls the logging methods
// The actual logging is tested in the LogSaver tests // The actual logging is tested in the LogSaver tests
// Arrange // 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(); $job = new ArticleDiscoveryJob();
// Act - Should not throw any exceptions // Act - Should not throw any exceptions
$job->handle(); $job->handle($logSaverMock);
// Assert - Job completes successfully // Assert - Job completes successfully
$this->assertTrue(true); $this->assertTrue(true);
} }
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}

View file

@ -0,0 +1,296 @@
<?php
namespace Tests\Unit\Jobs;
use App\Exceptions\PublishException;
use App\Jobs\PublishNextArticleJob;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Services\Article\ArticleFetcher;
use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class PublishNextArticleJobTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
}
public function test_constructor_sets_correct_queue(): void
{
$job = new PublishNextArticleJob();
$this->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();
}
}

View file

@ -3,65 +3,168 @@
namespace Tests\Unit\Jobs; namespace Tests\Unit\Jobs;
use App\Enums\PlatformEnum; use App\Enums\PlatformEnum;
use App\Exceptions\PlatformAuthException;
use App\Jobs\SyncChannelPostsJob; use App\Jobs\SyncChannelPostsJob;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel; 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\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
use Mockery;
use Tests\TestCase; use Tests\TestCase;
class SyncChannelPostsJobTest extends TestCase class SyncChannelPostsJobTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Cache::flush();
}
public function test_constructor_sets_correct_queue(): void public function test_constructor_sets_correct_queue(): void
{ {
// Arrange $channel = PlatformChannel::factory()->make();
$channel = new PlatformChannel(['name' => 'Test Channel']);
// Act
$job = new SyncChannelPostsJob($channel); $job = new SyncChannelPostsJob($channel);
// Assert
$this->assertEquals('sync', $job->queue); $this->assertEquals('sync', $job->queue);
} }
public function test_job_implements_required_interfaces(): void public function test_job_implements_should_queue(): void
{ {
// Arrange $channel = PlatformChannel::factory()->make();
$channel = new PlatformChannel(['name' => 'Test Channel']);
$job = new SyncChannelPostsJob($channel); $job = new SyncChannelPostsJob($channel);
// Assert
$this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); $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); $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job);
} }
public function test_job_uses_queueable_trait(): void 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 // 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); $job = new SyncChannelPostsJob($channel);
// Assert // Act - This will fail due to no active account, but we test the logging
$this->assertTrue(method_exists($job, 'onQueue')); try {
$this->assertTrue(method_exists($job, 'onConnection')); $job->handle($logSaverMock);
$this->assertTrue(method_exists($job, 'delay')); } 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 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')); $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 = PlatformChannel::factory()->make();
$channel = new PlatformChannel(['name' => 'Test Channel']);
$job = new SyncChannelPostsJob($channel); $job = new SyncChannelPostsJob($channel);
// Assert - Basic structure tests
$this->assertIsObject($job);
$this->assertTrue(method_exists($job, 'handle')); $this->assertTrue(method_exists($job, 'handle'));
$this->assertIsString($job->queue); }
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
} }
} }

View file

@ -3,16 +3,18 @@
namespace Tests\Unit\Services; namespace Tests\Unit\Services;
use App\Services\Article\ArticleFetcher; use App\Services\Article\ArticleFetcher;
use App\Services\Log\LogSaver;
use App\Models\Feed; use App\Models\Feed;
use App\Models\Article; use App\Models\Article;
use Tests\TestCase; use Tests\TestCase;
use Tests\Traits\CreatesArticleFetcher;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Mockery; use Mockery;
class ArticleFetcherTest extends TestCase class ArticleFetcherTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase, CreatesArticleFetcher;
protected function setUp(): void protected function setUp(): void
{ {
@ -22,16 +24,20 @@ protected function setUp(): void
Http::fake([ Http::fake([
'*' => Http::response('<html><body>Mock HTML content</body></html>', 200) '*' => Http::response('<html><body>Mock HTML content</body></html>', 200)
]); ]);
// Create ArticleFetcher only when needed - tests will create their own
} }
public function test_get_articles_from_feed_returns_collection(): void public function test_get_articles_from_feed_returns_collection(): void
{ {
$articleFetcher = $this->createArticleFetcher();
$feed = Feed::factory()->create([ $feed = Feed::factory()->create([
'type' => 'rss', 'type' => 'rss',
'url' => 'https://example.com/feed.rss' 'url' => 'https://example.com/feed.rss'
]); ]);
$result = ArticleFetcher::getArticlesFromFeed($feed); $result = $articleFetcher->getArticlesFromFeed($feed);
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); $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' '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 // RSS parsing is not implemented yet, should return empty collection
$this->assertEmpty($result); $this->assertEmpty($result);
@ -56,7 +63,8 @@ public function test_get_articles_from_website_feed_handles_no_parser(): void
'url' => 'https://unsupported-site.com/' '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 // Should return empty collection when no parser is available
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); $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' '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->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
$this->assertEmpty($result); $this->assertEmpty($result);
@ -82,7 +91,8 @@ public function test_fetch_article_data_returns_array(): void
'url' => 'https://example.com/article' 'url' => 'https://example.com/article'
]); ]);
$result = ArticleFetcher::fetchArticleData($article); $articleFetcher = $this->createArticleFetcher();
$result = $articleFetcher->fetchArticleData($article);
$this->assertIsArray($result); $this->assertIsArray($result);
// Will be empty array due to unsupported URL in test // 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' 'url' => 'invalid-url'
]); ]);
$result = ArticleFetcher::fetchArticleData($article); $articleFetcher = $this->createArticleFetcher();
$result = $articleFetcher->fetchArticleData($article);
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertEmpty($result); $this->assertEmpty($result);
@ -117,7 +128,8 @@ public function test_get_articles_from_feed_with_null_feed_type(): void
$attributes['type'] = 'invalid_type'; $attributes['type'] = 'invalid_type';
$property->setValue($feed, $attributes); $property->setValue($feed, $attributes);
$result = ArticleFetcher::getArticlesFromFeed($feed); $articleFetcher = $this->createArticleFetcher();
$result = $articleFetcher->getArticlesFromFeed($feed);
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
$this->assertEmpty($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 // 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); $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
// VRT parser will process the mocked HTML response // 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/' '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->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
$this->assertEmpty($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 // Test actual behavior - VRT parser should be available
$result = ArticleFetcher::fetchArticleData($article); $articleFetcher = $this->createArticleFetcher();
$result = $articleFetcher->fetchArticleData($article);
$this->assertIsArray($result); $this->assertIsArray($result);
// VRT parser will process the mocked HTML response // 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' 'url' => 'https://unsupported-domain.com/article'
]); ]);
$result = ArticleFetcher::fetchArticleData($article); $articleFetcher = $this->createArticleFetcher();
$result = $articleFetcher->fetchArticleData($article);
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertEmpty($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 // Ensure article doesn't exist
$this->assertDatabaseMissing('articles', ['url' => $url]); $this->assertDatabaseMissing('articles', ['url' => $url]);
$articleFetcher = $this->createArticleFetcher();
// Use reflection to access private method for testing // Use reflection to access private method for testing
$reflection = new \ReflectionClass(ArticleFetcher::class); $reflection = new \ReflectionClass($articleFetcher);
$saveArticleMethod = $reflection->getMethod('saveArticle'); $saveArticleMethod = $reflection->getMethod('saveArticle');
$saveArticleMethod->setAccessible(true); $saveArticleMethod->setAccessible(true);
$article = $saveArticleMethod->invoke(null, $url, $feed->id); $article = $saveArticleMethod->invoke($articleFetcher, $url, $feed->id);
$this->assertInstanceOf(Article::class, $article); $this->assertInstanceOf(Article::class, $article);
$this->assertEquals($url, $article->url); $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 = $reflection->getMethod('saveArticle');
$saveArticleMethod->setAccessible(true); $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->id, $article->id);
$this->assertEquals($existingArticle->url, $article->url); $this->assertEquals($existingArticle->url, $article->url);
@ -239,7 +257,8 @@ public function test_save_article_without_feed_id(): void
$saveArticleMethod = $reflection->getMethod('saveArticle'); $saveArticleMethod = $reflection->getMethod('saveArticle');
$saveArticleMethod->setAccessible(true); $saveArticleMethod->setAccessible(true);
$article = $saveArticleMethod->invoke(null, $url, null); $articleFetcher = $this->createArticleFetcher();
$article = $saveArticleMethod->invoke($articleFetcher, $url, null);
$this->assertInstanceOf(Article::class, $article); $this->assertInstanceOf(Article::class, $article);
$this->assertEquals($url, $article->url); $this->assertEquals($url, $article->url);

View file

@ -14,13 +14,21 @@
class LogSaverTest extends TestCase class LogSaverTest extends TestCase
{ {
use RefreshDatabase; 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 public function test_info_creates_log_record_with_info_level(): void
{ {
$message = 'Test info message'; $message = 'Test info message';
$context = ['key' => 'value']; $context = ['key' => 'value'];
LogSaver::info($message, null, $context); $this->logSaver->info($message, null, $context);
$this->assertDatabaseHas('logs', [ $this->assertDatabaseHas('logs', [
'level' => LogLevelEnum::INFO, 'level' => LogLevelEnum::INFO,
@ -36,7 +44,7 @@ public function test_error_creates_log_record_with_error_level(): void
$message = 'Test error message'; $message = 'Test error message';
$context = ['error_code' => 500]; $context = ['error_code' => 500];
LogSaver::error($message, null, $context); $this->logSaver->error($message, null, $context);
$this->assertDatabaseHas('logs', [ $this->assertDatabaseHas('logs', [
'level' => LogLevelEnum::ERROR, 'level' => LogLevelEnum::ERROR,
@ -52,7 +60,7 @@ public function test_warning_creates_log_record_with_warning_level(): void
$message = 'Test warning message'; $message = 'Test warning message';
$context = ['warning_type' => 'deprecation']; $context = ['warning_type' => 'deprecation'];
LogSaver::warning($message, null, $context); $this->logSaver->warning($message, null, $context);
$this->assertDatabaseHas('logs', [ $this->assertDatabaseHas('logs', [
'level' => LogLevelEnum::WARNING, 'level' => LogLevelEnum::WARNING,
@ -68,7 +76,7 @@ public function test_debug_creates_log_record_with_debug_level(): void
$message = 'Test debug message'; $message = 'Test debug message';
$context = ['debug_info' => 'trace']; $context = ['debug_info' => 'trace'];
LogSaver::debug($message, null, $context); $this->logSaver->debug($message, null, $context);
$this->assertDatabaseHas('logs', [ $this->assertDatabaseHas('logs', [
'level' => LogLevelEnum::DEBUG, 'level' => LogLevelEnum::DEBUG,
@ -94,7 +102,7 @@ public function test_log_with_channel_includes_channel_information_in_context():
$message = 'Test message with channel'; $message = 'Test message with channel';
$originalContext = ['original_key' => 'original_value']; $originalContext = ['original_key' => 'original_value'];
LogSaver::info($message, $channel, $originalContext); $this->logSaver->info($message, $channel, $originalContext);
$log = Log::first(); $log = Log::first();
@ -115,7 +123,7 @@ public function test_log_without_channel_uses_original_context_only(): void
$message = 'Test message without channel'; $message = 'Test message without channel';
$context = ['test_key' => 'test_value']; $context = ['test_key' => 'test_value'];
LogSaver::info($message, null, $context); $this->logSaver->info($message, null, $context);
$log = Log::first(); $log = Log::first();
@ -127,7 +135,7 @@ public function test_log_with_empty_context_creates_minimal_log(): void
{ {
$message = 'Simple message'; $message = 'Simple message';
LogSaver::info($message); $this->logSaver->info($message);
$this->assertDatabaseHas('logs', [ $this->assertDatabaseHas('logs', [
'level' => LogLevelEnum::INFO, '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'; $message = 'Message with channel but no context';
LogSaver::warning($message, $channel); $this->logSaver->warning($message, $channel);
$log = Log::first(); $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' '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(); $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 public function test_multiple_logs_are_created_independently(): void
{ {
LogSaver::info('First message', null, ['id' => 1]); $this->logSaver->info('First message', null, ['id' => 1]);
LogSaver::error('Second message', null, ['id' => 2]); $this->logSaver->error('Second message', null, ['id' => 2]);
LogSaver::warning('Third message', null, ['id' => 3]); $this->logSaver->warning('Third message', null, ['id' => 3]);
$this->assertDatabaseCount('logs', 3); $this->assertDatabaseCount('logs', 3);
@ -237,7 +245,7 @@ public function test_log_with_complex_context_data(): void
'null_value' => null 'null_value' => null
]; ];
LogSaver::debug('Complex context test', null, $complexContext); $this->logSaver->debug('Complex context test', null, $complexContext);
$log = Log::first(); $log = Log::first();
$this->assertEquals($complexContext, $log->context); $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]; $context = ['test' => true];
// Test all four log level methods // Test all four log level methods
LogSaver::info($message, null, $context); $this->logSaver->info($message, null, $context);
LogSaver::error($message, null, $context); $this->logSaver->error($message, null, $context);
LogSaver::warning($message, null, $context); $this->logSaver->warning($message, null, $context);
LogSaver::debug($message, null, $context); $this->logSaver->debug($message, null, $context);
// Should have 4 log entries // Should have 4 log entries
$this->assertDatabaseCount('logs', 4); $this->assertDatabaseCount('logs', 4);

View file

@ -26,11 +26,17 @@ class ArticlePublishingServiceTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
protected ArticlePublishingService $service; protected ArticlePublishingService $service;
protected LogSaver $logSaver;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); 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 protected function tearDown(): void
@ -123,7 +129,7 @@ public function test_publish_to_routed_channels_successfully_publishes_to_channe
$publisherDouble->shouldReceive('publishToChannel') $publisherDouble->shouldReceive('publishToChannel')
->once() ->once()
->andReturn(['post_view' => ['post' => ['id' => 123]]]); ->andReturn(['post_view' => ['post' => ['id' => 123]]]);
$service = \Mockery::mock(ArticlePublishingService::class)->makePartial(); $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $service->shouldReceive('makePublisher')->andReturn($publisherDouble);
@ -169,7 +175,7 @@ public function test_publish_to_routed_channels_handles_publishing_failure_grace
$publisherDouble->shouldReceive('publishToChannel') $publisherDouble->shouldReceive('publishToChannel')
->once() ->once()
->andThrow(new Exception('network error')); ->andThrow(new Exception('network error'));
$service = \Mockery::mock(ArticlePublishingService::class)->makePartial(); $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $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]]]); ->once()->andReturn(['post_view' => ['post' => ['id' => 100]]]);
$publisherDouble->shouldReceive('publishToChannel') $publisherDouble->shouldReceive('publishToChannel')
->once()->andReturn(['post_view' => ['post' => ['id' => 200]]]); ->once()->andReturn(['post_view' => ['post' => ['id' => 200]]]);
$service = \Mockery::mock(ArticlePublishingService::class)->makePartial(); $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $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]]]); ->once()->andReturn(['post_view' => ['post' => ['id' => 300]]]);
$publisherDouble->shouldReceive('publishToChannel') $publisherDouble->shouldReceive('publishToChannel')
->once()->andThrow(new Exception('failed')); ->once()->andThrow(new Exception('failed'));
$service = \Mockery::mock(ArticlePublishingService::class)->makePartial(); $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial();
$service->shouldAllowMockingProtectedMethods(); $service->shouldAllowMockingProtectedMethods();
$service->shouldReceive('makePublisher')->andReturn($publisherDouble); $service->shouldReceive('makePublisher')->andReturn($publisherDouble);

View file

@ -7,8 +7,10 @@
use App\Models\Keyword; use App\Models\Keyword;
use App\Models\PlatformChannel; use App\Models\PlatformChannel;
use App\Models\Route; use App\Models\Route;
use App\Services\Log\LogSaver;
use App\Services\Publishing\ArticlePublishingService; use App\Services\Publishing\ArticlePublishingService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase; use Tests\TestCase;
class KeywordFilteringTest extends TestCase class KeywordFilteringTest extends TestCase
@ -26,7 +28,12 @@ protected function setUp(): void
{ {
parent::setUp(); 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->feed = Feed::factory()->create();
$this->channel1 = PlatformChannel::factory()->create(); $this->channel1 = PlatformChannel::factory()->create();
$this->channel2 = PlatformChannel::factory()->create(); $this->channel2 = PlatformChannel::factory()->create();
@ -46,6 +53,12 @@ protected function setUp(): void
'priority' => 50 'priority' => 50
]); ]);
} }
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_route_with_no_keywords_matches_all_articles(): void public function test_route_with_no_keywords_matches_all_articles(): void
{ {

View file

@ -4,17 +4,36 @@
use App\Services\Article\ValidationService; use App\Services\Article\ValidationService;
use Tests\TestCase; use Tests\TestCase;
use Tests\Traits\CreatesArticleFetcher;
use ReflectionClass; use ReflectionClass;
use ReflectionMethod; use ReflectionMethod;
use Mockery;
class ValidationServiceKeywordTest extends TestCase 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 * Helper method to access private validateByKeywords method
*/ */
private function getValidateByKeywordsMethod(): ReflectionMethod private function getValidateByKeywordsMethod(): ReflectionMethod
{ {
$reflection = new ReflectionClass(ValidationService::class); $reflection = new ReflectionClass($this->validationService);
$method = $reflection->getMethod('validateByKeywords'); $method = $reflection->getMethod('validateByKeywords');
$method->setAccessible(true); $method->setAccessible(true);
return $method; return $method;
@ -24,93 +43,93 @@ public function test_validates_belgian_political_keywords(): void
{ {
$method = $this->getValidateByKeywordsMethod(); $method = $this->getValidateByKeywordsMethod();
$this->assertTrue($method->invoke(null, 'This article discusses N-VA party policies.')); $this->assertTrue($method->invoke($this->validationService, 'This article discusses N-VA party policies.'));
$this->assertTrue($method->invoke(null, 'Bart De Wever made a statement today.')); $this->assertTrue($method->invoke($this->validationService, 'Bart De Wever made a statement today.'));
$this->assertTrue($method->invoke(null, 'Frank Vandenbroucke announced new healthcare policies.')); $this->assertTrue($method->invoke($this->validationService, 'Frank Vandenbroucke announced new healthcare policies.'));
$this->assertTrue($method->invoke(null, 'Alexander De Croo addressed the nation.')); $this->assertTrue($method->invoke($this->validationService, 'Alexander De Croo addressed the nation.'));
$this->assertTrue($method->invoke(null, 'The Vooruit party proposed new legislation.')); $this->assertTrue($method->invoke($this->validationService, 'The Vooruit party proposed new legislation.'));
$this->assertTrue($method->invoke(null, 'Open Vld supports the new budget.')); $this->assertTrue($method->invoke($this->validationService, 'Open Vld supports the new budget.'));
$this->assertTrue($method->invoke(null, 'CD&V members voted on the proposal.')); $this->assertTrue($method->invoke($this->validationService, 'CD&V members voted on the proposal.'));
$this->assertTrue($method->invoke(null, 'Vlaams Belang criticized the decision.')); $this->assertTrue($method->invoke($this->validationService, 'Vlaams Belang criticized the decision.'));
$this->assertTrue($method->invoke(null, 'PTB organized a protest yesterday.')); $this->assertTrue($method->invoke($this->validationService, 'PTB organized a protest yesterday.'));
$this->assertTrue($method->invoke(null, 'PVDA released a statement.')); $this->assertTrue($method->invoke($this->validationService, 'PVDA released a statement.'));
} }
public function test_validates_belgian_location_keywords(): void public function test_validates_belgian_location_keywords(): void
{ {
$method = $this->getValidateByKeywordsMethod(); $method = $this->getValidateByKeywordsMethod();
$this->assertTrue($method->invoke(null, 'This event took place in Belgium.')); $this->assertTrue($method->invoke($this->validationService, 'This event took place in Belgium.'));
$this->assertTrue($method->invoke(null, 'The Belgian government announced new policies.')); $this->assertTrue($method->invoke($this->validationService, 'The Belgian government announced new policies.'));
$this->assertTrue($method->invoke(null, 'Flanders saw increased tourism this year.')); $this->assertTrue($method->invoke($this->validationService, 'Flanders saw increased tourism this year.'));
$this->assertTrue($method->invoke(null, 'The Flemish government supports this initiative.')); $this->assertTrue($method->invoke($this->validationService, 'The Flemish government supports this initiative.'));
$this->assertTrue($method->invoke(null, 'Wallonia will receive additional funding.')); $this->assertTrue($method->invoke($this->validationService, 'Wallonia will receive additional funding.'));
$this->assertTrue($method->invoke(null, 'Brussels hosted the international conference.')); $this->assertTrue($method->invoke($this->validationService, 'Brussels hosted the international conference.'));
$this->assertTrue($method->invoke(null, 'Antwerp Pride attracted thousands of participants.')); $this->assertTrue($method->invoke($this->validationService, 'Antwerp Pride attracted thousands of participants.'));
$this->assertTrue($method->invoke(null, 'Ghent University published the research.')); $this->assertTrue($method->invoke($this->validationService, 'Ghent University published the research.'));
$this->assertTrue($method->invoke(null, 'Bruges tourism numbers increased.')); $this->assertTrue($method->invoke($this->validationService, 'Bruges tourism numbers increased.'));
$this->assertTrue($method->invoke(null, 'Leuven students organized the protest.')); $this->assertTrue($method->invoke($this->validationService, 'Leuven students organized the protest.'));
$this->assertTrue($method->invoke(null, 'Mechelen city council voted on the proposal.')); $this->assertTrue($method->invoke($this->validationService, 'Mechelen city council voted on the proposal.'));
$this->assertTrue($method->invoke(null, 'Namur hosted the cultural event.')); $this->assertTrue($method->invoke($this->validationService, 'Namur hosted the cultural event.'));
$this->assertTrue($method->invoke(null, 'Liège airport saw increased traffic.')); $this->assertTrue($method->invoke($this->validationService, 'Liège airport saw increased traffic.'));
$this->assertTrue($method->invoke(null, 'Charleroi industrial zone expanded.')); $this->assertTrue($method->invoke($this->validationService, 'Charleroi industrial zone expanded.'));
} }
public function test_validates_government_keywords(): void public function test_validates_government_keywords(): void
{ {
$method = $this->getValidateByKeywordsMethod(); $method = $this->getValidateByKeywordsMethod();
$this->assertTrue($method->invoke(null, 'Parliament voted on the new legislation.')); $this->assertTrue($method->invoke($this->validationService, 'Parliament voted on the new legislation.'));
$this->assertTrue($method->invoke(null, 'The government announced budget cuts.')); $this->assertTrue($method->invoke($this->validationService, 'The government announced budget cuts.'));
$this->assertTrue($method->invoke(null, 'The minister addressed concerns about healthcare.')); $this->assertTrue($method->invoke($this->validationService, 'The minister addressed concerns about healthcare.'));
$this->assertTrue($method->invoke(null, 'New policy changes will take effect next month.')); $this->assertTrue($method->invoke($this->validationService, 'New policy changes will take effect next month.'));
$this->assertTrue($method->invoke(null, 'The law was passed with majority support.')); $this->assertTrue($method->invoke($this->validationService, 'The law was passed with majority support.'));
$this->assertTrue($method->invoke(null, 'New legislation affects education funding.')); $this->assertTrue($method->invoke($this->validationService, 'New legislation affects education funding.'));
} }
public function test_validates_news_topic_keywords(): void public function test_validates_news_topic_keywords(): void
{ {
$method = $this->getValidateByKeywordsMethod(); $method = $this->getValidateByKeywordsMethod();
$this->assertTrue($method->invoke(null, 'The economy showed signs of recovery.')); $this->assertTrue($method->invoke($this->validationService, 'The economy showed signs of recovery.'));
$this->assertTrue($method->invoke(null, 'Economic indicators improved this quarter.')); $this->assertTrue($method->invoke($this->validationService, 'Economic indicators improved this quarter.'));
$this->assertTrue($method->invoke(null, 'Education reforms were announced today.')); $this->assertTrue($method->invoke($this->validationService, 'Education reforms were announced today.'));
$this->assertTrue($method->invoke(null, 'Healthcare workers received additional support.')); $this->assertTrue($method->invoke($this->validationService, 'Healthcare workers received additional support.'));
$this->assertTrue($method->invoke(null, 'Transport infrastructure will be upgraded.')); $this->assertTrue($method->invoke($this->validationService, 'Transport infrastructure will be upgraded.'));
$this->assertTrue($method->invoke(null, 'Climate change policies were discussed.')); $this->assertTrue($method->invoke($this->validationService, 'Climate change policies were discussed.'));
$this->assertTrue($method->invoke(null, 'Energy prices have increased significantly.')); $this->assertTrue($method->invoke($this->validationService, 'Energy prices have increased significantly.'));
$this->assertTrue($method->invoke(null, 'European Union voted on trade agreements.')); $this->assertTrue($method->invoke($this->validationService, 'European Union voted on trade agreements.'));
$this->assertTrue($method->invoke(null, 'EU sanctions were extended.')); $this->assertTrue($method->invoke($this->validationService, 'EU sanctions were extended.'));
$this->assertTrue($method->invoke(null, 'Migration policies need urgent review.')); $this->assertTrue($method->invoke($this->validationService, 'Migration policies need urgent review.'));
$this->assertTrue($method->invoke(null, 'Security measures were enhanced.')); $this->assertTrue($method->invoke($this->validationService, 'Security measures were enhanced.'));
$this->assertTrue($method->invoke(null, 'Justice system reforms are underway.')); $this->assertTrue($method->invoke($this->validationService, 'Justice system reforms are underway.'));
$this->assertTrue($method->invoke(null, 'Culture festivals received government funding.')); $this->assertTrue($method->invoke($this->validationService, 'Culture festivals received government funding.'));
$this->assertTrue($method->invoke(null, 'Police reported 18 administrative detentions.')); $this->assertTrue($method->invoke($this->validationService, 'Police reported 18 administrative detentions.'));
} }
public function test_case_insensitive_keyword_matching(): void public function test_case_insensitive_keyword_matching(): void
{ {
$method = $this->getValidateByKeywordsMethod(); $method = $this->getValidateByKeywordsMethod();
$this->assertTrue($method->invoke(null, 'This article mentions ANTWERP in capital letters.')); $this->assertTrue($method->invoke($this->validationService, 'This article mentions ANTWERP in capital letters.'));
$this->assertTrue($method->invoke(null, 'brussels is mentioned in lowercase.')); $this->assertTrue($method->invoke($this->validationService, 'brussels is mentioned in lowercase.'));
$this->assertTrue($method->invoke(null, 'BeLgIuM is mentioned in mixed case.')); $this->assertTrue($method->invoke($this->validationService, 'BeLgIuM is mentioned in mixed case.'));
$this->assertTrue($method->invoke(null, 'The FLEMISH government announced policies.')); $this->assertTrue($method->invoke($this->validationService, 'The FLEMISH government announced policies.'));
$this->assertTrue($method->invoke(null, 'n-va party policies were discussed.')); $this->assertTrue($method->invoke($this->validationService, 'n-va party policies were discussed.'));
$this->assertTrue($method->invoke(null, 'EUROPEAN union directives apply.')); $this->assertTrue($method->invoke($this->validationService, 'EUROPEAN union directives apply.'));
} }
public function test_rejects_content_without_belgian_keywords(): void public function test_rejects_content_without_belgian_keywords(): void
{ {
$method = $this->getValidateByKeywordsMethod(); $method = $this->getValidateByKeywordsMethod();
$this->assertFalse($method->invoke(null, 'This article discusses random topics.')); $this->assertFalse($method->invoke($this->validationService, 'This article discusses random topics.'));
$this->assertFalse($method->invoke(null, 'International news from other countries.')); $this->assertFalse($method->invoke($this->validationService, 'International news from other countries.'));
$this->assertFalse($method->invoke(null, 'Technology updates and innovations.')); $this->assertFalse($method->invoke($this->validationService, 'Technology updates and innovations.'));
$this->assertFalse($method->invoke(null, 'Sports results from around the world.')); $this->assertFalse($method->invoke($this->validationService, 'Sports results from around the world.'));
$this->assertFalse($method->invoke(null, 'Entertainment news and celebrity gossip.')); $this->assertFalse($method->invoke($this->validationService, 'Entertainment news and celebrity gossip.'));
$this->assertFalse($method->invoke(null, 'Weather forecast for next week.')); $this->assertFalse($method->invoke($this->validationService, 'Weather forecast for next week.'));
$this->assertFalse($method->invoke(null, 'Stock market analysis and trends.')); $this->assertFalse($method->invoke($this->validationService, 'Stock market analysis and trends.'));
} }
public function test_keyword_matching_in_longer_text(): void 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. considered highly successful and will likely be repeated next year.
'; ';
$this->assertTrue($method->invoke(null, $longText)); $this->assertTrue($method->invoke($this->validationService, $longText));
$longTextWithoutKeywords = ' $longTextWithoutKeywords = '
This is a comprehensive article about various topics. 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. 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 public function test_empty_content_returns_false(): void
{ {
$method = $this->getValidateByKeywordsMethod(); $method = $this->getValidateByKeywordsMethod();
$this->assertFalse($method->invoke(null, '')); $this->assertFalse($method->invoke($this->validationService, ''));
$this->assertFalse($method->invoke(null, ' ')); $this->assertFalse($method->invoke($this->validationService, ' '));
$this->assertFalse($method->invoke(null, "\n\n\t")); $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) { foreach ($expectedKeywords as $keyword) {
$testContent = "This article contains the keyword: {$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"); $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(); $method = $this->getValidateByKeywordsMethod();
// Keywords should match when they appear as part of larger words or phrases // 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($this->validationService, 'Anti-government protesters gathered.'));
$this->assertTrue($method->invoke(null, 'The policeman directed traffic.')); $this->assertTrue($method->invoke($this->validationService, 'The policeman directed traffic.'));
$this->assertTrue($method->invoke(null, 'Educational reforms are needed.')); $this->assertTrue($method->invoke($this->validationService, 'Educational reforms are needed.'));
$this->assertTrue($method->invoke(null, 'Economic growth accelerated.')); $this->assertTrue($method->invoke($this->validationService, 'Economic growth accelerated.'));
$this->assertTrue($method->invoke(null, 'The European directive was implemented.')); $this->assertTrue($method->invoke($this->validationService, 'The European directive was implemented.'));
} }
} }

View file

@ -2,16 +2,35 @@
namespace Tests\Unit\Services; namespace Tests\Unit\Services;
use App\Services\Article\ArticleFetcher;
use App\Services\Article\ValidationService; use App\Services\Article\ValidationService;
use App\Services\Log\LogSaver;
use App\Models\Article; use App\Models\Article;
use App\Models\Feed; use App\Models\Feed;
use Tests\TestCase; use Tests\TestCase;
use Tests\Traits\CreatesArticleFetcher;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Mockery;
class ValidationServiceTest extends TestCase 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 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' 'approval_status' => 'pending'
]); ]);
$result = ValidationService::validate($article); $result = $this->validationService->validate($article);
$this->assertInstanceOf(Article::class, $result); $this->assertInstanceOf(Article::class, $result);
$this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); $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' 'approval_status' => 'pending'
]); ]);
$result = ValidationService::validate($article); $result = $this->validationService->validate($article);
$this->assertEquals('rejected', $result->approval_status); $this->assertEquals('rejected', $result->approval_status);
} }
@ -66,7 +85,7 @@ public function test_validate_with_supported_article_content(): void
'approval_status' => 'pending' '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 // Since we can't fetch real content in tests, it should be marked rejected
$this->assertEquals('rejected', $result->approval_status); $this->assertEquals('rejected', $result->approval_status);
@ -88,7 +107,7 @@ public function test_validate_updates_article_in_database(): void
$originalId = $article->id; $originalId = $article->id;
ValidationService::validate($article); $this->validationService->validate($article);
// Check that the article was updated in the database // Check that the article was updated in the database
$updatedArticle = Article::find($originalId); $updatedArticle = Article::find($originalId);
@ -111,7 +130,7 @@ public function test_validate_handles_article_with_existing_validation(): void
$originalApprovalStatus = $article->approval_status; $originalApprovalStatus = $article->approval_status;
$result = ValidationService::validate($article); $result = $this->validationService->validate($article);
// Should re-validate - status may change based on content validation // Should re-validate - status may change based on content validation
$this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']);
@ -136,7 +155,7 @@ public function test_validate_keyword_checking_logic(): void
'approval_status' => 'pending' 'approval_status' => 'pending'
]); ]);
$result = ValidationService::validate($article); $result = $this->validationService->validate($article);
// The service looks for keywords in the full_article content // The service looks for keywords in the full_article content
// Since we can't fetch real content, it will be marked rejected // Since we can't fetch real content, it will be marked rejected