Add tests.

This commit is contained in:
myrmidex 2025-08-02 03:48:06 +02:00
parent ace0db0446
commit ca428250fe
18 changed files with 2251 additions and 2 deletions

241
Jenkinsfile vendored Normal file
View file

@ -0,0 +1,241 @@
pipeline {
agent any
environment {
APP_ENV = 'testing'
DB_CONNECTION = 'mysql'
DB_HOST = 'mysql'
DB_PORT = '3306'
DB_DATABASE = 'ffr_testing'
DB_USERNAME = 'ffr_user'
DB_PASSWORD = 'ffr_password'
CACHE_STORE = 'array'
SESSION_DRIVER = 'array'
QUEUE_CONNECTION = 'sync'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Setup Environment') {
steps {
script {
sh '''
echo "Setting up environment for testing..."
cp .env.example .env.testing
echo "APP_ENV=testing" >> .env.testing
echo "DB_CONNECTION=${DB_CONNECTION}" >> .env.testing
echo "DB_HOST=${DB_HOST}" >> .env.testing
echo "DB_PORT=${DB_PORT}" >> .env.testing
echo "DB_DATABASE=${DB_DATABASE}" >> .env.testing
echo "DB_USERNAME=${DB_USERNAME}" >> .env.testing
echo "DB_PASSWORD=${DB_PASSWORD}" >> .env.testing
echo "CACHE_STORE=${CACHE_STORE}" >> .env.testing
echo "SESSION_DRIVER=${SESSION_DRIVER}" >> .env.testing
echo "QUEUE_CONNECTION=${QUEUE_CONNECTION}" >> .env.testing
'''
}
}
}
stage('Install Dependencies') {
parallel {
stage('PHP Dependencies') {
steps {
sh '''
echo "Installing PHP dependencies..."
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
'''
}
}
stage('Node Dependencies') {
steps {
sh '''
echo "Installing Node.js dependencies..."
npm ci
'''
}
}
}
}
stage('Generate Application Key') {
steps {
sh '''
php artisan key:generate --env=testing --force
'''
}
}
stage('Database Setup') {
steps {
sh '''
echo "Setting up test database..."
php artisan migrate:fresh --env=testing --force
php artisan config:clear --env=testing
'''
}
}
stage('Code Quality Checks') {
parallel {
stage('PHP Syntax Check') {
steps {
sh '''
echo "Checking PHP syntax..."
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -exec php -l {} \\;
'''
}
}
stage('PHPStan Analysis') {
steps {
script {
try {
sh '''
if [ -f "phpstan.neon" ]; then
echo "Running PHPStan static analysis..."
./vendor/bin/phpstan analyse --no-progress --error-format=table
else
echo "PHPStan configuration not found, skipping static analysis"
fi
'''
} catch (Exception e) {
unstable(message: "PHPStan found issues")
}
}
}
}
stage('Security Audit') {
steps {
script {
try {
sh '''
echo "Running security audit..."
composer audit
'''
} catch (Exception e) {
unstable(message: "Security vulnerabilities found")
}
}
}
}
}
}
stage('Unit Tests') {
steps {
sh '''
echo "Running Unit Tests..."
php artisan test tests/Unit/ --env=testing --stop-on-failure
'''
}
post {
always {
publishTestResults testResultsPattern: 'tests/Unit/results/*.xml'
}
}
}
stage('Feature Tests') {
steps {
sh '''
echo "Running Feature Tests..."
php artisan test tests/Feature/ --env=testing --stop-on-failure
'''
}
post {
always {
publishTestResults testResultsPattern: 'tests/Feature/results/*.xml'
}
}
}
stage('Full Regression Test Suite') {
steps {
sh '''
echo "Running comprehensive regression test suite..."
chmod +x ./run-regression-tests.sh
./run-regression-tests.sh
'''
}
post {
always {
// Archive test results
archiveArtifacts artifacts: 'tests/reports/**/*', allowEmptyArchive: true
// Publish coverage reports if available
publishHTML([
allowMissing: false,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'coverage',
reportFiles: 'index.html',
reportName: 'Coverage Report'
])
}
}
}
stage('Performance Tests') {
steps {
script {
try {
sh '''
echo "Running performance tests..."
# Test memory usage
php -d memory_limit=256M artisan test tests/Feature/DatabaseIntegrationTest.php --env=testing
# Test response times for API endpoints
time php artisan test tests/Feature/ApiEndpointRegressionTest.php --env=testing
'''
} catch (Exception e) {
unstable(message: "Performance tests indicated potential issues")
}
}
}
}
stage('Build Assets') {
when {
anyOf {
branch 'main'
branch 'develop'
}
}
steps {
sh '''
echo "Building production assets..."
npm run build
'''
}
}
}
post {
always {
// Clean up
sh '''
echo "Cleaning up..."
rm -f .env.testing
'''
}
success {
echo '✅ All regression tests passed successfully!'
// Notify success (customize as needed)
// slackSend channel: '#dev-team', color: 'good', message: "Regression tests passed for ${env.JOB_NAME} - ${env.BUILD_NUMBER}"
}
failure {
echo '❌ Regression tests failed!'
// Notify failure (customize as needed)
// slackSend channel: '#dev-team', color: 'danger', message: "Regression tests failed for ${env.JOB_NAME} - ${env.BUILD_NUMBER}"
}
unstable {
echo '⚠️ Tests completed with warnings'
}
}
}

View file

@ -2,6 +2,8 @@
namespace App\Models;
use Database\Factories\ArticlePublicationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -14,6 +16,9 @@
*/
class ArticlePublication extends Model
{
/** @use HasFactory<ArticlePublicationFactory> */
use HasFactory;
protected $fillable = [
'article_id',
'platform_channel_id',

View file

@ -2,6 +2,8 @@
namespace App\Models;
use Database\Factories\RouteFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
@ -18,6 +20,9 @@
*/
class Route extends Pivot
{
/** @use HasFactory<RouteFactory> */
use HasFactory;
protected $table = 'routes';
public $incrementing = false;

View file

@ -0,0 +1,33 @@
<?php
namespace Database\Factories;
use App\Models\ArticlePublication;
use App\Models\Article;
use App\Models\PlatformChannel;
use Illuminate\Database\Eloquent\Factories\Factory;
class ArticlePublicationFactory extends Factory
{
protected $model = ArticlePublication::class;
public function definition(): array
{
return [
'article_id' => Article::factory(),
'platform_channel_id' => PlatformChannel::factory(),
'post_id' => $this->faker->uuid(),
'published_at' => $this->faker->dateTimeBetween('-1 month', 'now'),
'published_by' => $this->faker->userName(),
'created_at' => $this->faker->dateTimeBetween('-1 month', 'now'),
'updated_at' => now(),
];
}
public function recentlyPublished(): static
{
return $this->state(fn (array $attributes) => [
'published_at' => $this->faker->dateTimeBetween('-1 day', 'now'),
]);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Models\Keyword;
use Illuminate\Database\Eloquent\Factories\Factory;
class KeywordFactory extends Factory
{
protected $model = Keyword::class;
public function definition(): array
{
return [
'keyword' => $this->faker->word(),
'is_blocked' => $this->faker->boolean(30), // 30% chance of being blocked
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
'updated_at' => now(),
];
}
public function blocked(): static
{
return $this->state(fn (array $attributes) => [
'is_blocked' => true,
]);
}
public function allowed(): static
{
return $this->state(fn (array $attributes) => [
'is_blocked' => false,
]);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Database\Factories;
use App\Models\Route;
use App\Models\Feed;
use App\Models\PlatformChannel;
use Illuminate\Database\Eloquent\Factories\Factory;
class RouteFactory extends Factory
{
protected $model = Route::class;
public function definition(): array
{
return [
'feed_id' => Feed::factory(),
'platform_channel_id' => PlatformChannel::factory(),
'is_active' => $this->faker->boolean(80), // 80% chance of being active
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
'updated_at' => now(),
];
}
public function active(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => true,
]);
}
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Factories\Factory;
class SettingFactory extends Factory
{
protected $model = Setting::class;
public function definition(): array
{
return [
'key' => $this->faker->unique()->slug(2, '_'),
'value' => $this->faker->word(),
'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
'updated_at' => now(),
];
}
public function withKey(string $key): static
{
return $this->state(fn (array $attributes) => [
'key' => $key,
]);
}
public function withValue(string $value): static
{
return $this->state(fn (array $attributes) => [
'value' => $value,
]);
}
}

View file

@ -19,10 +19,12 @@
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:2fl+Ktvkdg+Fuz4Qp/A75G2RTiWVA/ZoKwE4qJowQPI="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/>

157
run-regression-tests.sh Executable file
View file

@ -0,0 +1,157 @@
#!/bin/bash
# FFR Regression Test Suite Runner
# Comprehensive test runner for the FFR application
set -e
echo "🧪 Starting FFR Regression Test Suite"
echo "======================================"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE} $1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# Check if we're in the right directory
if [ ! -f "composer.json" ] || [ ! -f "phpunit.xml" ]; then
print_error "This script must be run from the project root directory"
exit 1
fi
# Set test environment
export APP_ENV=testing
print_status "Setting up test environment..."
# Clear configuration cache
php artisan config:clear --env=testing
# Ensure database is set up for testing
print_status "Preparing test database..."
php artisan migrate:fresh --env=testing --force
# Run database seeders for testing if they exist
if [ -f "database/seeders/TestSeeder.php" ]; then
php artisan db:seed --class=TestSeeder --env=testing
fi
echo ""
print_status "Running regression tests..."
echo ""
# Track test results
TOTAL_TESTS=0
FAILED_TESTS=0
# Function to run test suite and track results
run_test_suite() {
local suite_name="$1"
local test_path="$2"
local description="$3"
echo ""
print_status "Running $suite_name..."
echo "Description: $description"
if php artisan test "$test_path" --env=testing; then
print_success "$suite_name passed"
else
print_error "$suite_name failed"
FAILED_TESTS=$((FAILED_TESTS + 1))
fi
TOTAL_TESTS=$((TOTAL_TESTS + 1))
}
# Run individual test suites
run_test_suite "API Endpoint Tests" "tests/Feature/ApiEndpointRegressionTest.php" "Tests all HTTP endpoints and routes"
run_test_suite "Database Integration Tests" "tests/Feature/DatabaseIntegrationTest.php" "Tests models, relationships, and database operations"
run_test_suite "Article Discovery Tests" "tests/Feature/ArticleDiscoveryCommandTest.php" "Tests article discovery command functionality"
run_test_suite "Article Publishing Tests" "tests/Feature/ArticlePublishingTest.php" "Tests article publishing workflow"
run_test_suite "Jobs and Events Tests" "tests/Feature/JobsAndEventsTest.php" "Tests queue jobs and event handling"
run_test_suite "Authentication & Authorization Tests" "tests/Feature/AuthenticationAndAuthorizationTest.php" "Tests security and access control"
run_test_suite "New Article Fetched Event Tests" "tests/Feature/NewArticleFetchedEventTest.php" "Tests new article event handling"
run_test_suite "Validate Article Listener Tests" "tests/Feature/ValidateArticleListenerTest.php" "Tests article validation logic"
# Run Unit Tests
echo ""
print_status "Running Unit Tests..."
run_test_suite "Article Fetcher Unit Tests" "tests/Unit/Services/ArticleFetcherTest.php" "Tests article fetching service"
run_test_suite "Validation Service Unit Tests" "tests/Unit/Services/ValidationServiceTest.php" "Tests article validation service"
run_test_suite "Dashboard Stats Service Unit Tests" "tests/Unit/Services/DashboardStatsServiceTest.php" "Tests dashboard statistics service"
# Run full test suite for coverage
echo ""
print_status "Running complete test suite for coverage..."
if php artisan test --coverage-text --min=70 --env=testing; then
print_success "Test coverage meets minimum requirements"
else
print_warning "Test coverage below 70% - consider adding more tests"
fi
# Performance Tests
echo ""
print_status "Running performance checks..."
# Test database query performance
php artisan test tests/Feature/DatabaseIntegrationTest.php --env=testing --stop-on-failure
# Memory usage test
if php -d memory_limit=128M artisan test --env=testing --stop-on-failure; then
print_success "Memory usage within acceptable limits"
else
print_warning "High memory usage detected during tests"
fi
# Final Results
echo ""
echo "======================================"
print_status "Test Suite Complete"
echo "======================================"
if [ $FAILED_TESTS -eq 0 ]; then
print_success "All $TOTAL_TESTS test suites passed! 🎉"
echo ""
echo "Regression test suite completed successfully."
echo "The application is ready for deployment."
exit 0
else
print_error "$FAILED_TESTS out of $TOTAL_TESTS test suites failed"
echo ""
echo "Please review the failing tests before proceeding."
echo "Run individual test suites with:"
echo " php artisan test tests/Feature/[TestFile].php"
echo " php artisan test tests/Unit/[TestFile].php"
exit 1
fi

View file

@ -0,0 +1,21 @@
<?php
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Application;
trait CreatesApplication
{
/**
* Creates the application.
*/
public function createApplication(): Application
{
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
}

View file

@ -0,0 +1,315 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ApiEndpointRegressionTest extends TestCase
{
use RefreshDatabase;
public function test_homepage_loads_successfully(): void
{
$response = $this->get('/');
$response->assertSuccessful();
}
public function test_onboarding_platform_page_loads(): void
{
$response = $this->get('/onboarding/platform');
$response->assertSuccessful();
}
public function test_onboarding_feed_page_loads(): void
{
$response = $this->get('/onboarding/feed');
$response->assertSuccessful();
}
public function test_onboarding_channel_page_loads(): void
{
$response = $this->get('/onboarding/channel');
$response->assertSuccessful();
}
public function test_onboarding_complete_page_loads(): void
{
$response = $this->get('/onboarding/complete');
$response->assertSuccessful();
}
public function test_articles_page_loads_successfully(): void
{
$response = $this->get('/articles');
$response->assertSuccessful();
}
public function test_articles_approve_endpoint_works(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending'
]);
$response = $this->post("/articles/{$article->id}/approve");
$response->assertRedirect();
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
}
public function test_articles_reject_endpoint_works(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending'
]);
$response = $this->post("/articles/{$article->id}/reject");
$response->assertRedirect();
$article->refresh();
$this->assertEquals('rejected', $article->approval_status);
}
public function test_logs_page_loads_successfully(): void
{
$response = $this->get('/logs');
$response->assertSuccessful();
}
public function test_settings_page_loads_successfully(): void
{
$response = $this->get('/settings');
$response->assertSuccessful();
}
public function test_settings_update_endpoint_works(): void
{
Setting::factory()->create([
'key' => 'test_setting',
'value' => 'old_value'
]);
$response = $this->put('/settings', [
'test_setting' => 'new_value'
]);
$response->assertRedirect();
$this->assertEquals('new_value', Setting::where('key', 'test_setting')->first()->value);
}
public function test_platforms_index_loads_successfully(): void
{
$response = $this->get('/platforms');
$response->assertSuccessful();
}
public function test_platforms_create_loads_successfully(): void
{
$response = $this->get('/platforms/create');
$response->assertSuccessful();
}
public function test_platforms_show_loads_successfully(): void
{
$platform = PlatformAccount::factory()->create();
$response = $this->get("/platforms/{$platform->id}");
$response->assertSuccessful();
}
public function test_platforms_edit_loads_successfully(): void
{
$platform = PlatformAccount::factory()->create();
$response = $this->get("/platforms/{$platform->id}/edit");
$response->assertSuccessful();
}
public function test_platforms_set_active_endpoint_works(): void
{
$platform = PlatformAccount::factory()->create(['is_active' => false]);
$response = $this->post("/platforms/{$platform->id}/set-active");
$response->assertRedirect();
$platform->refresh();
$this->assertTrue($platform->is_active);
}
public function test_channels_index_loads_successfully(): void
{
$response = $this->get('/channels');
$response->assertSuccessful();
}
public function test_channels_create_loads_successfully(): void
{
$response = $this->get('/channels/create');
$response->assertSuccessful();
}
public function test_channels_show_loads_successfully(): void
{
$channel = PlatformChannel::factory()->create();
$response = $this->get("/channels/{$channel->id}");
$response->assertSuccessful();
}
public function test_channels_edit_loads_successfully(): void
{
$channel = PlatformChannel::factory()->create();
$response = $this->get("/channels/{$channel->id}/edit");
$response->assertSuccessful();
}
public function test_channels_toggle_endpoint_works(): void
{
$channel = PlatformChannel::factory()->create(['is_active' => false]);
$response = $this->post("/channels/{$channel->id}/toggle");
$response->assertRedirect();
$channel->refresh();
$this->assertTrue($channel->is_active);
}
public function test_feeds_index_loads_successfully(): void
{
$response = $this->get('/feeds');
$response->assertSuccessful();
}
public function test_feeds_create_loads_successfully(): void
{
$response = $this->get('/feeds/create');
$response->assertSuccessful();
}
public function test_feeds_show_loads_successfully(): void
{
$feed = Feed::factory()->create();
$response = $this->get("/feeds/{$feed->id}");
$response->assertSuccessful();
}
public function test_feeds_edit_loads_successfully(): void
{
$feed = Feed::factory()->create();
$response = $this->get("/feeds/{$feed->id}/edit");
$response->assertSuccessful();
}
public function test_feeds_toggle_endpoint_works(): void
{
$feed = Feed::factory()->create(['is_active' => false]);
$response = $this->post("/feeds/{$feed->id}/toggle");
$response->assertRedirect();
$feed->refresh();
$this->assertTrue($feed->is_active);
}
public function test_routing_index_loads_successfully(): void
{
$response = $this->get('/routing');
$response->assertSuccessful();
}
public function test_routing_create_loads_successfully(): void
{
$response = $this->get('/routing/create');
$response->assertSuccessful();
}
public function test_routing_edit_loads_successfully(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
Route::factory()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id
]);
$response = $this->get("/routing/{$feed->id}/{$channel->id}/edit");
$response->assertSuccessful();
}
public function test_routing_toggle_endpoint_works(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
$route = Route::factory()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => false
]);
$response = $this->post("/routing/{$feed->id}/{$channel->id}/toggle");
$response->assertRedirect();
$route->refresh();
$this->assertTrue($route->is_active);
}
public function test_all_get_routes_return_successful_status(): void
{
// Create necessary test data
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
$platform = PlatformAccount::factory()->create();
Route::factory()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id
]);
$routes = [
'/',
'/onboarding/platform',
'/onboarding/feed',
'/onboarding/channel',
'/onboarding/complete',
'/articles',
'/logs',
'/settings',
'/platforms',
'/platforms/create',
"/platforms/{$platform->id}",
"/platforms/{$platform->id}/edit",
'/channels',
'/channels/create',
"/channels/{$channel->id}",
"/channels/{$channel->id}/edit",
'/feeds',
'/feeds/create',
"/feeds/{$feed->id}",
"/feeds/{$feed->id}/edit",
'/routing',
'/routing/create',
"/routing/{$feed->id}/{$channel->id}/edit",
];
foreach ($routes as $route) {
$response = $this->get($route);
$this->assertTrue(
$response->isSuccessful(),
"Route {$route} failed with status {$response->getStatusCode()}"
);
}
}
}

View file

@ -0,0 +1,300 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\Setting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthenticationAndAuthorizationTest extends TestCase
{
use RefreshDatabase;
public function test_guest_can_access_public_routes(): void
{
$publicRoutes = [
'/',
'/onboarding/platform',
'/onboarding/feed',
'/onboarding/channel',
'/onboarding/complete'
];
foreach ($publicRoutes as $route) {
$response = $this->get($route);
$this->assertTrue(
$response->isSuccessful(),
"Public route {$route} should be accessible to guests"
);
}
}
public function test_application_routes_require_no_authentication_by_default(): void
{
// Test that main application routes are accessible
// This assumes the application doesn't have authentication middleware by default
$routes = [
'/articles',
'/logs',
'/settings',
'/platforms',
'/channels',
'/feeds',
'/routing'
];
foreach ($routes as $route) {
$response = $this->get($route);
$this->assertTrue(
$response->isSuccessful(),
"Route {$route} should be accessible"
);
}
}
public function test_article_approval_permissions(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending'
]);
// Test approval endpoint
$response = $this->post("/articles/{$article->id}/approve");
$response->assertRedirect(); // Should redirect after successful approval
$article->refresh();
$this->assertEquals('approved', $article->approval_status);
}
public function test_article_rejection_permissions(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'approval_status' => 'pending'
]);
// Test rejection endpoint
$response = $this->post("/articles/{$article->id}/reject");
$response->assertRedirect(); // Should redirect after successful rejection
$article->refresh();
$this->assertEquals('rejected', $article->approval_status);
}
public function test_platform_management_permissions(): void
{
$platform = PlatformAccount::factory()->create(['is_active' => false]);
// Test platform activation
$response = $this->post("/platforms/{$platform->id}/set-active");
$response->assertRedirect();
$platform->refresh();
$this->assertTrue($platform->is_active);
}
public function test_channel_management_permissions(): void
{
$channel = PlatformChannel::factory()->create(['is_active' => false]);
// Test channel toggle
$response = $this->post("/channels/{$channel->id}/toggle");
$response->assertRedirect();
$channel->refresh();
$this->assertTrue($channel->is_active);
}
public function test_feed_management_permissions(): void
{
$feed = Feed::factory()->create(['is_active' => false]);
// Test feed toggle
$response = $this->post("/feeds/{$feed->id}/toggle");
$response->assertRedirect();
$feed->refresh();
$this->assertTrue($feed->is_active);
}
public function test_settings_update_permissions(): void
{
Setting::factory()->create([
'key' => 'test_setting',
'value' => 'old_value'
]);
// Test settings update
$response = $this->put('/settings', [
'test_setting' => 'new_value'
]);
$response->assertRedirect();
$this->assertEquals('new_value', Setting::where('key', 'test_setting')->first()->value);
}
public function test_routing_management_permissions(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
// Test routing creation
$response = $this->post('/routing', [
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true
]);
// Should either create successfully or have validation errors
$this->assertTrue(
$response->isRedirect() || $response->status() === 422,
'Routing creation should either succeed or fail with validation'
);
}
public function test_crud_operations_on_resources(): void
{
// Test platform CRUD
$platform = PlatformAccount::factory()->create();
$response = $this->get("/platforms/{$platform->id}");
$response->assertSuccessful();
$response = $this->get("/platforms/{$platform->id}/edit");
$response->assertSuccessful();
// Test channel CRUD
$channel = PlatformChannel::factory()->create();
$response = $this->get("/channels/{$channel->id}");
$response->assertSuccessful();
$response = $this->get("/channels/{$channel->id}/edit");
$response->assertSuccessful();
// Test feed CRUD
$feed = Feed::factory()->create();
$response = $this->get("/feeds/{$feed->id}");
$response->assertSuccessful();
$response = $this->get("/feeds/{$feed->id}/edit");
$response->assertSuccessful();
}
public function test_resource_creation_pages_are_accessible(): void
{
$createRoutes = [
'/platforms/create',
'/channels/create',
'/feeds/create',
'/routing/create'
];
foreach ($createRoutes as $route) {
$response = $this->get($route);
$this->assertTrue(
$response->isSuccessful(),
"Create route {$route} should be accessible"
);
}
}
public function test_nonexistent_resource_returns_404(): void
{
$response = $this->get('/platforms/99999');
$response->assertNotFound();
$response = $this->get('/channels/99999');
$response->assertNotFound();
$response = $this->get('/feeds/99999');
$response->assertNotFound();
}
public function test_invalid_article_operations_handle_gracefully(): void
{
// Test operations on nonexistent articles
$response = $this->post('/articles/99999/approve');
$response->assertNotFound();
$response = $this->post('/articles/99999/reject');
$response->assertNotFound();
}
public function test_invalid_platform_operations_handle_gracefully(): void
{
// Test operations on nonexistent platforms
$response = $this->post('/platforms/99999/set-active');
$response->assertNotFound();
}
public function test_invalid_channel_operations_handle_gracefully(): void
{
// Test operations on nonexistent channels
$response = $this->post('/channels/99999/toggle');
$response->assertNotFound();
}
public function test_invalid_feed_operations_handle_gracefully(): void
{
// Test operations on nonexistent feeds
$response = $this->post('/feeds/99999/toggle');
$response->assertNotFound();
}
public function test_csrf_protection_on_post_requests(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]);
// Test that POST requests without CSRF token are rejected
$response = $this->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class)
->post("/articles/{$article->id}/approve");
// Should work when CSRF middleware is disabled for testing
$response->assertRedirect();
}
public function test_method_spoofing_works_for_put_delete(): void
{
// Test that method spoofing works for PUT/DELETE requests
Setting::factory()->create([
'key' => 'test_setting',
'value' => 'old_value'
]);
$response = $this->put('/settings', [
'test_setting' => 'new_value'
]);
$response->assertRedirect();
}
public function test_route_model_binding_works_correctly(): void
{
// Test that route model binding resolves correctly
$platform = PlatformAccount::factory()->create();
$channel = PlatformChannel::factory()->create();
$feed = Feed::factory()->create();
// These should all resolve the models correctly
$response = $this->get("/platforms/{$platform->id}");
$response->assertSuccessful();
$response = $this->get("/channels/{$channel->id}");
$response->assertSuccessful();
$response = $this->get("/feeds/{$feed->id}");
$response->assertSuccessful();
}
}

View file

@ -0,0 +1,371 @@
<?php
namespace Tests\Feature;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\Feed;
use App\Models\Keyword;
use App\Models\Language;
use App\Models\Log;
use App\Models\PlatformAccount;
use App\Models\PlatformChannel;
use App\Models\PlatformChannelPost;
use App\Models\PlatformInstance;
use App\Models\Route;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DatabaseIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_user_model_creates_successfully(): void
{
$user = User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com'
]);
$this->assertDatabaseHas('users', [
'name' => 'Test User',
'email' => 'test@example.com'
]);
$this->assertEquals('Test User', $user->name);
$this->assertEquals('test@example.com', $user->email);
}
public function test_language_model_creates_successfully(): void
{
$language = Language::factory()->create([
'name' => 'English',
'code' => 'en'
]);
$this->assertDatabaseHas('languages', [
'name' => 'English',
'code' => 'en'
]);
}
public function test_platform_instance_model_creates_successfully(): void
{
$instance = PlatformInstance::factory()->create([
'name' => 'Test Instance',
'url' => 'https://test.lemmy.world'
]);
$this->assertDatabaseHas('platform_instances', [
'name' => 'Test Instance',
'url' => 'https://test.lemmy.world'
]);
}
public function test_platform_account_model_creates_successfully(): void
{
$instance = PlatformInstance::factory()->create();
$account = PlatformAccount::factory()->create([
'platform_instance_id' => $instance->id,
'username' => 'testuser',
'is_active' => true
]);
$this->assertDatabaseHas('platform_accounts', [
'platform_instance_id' => $instance->id,
'username' => 'testuser',
'is_active' => true
]);
$this->assertEquals($instance->id, $account->platformInstance->id);
}
public function test_platform_channel_model_creates_successfully(): void
{
$language = Language::factory()->create();
$account = PlatformAccount::factory()->create();
$channel = PlatformChannel::factory()->create([
'platform_account_id' => $account->id,
'language_id' => $language->id,
'name' => 'Test Channel',
'is_active' => true
]);
$this->assertDatabaseHas('platform_channels', [
'platform_account_id' => $account->id,
'language_id' => $language->id,
'name' => 'Test Channel',
'is_active' => true
]);
$this->assertEquals($account->id, $channel->platformAccount->id);
$this->assertEquals($language->id, $channel->language->id);
}
public function test_feed_model_creates_successfully(): void
{
$language = Language::factory()->create();
$feed = Feed::factory()->create([
'language_id' => $language->id,
'name' => 'Test Feed',
'url' => 'https://example.com/feed.rss',
'is_active' => true
]);
$this->assertDatabaseHas('feeds', [
'language_id' => $language->id,
'name' => 'Test Feed',
'url' => 'https://example.com/feed.rss',
'is_active' => true
]);
$this->assertEquals($language->id, $feed->language->id);
}
public function test_article_model_creates_successfully(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'title' => 'Test Article',
'url' => 'https://example.com/article',
'approval_status' => 'pending'
]);
$this->assertDatabaseHas('articles', [
'feed_id' => $feed->id,
'title' => 'Test Article',
'url' => 'https://example.com/article',
'approval_status' => 'pending'
]);
$this->assertEquals($feed->id, $article->feed->id);
}
public function test_article_publication_model_creates_successfully(): void
{
$article = Article::factory()->create();
$channel = PlatformChannel::factory()->create();
$publication = ArticlePublication::create([
'article_id' => $article->id,
'platform_channel_id' => $channel->id,
'post_id' => 'test-post-123',
'published_at' => now(),
'published_by' => 'test-user'
]);
$this->assertDatabaseHas('article_publications', [
'article_id' => $article->id,
'platform_channel_id' => $channel->id,
'post_id' => 'test-post-123',
'published_by' => 'test-user'
]);
$this->assertEquals($article->id, $publication->article->id);
$this->assertEquals($channel->id, $publication->platformChannel->id);
}
public function test_route_model_creates_successfully(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
$route = Route::factory()->create([
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true
]);
$this->assertDatabaseHas('routes', [
'feed_id' => $feed->id,
'platform_channel_id' => $channel->id,
'is_active' => true
]);
$this->assertEquals($feed->id, $route->feed->id);
$this->assertEquals($channel->id, $route->platformChannel->id);
}
public function test_platform_channel_post_model_creates_successfully(): void
{
$channel = PlatformChannel::factory()->create();
$post = PlatformChannelPost::create([
'platform_channel_id' => $channel->id,
'post_id' => 'external-post-123',
'title' => 'Test Post',
'content' => 'Test content',
'url' => 'https://example.com/post',
'created_at' => now(),
'updated_at' => now()
]);
$this->assertDatabaseHas('platform_channel_posts', [
'platform_channel_id' => $channel->id,
'post_id' => 'external-post-123',
'title' => 'Test Post'
]);
$this->assertEquals($channel->id, $post->platformChannel->id);
}
public function test_keyword_model_creates_successfully(): void
{
$keyword = Keyword::factory()->create([
'keyword' => 'test keyword',
'is_blocked' => false
]);
$this->assertDatabaseHas('keywords', [
'keyword' => 'test keyword',
'is_blocked' => false
]);
}
public function test_log_model_creates_successfully(): void
{
$log = Log::create([
'level' => 'info',
'message' => 'Test log message',
'context' => json_encode(['key' => 'value']),
'logged_at' => now()
]);
$this->assertDatabaseHas('logs', [
'level' => 'info',
'message' => 'Test log message'
]);
}
public function test_setting_model_creates_successfully(): void
{
$setting = Setting::factory()->create([
'key' => 'test_setting',
'value' => 'test_value'
]);
$this->assertDatabaseHas('settings', [
'key' => 'test_setting',
'value' => 'test_value'
]);
}
public function test_feed_articles_relationship(): void
{
$feed = Feed::factory()->create();
$articles = Article::factory()->count(3)->create(['feed_id' => $feed->id]);
$this->assertCount(3, $feed->articles);
foreach ($articles as $article) {
$this->assertTrue($feed->articles->contains($article));
}
}
public function test_article_publications_relationship(): void
{
$article = Article::factory()->create();
$publications = ArticlePublication::factory()->count(2)->create(['article_id' => $article->id]);
$this->assertCount(2, $article->publications);
foreach ($publications as $publication) {
$this->assertTrue($article->publications->contains($publication));
}
}
public function test_platform_account_channels_relationship(): void
{
$account = PlatformAccount::factory()->create();
$channels = PlatformChannel::factory()->count(2)->create(['platform_account_id' => $account->id]);
$this->assertCount(2, $account->channels);
foreach ($channels as $channel) {
$this->assertTrue($account->channels->contains($channel));
}
}
public function test_platform_channel_routes_relationship(): void
{
$channel = PlatformChannel::factory()->create();
$routes = Route::factory()->count(2)->create(['platform_channel_id' => $channel->id]);
$this->assertCount(2, $channel->routes);
foreach ($routes as $route) {
$this->assertTrue($channel->routes->contains($route));
}
}
public function test_language_platform_instances_relationship(): void
{
$language = Language::factory()->create();
$instances = PlatformInstance::factory()->count(2)->create();
// Attach language to instances via pivot table
foreach ($instances as $instance) {
$language->platformInstances()->attach($instance->id);
}
$this->assertCount(2, $language->platformInstances);
foreach ($instances as $instance) {
$this->assertTrue($language->platformInstances->contains($instance));
}
}
public function test_model_soft_deletes_work_correctly(): void
{
// Test models that might use soft deletes
$feed = Feed::factory()->create();
$feedId = $feed->id;
$feed->delete();
// Should not find with normal query if soft deleted
$this->assertNull(Feed::find($feedId));
// Should find with withTrashed if model uses soft deletes
if (method_exists($feed, 'withTrashed')) {
$this->assertNotNull(Feed::withTrashed()->find($feedId));
}
}
public function test_database_constraints_are_enforced(): void
{
// Test foreign key constraints
$this->expectException(\Illuminate\Database\QueryException::class);
// Try to create article with non-existent feed_id
Article::factory()->create(['feed_id' => 99999]);
}
public function test_all_factories_work_correctly(): void
{
// Test that all model factories can create valid records
$models = [
User::factory()->make(),
Language::factory()->make(),
PlatformInstance::factory()->make(),
PlatformAccount::factory()->make(),
PlatformChannel::factory()->make(),
Feed::factory()->make(),
Article::factory()->make(),
Route::factory()->make(),
Keyword::factory()->make(),
];
foreach ($models as $model) {
$this->assertNotNull($model);
$this->assertInstanceOf(\Illuminate\Database\Eloquent\Model::class, $model);
}
}
}

View file

@ -0,0 +1,301 @@
<?php
namespace Tests\Feature;
use App\Events\ArticleApproved;
use App\Events\ArticleReadyToPublish;
use App\Events\ExceptionLogged;
use App\Events\ExceptionOccurred;
use App\Events\NewArticleFetched;
use App\Jobs\ArticleDiscoveryJob;
use App\Jobs\ArticleDiscoveryForFeedJob;
use App\Jobs\PublishToLemmyJob;
use App\Jobs\SyncChannelPostsJob;
use App\Listeners\LogExceptionToDatabase;
use App\Listeners\PublishApprovedArticle;
use App\Listeners\PublishArticle;
use App\Listeners\ValidateArticleListener;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Log;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class JobsAndEventsTest extends TestCase
{
use RefreshDatabase;
public function test_article_discovery_job_processes_successfully(): void
{
Queue::fake();
$feed = Feed::factory()->create(['is_active' => true]);
$job = new ArticleDiscoveryJob();
$job->handle();
// Should dispatch individual feed jobs
Queue::assertPushed(ArticleDiscoveryForFeedJob::class);
}
public function test_article_discovery_for_feed_job_processes_feed(): void
{
Event::fake();
$feed = Feed::factory()->create([
'url' => 'https://example.com/feed',
'is_active' => true
]);
$job = new ArticleDiscoveryForFeedJob($feed);
// Mock the external dependency behavior
$this->app->bind(\App\Services\Article\ArticleFetcher::class, function () {
$mock = \Mockery::mock(\App\Services\Article\ArticleFetcher::class);
$mock->shouldReceive('fetchArticles')->andReturn([
['title' => 'Test Article', 'url' => 'https://example.com/article1'],
['title' => 'Another Article', 'url' => 'https://example.com/article2']
]);
return $mock;
});
$job->handle();
// Should create articles and fire events
$this->assertCount(2, Article::all());
Event::assertDispatched(NewArticleFetched::class, 2);
}
public function test_sync_channel_posts_job_processes_successfully(): void
{
$channel = PlatformChannel::factory()->create();
$job = new SyncChannelPostsJob($channel);
// Mock the external dependency
$this->app->bind(\App\Modules\Lemmy\Services\LemmyApiService::class, function () {
$mock = \Mockery::mock(\App\Modules\Lemmy\Services\LemmyApiService::class);
$mock->shouldReceive('getChannelPosts')->andReturn([]);
return $mock;
});
$result = $job->handle();
$this->assertTrue($result);
}
public function test_publish_to_lemmy_job_has_correct_configuration(): void
{
$article = Article::factory()->create();
$job = new PublishToLemmyJob($article);
$this->assertEquals('lemmy-posts', $job->queue);
$this->assertInstanceOf(PublishToLemmyJob::class, $job);
}
public function test_new_article_fetched_event_is_dispatched(): void
{
Event::fake();
$feed = Feed::factory()->create();
$article = Article::factory()->create(['feed_id' => $feed->id]);
event(new NewArticleFetched($article));
Event::assertDispatched(NewArticleFetched::class, function (NewArticleFetched $event) use ($article) {
return $event->article->id === $article->id;
});
}
public function test_article_approved_event_is_dispatched(): void
{
Event::fake();
$article = Article::factory()->create();
event(new ArticleApproved($article));
Event::assertDispatched(ArticleApproved::class, function (ArticleApproved $event) use ($article) {
return $event->article->id === $article->id;
});
}
public function test_article_ready_to_publish_event_is_dispatched(): void
{
Event::fake();
$article = Article::factory()->create();
event(new ArticleReadyToPublish($article));
Event::assertDispatched(ArticleReadyToPublish::class, function (ArticleReadyToPublish $event) use ($article) {
return $event->article->id === $article->id;
});
}
public function test_exception_occurred_event_is_dispatched(): void
{
Event::fake();
$exception = new \Exception('Test exception');
event(new ExceptionOccurred($exception, ['context' => 'test']));
Event::assertDispatched(ExceptionOccurred::class, function (ExceptionOccurred $event) {
return $event->exception->getMessage() === 'Test exception';
});
}
public function test_exception_logged_event_is_dispatched(): void
{
Event::fake();
$logData = [
'level' => 'error',
'message' => 'Test error',
'context' => ['key' => 'value']
];
event(new ExceptionLogged($logData));
Event::assertDispatched(ExceptionLogged::class, function (ExceptionLogged $event) use ($logData) {
return $event->logData['message'] === 'Test error';
});
}
public function test_validate_article_listener_processes_new_article(): void
{
Event::fake([ArticleReadyToPublish::class]);
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'is_valid' => null,
'validated_at' => null
]);
$listener = new ValidateArticleListener();
$event = new NewArticleFetched($article);
$listener->handle($event);
$article->refresh();
$this->assertNotNull($article->validated_at);
$this->assertNotNull($article->is_valid);
}
public function test_publish_approved_article_listener_queues_job(): void
{
Queue::fake();
$article = Article::factory()->create([
'approval_status' => 'approved',
'is_valid' => true,
'validated_at' => now()
]);
$listener = new PublishApprovedArticle();
$event = new ArticleApproved($article);
$listener->handle($event);
Event::assertDispatched(ArticleReadyToPublish::class);
}
public function test_publish_article_listener_queues_publish_job(): void
{
Queue::fake();
$article = Article::factory()->create([
'is_valid' => true,
'validated_at' => now()
]);
$listener = new PublishArticle();
$event = new ArticleReadyToPublish($article);
$listener->handle($event);
Queue::assertPushed(PublishToLemmyJob::class, function (PublishToLemmyJob $job) use ($article) {
return $job->article->id === $article->id;
});
}
public function test_log_exception_to_database_listener_creates_log(): void
{
$logData = [
'level' => 'error',
'message' => 'Test exception message',
'context' => json_encode(['error' => 'details'])
];
$listener = new LogExceptionToDatabase();
$event = new ExceptionLogged($logData);
$listener->handle($event);
$this->assertDatabaseHas('logs', [
'level' => 'error',
'message' => 'Test exception message'
]);
$log = Log::where('message', 'Test exception message')->first();
$this->assertNotNull($log);
$this->assertEquals('error', $log->level);
}
public function test_event_listener_registration_works(): void
{
// Test that events are properly bound to listeners
$listeners = Event::getListeners(NewArticleFetched::class);
$this->assertNotEmpty($listeners);
$listeners = Event::getListeners(ArticleApproved::class);
$this->assertNotEmpty($listeners);
$listeners = Event::getListeners(ArticleReadyToPublish::class);
$this->assertNotEmpty($listeners);
$listeners = Event::getListeners(ExceptionLogged::class);
$this->assertNotEmpty($listeners);
}
public function test_job_retry_configuration(): void
{
$article = Article::factory()->create();
$job = new PublishToLemmyJob($article);
// Test that job has retry configuration
$this->assertObjectHasProperty('tries', $job);
$this->assertObjectHasProperty('backoff', $job);
}
public function test_job_queue_configuration(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
$article = Article::factory()->create();
$discoveryJob = new ArticleDiscoveryJob();
$feedJob = new ArticleDiscoveryForFeedJob($feed);
$publishJob = new PublishToLemmyJob($article);
$syncJob = new SyncChannelPostsJob($channel);
// Test queue assignments
$this->assertEquals('default', $discoveryJob->queue ?? 'default');
$this->assertEquals('discovery', $feedJob->queue ?? 'discovery');
$this->assertEquals('lemmy-posts', $publishJob->queue);
$this->assertEquals('sync', $syncJob->queue ?? 'sync');
}
protected function tearDown(): void
{
\Mockery::close();
parent::tearDown();
}
}

View file

@ -6,5 +6,5 @@
abstract class TestCase extends BaseTestCase
{
//
use CreatesApplication;
}

View file

@ -0,0 +1,91 @@
<?php
namespace Tests\Unit\Services;
use App\Services\Article\ArticleFetcher;
use App\Models\Feed;
use App\Models\Article;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ArticleFetcherTest extends TestCase
{
use RefreshDatabase;
public function test_get_articles_from_feed_returns_collection(): void
{
$feed = Feed::factory()->create([
'type' => 'rss',
'url' => 'https://example.com/feed.rss'
]);
$result = ArticleFetcher::getArticlesFromFeed($feed);
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
}
public function test_get_articles_from_rss_feed_returns_empty_collection(): void
{
$feed = Feed::factory()->create([
'type' => 'rss',
'url' => 'https://example.com/feed.rss'
]);
$result = ArticleFetcher::getArticlesFromFeed($feed);
// RSS parsing is not implemented yet, should return empty collection
$this->assertEmpty($result);
}
public function test_get_articles_from_website_feed_handles_no_parser(): void
{
$feed = Feed::factory()->create([
'type' => 'website',
'url' => 'https://unsupported-site.com/'
]);
$result = ArticleFetcher::getArticlesFromFeed($feed);
// Should return empty collection when no parser is available
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
$this->assertEmpty($result);
}
public function test_get_articles_from_unsupported_feed_type(): void
{
$feed = Feed::factory()->create([
'type' => 'website', // Use valid type but with unsupported URL
'url' => 'https://unsupported-feed-type.com/feed'
]);
$result = ArticleFetcher::getArticlesFromFeed($feed);
$this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
$this->assertEmpty($result);
}
public function test_fetch_article_data_returns_array(): void
{
$article = Article::factory()->create([
'url' => 'https://example.com/article'
]);
$result = ArticleFetcher::fetchArticleData($article);
$this->assertIsArray($result);
// Will be empty array due to unsupported URL in test
$this->assertEmpty($result);
}
public function test_fetch_article_data_handles_invalid_url(): void
{
$article = Article::factory()->create([
'url' => 'invalid-url'
]);
$result = ArticleFetcher::fetchArticleData($article);
$this->assertIsArray($result);
$this->assertEmpty($result);
}
}

View file

@ -0,0 +1,178 @@
<?php
namespace Tests\Unit\Services;
use App\Services\DashboardStatsService;
use App\Models\Article;
use App\Models\Feed;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Models\ArticlePublication;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class DashboardStatsServiceTest extends TestCase
{
use RefreshDatabase;
protected DashboardStatsService $dashboardStatsService;
protected function setUp(): void
{
parent::setUp();
$this->dashboardStatsService = new DashboardStatsService();
}
public function test_get_stats_returns_correct_structure(): void
{
$stats = $this->dashboardStatsService->getStats();
$this->assertIsArray($stats);
$this->assertArrayHasKey('articles_fetched', $stats);
$this->assertArrayHasKey('articles_published', $stats);
$this->assertArrayHasKey('published_percentage', $stats);
}
public function test_get_stats_with_today_period(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
// Create articles for today
$todayArticle = Article::factory()->create([
'feed_id' => $feed->id,
'created_at' => now()
]);
// Create publication for today
ArticlePublication::factory()->create([
'article_id' => $todayArticle->id,
'platform_channel_id' => $channel->id,
'published_at' => now()
]);
$stats = $this->dashboardStatsService->getStats('today');
$this->assertEquals(1, $stats['articles_fetched']);
$this->assertEquals(1, $stats['articles_published']);
$this->assertEquals(100.0, $stats['published_percentage']);
}
public function test_get_stats_with_week_period(): void
{
$stats = $this->dashboardStatsService->getStats('week');
$this->assertArrayHasKey('articles_fetched', $stats);
$this->assertArrayHasKey('articles_published', $stats);
$this->assertArrayHasKey('published_percentage', $stats);
}
public function test_get_stats_with_all_time_period(): void
{
$feed = Feed::factory()->create();
// Create articles across different times
Article::factory()->count(5)->create(['feed_id' => $feed->id]);
$stats = $this->dashboardStatsService->getStats('all');
$this->assertEquals(5, $stats['articles_fetched']);
$this->assertIsFloat($stats['published_percentage']);
}
public function test_get_stats_calculates_percentage_correctly(): void
{
$feed = Feed::factory()->create();
$channel = PlatformChannel::factory()->create();
// Create 4 articles
$articles = Article::factory()->count(4)->create(['feed_id' => $feed->id]);
// Publish 2 of them
foreach ($articles->take(2) as $article) {
ArticlePublication::factory()->create([
'article_id' => $article->id,
'platform_channel_id' => $channel->id,
'published_at' => now()
]);
}
$stats = $this->dashboardStatsService->getStats('all');
$this->assertEquals(4, $stats['articles_fetched']);
$this->assertEquals(2, $stats['articles_published']);
$this->assertEquals(50.0, $stats['published_percentage']);
}
public function test_get_stats_handles_zero_articles(): void
{
$stats = $this->dashboardStatsService->getStats();
$this->assertEquals(0, $stats['articles_fetched']);
$this->assertEquals(0, $stats['articles_published']);
$this->assertEquals(0.0, $stats['published_percentage']);
}
public function test_get_available_periods_returns_correct_options(): void
{
$periods = $this->dashboardStatsService->getAvailablePeriods();
$this->assertIsArray($periods);
$this->assertArrayHasKey('today', $periods);
$this->assertArrayHasKey('week', $periods);
$this->assertArrayHasKey('month', $periods);
$this->assertArrayHasKey('year', $periods);
$this->assertArrayHasKey('all', $periods);
$this->assertEquals('Today', $periods['today']);
$this->assertEquals('All Time', $periods['all']);
}
public function test_get_system_stats_returns_correct_structure(): void
{
$stats = $this->dashboardStatsService->getSystemStats();
$this->assertIsArray($stats);
$this->assertArrayHasKey('total_feeds', $stats);
$this->assertArrayHasKey('active_feeds', $stats);
$this->assertArrayHasKey('total_channels', $stats);
$this->assertArrayHasKey('active_channels', $stats);
$this->assertArrayHasKey('total_routes', $stats);
$this->assertArrayHasKey('active_routes', $stats);
}
public function test_get_system_stats_counts_correctly(): void
{
// Create a single feed, channel, and route to test counting
$feed = Feed::factory()->create(['is_active' => true]);
$channel = PlatformChannel::factory()->create(['is_active' => true]);
$route = Route::factory()->create(['is_active' => true]);
$stats = $this->dashboardStatsService->getSystemStats();
// Verify that all stats are properly counted (at least our created items exist)
$this->assertGreaterThanOrEqual(1, $stats['total_feeds']);
$this->assertGreaterThanOrEqual(1, $stats['active_feeds']);
$this->assertGreaterThanOrEqual(1, $stats['total_channels']);
$this->assertGreaterThanOrEqual(1, $stats['active_channels']);
$this->assertGreaterThanOrEqual(1, $stats['total_routes']);
$this->assertGreaterThanOrEqual(1, $stats['active_routes']);
// Verify that active counts are less than or equal to total counts
$this->assertLessThanOrEqual($stats['total_feeds'], $stats['active_feeds']);
$this->assertLessThanOrEqual($stats['total_channels'], $stats['active_channels']);
$this->assertLessThanOrEqual($stats['total_routes'], $stats['active_routes']);
}
public function test_get_system_stats_handles_empty_database(): void
{
$stats = $this->dashboardStatsService->getSystemStats();
$this->assertEquals(0, $stats['total_feeds']);
$this->assertEquals(0, $stats['active_feeds']);
$this->assertEquals(0, $stats['total_channels']);
$this->assertEquals(0, $stats['active_channels']);
$this->assertEquals(0, $stats['total_routes']);
$this->assertEquals(0, $stats['active_routes']);
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace Tests\Unit\Services;
use App\Services\Article\ValidationService;
use App\Models\Article;
use App\Models\Feed;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ValidationServiceTest extends TestCase
{
use RefreshDatabase;
public function test_validate_returns_article_with_validation_status(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'is_valid' => null,
'validated_at' => null
]);
$result = ValidationService::validate($article);
$this->assertInstanceOf(Article::class, $result);
$this->assertNotNull($result->validated_at);
$this->assertIsBool($result->is_valid);
}
public function test_validate_marks_article_invalid_when_missing_data(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://invalid-url-without-parser.com/article',
'is_valid' => null,
'validated_at' => null
]);
$result = ValidationService::validate($article);
$this->assertFalse($result->is_valid);
$this->assertNotNull($result->validated_at);
}
public function test_validate_with_supported_article_content(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'is_valid' => null,
'validated_at' => null
]);
$result = ValidationService::validate($article);
// Since we can't fetch real content in tests, it should be marked invalid
$this->assertFalse($result->is_valid);
$this->assertNotNull($result->validated_at);
}
public function test_validate_updates_article_in_database(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'is_valid' => null,
'validated_at' => null
]);
$originalId = $article->id;
ValidationService::validate($article);
// Check that the article was updated in the database
$updatedArticle = Article::find($originalId);
$this->assertNotNull($updatedArticle->validated_at);
$this->assertNotNull($updatedArticle->is_valid);
}
public function test_validate_handles_article_with_existing_validation(): void
{
$feed = Feed::factory()->create();
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article',
'is_valid' => true,
'validated_at' => now()->subHour()
]);
$originalValidatedAt = $article->validated_at;
$result = ValidationService::validate($article);
// Should re-validate and update timestamp
$this->assertNotEquals($originalValidatedAt, $result->validated_at);
}
public function test_validate_keyword_checking_logic(): void
{
$feed = Feed::factory()->create();
// Create an article that would match the validation keywords if content was available
$article = Article::factory()->create([
'feed_id' => $feed->id,
'url' => 'https://example.com/article-about-bart-de-wever',
'is_valid' => null,
'validated_at' => null
]);
$result = ValidationService::validate($article);
// The service looks for keywords in the full_article content
// Since we can't fetch real content, it will be marked invalid
$this->assertFalse($result->is_valid);
}
}