Fix failing tests
This commit is contained in:
parent
65cb836b51
commit
0de5ea795d
27 changed files with 1257 additions and 238 deletions
13
backend/app/Facades/LogSaver.php
Normal file
13
backend/app/Facades/LogSaver.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
backend/tests/Traits/CreatesArticleFetcher.php
Normal file
36
backend/tests/Traits/CreatesArticleFetcher.php
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
108
backend/tests/Unit/Enums/LogLevelEnumTest.php
Normal file
108
backend/tests/Unit/Enums/LogLevelEnumTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend/tests/Unit/Enums/PlatformEnumTest.php
Normal file
89
backend/tests/Unit/Enums/PlatformEnumTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
222
backend/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php
Normal file
222
backend/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -94,14 +120,27 @@ public function test_handle_logs_appropriate_messages(): void
|
||||||
// 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
296
backend/tests/Unit/Jobs/PublishNextArticleJobTest.php
Normal file
296
backend/tests/Unit/Jobs/PublishNextArticleJobTest.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
// Arrange
|
$channel = PlatformChannel::factory()->make();
|
||||||
$channel = new PlatformChannel(['name' => 'Test Channel']);
|
|
||||||
$job = new SyncChannelPostsJob($channel);
|
$job = new SyncChannelPostsJob($channel);
|
||||||
|
|
||||||
// Assert
|
$this->assertContains(
|
||||||
$this->assertTrue(method_exists($job, 'onQueue'));
|
\Illuminate\Foundation\Queue\Queueable::class,
|
||||||
$this->assertTrue(method_exists($job, 'onConnection'));
|
class_uses($job)
|
||||||
$this->assertTrue(method_exists($job, 'delay'));
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_dispatch_for_all_active_channels_dispatches_jobs(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$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);
|
||||||
|
|
||||||
|
// Act - This will fail due to no active account, but we test the logging
|
||||||
|
try {
|
||||||
|
$job->handle($logSaverMock);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Expected to fail, we're testing that logging is called
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert - Test completes if no exceptions during setup
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_job_can_be_serialized(): void
|
||||||
|
{
|
||||||
|
$platformInstance = PlatformInstance::factory()->create();
|
||||||
|
$channel = PlatformChannel::factory()->create([
|
||||||
|
'platform_instance_id' => $platformInstance->id,
|
||||||
|
'name' => 'Test Channel'
|
||||||
|
]);
|
||||||
|
$job = new SyncChannelPostsJob($channel);
|
||||||
|
|
||||||
|
$serialized = serialize($job);
|
||||||
|
$unserialized = unserialize($serialized);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(SyncChannelPostsJob::class, $unserialized);
|
||||||
|
$this->assertEquals($job->queue, $unserialized->queue);
|
||||||
|
// Note: Cannot test channel property directly as it's private
|
||||||
|
// but serialization/unserialization working proves the job structure is intact
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_dispatch_for_all_active_channels_method_exists(): void
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,20 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -47,6 +54,12 @@ protected function setUp(): void
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
{
|
{
|
||||||
$article = Article::factory()->create([
|
$article = Article::factory()->create([
|
||||||
|
|
|
||||||
|
|
@ -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.'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue