diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 2b2e134..0000000 --- a/.dockerignore +++ /dev/null @@ -1,44 +0,0 @@ -# Development files -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Environment files -.env -.env.* -!.env.production - -# Development tools -.git/ -.gitignore -README.md -docker-compose.yml - -# IDE files -.vscode/ -.idea/ -*.swp -*.swo - -# OS files -.DS_Store -Thumbs.db - -# Laravel development -storage/logs/* -storage/framework/cache/* -storage/framework/sessions/* -storage/framework/testing/* -storage/framework/views/* -bootstrap/cache/* - -# Testing -tests/ -phpunit.xml -.phpunit.result.cache - -# Build artifacts -vendor/ -build/ -dist/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6f5ed04..d9f22cf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /vendor .env .env.backup +.env.production .phpactor.json .phpunit.result.cache Homestead.json @@ -17,12 +18,8 @@ npm-debug.log yarn-error.log /package-lock.json /auth.json -/composer.lock /.fleet /.idea /.nova /.vscode /.zed -/coverage-report* -/coverage.xml -/.claude diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..df954ec --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +resources/js/components/ui/* +resources/js/ziggy.js +resources/views/mail/* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5a33bc8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,19 @@ +{ + "semi": true, + "singleQuote": true, + "singleAttributePerLine": false, + "htmlWhitespaceSensitivity": "css", + "printWidth": 150, + "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"], + "tailwindFunctions": ["clsx", "cn"], + "tailwindStylesheet": "resources/css/app.css", + "tabWidth": 4, + "overrides": [ + { + "files": "**/*.yml", + "options": { + "tabWidth": 2 + } + } + ] +} diff --git a/Dockerfile b/Dockerfile index 11ce824..ce5bee4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,127 +1,53 @@ -# Production Dockerfile with FrankenPHP -FROM dunglas/frankenphp:latest-php8.3-alpine +# Multi-stage build for Laravel with React frontend +FROM node:22-alpine AS frontend-builder -# Install system dependencies -RUN apk add --no-cache \ - nodejs \ - npm \ - git \ - mysql-client - -# Install PHP extensions -RUN install-php-extensions \ - pdo_mysql \ - opcache \ - zip \ - gd \ - intl \ - bcmath \ - redis \ - pcntl - -# Install Composer -COPY --from=composer:2 /usr/bin/composer /usr/bin/composer - -# Set working directory WORKDIR /app - -# Set fixed production environment variables -ENV APP_ENV=production \ - APP_DEBUG=false \ - DB_CONNECTION=mysql \ - DB_HOST=db \ - DB_PORT=3306 \ - SESSION_DRIVER=redis \ - CACHE_STORE=redis \ - QUEUE_CONNECTION=redis \ - LOG_CHANNEL=stack \ - LOG_LEVEL=error - -# Copy application code first +COPY package*.json ./ +RUN npm install --only=production COPY . . - -# Install PHP dependencies (production only) -RUN composer install --no-dev --no-interaction --optimize-autoloader - -# Install ALL Node dependencies (including dev for building) -RUN npm ci - -# Build frontend assets RUN npm run build -# Remove node_modules after build to save space -RUN rm -rf node_modules +FROM php:8.4-fpm-alpine -# Laravel optimizations -RUN php artisan config:cache \ - && php artisan route:cache \ - && php artisan view:cache \ - && composer dump-autoload --optimize +# Install system dependencies and PHP extensions +RUN apk add --no-cache \ + git \ + curl \ + libpng-dev \ + oniguruma-dev \ + libxml2-dev \ + zip \ + unzip \ + && docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /var/www/html + +# Copy composer files +COPY composer*.json ./ + +# Copy application files (needed for artisan in composer scripts) +COPY . . + +# Install dependencies +RUN composer install --no-dev --optimize-autoloader --no-interaction + +# Copy built frontend assets +COPY --from=frontend-builder /app/public/build /var/www/html/public/build # Set permissions -RUN chown -R www-data:www-data /app/storage /app/bootstrap/cache +RUN chown -R www-data:www-data /var/www/html \ + && chmod -R 755 /var/www/html/storage \ + && chmod -R 755 /var/www/html/bootstrap/cache -# Configure Caddy -RUN cat > /etc/caddy/Caddyfile < /start-prod.sh <<'EOF' -#!/bin/sh -set -e - -# Wait for database to be ready -echo "Waiting for database..." -for i in $(seq 1 30); do - if mysqladmin ping -h "$DB_HOST" -u "$DB_USERNAME" -p"$DB_PASSWORD" --silent 2>/dev/null; then - echo "Database is ready!" - break - fi - echo "Waiting for database... ($i/30)" - sleep 2 -done - -# Run migrations -echo "Running migrations..." -php artisan migrate --force || echo "Migrations failed or already up-to-date" - -# Start Horizon in the background -php artisan horizon & - -# Start FrankenPHP -exec frankenphp run --config /etc/caddy/Caddyfile -EOF - -RUN chmod +x /start-prod.sh - -# Start with our script -CMD ["/start-prod.sh"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["web"] \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 124ace1..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,127 +0,0 @@ -# Development Dockerfile with FrankenPHP -FROM dunglas/frankenphp:latest-php8.3-alpine - -# Install system dependencies + development tools -RUN apk add --no-cache \ - nodejs \ - npm \ - git \ - mysql-client \ - vim \ - bash \ - nano - -# Install PHP extensions including xdebug for development -RUN install-php-extensions \ - pdo_mysql \ - opcache \ - zip \ - gd \ - intl \ - bcmath \ - redis \ - pcntl \ - xdebug - -# Install Composer -COPY --from=composer:2 /usr/bin/composer /usr/bin/composer - -# Set working directory -WORKDIR /app - -# Configure PHP for development -RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" - -# Configure Xdebug (disabled by default to reduce noise) -RUN echo "xdebug.mode=off" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ - && echo ";xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ - && echo ";xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \ - && echo ";xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - -# Configure Caddy for development (simpler, no worker mode) -RUN cat > /etc/caddy/Caddyfile < /start.sh <<'EOF' -#!/bin/sh -set -e - -# Create .env file if it doesn't exist -if [ ! -f ".env" ]; then - echo "Creating .env file from .env.example..." - cp .env.example .env -fi - -# Install dependencies if volumes are empty -if [ ! -f "vendor/autoload.php" ]; then - echo "Installing composer dependencies..." - composer install -fi - -# Always reinstall node_modules in container to get correct native binaries for Alpine/musl -echo "Installing npm dependencies..." -rm -rf node_modules 2>/dev/null || true -rm -rf /app/.npm 2>/dev/null || true -npm install --cache /tmp/.npm - -# Clear Laravel caches -php artisan config:clear || true -php artisan cache:clear || true - -# Wait for database and run migrations -echo "Waiting for database..." -sleep 5 -php artisan migrate --force || echo "Migration failed or not needed" - -# Run seeders -echo "Running seeders..." -php artisan db:seed --force || echo "Seeding skipped or already done" - -# Generate app key if not set -if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "base64:YOUR_KEY_HERE" ]; then - echo "Generating application key..." - php artisan key:generate -fi - -# Start Vite dev server in background -npm run dev & - -# Start Horizon (queue worker) in background -php artisan horizon & - -# Start FrankenPHP -exec frankenphp run --config /etc/caddy/Caddyfile -EOF - -RUN chmod +x /start.sh - -# Expose ports -EXPOSE 8000 5173 - -# Use the startup script -CMD ["/start.sh"] diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index cb3dd35..0000000 --- a/Jenkinsfile +++ /dev/null @@ -1,241 +0,0 @@ -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' - } - } -} \ No newline at end of file diff --git a/README.md b/README.md index 47f8177..a7ac6f3 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,110 @@ -# FFR (Feed to Fediverse Router) +# Lemmy Poster -A Laravel-based application for routing RSS/Atom feeds to Fediverse platforms like Lemmy. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment. +A Laravel application for posting articles to Lemmy platforms. -## Features +## Docker Deployment -- **Feed aggregation** - Fetch articles from multiple RSS/Atom feeds -- **Fediverse publishing** - Automatically post to Lemmy communities -- **Route configuration** - Map feeds to specific channels with keywords -- **Approval workflow** - Optional manual approval before publishing -- **Queue processing** - Background job handling with Laravel Horizon -- **Single container deployment** - Simplified hosting with FrankenPHP +### Building the Image -## Self-hosting +```bash +docker build -t your-registry/lemmy-poster:latest . +docker push your-registry/lemmy-poster:latest +``` -The production image is available at `codeberg.org/lvl0/ffr:latest`. +### Docker Compose -### docker-compose.yml +Create a `docker-compose.yml` file: ```yaml services: - app: - image: codeberg.org/lvl0/ffr:latest - container_name: ffr_app - restart: always + app-web: + image: your-registry/lemmy-poster:latest + command: ["web"] ports: - "8000:8000" environment: - APP_KEY: "${APP_KEY}" - APP_URL: "${APP_URL}" - DB_DATABASE: "${DB_DATABASE}" - DB_USERNAME: "${DB_USERNAME}" - DB_PASSWORD: "${DB_PASSWORD}" - REDIS_HOST: redis - REDIS_PORT: 6379 - volumes: - - app_storage:/app/storage + - APP_ENV=production + - APP_KEY=${APP_KEY} + - DB_CONNECTION=mysql + - DB_HOST=mysql + - DB_PORT=3306 + - DB_DATABASE=lemmy_poster + - DB_USERNAME=lemmy_user + - DB_PASSWORD=${DB_PASSWORD} + - QUEUE_CONNECTION=database + - LEMMY_INSTANCE=${LEMMY_INSTANCE} + - LEMMY_USERNAME=${LEMMY_USERNAME} + - LEMMY_PASSWORD=${LEMMY_PASSWORD} + - LEMMY_COMMUNITY=${LEMMY_COMMUNITY} depends_on: - - db - - redis - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/up"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s + - mysql + volumes: + - storage_data:/var/www/html/storage/app + restart: unless-stopped - db: - image: mariadb:11 - container_name: ffr_db - restart: always + app-queue: + image: your-registry/lemmy-poster:latest + command: ["queue"] environment: - MYSQL_DATABASE: "${DB_DATABASE}" - MYSQL_USER: "${DB_USERNAME}" - MYSQL_PASSWORD: "${DB_PASSWORD}" - MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}" + - APP_ENV=production + - APP_KEY=${APP_KEY} + - DB_CONNECTION=mysql + - DB_HOST=mysql + - DB_PORT=3306 + - DB_DATABASE=lemmy_poster + - DB_USERNAME=lemmy_user + - DB_PASSWORD=${DB_PASSWORD} + - QUEUE_CONNECTION=database + - LEMMY_INSTANCE=${LEMMY_INSTANCE} + - LEMMY_USERNAME=${LEMMY_USERNAME} + - LEMMY_PASSWORD=${LEMMY_PASSWORD} + - LEMMY_COMMUNITY=${LEMMY_COMMUNITY} + depends_on: + - mysql volumes: - - db_data:/var/lib/mysql + - storage_data:/var/www/html/storage/app + restart: unless-stopped - redis: - image: redis:7-alpine - container_name: ffr_redis - restart: always + mysql: + image: mysql:8.0 + environment: + - MYSQL_DATABASE=lemmy_poster + - MYSQL_USER=lemmy_user + - MYSQL_PASSWORD=${DB_PASSWORD} + - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD} volumes: - - redis_data:/data + - mysql_data:/var/lib/mysql + restart: unless-stopped volumes: - db_data: - redis_data: - app_storage: + mysql_data: + storage_data: ``` ### Environment Variables -| Variable | Required | Description | -|----------|----------|-------------| -| `APP_KEY` | Yes | Encryption key. Generate with: `echo "base64:$(openssl rand -base64 32)"` | -| `APP_URL` | Yes | Your domain (e.g., `https://ffr.example.com`) | -| `DB_DATABASE` | Yes | Database name | -| `DB_USERNAME` | Yes | Database user | -| `DB_PASSWORD` | Yes | Database password | -| `DB_ROOT_PASSWORD` | Yes | MariaDB root password | +Create a `.env` file with: -## Development - -### NixOS / Nix - -```bash -git clone https://codeberg.org/lvl0/ffr.git -cd ffr -nix-shell +```env +APP_KEY=your-app-key-here +DB_PASSWORD=your-db-password +DB_ROOT_PASSWORD=your-root-password +LEMMY_INSTANCE=your-lemmy-instance.com +LEMMY_USERNAME=your-lemmy-username +LEMMY_PASSWORD=your-lemmy-password +LEMMY_COMMUNITY=your-target-community ``` -The shell will display available commands and optionally start the containers for you. +Generate the APP_KEY: +```bash +openssl rand -base64 32 | tr -d '=' +``` -#### Available Commands +### Deployment -| Command | Description | -|---------|-------------| -| `dev-up` | Start development environment | -| `dev-down` | Stop development environment | -| `dev-restart` | Restart containers | -| `dev-logs` | Follow app logs | -| `dev-logs-db` | Follow database logs | -| `dev-shell` | Enter app container | -| `dev-artisan ` | Run artisan commands | -| `prod-build [tag]` | Build and push prod image (default: latest) | +1. Build and push the image +2. Copy the docker-compose.yml to your server +3. Create the .env file with your environment variables +4. Run: `docker-compose up -d` -#### Services - -| Service | URL | -|---------|-----| -| App | http://localhost:8000 | -| Vite | http://localhost:5173 | -| MariaDB | localhost:3307 | -| Redis | localhost:6380 | - -### Other Platforms - -Contributions welcome for development setup instructions on other platforms. - -## License - -This project is open-source software licensed under the [AGPL-3.0 license](LICENSE). - -## Support - -For issues and questions, please use [Codeberg Issues](https://codeberg.org/lvl0/ffr/issues). +The web interface will be available on port 8000, ready for CloudFlare tunnel configuration. diff --git a/app/Console/Commands/FetchArticleCommand.php b/app/Console/Commands/FetchArticleCommand.php new file mode 100644 index 0000000..0b28190 --- /dev/null +++ b/app/Console/Commands/FetchArticleCommand.php @@ -0,0 +1,27 @@ + $this->argument('url'), + ]); + + $res = ArticleFetcher::fetchArticleData($article); + + dump($res); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/FetchNewArticlesCommand.php b/app/Console/Commands/FetchNewArticlesCommand.php index c0aef79..e2ba1db 100644 --- a/app/Console/Commands/FetchNewArticlesCommand.php +++ b/app/Console/Commands/FetchNewArticlesCommand.php @@ -2,9 +2,7 @@ namespace App\Console\Commands; -use App\Jobs\ArticleDiscoveryJob; -use App\Models\Feed; -use App\Models\Setting; +use App\Services\Article\ArticleFetcher; use Illuminate\Console\Command; class FetchNewArticlesCommand extends Command @@ -15,19 +13,9 @@ class FetchNewArticlesCommand extends Command public function handle(): int { - if (!Setting::isArticleProcessingEnabled()) { - $this->info('Article processing is disabled. Article discovery skipped.'); + logger('Fetch new articles command'); - return self::SUCCESS; - } - - if (!Feed::where('is_active', true)->exists()) { - $this->info('No active feeds found. Article discovery skipped.'); - - return self::SUCCESS; - } - - ArticleDiscoveryJob::dispatch(); + ArticleFetcher::getNewArticles(); return self::SUCCESS; } diff --git a/app/Console/Commands/PublishToLemmyCommand.php b/app/Console/Commands/PublishToLemmyCommand.php new file mode 100644 index 0000000..2e32327 --- /dev/null +++ b/app/Console/Commands/PublishToLemmyCommand.php @@ -0,0 +1,29 @@ +filter(fn (Article $article) => $article->articlePublication === null) + ->firstOrFail(); + + $this->info('Queuing article for publishing: ' . $article->url); + + PublishToLemmyJob::dispatch($article); + + $this->info('Article queued successfully'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncChannelPostsCommand.php b/app/Console/Commands/SyncChannelPostsCommand.php index 6714677..f41e8d9 100644 --- a/app/Console/Commands/SyncChannelPostsCommand.php +++ b/app/Console/Commands/SyncChannelPostsCommand.php @@ -2,33 +2,59 @@ namespace App\Console\Commands; +use App\Enums\PlatformEnum; use App\Jobs\SyncChannelPostsJob; +use App\Modules\Lemmy\Services\LemmyApiService; use Illuminate\Console\Command; class SyncChannelPostsCommand extends Command { protected $signature = 'channel:sync {platform=lemmy}'; - + protected $description = 'Manually sync channel posts for a platform'; public function handle(): int { $platform = $this->argument('platform'); - + if ($platform === 'lemmy') { return $this->syncLemmy(); } - + $this->error("Unsupported platform: {$platform}"); - return self::FAILURE; } private function syncLemmy(): int { - SyncChannelPostsJob::dispatchForAllActiveChannels(); - $this->info('Successfully dispatched sync jobs for all active Lemmy channels'); + $communityName = config('lemmy.community'); + + if (!$communityName) { + $this->error('Missing Lemmy community configuration (lemmy.community)'); + return self::FAILURE; + } - return self::SUCCESS; + try { + $this->info("Getting community ID for: {$communityName}"); + + $api = new LemmyApiService(config('lemmy.instance')); + $communityId = $api->getCommunityId($communityName); + + $this->info("Running sync job for Lemmy community: {$communityName} (ID: {$communityId})"); + + SyncChannelPostsJob::dispatchSync( + PlatformEnum::LEMMY, + (string) $communityId, + $communityName + ); + + $this->info('Channel posts synced successfully'); + + return self::SUCCESS; + + } catch (\Exception $e) { + $this->error("Failed to sync channel posts: {$e->getMessage()}"); + return self::FAILURE; + } } } diff --git a/app/Contracts/ArticleParserInterface.php b/app/Contracts/ArticleParserInterface.php index ab6c89f..14b651a 100644 --- a/app/Contracts/ArticleParserInterface.php +++ b/app/Contracts/ArticleParserInterface.php @@ -11,7 +11,6 @@ public function canParse(string $url): bool; /** * Extract article data from HTML - * @return array */ public function extractData(string $html): array; diff --git a/app/Contracts/HomepageParserInterface.php b/app/Contracts/HomepageParserInterface.php index 50301d6..a940663 100644 --- a/app/Contracts/HomepageParserInterface.php +++ b/app/Contracts/HomepageParserInterface.php @@ -11,7 +11,6 @@ public function canParse(string $url): bool; /** * Extract article URLs from homepage HTML - * @return array */ public function extractArticleUrls(string $html): array; diff --git a/app/Events/NewArticleFetched.php b/app/Events/ArticleFetched.php similarity index 93% rename from app/Events/NewArticleFetched.php rename to app/Events/ArticleFetched.php index 3e657f9..64d2cfd 100644 --- a/app/Events/NewArticleFetched.php +++ b/app/Events/ArticleFetched.php @@ -7,11 +7,12 @@ use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class NewArticleFetched +class ArticleFetched { use Dispatchable, InteractsWithSockets, SerializesModels; public function __construct(public Article $article) { + } } diff --git a/app/Events/ArticleApproved.php b/app/Events/ArticleReadyToPublish.php similarity index 60% rename from app/Events/ArticleApproved.php rename to app/Events/ArticleReadyToPublish.php index 1e9ec9b..a23ca5c 100644 --- a/app/Events/ArticleApproved.php +++ b/app/Events/ArticleReadyToPublish.php @@ -3,15 +3,16 @@ namespace App\Events; use App\Models\Article; +use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class ArticleApproved +class ArticleReadyToPublish { - use Dispatchable, SerializesModels; + use Dispatchable, InteractsWithSockets, SerializesModels; public function __construct(public Article $article) { - // + } -} +} \ No newline at end of file diff --git a/app/Events/ExceptionOccurred.php b/app/Events/ExceptionOccurred.php index 79ea1cd..ff559b2 100644 --- a/app/Events/ExceptionOccurred.php +++ b/app/Events/ExceptionOccurred.php @@ -2,7 +2,7 @@ namespace App\Events; -use App\Enums\LogLevelEnum; +use App\LogLevelEnum; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; use Throwable; @@ -15,7 +15,6 @@ public function __construct( public Throwable $exception, public LogLevelEnum $level, public string $message, - /** @var array */ public array $context = [] ) { } diff --git a/app/Exceptions/ChannelException.php b/app/Exceptions/ChannelException.php deleted file mode 100644 index 5f526b6..0000000 --- a/app/Exceptions/ChannelException.php +++ /dev/null @@ -1,9 +0,0 @@ -id"; - - if ($this->platform) { - $message .= " to $platform->value"; - } + $message = "Failed to publish article #{$article->id} to {$platform->value}"; if ($previous) { $message .= ": {$previous->getMessage()}"; @@ -32,7 +28,7 @@ public function getArticle(): Article return $this->article; } - public function getPlatform(): ?PlatformEnum + public function getPlatform(): PlatformEnum { return $this->platform; } diff --git a/app/Exceptions/RoutingException.php b/app/Exceptions/RoutingException.php deleted file mode 100644 index acbc082..0000000 --- a/app/Exceptions/RoutingException.php +++ /dev/null @@ -1,9 +0,0 @@ -name, - $feed->language, - $channel->name, - $channel->language - ); - - parent::__construct($message); - } -} diff --git a/app/Facades/LogSaver.php b/app/Facades/LogSaver.php deleted file mode 100644 index 661e618..0000000 --- a/app/Facades/LogSaver.php +++ /dev/null @@ -1,13 +0,0 @@ -get('per_page', 15), 100); // Max 100 items per page - $articles = Article::with(['feed', 'articlePublication']) - ->orderBy('created_at', 'desc') - ->paginate($perPage); - - $publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled(); - - return $this->sendResponse([ - 'articles' => ArticleResource::collection($articles->items()), - 'pagination' => [ - 'current_page' => $articles->currentPage(), - 'last_page' => $articles->lastPage(), - 'per_page' => $articles->perPage(), - 'total' => $articles->total(), - 'from' => $articles->firstItem(), - 'to' => $articles->lastItem(), - ], - 'settings' => [ - 'publishing_approvals_enabled' => $publishingApprovalsEnabled, - ], - ]); - } - - /** - * Approve an article - */ - public function approve(Article $article): JsonResponse - { - try { - $article->approve('manual'); - - return $this->sendResponse( - new ArticleResource($article->fresh(['feed', 'articlePublication'])), - 'Article approved and queued for publishing.' - ); - } catch (Exception $e) { - return $this->sendError('Failed to approve article: ' . $e->getMessage(), [], 500); - } - } - - /** - * Reject an article - */ - public function reject(Article $article): JsonResponse - { - try { - $article->reject('manual'); - - return $this->sendResponse( - new ArticleResource($article->fresh(['feed', 'articlePublication'])), - 'Article rejected.' - ); - } catch (Exception $e) { - return $this->sendError('Failed to reject article: ' . $e->getMessage(), [], 500); - } - } - - /** - * Manually refresh articles from all active feeds - */ - public function refresh(): JsonResponse - { - try { - ArticleDiscoveryJob::dispatch(); - - return $this->sendResponse( - null, - 'Article refresh started. New articles will appear shortly.' - ); - } catch (Exception $e) { - return $this->sendError('Failed to start article refresh: ' . $e->getMessage(), [], 500); - } - } -} diff --git a/app/Http/Controllers/Api/V1/AuthController.php b/app/Http/Controllers/Api/V1/AuthController.php deleted file mode 100644 index 8f336e0..0000000 --- a/app/Http/Controllers/Api/V1/AuthController.php +++ /dev/null @@ -1,112 +0,0 @@ -validate([ - 'email' => 'required|email', - 'password' => 'required', - ]); - - $user = User::where('email', $request->email)->first(); - - if (!$user || !Hash::check($request->password, $user->password)) { - return $this->sendError('Invalid credentials', [], 401); - } - - $token = $user->createToken('api-token')->plainTextToken; - - return $this->sendResponse([ - 'user' => [ - 'id' => $user->id, - 'name' => $user->name, - 'email' => $user->email, - ], - 'token' => $token, - 'token_type' => 'Bearer', - ], 'Login successful'); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Login failed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Register a new user - */ - public function register(Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'email' => 'required|string|email|max:255|unique:users', - 'password' => 'required|string|min:8|confirmed', - ]); - - $user = User::create([ - 'name' => $validated['name'], - 'email' => $validated['email'], - 'password' => Hash::make($validated['password']), - ]); - - $token = $user->createToken('api-token')->plainTextToken; - - return $this->sendResponse([ - 'user' => [ - 'id' => $user->id, - 'name' => $user->name, - 'email' => $user->email, - ], - 'token' => $token, - 'token_type' => 'Bearer', - ], 'Registration successful', 201); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Registration failed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Logout user (revoke token) - */ - public function logout(Request $request): JsonResponse - { - try { - $request->user()->currentAccessToken()->delete(); - - return $this->sendResponse(null, 'Logged out successfully'); - } catch (\Exception $e) { - return $this->sendError('Logout failed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Get current authenticated user - */ - public function me(Request $request): JsonResponse - { - return $this->sendResponse([ - 'user' => [ - 'id' => $request->user()->id, - 'name' => $request->user()->name, - 'email' => $request->user()->email, - ], - ], 'User retrieved successfully'); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/BaseController.php b/app/Http/Controllers/Api/V1/BaseController.php deleted file mode 100644 index 4c9b10b..0000000 --- a/app/Http/Controllers/Api/V1/BaseController.php +++ /dev/null @@ -1,64 +0,0 @@ - true, - 'data' => $result, - 'message' => $message, - ]; - - return response()->json($response, $code); - } - - /** - * Error response method - */ - public function sendError(string $error, array $errorMessages = [], int $code = 400): JsonResponse - { - $response = [ - 'success' => false, - 'message' => $error, - ]; - - if (!empty($errorMessages)) { - $response['errors'] = $errorMessages; - } - - return response()->json($response, $code); - } - - /** - * Validation error response method - */ - public function sendValidationError(array $errors): JsonResponse - { - return $this->sendError('Validation failed', $errors, 422); - } - - /** - * Not found response method - */ - public function sendNotFound(string $message = 'Resource not found'): JsonResponse - { - return $this->sendError($message, [], 404); - } - - /** - * Unauthorized response method - */ - public function sendUnauthorized(string $message = 'Unauthorized'): JsonResponse - { - return $this->sendError($message, [], 401); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/DashboardController.php b/app/Http/Controllers/Api/V1/DashboardController.php deleted file mode 100644 index 4410879..0000000 --- a/app/Http/Controllers/Api/V1/DashboardController.php +++ /dev/null @@ -1,47 +0,0 @@ -get('period', 'today'); - - try { - // Get article stats from service - $articleStats = $this->dashboardStatsService->getStats($period); - - // Get system stats - $systemStats = $this->dashboardStatsService->getSystemStats(); - - // Get available periods - $availablePeriods = $this->dashboardStatsService->getAvailablePeriods(); - - return $this->sendResponse([ - 'article_stats' => $articleStats, - 'system_stats' => $systemStats, - 'available_periods' => $availablePeriods, - 'current_period' => $period, - ]); - } catch (\Exception $e) { - throw $e; - return $this->sendError('Failed to fetch dashboard stats: ' . $e->getMessage(), [], 500); - } - } -} diff --git a/app/Http/Controllers/Api/V1/FeedsController.php b/app/Http/Controllers/Api/V1/FeedsController.php deleted file mode 100644 index f8b07f0..0000000 --- a/app/Http/Controllers/Api/V1/FeedsController.php +++ /dev/null @@ -1,143 +0,0 @@ -get('per_page', 15), 100); - - $feeds = Feed::with(['language']) - ->withCount('articles') - ->orderBy('is_active', 'desc') - ->orderBy('name') - ->paginate($perPage); - - return $this->sendResponse([ - 'feeds' => FeedResource::collection($feeds->items()), - 'pagination' => [ - 'current_page' => $feeds->currentPage(), - 'last_page' => $feeds->lastPage(), - 'per_page' => $feeds->perPage(), - 'total' => $feeds->total(), - 'from' => $feeds->firstItem(), - 'to' => $feeds->lastItem(), - ] - ], 'Feeds retrieved successfully.'); - } - - /** - * Store a newly created feed - */ - public function store(StoreFeedRequest $request): JsonResponse - { - try { - $validated = $request->validated(); - $validated['is_active'] = $validated['is_active'] ?? true; - - // Map provider to URL and set type - $providers = [ - 'vrt' => new \App\Services\Parsers\VrtHomepageParserAdapter(), - 'belga' => new \App\Services\Parsers\BelgaHomepageParserAdapter(), - ]; - - $adapter = $providers[$validated['provider']]; - $validated['url'] = $adapter->getHomepageUrl(); - $validated['type'] = 'website'; - - $feed = Feed::create($validated); - - return $this->sendResponse( - new FeedResource($feed), - 'Feed created successfully!', - 201 - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to create feed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Display the specified feed - */ - public function show(Feed $feed): JsonResponse - { - return $this->sendResponse( - new FeedResource($feed), - 'Feed retrieved successfully.' - ); - } - - /** - * Update the specified feed - */ - public function update(UpdateFeedRequest $request, Feed $feed): JsonResponse - { - try { - $validated = $request->validated(); - $validated['is_active'] = $validated['is_active'] ?? $feed->is_active; - - $feed->update($validated); - - return $this->sendResponse( - new FeedResource($feed->fresh()), - 'Feed updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update feed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Remove the specified feed - */ - public function destroy(Feed $feed): JsonResponse - { - try { - $feed->delete(); - - return $this->sendResponse( - null, - 'Feed deleted successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to delete feed: ' . $e->getMessage(), [], 500); - } - } - - /** - * Toggle feed active status - */ - public function toggle(Feed $feed): JsonResponse - { - try { - $newStatus = !$feed->is_active; - $feed->update(['is_active' => $newStatus]); - - $status = $newStatus ? 'activated' : 'deactivated'; - - return $this->sendResponse( - new FeedResource($feed->fresh()), - "Feed {$status} successfully!" - ); - } catch (\Exception $e) { - return $this->sendError('Failed to toggle feed status: ' . $e->getMessage(), [], 500); - } - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/KeywordsController.php b/app/Http/Controllers/Api/V1/KeywordsController.php deleted file mode 100644 index e120b41..0000000 --- a/app/Http/Controllers/Api/V1/KeywordsController.php +++ /dev/null @@ -1,143 +0,0 @@ -id) - ->where('platform_channel_id', $channel->id) - ->orderBy('keyword') - ->get(); - - return $this->sendResponse( - $keywords->toArray(), - 'Keywords retrieved successfully.' - ); - } - - /** - * Store a new keyword for a route - */ - public function store(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse - { - try { - $validated = $request->validate([ - 'keyword' => 'required|string|max:255', - 'is_active' => 'boolean', - ]); - - $validated['feed_id'] = $feed->id; - $validated['platform_channel_id'] = $channel->id; - $validated['is_active'] = $validated['is_active'] ?? true; - - // Check if keyword already exists for this route - $existingKeyword = Keyword::where('feed_id', $feed->id) - ->where('platform_channel_id', $channel->id) - ->where('keyword', $validated['keyword']) - ->first(); - - if ($existingKeyword) { - return $this->sendError('Keyword already exists for this route.', [], 409); - } - - $keyword = Keyword::create($validated); - - return $this->sendResponse( - $keyword->toArray(), - 'Keyword created successfully!', - 201 - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to create keyword: ' . $e->getMessage(), [], 500); - } - } - - /** - * Update a keyword's status - */ - public function update(Request $request, Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse - { - try { - // Verify the keyword belongs to this route - if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { - return $this->sendNotFound('Keyword not found for this route.'); - } - - $validated = $request->validate([ - 'is_active' => 'boolean', - ]); - - $keyword->update($validated); - - return $this->sendResponse( - $keyword->fresh()->toArray(), - 'Keyword updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update keyword: ' . $e->getMessage(), [], 500); - } - } - - /** - * Remove a keyword from a route - */ - public function destroy(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse - { - try { - // Verify the keyword belongs to this route - if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { - return $this->sendNotFound('Keyword not found for this route.'); - } - - $keyword->delete(); - - return $this->sendResponse( - null, - 'Keyword deleted successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to delete keyword: ' . $e->getMessage(), [], 500); - } - } - - /** - * Toggle keyword active status - */ - public function toggle(Feed $feed, PlatformChannel $channel, Keyword $keyword): JsonResponse - { - try { - // Verify the keyword belongs to this route - if ($keyword->feed_id !== $feed->id || $keyword->platform_channel_id !== $channel->id) { - return $this->sendNotFound('Keyword not found for this route.'); - } - - $newStatus = !$keyword->is_active; - $keyword->update(['is_active' => $newStatus]); - - $status = $newStatus ? 'activated' : 'deactivated'; - - return $this->sendResponse( - $keyword->fresh()->toArray(), - "Keyword {$status} successfully!" - ); - } catch (\Exception $e) { - return $this->sendError('Failed to toggle keyword status: ' . $e->getMessage(), [], 500); - } - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/LogsController.php b/app/Http/Controllers/Api/V1/LogsController.php deleted file mode 100644 index 7f5867c..0000000 --- a/app/Http/Controllers/Api/V1/LogsController.php +++ /dev/null @@ -1,55 +0,0 @@ -query('per_page', 20); - if ($perPage < 1) { - $perPage = 20; - } - $perPage = min($perPage, 100); - - $level = $request->query('level'); - - // Stable ordering: created_at desc, then id desc for deterministic results - $query = Log::orderBy('created_at', 'desc') - ->orderBy('id', 'desc'); - - // Exclude known system/console noise that may appear during test bootstrap - $query->where('message', '!=', 'No active feeds found. Article discovery skipped.'); - - if ($level) { - $query->where('level', $level); - } - - $logs = $query->paginate($perPage); - - return $this->sendResponse([ - 'logs' => $logs->items(), - 'pagination' => [ - 'current_page' => $logs->currentPage(), - // Ensure last_page is at least 1 to satisfy empty dataset expectation - 'last_page' => max(1, $logs->lastPage()), - 'per_page' => $logs->perPage(), - 'total' => $logs->total(), - 'from' => $logs->firstItem(), - 'to' => $logs->lastItem(), - ], - ], 'Logs retrieved successfully.'); - } catch (\Exception $e) { - return $this->sendError('Failed to retrieve logs: ' . $e->getMessage(), [], 500); - } - } -} diff --git a/app/Http/Controllers/Api/V1/OnboardingController.php b/app/Http/Controllers/Api/V1/OnboardingController.php deleted file mode 100644 index f39c83c..0000000 --- a/app/Http/Controllers/Api/V1/OnboardingController.php +++ /dev/null @@ -1,388 +0,0 @@ -exists(); - $hasFeed = Feed::where('is_active', true)->exists(); - $hasChannel = PlatformChannel::where('is_active', true)->exists(); - $hasRoute = Route::where('is_active', true)->exists(); - - // Check if onboarding was explicitly skipped or completed - $onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true'; - $onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists(); - - // User needs onboarding if: - // 1. They haven't completed or skipped onboarding AND - // 2. They don't have all required components - $hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute; - $needsOnboarding = !$onboardingCompleted && !$onboardingSkipped && !$hasAllComponents; - - // Determine current step - $currentStep = null; - if ($needsOnboarding) { - if (!$hasPlatformAccount) { - $currentStep = 'platform'; - } elseif (!$hasFeed) { - $currentStep = 'feed'; - } elseif (!$hasChannel) { - $currentStep = 'channel'; - } elseif (!$hasRoute) { - $currentStep = 'route'; - } - } - - return $this->sendResponse([ - 'needs_onboarding' => $needsOnboarding, - 'current_step' => $currentStep, - 'has_platform_account' => $hasPlatformAccount, - 'has_feed' => $hasFeed, - 'has_channel' => $hasChannel, - 'has_route' => $hasRoute, - 'onboarding_skipped' => $onboardingSkipped, - 'onboarding_completed' => $onboardingCompleted, - 'missing_components' => !$hasAllComponents && $onboardingCompleted, - ], 'Onboarding status retrieved successfully.'); - } - - /** - * Get onboarding options (languages, platform instances) - */ - public function options(): JsonResponse - { - $languages = Language::where('is_active', true) - ->orderBy('name') - ->get(['id', 'short_code', 'name', 'native_name', 'is_active']); - - $platformInstances = PlatformInstance::where('is_active', true) - ->orderBy('name') - ->get(['id', 'platform', 'url', 'name', 'description', 'is_active']); - - // Get existing feeds and channels for route creation - $feeds = Feed::where('is_active', true) - ->orderBy('name') - ->get(['id', 'name', 'url', 'type']); - - $platformChannels = PlatformChannel::where('is_active', true) - ->with(['platformInstance:id,name,url']) - ->orderBy('name') - ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description']); - - // Get feed providers from config - $feedProviders = collect(config('feed.providers', [])) - ->filter(fn($provider) => $provider['is_active']) - ->values(); - - return $this->sendResponse([ - 'languages' => $languages, - 'platform_instances' => $platformInstances, - 'feeds' => $feeds, - 'platform_channels' => $platformChannels, - 'feed_providers' => $feedProviders, - ], 'Onboarding options retrieved successfully.'); - } - - /** - * Create platform account for onboarding - */ - public function createPlatform(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'instance_url' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/', - 'username' => 'required|string|max:255', - 'password' => 'required|string|min:6', - 'platform' => 'required|in:lemmy', - ], [ - 'instance_url.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)' - ]); - - if ($validator->fails()) { - throw new ValidationException($validator); - } - - $validated = $validator->validated(); - - // Normalize the instance URL - prepend https:// if needed - $instanceDomain = $validated['instance_url']; - $fullInstanceUrl = 'https://' . $instanceDomain; - - try { - // Create or get platform instance - $platformInstance = PlatformInstance::firstOrCreate([ - 'url' => $fullInstanceUrl, - 'platform' => $validated['platform'], - ], [ - 'name' => ucfirst($instanceDomain), - 'is_active' => true, - ]); - - // Authenticate with Lemmy API using the full URL - $authResponse = $this->lemmyAuthService->authenticate( - $fullInstanceUrl, - $validated['username'], - $validated['password'] - ); - - // Create platform account with the current schema - $platformAccount = PlatformAccount::create([ - 'platform' => $validated['platform'], - 'instance_url' => $fullInstanceUrl, - 'username' => $validated['username'], - 'password' => $validated['password'], - 'settings' => [ - 'display_name' => $authResponse['person_view']['person']['display_name'] ?? null, - 'description' => $authResponse['person_view']['person']['bio'] ?? null, - 'person_id' => $authResponse['person_view']['person']['id'] ?? null, - 'platform_instance_id' => $platformInstance->id, - 'api_token' => $authResponse['jwt'] ?? null, // Store JWT in settings for now - ], - 'is_active' => true, - 'status' => 'active', - ]); - - return $this->sendResponse( - new PlatformAccountResource($platformAccount), - 'Platform account created successfully.' - ); - - } catch (\App\Exceptions\PlatformAuthException $e) { - // Check if it's a rate limit error - if (str_contains($e->getMessage(), 'Rate limited by')) { - return $this->sendError($e->getMessage(), [], 429); - } - - return $this->sendError('Invalid username or password. Please check your credentials and try again.', [], 422); - } catch (\Exception $e) { - - $message = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; - - // If it's a network/connection issue, provide a more specific message - if (str_contains(strtolower($e->getMessage()), 'connection') || - str_contains(strtolower($e->getMessage()), 'network') || - str_contains(strtolower($e->getMessage()), 'timeout')) { - $message = 'Connection failed. Please check the instance URL and your internet connection.'; - } - - return $this->sendError($message, [], 422); - } - } - - /** - * Create feed for onboarding - */ - public function createFeed(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'name' => 'required|string|max:255', - 'provider' => 'required|in:belga,vrt', - 'language_id' => 'required|exists:languages,id', - 'description' => 'nullable|string|max:1000', - ]); - - if ($validator->fails()) { - throw new ValidationException($validator); - } - - $validated = $validator->validated(); - - // Map provider to preset URL and type as required by onboarding tests - $provider = $validated['provider']; - $url = null; - $type = 'website'; - if ($provider === 'vrt') { - $url = 'https://www.vrt.be/vrtnws/en/'; - } elseif ($provider === 'belga') { - $url = 'https://www.belganewsagency.eu/'; - } - - $feed = Feed::firstOrCreate( - ['url' => $url], - [ - 'name' => $validated['name'], - 'type' => $type, - 'provider' => $provider, - 'language_id' => $validated['language_id'], - 'description' => $validated['description'] ?? null, - 'is_active' => true, - ] - ); - - return $this->sendResponse( - new FeedResource($feed->load('language')), - 'Feed created successfully.' - ); - } - - /** - * Create channel for onboarding - * @throws ValidationException - */ - public function createChannel(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'name' => 'required|string|max:255', - 'platform_instance_id' => 'required|exists:platform_instances,id', - 'language_id' => 'required|exists:languages,id', - 'description' => 'nullable|string|max:1000', - ]); - - if ($validator->fails()) { - throw new ValidationException($validator); - } - - $validated = $validator->validated(); - - // Get the platform instance to check for active accounts - $platformInstance = PlatformInstance::findOrFail($validated['platform_instance_id']); - - // Check if there are active platform accounts for this instance - $activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url) - ->where('is_active', true) - ->get(); - - if ($activeAccounts->isEmpty()) { - return $this->sendError( - 'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.', - [], - 422 - ); - } - - $channel = PlatformChannel::create([ - 'platform_instance_id' => $validated['platform_instance_id'], - 'channel_id' => $validated['name'], // For Lemmy, this is the community name - 'name' => $validated['name'], - 'display_name' => ucfirst($validated['name']), - 'description' => $validated['description'] ?? null, - 'language_id' => $validated['language_id'], - 'is_active' => true, - ]); - - // Automatically attach the first active account to the channel - $firstAccount = $activeAccounts->first(); - $channel->platformAccounts()->attach($firstAccount->id, [ - 'is_active' => true, - 'priority' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return $this->sendResponse( - new PlatformChannelResource($channel->load(['platformInstance', 'language', 'platformAccounts'])), - 'Channel created successfully and linked to platform account.' - ); - } - - /** - * Create route for onboarding - * - * @throws ValidationException - */ - public function createRoute(Request $request): JsonResponse - { - $validator = Validator::make($request->all(), [ - 'feed_id' => 'required|exists:feeds,id', - 'platform_channel_id' => 'required|exists:platform_channels,id', - 'priority' => 'nullable|integer|min:1|max:100', - ]); - - if ($validator->fails()) { - throw new ValidationException($validator); - } - - $validated = $validator->validated(); - - $route = Route::create([ - 'feed_id' => $validated['feed_id'], - 'platform_channel_id' => $validated['platform_channel_id'], - 'priority' => $validated['priority'] ?? 50, - 'is_active' => true, - ]); - - // Trigger article discovery when the first route is created during onboarding - // This ensures articles start being fetched immediately after setup - ArticleDiscoveryJob::dispatch(); - - return $this->sendResponse( - new RouteResource($route->load(['feed', 'platformChannel'])), - 'Route created successfully.' - ); - } - - /** - * Mark onboarding as complete - */ - public function complete(): JsonResponse - { - // Track that onboarding has been completed with a timestamp - Setting::updateOrCreate( - ['key' => 'onboarding_completed'], - ['value' => now()->toIso8601String()] - ); - - return $this->sendResponse( - ['completed' => true], - 'Onboarding completed successfully.' - ); - } - - /** - * Skip onboarding - user can access the app without completing setup - */ - public function skip(): JsonResponse - { - Setting::updateOrCreate( - ['key' => 'onboarding_skipped'], - ['value' => 'true'] - ); - - return $this->sendResponse( - ['skipped' => true], - 'Onboarding skipped successfully.' - ); - } - - /** - * Reset onboarding skip status - force user back to onboarding - */ - public function resetSkip(): JsonResponse - { - Setting::where('key', 'onboarding_skipped')->delete(); - // Also reset completion status to allow re-onboarding - Setting::where('key', 'onboarding_completed')->delete(); - - return $this->sendResponse( - ['reset' => true], - 'Onboarding status reset successfully.' - ); - } -} diff --git a/app/Http/Controllers/Api/V1/PlatformAccountsController.php b/app/Http/Controllers/Api/V1/PlatformAccountsController.php deleted file mode 100644 index f9b3a58..0000000 --- a/app/Http/Controllers/Api/V1/PlatformAccountsController.php +++ /dev/null @@ -1,151 +0,0 @@ -orderBy('created_at', 'desc') - ->get(); - - return $this->sendResponse( - PlatformAccountResource::collection($accounts), - 'Platform accounts retrieved successfully.' - ); - } - - /** - * Store a newly created platform account - */ - public function store(Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'platform' => 'required|in:lemmy,mastodon,reddit', - 'instance_url' => 'required|url', - 'username' => 'required|string|max:255', - 'password' => 'required|string', - 'settings' => 'nullable|array', - ]); - - // Create or find platform instance - $platformEnum = PlatformEnum::from($validated['platform']); - $instance = PlatformInstance::firstOrCreate([ - 'platform' => $platformEnum, - 'url' => $validated['instance_url'], - ], [ - 'name' => parse_url($validated['instance_url'], PHP_URL_HOST), - 'description' => ucfirst($validated['platform']) . ' instance', - 'is_active' => true, - ]); - - $account = PlatformAccount::create($validated); - - // If this is the first account for this platform, make it active - if (!PlatformAccount::where('platform', $validated['platform']) - ->where('is_active', true) - ->exists()) { - $account->setAsActive(); - } - - return $this->sendResponse( - new PlatformAccountResource($account), - 'Platform account created successfully!', - 201 - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to create platform account: ' . $e->getMessage(), [], 500); - } - } - - /** - * Display the specified platform account - */ - public function show(PlatformAccount $platformAccount): JsonResponse - { - return $this->sendResponse( - new PlatformAccountResource($platformAccount), - 'Platform account retrieved successfully.' - ); - } - - /** - * Update the specified platform account - */ - public function update(Request $request, PlatformAccount $platformAccount): JsonResponse - { - try { - $validated = $request->validate([ - 'instance_url' => 'required|url', - 'username' => 'required|string|max:255', - 'password' => 'nullable|string', - 'settings' => 'nullable|array', - ]); - - // Don't update password if not provided - if (empty($validated['password'])) { - unset($validated['password']); - } - - $platformAccount->update($validated); - - return $this->sendResponse( - new PlatformAccountResource($platformAccount->fresh()), - 'Platform account updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update platform account: ' . $e->getMessage(), [], 500); - } - } - - /** - * Remove the specified platform account - */ - public function destroy(PlatformAccount $platformAccount): JsonResponse - { - try { - $platformAccount->delete(); - - return $this->sendResponse( - null, - 'Platform account deleted successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to delete platform account: ' . $e->getMessage(), [], 500); - } - } - - /** - * Set platform account as active - */ - public function setActive(PlatformAccount $platformAccount): JsonResponse - { - try { - $platformAccount->setAsActive(); - - return $this->sendResponse( - new PlatformAccountResource($platformAccount->fresh()), - "Set {$platformAccount->username}@{$platformAccount->instance_url} as active for {$platformAccount->platform->value}!" - ); - } catch (\Exception $e) { - return $this->sendError('Failed to set platform account as active: ' . $e->getMessage(), [], 500); - } - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/PlatformChannelsController.php b/app/Http/Controllers/Api/V1/PlatformChannelsController.php deleted file mode 100644 index 9e7fcfa..0000000 --- a/app/Http/Controllers/Api/V1/PlatformChannelsController.php +++ /dev/null @@ -1,249 +0,0 @@ -orderBy('is_active', 'desc') - ->orderBy('name') - ->get(); - - return $this->sendResponse( - PlatformChannelResource::collection($channels), - 'Platform channels retrieved successfully.' - ); - } - - /** - * Store a newly created platform channel - */ - public function store(Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'platform_instance_id' => 'required|exists:platform_instances,id', - 'channel_id' => 'required|string|max:255', - 'name' => 'required|string|max:255', - 'display_name' => 'nullable|string|max:255', - 'description' => 'nullable|string', - 'is_active' => 'boolean', - ]); - - $validated['is_active'] = $validated['is_active'] ?? true; - - // Get the platform instance to check for active accounts - $platformInstance = \App\Models\PlatformInstance::findOrFail($validated['platform_instance_id']); - - // Check if there are active platform accounts for this instance - $activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url) - ->where('is_active', true) - ->get(); - - if ($activeAccounts->isEmpty()) { - return $this->sendError( - 'Cannot create channel: No active platform accounts found for this instance. Please create a platform account first.', - [], - 422 - ); - } - - $channel = PlatformChannel::create($validated); - - // Automatically attach the first active account to the channel - $firstAccount = $activeAccounts->first(); - $channel->platformAccounts()->attach($firstAccount->id, [ - 'is_active' => true, - 'priority' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return $this->sendResponse( - new PlatformChannelResource($channel->load(['platformInstance', 'platformAccounts'])), - 'Platform channel created successfully and linked to platform account!', - 201 - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to create platform channel: ' . $e->getMessage(), [], 500); - } - } - - /** - * Display the specified platform channel - */ - public function show(PlatformChannel $platformChannel): JsonResponse - { - return $this->sendResponse( - new PlatformChannelResource($platformChannel->load('platformInstance')), - 'Platform channel retrieved successfully.' - ); - } - - /** - * Update the specified platform channel - */ - public function update(Request $request, PlatformChannel $platformChannel): JsonResponse - { - try { - $validated = $request->validate([ - 'name' => 'required|string|max:255', - 'display_name' => 'nullable|string|max:255', - 'description' => 'nullable|string', - 'is_active' => 'boolean', - ]); - - $platformChannel->update($validated); - - return $this->sendResponse( - new PlatformChannelResource($platformChannel->fresh(['platformInstance'])), - 'Platform channel updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update platform channel: ' . $e->getMessage(), [], 500); - } - } - - /** - * Remove the specified platform channel - */ - public function destroy(PlatformChannel $platformChannel): JsonResponse - { - try { - $platformChannel->delete(); - - return $this->sendResponse( - null, - 'Platform channel deleted successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to delete platform channel: ' . $e->getMessage(), [], 500); - } - } - - /** - * Toggle platform channel active status - */ - public function toggle(PlatformChannel $channel): JsonResponse - { - try { - $newStatus = !$channel->is_active; - $channel->update(['is_active' => $newStatus]); - - $status = $newStatus ? 'activated' : 'deactivated'; - - return $this->sendResponse( - new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])), - "Platform channel {$status} successfully!" - ); - } catch (\Exception $e) { - return $this->sendError('Failed to toggle platform channel status: ' . $e->getMessage(), [], 500); - } - } - - /** - * Attach a platform account to a channel - */ - public function attachAccount(PlatformChannel $channel, Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'platform_account_id' => 'required|exists:platform_accounts,id', - 'is_active' => 'boolean', - 'priority' => 'nullable|integer|min:1|max:100', - ]); - - $platformAccount = PlatformAccount::findOrFail($validated['platform_account_id']); - - // Check if account is already attached - if ($channel->platformAccounts()->where('platform_account_id', $platformAccount->id)->exists()) { - return $this->sendError('Platform account is already attached to this channel.', [], 422); - } - - $channel->platformAccounts()->attach($platformAccount->id, [ - 'is_active' => $validated['is_active'] ?? true, - 'priority' => $validated['priority'] ?? 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - return $this->sendResponse( - new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])), - 'Platform account attached to channel successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to attach platform account: ' . $e->getMessage(), [], 500); - } - } - - /** - * Detach a platform account from a channel - */ - public function detachAccount(PlatformChannel $channel, PlatformAccount $account): JsonResponse - { - try { - if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) { - return $this->sendError('Platform account is not attached to this channel.', [], 422); - } - - $channel->platformAccounts()->detach($account->id); - - return $this->sendResponse( - new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])), - 'Platform account detached from channel successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to detach platform account: ' . $e->getMessage(), [], 500); - } - } - - /** - * Update platform account-channel relationship settings - */ - public function updateAccountRelation(PlatformChannel $channel, PlatformAccount $account, Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'is_active' => 'boolean', - 'priority' => 'nullable|integer|min:1|max:100', - ]); - - if (!$channel->platformAccounts()->where('platform_account_id', $account->id)->exists()) { - return $this->sendError('Platform account is not attached to this channel.', [], 422); - } - - $channel->platformAccounts()->updateExistingPivot($account->id, [ - 'is_active' => $validated['is_active'] ?? true, - 'priority' => $validated['priority'] ?? 1, - 'updated_at' => now(), - ]); - - return $this->sendResponse( - new PlatformChannelResource($channel->fresh(['platformInstance', 'platformAccounts'])), - 'Platform account-channel relationship updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update platform account relationship: ' . $e->getMessage(), [], 500); - } - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/RoutingController.php b/app/Http/Controllers/Api/V1/RoutingController.php deleted file mode 100644 index 1693cd8..0000000 --- a/app/Http/Controllers/Api/V1/RoutingController.php +++ /dev/null @@ -1,174 +0,0 @@ -orderBy('is_active', 'desc') - ->orderBy('priority', 'asc') - ->get(); - - return $this->sendResponse( - RouteResource::collection($routes), - 'Routing configurations retrieved successfully.' - ); - } - - /** - * Store a newly created routing configuration - */ - public function store(Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'feed_id' => 'required|exists:feeds,id', - 'platform_channel_id' => 'required|exists:platform_channels,id', - 'is_active' => 'boolean', - 'priority' => 'nullable|integer|min:0', - ]); - - $validated['is_active'] = $validated['is_active'] ?? true; - $validated['priority'] = $validated['priority'] ?? 0; - - $route = Route::create($validated); - - return $this->sendResponse( - new RouteResource($route->load(['feed', 'platformChannel', 'keywords'])), - 'Routing configuration created successfully!', - 201 - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to create routing configuration: ' . $e->getMessage(), [], 500); - } - } - - /** - * Display the specified routing configuration - */ - public function show(Feed $feed, PlatformChannel $channel): JsonResponse - { - $route = $this->findRoute($feed, $channel); - - if (!$route) { - return $this->sendNotFound('Routing configuration not found.'); - } - - $route->load(['feed', 'platformChannel', 'keywords']); - - return $this->sendResponse( - new RouteResource($route), - 'Routing configuration retrieved successfully.' - ); - } - - /** - * Update the specified routing configuration - */ - public function update(Request $request, Feed $feed, PlatformChannel $channel): JsonResponse - { - try { - $route = $this->findRoute($feed, $channel); - - if (!$route) { - return $this->sendNotFound('Routing configuration not found.'); - } - - $validated = $request->validate([ - 'is_active' => 'boolean', - 'priority' => 'nullable|integer|min:0', - ]); - - Route::where('feed_id', $feed->id) - ->where('platform_channel_id', $channel->id) - ->update($validated); - - return $this->sendResponse( - new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])), - 'Routing configuration updated successfully!' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update routing configuration: ' . $e->getMessage(), [], 500); - } - } - - /** - * Remove the specified routing configuration - */ - public function destroy(Feed $feed, PlatformChannel $channel): JsonResponse - { - try { - $route = $this->findRoute($feed, $channel); - - if (!$route) { - return $this->sendNotFound('Routing configuration not found.'); - } - - Route::where('feed_id', $feed->id) - ->where('platform_channel_id', $channel->id) - ->delete(); - - return $this->sendResponse( - null, - 'Routing configuration deleted successfully!' - ); - } catch (\Exception $e) { - return $this->sendError('Failed to delete routing configuration: ' . $e->getMessage(), [], 500); - } - } - - /** - * Toggle routing configuration active status - */ - public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse - { - try { - $route = $this->findRoute($feed, $channel); - - if (!$route) { - return $this->sendNotFound('Routing configuration not found.'); - } - - $newStatus = !$route->is_active; - Route::where('feed_id', $feed->id) - ->where('platform_channel_id', $channel->id) - ->update(['is_active' => $newStatus]); - - $status = $newStatus ? 'activated' : 'deactivated'; - - return $this->sendResponse( - new RouteResource($route->fresh(['feed', 'platformChannel', 'keywords'])), - "Routing configuration {$status} successfully!" - ); - } catch (\Exception $e) { - return $this->sendError('Failed to toggle routing configuration status: ' . $e->getMessage(), [], 500); - } - } - - /** - * Find a route by feed and channel - */ - private function findRoute(Feed $feed, PlatformChannel $channel): ?Route - { - return Route::where('feed_id', $feed->id) - ->where('platform_channel_id', $channel->id) - ->first(); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/SettingsController.php b/app/Http/Controllers/Api/V1/SettingsController.php deleted file mode 100644 index 6e809ec..0000000 --- a/app/Http/Controllers/Api/V1/SettingsController.php +++ /dev/null @@ -1,63 +0,0 @@ - Setting::isArticleProcessingEnabled(), - 'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(), - ]; - - return $this->sendResponse($settings, 'Settings retrieved successfully.'); - } catch (\Exception $e) { - return $this->sendError('Failed to retrieve settings: ' . $e->getMessage(), [], 500); - } - } - - /** - * Update settings - */ - public function update(Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'article_processing_enabled' => 'boolean', - 'enable_publishing_approvals' => 'boolean', - ]); - - if (isset($validated['article_processing_enabled'])) { - Setting::setArticleProcessingEnabled($validated['article_processing_enabled']); - } - - if (isset($validated['enable_publishing_approvals'])) { - Setting::setPublishingApprovalsEnabled($validated['enable_publishing_approvals']); - } - - $updatedSettings = [ - 'article_processing_enabled' => Setting::isArticleProcessingEnabled(), - 'publishing_approvals_enabled' => Setting::isPublishingApprovalsEnabled(), - ]; - - return $this->sendResponse( - $updatedSettings, - 'Settings updated successfully.' - ); - } catch (ValidationException $e) { - return $this->sendValidationError($e->errors()); - } catch (\Exception $e) { - return $this->sendError('Failed to update settings: ' . $e->getMessage(), [], 500); - } - } -} \ No newline at end of file diff --git a/app/Http/Controllers/ArticlesController.php b/app/Http/Controllers/ArticlesController.php new file mode 100644 index 0000000..a93bc4c --- /dev/null +++ b/app/Http/Controllers/ArticlesController.php @@ -0,0 +1,17 @@ +sortByDesc('created_at'); + + return view('pages.articles.index', compact('articles')); + } +} diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 613bcd9..b4a48d9 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -7,16 +7,21 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\View\View; +use Illuminate\Support\Facades\Route; +use Inertia\Inertia; +use Inertia\Response; class AuthenticatedSessionController extends Controller { /** - * Display the login view. + * Show the login page. */ - public function create(): View + public function create(Request $request): Response { - return view('auth.login'); + return Inertia::render('auth/login', [ + 'canResetPassword' => Route::has('password.request'), + 'status' => $request->session()->get('status'), + ]); } /** @@ -39,7 +44,6 @@ public function destroy(Request $request): RedirectResponse Auth::guard('web')->logout(); $request->session()->invalidate(); - $request->session()->regenerateToken(); return redirect('/'); diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php index 712394a..c729706 100644 --- a/app/Http/Controllers/Auth/ConfirmablePasswordController.php +++ b/app/Http/Controllers/Auth/ConfirmablePasswordController.php @@ -7,16 +7,17 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\ValidationException; -use Illuminate\View\View; +use Inertia\Inertia; +use Inertia\Response; class ConfirmablePasswordController extends Controller { /** - * Show the confirm password view. + * Show the confirm password page. */ - public function show(): View + public function show(): Response { - return view('auth.confirm-password'); + return Inertia::render('auth/confirm-password'); } /** diff --git a/app/Http/Controllers/Auth/EmailVerificationPromptController.php b/app/Http/Controllers/Auth/EmailVerificationPromptController.php index ee3cb6f..672f7cf 100644 --- a/app/Http/Controllers/Auth/EmailVerificationPromptController.php +++ b/app/Http/Controllers/Auth/EmailVerificationPromptController.php @@ -5,17 +5,18 @@ use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; -use Illuminate\View\View; +use Inertia\Inertia; +use Inertia\Response; class EmailVerificationPromptController extends Controller { /** - * Display the email verification prompt. + * Show the email verification prompt page. */ - public function __invoke(Request $request): RedirectResponse|View + public function __invoke(Request $request): Response|RedirectResponse { return $request->user()->hasVerifiedEmail() ? redirect()->intended(route('dashboard', absolute: false)) - : view('auth.verify-email'); + : Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]); } } diff --git a/app/Http/Controllers/Auth/NewPasswordController.php b/app/Http/Controllers/Auth/NewPasswordController.php index e8368bd..0b4c6cb 100644 --- a/app/Http/Controllers/Auth/NewPasswordController.php +++ b/app/Http/Controllers/Auth/NewPasswordController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use App\Models\User; use Illuminate\Auth\Events\PasswordReset; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -11,16 +10,21 @@ use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; use Illuminate\Validation\Rules; -use Illuminate\View\View; +use Illuminate\Validation\ValidationException; +use Inertia\Inertia; +use Inertia\Response; class NewPasswordController extends Controller { /** - * Display the password reset view. + * Show the password reset page. */ - public function create(Request $request): View + public function create(Request $request): Response { - return view('auth.reset-password', ['request' => $request]); + return Inertia::render('auth/reset-password', [ + 'email' => $request->email, + 'token' => $request->route('token'), + ]); } /** @@ -31,8 +35,8 @@ public function create(Request $request): View public function store(Request $request): RedirectResponse { $request->validate([ - 'token' => ['required'], - 'email' => ['required', 'email'], + 'token' => 'required', + 'email' => 'required|email', 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); @@ -41,7 +45,7 @@ public function store(Request $request): RedirectResponse // database. Otherwise we will parse the error and return the response. $status = Password::reset( $request->only('email', 'password', 'password_confirmation', 'token'), - function (User $user) use ($request) { + function ($user) use ($request) { $user->forceFill([ 'password' => Hash::make($request->password), 'remember_token' => Str::random(60), @@ -54,9 +58,12 @@ function (User $user) use ($request) { // If the password was successfully reset, we will redirect the user back to // the application's home authenticated view. If there is an error we can // redirect them back to where they came from with their error message. - return $status == Password::PASSWORD_RESET - ? redirect()->route('login')->with('status', __($status)) - : back()->withInput($request->only('email')) - ->withErrors(['email' => __($status)]); + if ($status == Password::PasswordReset) { + return to_route('login')->with('status', __($status)); + } + + throw ValidationException::withMessages([ + 'email' => [__($status)], + ]); } } diff --git a/app/Http/Controllers/Auth/PasswordResetLinkController.php b/app/Http/Controllers/Auth/PasswordResetLinkController.php index bf1ebfa..9fcfe49 100644 --- a/app/Http/Controllers/Auth/PasswordResetLinkController.php +++ b/app/Http/Controllers/Auth/PasswordResetLinkController.php @@ -6,16 +6,19 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Password; -use Illuminate\View\View; +use Inertia\Inertia; +use Inertia\Response; class PasswordResetLinkController extends Controller { /** - * Display the password reset link request view. + * Show the password reset link request page. */ - public function create(): View + public function create(Request $request): Response { - return view('auth.forgot-password'); + return Inertia::render('auth/forgot-password', [ + 'status' => $request->session()->get('status'), + ]); } /** @@ -26,19 +29,13 @@ public function create(): View public function store(Request $request): RedirectResponse { $request->validate([ - 'email' => ['required', 'email'], + 'email' => 'required|email', ]); - // We will send the password reset link to this user. Once we have attempted - // to send the link, we will examine the response then see the message we - // need to show to the user. Finally, we'll send out a proper response. - $status = Password::sendResetLink( + Password::sendResetLink( $request->only('email') ); - return $status == Password::RESET_LINK_SENT - ? back()->with('status', __($status)) - : back()->withInput($request->only('email')) - ->withErrors(['email' => __($status)]); + return back()->with('status', __('A reset link will be sent if the account exists.')); } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 0739e2e..08caeef 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -10,16 +10,17 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rules; -use Illuminate\View\View; +use Inertia\Inertia; +use Inertia\Response; class RegisteredUserController extends Controller { /** - * Display the registration view. + * Show the registration page. */ - public function create(): View + public function create(): Response { - return view('auth.register'); + return Inertia::render('auth/register'); } /** @@ -30,8 +31,8 @@ public function create(): View public function store(Request $request): RedirectResponse { $request->validate([ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], + 'name' => 'required|string|max:255', + 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, 'password' => ['required', 'confirmed', Rules\Password::defaults()], ]); @@ -45,6 +46,6 @@ public function store(Request $request): RedirectResponse Auth::login($user); - return redirect(route('dashboard', absolute: false)); + return redirect()->intended(route('dashboard', absolute: false)); } } diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php index 784765e..a300bfa 100644 --- a/app/Http/Controllers/Auth/VerifyEmailController.php +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -19,7 +19,10 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse } if ($request->user()->markEmailAsVerified()) { - event(new Verified($request->user())); + /** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */ + $user = $request->user(); + + event(new Verified($user)); } return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); diff --git a/app/Http/Controllers/LogsController.php b/app/Http/Controllers/LogsController.php new file mode 100644 index 0000000..0522f00 --- /dev/null +++ b/app/Http/Controllers/LogsController.php @@ -0,0 +1,17 @@ +sortByDesc('created_at'); + + return view('pages.logs.index', compact('logs')); + } +} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Settings/PasswordController.php similarity index 66% rename from app/Http/Controllers/Auth/PasswordController.php rename to app/Http/Controllers/Settings/PasswordController.php index 6916409..f8d19b9 100644 --- a/app/Http/Controllers/Auth/PasswordController.php +++ b/app/Http/Controllers/Settings/PasswordController.php @@ -1,21 +1,31 @@ validateWithBag('updatePassword', [ + $validated = $request->validate([ 'current_password' => ['required', 'current_password'], 'password' => ['required', Password::defaults(), 'confirmed'], ]); @@ -24,6 +34,6 @@ public function update(Request $request): RedirectResponse 'password' => Hash::make($validated['password']), ]); - return back()->with('status', 'password-updated'); + return back(); } } diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php similarity index 57% rename from app/Http/Controllers/ProfileController.php rename to app/Http/Controllers/Settings/ProfileController.php index a48eb8d..a6cb7e1 100644 --- a/app/Http/Controllers/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -1,28 +1,31 @@ $request->user(), + return Inertia::render('settings/profile', [ + 'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail, + 'status' => $request->session()->get('status'), ]); } /** - * Update the user's profile information. + * Update the user's profile settings. */ public function update(ProfileUpdateRequest $request): RedirectResponse { @@ -34,7 +37,7 @@ public function update(ProfileUpdateRequest $request): RedirectResponse $request->user()->save(); - return Redirect::route('profile.edit')->with('status', 'profile-updated'); + return to_route('profile.edit'); } /** @@ -42,7 +45,7 @@ public function update(ProfileUpdateRequest $request): RedirectResponse */ public function destroy(Request $request): RedirectResponse { - $request->validateWithBag('userDeletion', [ + $request->validate([ 'password' => ['required', 'current_password'], ]); @@ -55,6 +58,6 @@ public function destroy(Request $request): RedirectResponse $request->session()->invalidate(); $request->session()->regenerateToken(); - return Redirect::to('/'); + return redirect('/'); } } diff --git a/app/Http/Middleware/EnsureOnboardingComplete.php b/app/Http/Middleware/EnsureOnboardingComplete.php deleted file mode 100644 index b57d117..0000000 --- a/app/Http/Middleware/EnsureOnboardingComplete.php +++ /dev/null @@ -1,29 +0,0 @@ -onboardingService->needsOnboarding()) { - return redirect()->route('onboarding'); - } - - return $next($request); - } -} \ No newline at end of file diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 303786e..3af9fd4 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -2,8 +2,10 @@ namespace App\Http\Middleware; +use Illuminate\Foundation\Inspiring; use Illuminate\Http\Request; use Inertia\Middleware; +use Tighten\Ziggy\Ziggy; class HandleInertiaRequests extends Middleware { @@ -35,8 +37,20 @@ public function version(Request $request): ?string */ public function share(Request $request): array { - return array_merge(parent::share($request), [ - // - ]); + [$message, $author] = str(Inspiring::quotes()->random())->explode('-'); + + return [ + ...parent::share($request), + 'name' => config('app.name'), + 'quote' => ['message' => trim($message), 'author' => trim($author)], + 'auth' => [ + 'user' => $request->user(), + ], + 'ziggy' => fn (): array => [ + ...(new Ziggy)->toArray(), + 'location' => $request->url(), + ], + 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + ]; } -} \ No newline at end of file +} diff --git a/app/Http/Middleware/RedirectIfOnboardingComplete.php b/app/Http/Middleware/RedirectIfOnboardingComplete.php deleted file mode 100644 index 190bd77..0000000 --- a/app/Http/Middleware/RedirectIfOnboardingComplete.php +++ /dev/null @@ -1,29 +0,0 @@ -onboardingService->needsOnboarding()) { - return redirect()->route('dashboard'); - } - - return $next($request); - } -} \ No newline at end of file diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index 2574642..d236bf9 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -45,7 +45,7 @@ public function authenticate(): void RateLimiter::hit($this->throttleKey()); throw ValidationException::withMessages([ - 'email' => trans('auth.failed'), + 'email' => __('auth.failed'), ]); } @@ -68,7 +68,7 @@ public function ensureIsNotRateLimited(): void $seconds = RateLimiter::availableIn($this->throttleKey()); throw ValidationException::withMessages([ - 'email' => trans('auth.throttle', [ + 'email' => __('auth.throttle', [ 'seconds' => $seconds, 'minutes' => ceil($seconds / 60), ]), diff --git a/app/Http/Requests/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php similarity index 79% rename from app/Http/Requests/ProfileUpdateRequest.php rename to app/Http/Requests/Settings/ProfileUpdateRequest.php index 3622a8f..64cf26b 100644 --- a/app/Http/Requests/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -1,8 +1,9 @@ |string> + * @return array|string> */ public function rules(): array { return [ 'name' => ['required', 'string', 'max:255'], + 'email' => [ 'required', 'string', diff --git a/app/Http/Requests/StoreFeedRequest.php b/app/Http/Requests/StoreFeedRequest.php deleted file mode 100644 index a49570c..0000000 --- a/app/Http/Requests/StoreFeedRequest.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'name' => 'required|string|max:255', - 'provider' => 'required|in:vrt,belga', - 'language_id' => 'required|exists:languages,id', - 'description' => 'nullable|string', - 'is_active' => 'boolean' - ]; - } -} \ No newline at end of file diff --git a/app/Http/Requests/UpdateFeedRequest.php b/app/Http/Requests/UpdateFeedRequest.php deleted file mode 100644 index f6ad39c..0000000 --- a/app/Http/Requests/UpdateFeedRequest.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - public function rules(): array - { - return [ - 'name' => 'required|string|max:255', - 'url' => 'required|url|unique:feeds,url,' . ($this->route('feed') instanceof Feed ? (string)$this->route('feed')->id : (string)$this->route('feed')), - 'type' => 'required|in:website,rss', - 'language_id' => 'required|exists:languages,id', - 'description' => 'nullable|string', - 'is_active' => 'boolean' - ]; - } -} \ No newline at end of file diff --git a/app/Http/Resources/ArticlePublicationResource.php b/app/Http/Resources/ArticlePublicationResource.php deleted file mode 100644 index 11a2a9b..0000000 --- a/app/Http/Resources/ArticlePublicationResource.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'article_id' => $this->article_id, - 'status' => $this->status, - 'published_at' => $this->published_at?->toISOString(), - 'created_at' => $this->created_at->toISOString(), - 'updated_at' => $this->updated_at->toISOString(), - ]; - } -} \ No newline at end of file diff --git a/app/Http/Resources/ArticleResource.php b/app/Http/Resources/ArticleResource.php deleted file mode 100644 index 506cf14..0000000 --- a/app/Http/Resources/ArticleResource.php +++ /dev/null @@ -1,35 +0,0 @@ - $this->id, - 'feed_id' => $this->feed_id, - 'url' => $this->url, - 'title' => $this->title, - 'description' => $this->description, - 'is_valid' => $this->is_valid, - 'is_duplicate' => $this->is_duplicate, - 'approval_status' => $this->approval_status, - 'approved_at' => $this->approved_at?->toISOString(), - 'approved_by' => $this->approved_by, - 'fetched_at' => $this->fetched_at?->toISOString(), - 'validated_at' => $this->validated_at?->toISOString(), - 'is_published' => $this->relationLoaded('articlePublication') && $this->articlePublication !== null, - 'created_at' => $this->created_at->toISOString(), - 'updated_at' => $this->updated_at->toISOString(), - 'feed' => new FeedResource($this->whenLoaded('feed')), - 'article_publication' => new ArticlePublicationResource($this->whenLoaded('articlePublication')), - ]; - } -} diff --git a/app/Http/Resources/FeedResource.php b/app/Http/Resources/FeedResource.php deleted file mode 100644 index c220d40..0000000 --- a/app/Http/Resources/FeedResource.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'name' => $this->name, - 'url' => $this->url, - 'type' => $this->type, - 'provider' => $this->provider, - 'language_id' => $this->language_id, - 'is_active' => $this->is_active, - 'description' => $this->description, - 'created_at' => $this->created_at->toISOString(), - 'updated_at' => $this->updated_at->toISOString(), - 'articles_count' => $this->when( - $request->routeIs('api.feeds.*') && isset($this->articles_count), - $this->articles_count - ), - ]; - } -} \ No newline at end of file diff --git a/app/Http/Resources/PlatformAccountResource.php b/app/Http/Resources/PlatformAccountResource.php deleted file mode 100644 index a32f771..0000000 --- a/app/Http/Resources/PlatformAccountResource.php +++ /dev/null @@ -1,31 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'platform' => $this->platform->value, - 'instance_url' => $this->instance_url, - 'username' => $this->username, - 'settings' => $this->settings, - 'is_active' => $this->is_active, - 'last_tested_at' => $this->last_tested_at?->toISOString(), - 'status' => $this->status, - 'created_at' => $this->created_at->toISOString(), - 'updated_at' => $this->updated_at->toISOString(), - 'channels' => PlatformChannelResource::collection($this->whenLoaded('channels')), - ]; - } -} \ No newline at end of file diff --git a/app/Http/Resources/PlatformChannelResource.php b/app/Http/Resources/PlatformChannelResource.php deleted file mode 100644 index cdebaa4..0000000 --- a/app/Http/Resources/PlatformChannelResource.php +++ /dev/null @@ -1,33 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'platform_instance_id' => $this->platform_instance_id, - 'channel_id' => $this->channel_id, - 'name' => $this->name, - 'display_name' => $this->display_name, - 'description' => $this->description, - 'language_id' => $this->language_id, - 'is_active' => $this->is_active, - 'created_at' => $this->created_at->toISOString(), - 'updated_at' => $this->updated_at->toISOString(), - 'platform_instance' => new PlatformInstanceResource($this->whenLoaded('platformInstance')), - 'platform_accounts' => PlatformAccountResource::collection($this->whenLoaded('platformAccounts')), - 'routes' => RouteResource::collection($this->whenLoaded('routes')), - ]; - } -} \ No newline at end of file diff --git a/app/Http/Resources/PlatformInstanceResource.php b/app/Http/Resources/PlatformInstanceResource.php deleted file mode 100644 index 3f708e4..0000000 --- a/app/Http/Resources/PlatformInstanceResource.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'name' => $this->name, - 'url' => $this->url, - 'description' => $this->description, - 'is_active' => $this->is_active, - 'created_at' => $this->created_at->toISOString(), - 'updated_at' => $this->updated_at->toISOString(), - ]; - } -} \ No newline at end of file diff --git a/app/Http/Resources/RouteResource.php b/app/Http/Resources/RouteResource.php deleted file mode 100644 index 6a02c8d..0000000 --- a/app/Http/Resources/RouteResource.php +++ /dev/null @@ -1,38 +0,0 @@ - - */ - public function toArray(Request $request): array - { - return [ - 'id' => $this->id, - 'feed_id' => $this->feed_id, - 'platform_channel_id' => $this->platform_channel_id, - 'is_active' => $this->is_active, - 'priority' => $this->priority, - 'created_at' => $this->created_at->toISOString(), - 'updated_at' => $this->updated_at->toISOString(), - 'feed' => new FeedResource($this->whenLoaded('feed')), - 'platform_channel' => new PlatformChannelResource($this->whenLoaded('platformChannel')), - 'keywords' => $this->whenLoaded('keywords', function () { - return $this->keywords->map(function ($keyword) { - return [ - 'id' => $keyword->id, - 'keyword' => $keyword->keyword, - 'is_active' => $keyword->is_active, - ]; - }); - }), - ]; - } -} \ No newline at end of file diff --git a/app/Jobs/ArticleDiscoveryForFeedJob.php b/app/Jobs/ArticleDiscoveryForFeedJob.php deleted file mode 100644 index db494a6..0000000 --- a/app/Jobs/ArticleDiscoveryForFeedJob.php +++ /dev/null @@ -1,63 +0,0 @@ -onQueue('feed-discovery'); - } - - public function handle(LogSaver $logSaver, ArticleFetcher $articleFetcher): void - { - $logSaver->info('Starting feed article fetch', null, [ - 'feed_id' => $this->feed->id, - 'feed_name' => $this->feed->name, - 'feed_url' => $this->feed->url - ]); - - $articles = $articleFetcher->getArticlesFromFeed($this->feed); - - $logSaver->info('Feed article fetch completed', null, [ - 'feed_id' => $this->feed->id, - 'feed_name' => $this->feed->name, - 'articles_count' => $articles->count() - ]); - - $this->feed->update(['last_fetched_at' => now()]); - } - - public static function dispatchForAllActiveFeeds(): void - { - $logSaver = app(LogSaver::class); - - Feed::where('is_active', true) - ->get() - ->each(function (Feed $feed, $index) use ($logSaver) { - // Space jobs apart to avoid overwhelming feeds - $delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES; - - self::dispatch($feed) - ->delay(now()->addMinutes($delayMinutes)) - ->onQueue('feed-discovery'); - - $logSaver->info('Dispatched feed discovery job', null, [ - 'feed_id' => $feed->id, - 'feed_name' => $feed->name, - 'delay_minutes' => $delayMinutes - ]); - }); - } -} diff --git a/app/Jobs/ArticleDiscoveryJob.php b/app/Jobs/ArticleDiscoveryJob.php deleted file mode 100644 index c89894e..0000000 --- a/app/Jobs/ArticleDiscoveryJob.php +++ /dev/null @@ -1,33 +0,0 @@ -onQueue('feed-discovery'); - } - - public function handle(LogSaver $logSaver): void - { - if (!Setting::isArticleProcessingEnabled()) { - $logSaver->info('Article processing is disabled. Article discovery skipped.'); - - return; - } - - $logSaver->info('Starting article discovery for all active feeds'); - - ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - - $logSaver->info('Article discovery jobs dispatched for all active feeds'); - } -} diff --git a/app/Jobs/PublishNextArticleJob.php b/app/Jobs/PublishNextArticleJob.php deleted file mode 100644 index 4e5fc0d..0000000 --- a/app/Jobs/PublishNextArticleJob.php +++ /dev/null @@ -1,69 +0,0 @@ -onQueue('publishing'); - } - - /** - * Execute the job. - * @throws PublishException - */ - public function handle(ArticleFetcher $articleFetcher, ArticlePublishingService $publishingService): void - { - // Get the oldest approved article that hasn't been published yet - $article = Article::where('approval_status', 'approved') - ->whereDoesntHave('articlePublication') - ->oldest('created_at') - ->first(); - - if (! $article) { - return; - } - - logger()->info('Publishing next article from scheduled job', [ - 'article_id' => $article->id, - 'title' => $article->title, - 'url' => $article->url, - 'created_at' => $article->created_at - ]); - - // Fetch article data - $extractedData = $articleFetcher->fetchArticleData($article); - - try { - $publishingService->publishToRoutedChannels($article, $extractedData); - - logger()->info('Successfully published article', [ - 'article_id' => $article->id, - 'title' => $article->title - ]); - } catch (PublishException $e) { - logger()->error('Failed to publish article', [ - 'article_id' => $article->id, - 'error' => $e->getMessage() - ]); - - throw $e; - } - } -} \ No newline at end of file diff --git a/app/Jobs/PublishToLemmyJob.php b/app/Jobs/PublishToLemmyJob.php new file mode 100644 index 0000000..08341d9 --- /dev/null +++ b/app/Jobs/PublishToLemmyJob.php @@ -0,0 +1,50 @@ +onQueue('lemmy-posts'); + } + + public function handle(): void + { + if ($this->article->articlePublication !== null) { + logger()->info('Article already published, skipping', [ + 'article_id' => $this->article->id + ]); + + return; + } + + $extractedData = ArticleFetcher::fetchArticleData($this->article); + + logger()->info('Publishing article to Lemmy', [ + 'article_id' => $this->article->id, + 'url' => $this->article->url + ]); + + try { + LemmyPublisher::fromConfig()->publish($this->article, $extractedData); + + logger()->info('Article published successfully', [ + 'article_id' => $this->article->id + ]); + + } catch (PublishException $e) { + $this->fail($e); + } + } +} diff --git a/app/Jobs/SyncChannelPostsJob.php b/app/Jobs/SyncChannelPostsJob.php index 2f5bee6..66d1316 100644 --- a/app/Jobs/SyncChannelPostsJob.php +++ b/app/Jobs/SyncChannelPostsJob.php @@ -4,111 +4,73 @@ use App\Enums\PlatformEnum; use App\Exceptions\PlatformAuthException; -use App\Models\PlatformAccount; -use App\Models\PlatformChannel; use App\Modules\Lemmy\Services\LemmyApiService; -use App\Services\Log\LogSaver; use Exception; -use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Cache; -class SyncChannelPostsJob implements ShouldQueue, ShouldBeUnique +class SyncChannelPostsJob implements ShouldQueue { use Queueable; public function __construct( - private readonly PlatformChannel $channel + private readonly PlatformEnum $platform, + private readonly string $channelId, + private readonly string $channelName ) { - $this->onQueue('sync'); + $this->onQueue('lemmy-posts'); } - public static function dispatchForAllActiveChannels(): void + public function handle(): void { - $logSaver = app(LogSaver::class); - - PlatformChannel::with(['platformInstance', 'platformAccounts']) - ->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY)) - ->whereHas('platformAccounts', fn ($query) => $query->where('platform_accounts.is_active', true)) - ->where('platform_channels.is_active', true) - ->get() - ->each(function (PlatformChannel $channel) use ($logSaver) { - self::dispatch($channel); - $logSaver->info('Dispatched sync job for channel', $channel); - }); + if ($this->platform === PlatformEnum::LEMMY) { + $this->syncLemmyChannelPosts(); + } } - public function handle(LogSaver $logSaver): void - { - $logSaver->info('Starting channel posts sync job', $this->channel); - - match ($this->channel->platformInstance->platform) { - PlatformEnum::LEMMY => $this->syncLemmyChannelPosts($logSaver), - }; - - $logSaver->info('Channel posts sync job completed', $this->channel); - } - - /** - * @throws PlatformAuthException - */ - private function syncLemmyChannelPosts(LogSaver $logSaver): void + private function syncLemmyChannelPosts(): void { try { - /** @var Collection $accounts */ - $accounts = $this->channel->activePlatformAccounts()->get(); - $account = $accounts->first(); + $api = new LemmyApiService(config('lemmy.instance')); + $token = $this->getAuthToken($api); - if (! $account) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'No active account found for channel'); - } + $api->syncChannelPosts($token, (int) $this->channelId, $this->channelName); - $api = new LemmyApiService($this->channel->platformInstance->url); - $token = $this->getAuthToken($api, $account); - - $platformChannelId = $this->channel->channel_id - ? $this->channel->channel_id - : $api->getCommunityId($this->channel->name, $token); - - $api->syncChannelPosts($token, $platformChannelId, $this->channel->name); - - $logSaver->info('Channel posts synced successfully', $this->channel); - - } catch (Exception $e) { - $logSaver->error('Failed to sync channel posts', $this->channel, [ - 'error' => $e->getMessage() + logger()->info('Channel posts synced successfully', [ + 'platform' => $this->platform->value, + 'channel_id' => $this->channelId, + 'channel_name' => $this->channelName ]); + } catch (Exception $e) { + logger()->error('Failed to sync channel posts', [ + 'platform' => $this->platform->value, + 'channel_id' => $this->channelId, + 'error' => $e->getMessage() + ]); + throw $e; } } - /** - * @throws PlatformAuthException - */ - private function getAuthToken(LemmyApiService $api, PlatformAccount $account): string + private function getAuthToken(LemmyApiService $api): string { - $cacheKey = "lemmy_jwt_token_{$account->id}"; - $cachedToken = Cache::get($cacheKey); + return Cache::remember('lemmy_jwt_token', 3600, function () use ($api) { + $username = config('lemmy.username'); + $password = config('lemmy.password'); - if ($cachedToken) { - return $cachedToken; - } + if (!$username || !$password) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials'); + } - if (!$account->username || !$account->password) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account'); - } + $token = $api->login($username, $password); + + if (!$token) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed'); + } - $token = $api->login($account->username, $account->password); - - if (!$token) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account'); - } - - Cache::put($cacheKey, $token, 3600); - - return $token; + return $token; + }); } } diff --git a/app/Listeners/LogExceptionToDatabase.php b/app/Listeners/LogExceptionToDatabase.php index 3ccfa07..06eb5a7 100644 --- a/app/Listeners/LogExceptionToDatabase.php +++ b/app/Listeners/LogExceptionToDatabase.php @@ -5,34 +5,27 @@ use App\Events\ExceptionLogged; use App\Events\ExceptionOccurred; use App\Models\Log; -class LogExceptionToDatabase +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\InteractsWithQueue; + +class LogExceptionToDatabase implements ShouldQueue { + use InteractsWithQueue; public function handle(ExceptionOccurred $event): void { - // Truncate the message to prevent database errors - $message = strlen($event->message) > 255 - ? substr($event->message, 0, 252) . '...' - : $event->message; + $log = Log::create([ + 'level' => $event->level, + 'message' => $event->message, + 'context' => [ + 'exception_class' => get_class($event->exception), + 'file' => $event->exception->getFile(), + 'line' => $event->exception->getLine(), + 'trace' => $event->exception->getTraceAsString(), + ...$event->context + ] + ]); - try { - $log = Log::create([ - 'level' => $event->level, - 'message' => $message, - 'context' => [ - 'exception_class' => get_class($event->exception), - 'file' => $event->exception->getFile(), - 'line' => $event->exception->getLine(), - 'trace' => $event->exception->getTraceAsString(), - ...$event->context - ] - ]); - - ExceptionLogged::dispatch($log); - } catch (\Exception $e) { - // Prevent infinite recursion by not logging this exception - // Optionally log to file or other non-database destination - error_log("Failed to log exception to database: " . $e->getMessage()); - } + ExceptionLogged::dispatch($log); } } diff --git a/app/Listeners/PublishArticle.php b/app/Listeners/PublishArticle.php new file mode 100644 index 0000000..86d703f --- /dev/null +++ b/app/Listeners/PublishArticle.php @@ -0,0 +1,25 @@ +article; + + logger()->info('Article queued for publishing to Lemmy', [ + 'article_id' => $article->id, + 'url' => $article->url + ]); + + PublishToLemmyJob::dispatch($article); + } +} diff --git a/app/Listeners/ValidateArticle.php b/app/Listeners/ValidateArticle.php new file mode 100644 index 0000000..5cc03a9 --- /dev/null +++ b/app/Listeners/ValidateArticle.php @@ -0,0 +1,28 @@ +article; + + if (! is_null($article->validated_at)) { + return; + } + + $article = ValidationService::validate($article); + + if ($article->isValid()) { + event(new ArticleReadyToPublish($event->article)); + } + } +} diff --git a/app/Listeners/ValidateArticleListener.php b/app/Listeners/ValidateArticleListener.php deleted file mode 100644 index 9c5ebcf..0000000 --- a/app/Listeners/ValidateArticleListener.php +++ /dev/null @@ -1,49 +0,0 @@ -article; - - if (! is_null($article->validated_at)) { - return; - } - - // Skip if already has publication (prevents duplicate processing) - if ($article->articlePublication()->exists()) { - return; - } - - $article = $validationService->validate($article); - - if ($article->isValid()) { - // Double-check publication doesn't exist (race condition protection) - if ($article->articlePublication()->exists()) { - return; - } - - // Check if approval system is enabled - if (Setting::isPublishingApprovalsEnabled()) { - // If approvals are enabled, only proceed if article is approved - if ($article->isApproved()) { - event(new ArticleApproved($article)); - } - // If not approved, article will wait for manual approval - } else { - // If approvals are disabled, proceed with publishing - event(new ArticleApproved($article)); - } - } - } -} diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php deleted file mode 100644 index 4cce6b3..0000000 --- a/app/Livewire/Articles.php +++ /dev/null @@ -1,61 +0,0 @@ -approve(); - - $this->dispatch('article-updated'); - } - - public function reject(int $articleId): void - { - $article = Article::findOrFail($articleId); - $article->reject(); - - $this->dispatch('article-updated'); - } - - public function refresh(): void - { - $this->isRefreshing = true; - - ArticleDiscoveryJob::dispatch(); - - // Reset after 10 seconds - $this->dispatch('refresh-complete')->self(); - } - - public function refreshComplete(): void - { - $this->isRefreshing = false; - } - - public function render() - { - $articles = Article::with(['feed', 'articlePublication']) - ->orderBy('created_at', 'desc') - ->paginate(15); - - $approvalsEnabled = Setting::isPublishingApprovalsEnabled(); - - return view('livewire.articles', [ - 'articles' => $articles, - 'approvalsEnabled' => $approvalsEnabled, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Channels.php b/app/Livewire/Channels.php deleted file mode 100644 index c9a41ff..0000000 --- a/app/Livewire/Channels.php +++ /dev/null @@ -1,73 +0,0 @@ -is_active = !$channel->is_active; - $channel->save(); - } - - public function openAccountModal(int $channelId): void - { - $this->managingChannelId = $channelId; - } - - public function closeAccountModal(): void - { - $this->managingChannelId = null; - } - - public function attachAccount(int $accountId): void - { - if (!$this->managingChannelId) { - return; - } - - $channel = PlatformChannel::findOrFail($this->managingChannelId); - - if (!$channel->platformAccounts()->where('platform_account_id', $accountId)->exists()) { - $channel->platformAccounts()->attach($accountId, [ - 'is_active' => true, - 'priority' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - } - - public function detachAccount(int $channelId, int $accountId): void - { - $channel = PlatformChannel::findOrFail($channelId); - $channel->platformAccounts()->detach($accountId); - } - - public function render() - { - $channels = PlatformChannel::with(['platformInstance', 'platformAccounts'])->orderBy('name')->get(); - $allAccounts = PlatformAccount::where('is_active', true)->get(); - - $managingChannel = $this->managingChannelId - ? PlatformChannel::with('platformAccounts')->find($this->managingChannelId) - : null; - - $availableAccounts = $managingChannel - ? $allAccounts->filter(fn($account) => !$managingChannel->platformAccounts->contains('id', $account->id)) - : collect(); - - return view('livewire.channels', [ - 'channels' => $channels, - 'managingChannel' => $managingChannel, - 'availableAccounts' => $availableAccounts, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php deleted file mode 100644 index 66e30b1..0000000 --- a/app/Livewire/Dashboard.php +++ /dev/null @@ -1,36 +0,0 @@ -period = $period; - } - - public function render() - { - $service = app(DashboardStatsService::class); - - $articleStats = $service->getStats($this->period); - $systemStats = $service->getSystemStats(); - $availablePeriods = $service->getAvailablePeriods(); - - return view('livewire.dashboard', [ - 'articleStats' => $articleStats, - 'systemStats' => $systemStats, - 'availablePeriods' => $availablePeriods, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Feeds.php b/app/Livewire/Feeds.php deleted file mode 100644 index e2f9ae7..0000000 --- a/app/Livewire/Feeds.php +++ /dev/null @@ -1,25 +0,0 @@ -is_active = !$feed->is_active; - $feed->save(); - } - - public function render() - { - $feeds = Feed::orderBy('name')->get(); - - return view('livewire.feeds', [ - 'feeds' => $feeds, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Onboarding.php b/app/Livewire/Onboarding.php deleted file mode 100644 index defd807..0000000 --- a/app/Livewire/Onboarding.php +++ /dev/null @@ -1,349 +0,0 @@ -lemmyAuthService = $lemmyAuthService; - } - - public function mount(): void - { - // Check for existing platform account - $account = PlatformAccount::where('is_active', true)->first(); - if ($account) { - $this->existingAccount = [ - 'id' => $account->id, - 'username' => $account->username, - 'instance_url' => $account->instance_url, - ]; - } - - // Pre-fill feed form if exists - $feed = Feed::where('is_active', true)->first(); - if ($feed) { - $this->feedName = $feed->name; - $this->feedProvider = $feed->provider ?? 'vrt'; - $this->feedLanguageId = $feed->language_id; - $this->feedDescription = $feed->description ?? ''; - } - - // Pre-fill channel form if exists - $channel = PlatformChannel::where('is_active', true)->first(); - if ($channel) { - $this->channelName = $channel->name; - $this->platformInstanceId = $channel->platform_instance_id; - $this->channelLanguageId = $channel->language_id; - $this->channelDescription = $channel->description ?? ''; - } - - // Pre-fill route form if exists - $route = Route::where('is_active', true)->first(); - if ($route) { - $this->routeFeedId = $route->feed_id; - $this->routeChannelId = $route->platform_channel_id; - $this->routePriority = $route->priority; - } - } - - public function goToStep(int $step): void - { - $this->step = $step; - $this->errors = []; - } - - public function nextStep(): void - { - $this->step++; - $this->errors = []; - } - - public function previousStep(): void - { - if ($this->step > 1) { - $this->step--; - $this->errors = []; - } - } - - public function continueWithExistingAccount(): void - { - $this->nextStep(); - } - - public function deleteAccount(): void - { - if ($this->existingAccount) { - PlatformAccount::destroy($this->existingAccount['id']); - $this->existingAccount = null; - } - } - - public function createPlatformAccount(): void - { - $this->errors = []; - $this->isLoading = true; - - $this->validate([ - 'instanceUrl' => 'required|string|max:255|regex:/^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$/', - 'username' => 'required|string|max:255', - 'password' => 'required|string|min:6', - ], [ - 'instanceUrl.regex' => 'Please enter a valid domain name (e.g., lemmy.world, belgae.social)', - ]); - - $fullInstanceUrl = 'https://' . $this->instanceUrl; - - try { - // Create or get platform instance - $platformInstance = PlatformInstance::firstOrCreate([ - 'url' => $fullInstanceUrl, - 'platform' => 'lemmy', - ], [ - 'name' => ucfirst($this->instanceUrl), - 'is_active' => true, - ]); - - // Authenticate with Lemmy API - $authResponse = $this->lemmyAuthService->authenticate( - $fullInstanceUrl, - $this->username, - $this->password - ); - - // Create platform account - $platformAccount = PlatformAccount::create([ - 'platform' => 'lemmy', - 'instance_url' => $fullInstanceUrl, - 'username' => $this->username, - 'password' => Crypt::encryptString($this->password), - 'settings' => [ - 'display_name' => $authResponse['person_view']['person']['display_name'] ?? null, - 'description' => $authResponse['person_view']['person']['bio'] ?? null, - 'person_id' => $authResponse['person_view']['person']['id'] ?? null, - 'platform_instance_id' => $platformInstance->id, - 'api_token' => $authResponse['jwt'] ?? null, - ], - 'is_active' => true, - 'status' => 'active', - ]); - - $this->existingAccount = [ - 'id' => $platformAccount->id, - 'username' => $platformAccount->username, - 'instance_url' => $platformAccount->instance_url, - ]; - - $this->nextStep(); - } catch (\App\Exceptions\PlatformAuthException $e) { - if (str_contains($e->getMessage(), 'Rate limited by')) { - $this->errors['general'] = $e->getMessage(); - } else { - $this->errors['general'] = 'Invalid username or password. Please check your credentials and try again.'; - } - } catch (\Exception $e) { - $this->errors['general'] = 'Unable to connect to the Lemmy instance. Please check the URL and try again.'; - } finally { - $this->isLoading = false; - } - } - - public function createFeed(): void - { - $this->errors = []; - $this->isLoading = true; - - $this->validate([ - 'feedName' => 'required|string|max:255', - 'feedProvider' => 'required|in:belga,vrt', - 'feedLanguageId' => 'required|exists:languages,id', - 'feedDescription' => 'nullable|string|max:1000', - ]); - - try { - // Map provider to URL - $url = $this->feedProvider === 'vrt' - ? 'https://www.vrt.be/vrtnws/en/' - : 'https://www.belganewsagency.eu/'; - - Feed::firstOrCreate( - ['url' => $url], - [ - 'name' => $this->feedName, - 'type' => 'website', - 'provider' => $this->feedProvider, - 'language_id' => $this->feedLanguageId, - 'description' => $this->feedDescription ?: null, - 'is_active' => true, - ] - ); - - $this->nextStep(); - } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create feed. Please try again.'; - } finally { - $this->isLoading = false; - } - } - - public function createChannel(): void - { - $this->errors = []; - $this->isLoading = true; - - $this->validate([ - 'channelName' => 'required|string|max:255', - 'platformInstanceId' => 'required|exists:platform_instances,id', - 'channelLanguageId' => 'required|exists:languages,id', - 'channelDescription' => 'nullable|string|max:1000', - ]); - - try { - $platformInstance = PlatformInstance::findOrFail($this->platformInstanceId); - - // Check for active platform accounts - $activeAccounts = PlatformAccount::where('instance_url', $platformInstance->url) - ->where('is_active', true) - ->get(); - - if ($activeAccounts->isEmpty()) { - $this->errors['general'] = 'No active platform accounts found for this instance. Please create a platform account first.'; - $this->isLoading = false; - return; - } - - $channel = PlatformChannel::create([ - 'platform_instance_id' => $this->platformInstanceId, - 'channel_id' => $this->channelName, - 'name' => $this->channelName, - 'display_name' => ucfirst($this->channelName), - 'description' => $this->channelDescription ?: null, - 'language_id' => $this->channelLanguageId, - 'is_active' => true, - ]); - - // Attach first active account - $channel->platformAccounts()->attach($activeAccounts->first()->id, [ - 'is_active' => true, - 'priority' => 1, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - $this->nextStep(); - } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create channel. Please try again.'; - } finally { - $this->isLoading = false; - } - } - - public function createRoute(): void - { - $this->errors = []; - $this->isLoading = true; - - $this->validate([ - 'routeFeedId' => 'required|exists:feeds,id', - 'routeChannelId' => 'required|exists:platform_channels,id', - 'routePriority' => 'nullable|integer|min:1|max:100', - ]); - - try { - Route::create([ - 'feed_id' => $this->routeFeedId, - 'platform_channel_id' => $this->routeChannelId, - 'priority' => $this->routePriority, - 'is_active' => true, - ]); - - // Trigger article discovery - ArticleDiscoveryJob::dispatch(); - - $this->nextStep(); - } catch (\Exception $e) { - $this->errors['general'] = 'Failed to create route. Please try again.'; - } finally { - $this->isLoading = false; - } - } - - public function completeOnboarding(): void - { - Setting::updateOrCreate( - ['key' => 'onboarding_completed'], - ['value' => now()->toIso8601String()] - ); - - app(OnboardingService::class)->clearCache(); - - $this->redirect(route('dashboard')); - } - - public function render() - { - $languages = Language::where('is_active', true)->orderBy('name')->get(); - $platformInstances = PlatformInstance::where('is_active', true)->orderBy('name')->get(); - $feeds = Feed::where('is_active', true)->orderBy('name')->get(); - $channels = PlatformChannel::where('is_active', true)->orderBy('name')->get(); - - $feedProviders = collect(config('feed.providers', [])) - ->filter(fn($provider) => $provider['is_active'] ?? false) - ->values(); - - return view('livewire.onboarding', [ - 'languages' => $languages, - 'platformInstances' => $platformInstances, - 'feeds' => $feeds, - 'channels' => $channels, - 'feedProviders' => $feedProviders, - ])->layout('layouts.guest'); - } -} diff --git a/app/Livewire/Routes.php b/app/Livewire/Routes.php deleted file mode 100644 index 29e3764..0000000 --- a/app/Livewire/Routes.php +++ /dev/null @@ -1,200 +0,0 @@ -showCreateModal = true; - $this->newFeedId = null; - $this->newChannelId = null; - $this->newPriority = 50; - } - - public function closeCreateModal(): void - { - $this->showCreateModal = false; - } - - public function createRoute(): void - { - $this->validate([ - 'newFeedId' => 'required|exists:feeds,id', - 'newChannelId' => 'required|exists:platform_channels,id', - 'newPriority' => 'required|integer|min:0', - ]); - - $exists = Route::where('feed_id', $this->newFeedId) - ->where('platform_channel_id', $this->newChannelId) - ->exists(); - - if ($exists) { - $this->addError('newFeedId', 'This route already exists.'); - return; - } - - Route::create([ - 'feed_id' => $this->newFeedId, - 'platform_channel_id' => $this->newChannelId, - 'priority' => $this->newPriority, - 'is_active' => true, - ]); - - $this->closeCreateModal(); - } - - public function openEditModal(int $feedId, int $channelId): void - { - $route = Route::where('feed_id', $feedId) - ->where('platform_channel_id', $channelId) - ->firstOrFail(); - - $this->editingFeedId = $feedId; - $this->editingChannelId = $channelId; - $this->editPriority = $route->priority; - $this->newKeyword = ''; - $this->showKeywordInput = false; - } - - public function closeEditModal(): void - { - $this->editingFeedId = null; - $this->editingChannelId = null; - } - - public function updateRoute(): void - { - if (!$this->editingFeedId || !$this->editingChannelId) { - return; - } - - $this->validate([ - 'editPriority' => 'required|integer|min:0', - ]); - - Route::where('feed_id', $this->editingFeedId) - ->where('platform_channel_id', $this->editingChannelId) - ->update(['priority' => $this->editPriority]); - - $this->closeEditModal(); - } - - public function toggle(int $feedId, int $channelId): void - { - $route = Route::where('feed_id', $feedId) - ->where('platform_channel_id', $channelId) - ->firstOrFail(); - - $route->is_active = !$route->is_active; - $route->save(); - } - - public function delete(int $feedId, int $channelId): void - { - // Delete associated keywords first - Keyword::where('feed_id', $feedId) - ->where('platform_channel_id', $channelId) - ->delete(); - - Route::where('feed_id', $feedId) - ->where('platform_channel_id', $channelId) - ->delete(); - } - - public function addKeyword(): void - { - if (!$this->editingFeedId || !$this->editingChannelId || empty(trim($this->newKeyword))) { - return; - } - - Keyword::create([ - 'feed_id' => $this->editingFeedId, - 'platform_channel_id' => $this->editingChannelId, - 'keyword' => trim($this->newKeyword), - 'is_active' => true, - ]); - - $this->newKeyword = ''; - $this->showKeywordInput = false; - } - - public function toggleKeyword(int $keywordId): void - { - $keyword = Keyword::findOrFail($keywordId); - $keyword->is_active = !$keyword->is_active; - $keyword->save(); - } - - public function deleteKeyword(int $keywordId): void - { - Keyword::destroy($keywordId); - } - - public function render() - { - $routes = Route::with(['feed', 'platformChannel']) - ->orderBy('priority', 'desc') - ->get(); - - // Batch load keywords for all routes to avoid N+1 queries - $routeKeys = $routes->map(fn($r) => $r->feed_id . '-' . $r->platform_channel_id); - $allKeywords = Keyword::whereIn('feed_id', $routes->pluck('feed_id')) - ->whereIn('platform_channel_id', $routes->pluck('platform_channel_id')) - ->get() - ->groupBy(fn($k) => $k->feed_id . '-' . $k->platform_channel_id); - - $routes = $routes->map(function ($route) use ($allKeywords) { - $key = $route->feed_id . '-' . $route->platform_channel_id; - $route->keywords = $allKeywords->get($key, collect()); - return $route; - }); - - $feeds = Feed::where('is_active', true)->orderBy('name')->get(); - $channels = PlatformChannel::where('is_active', true)->orderBy('name')->get(); - - $editingRoute = null; - $editingKeywords = collect(); - - if ($this->editingFeedId && $this->editingChannelId) { - $editingRoute = Route::with(['feed', 'platformChannel']) - ->where('feed_id', $this->editingFeedId) - ->where('platform_channel_id', $this->editingChannelId) - ->first(); - - $editingKeywords = Keyword::where('feed_id', $this->editingFeedId) - ->where('platform_channel_id', $this->editingChannelId) - ->get(); - } - - return view('livewire.routes', [ - 'routes' => $routes, - 'feeds' => $feeds, - 'channels' => $channels, - 'editingRoute' => $editingRoute, - 'editingKeywords' => $editingKeywords, - ])->layout('layouts.app'); - } -} diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php deleted file mode 100644 index f63de38..0000000 --- a/app/Livewire/Settings.php +++ /dev/null @@ -1,55 +0,0 @@ -articleProcessingEnabled = Setting::isArticleProcessingEnabled(); - $this->publishingApprovalsEnabled = Setting::isPublishingApprovalsEnabled(); - } - - public function toggleArticleProcessing(): void - { - $this->articleProcessingEnabled = !$this->articleProcessingEnabled; - Setting::setArticleProcessingEnabled($this->articleProcessingEnabled); - $this->showSuccess(); - } - - public function togglePublishingApprovals(): void - { - $this->publishingApprovalsEnabled = !$this->publishingApprovalsEnabled; - Setting::setPublishingApprovalsEnabled($this->publishingApprovalsEnabled); - $this->showSuccess(); - } - - protected function showSuccess(): void - { - $this->successMessage = 'Settings updated successfully!'; - $this->errorMessage = null; - - // Clear success message after 3 seconds - $this->dispatch('clear-message'); - } - - public function clearMessages(): void - { - $this->successMessage = null; - $this->errorMessage = null; - } - - public function render() - { - return view('livewire.settings')->layout('layouts.app'); - } -} diff --git a/app/Enums/LogLevelEnum.php b/app/LogLevelEnum.php similarity index 85% rename from app/Enums/LogLevelEnum.php rename to app/LogLevelEnum.php index 78d60fe..7897106 100644 --- a/app/Enums/LogLevelEnum.php +++ b/app/LogLevelEnum.php @@ -1,6 +1,6 @@ - */ public static function toArray(): array { return [ diff --git a/app/Models/Article.php b/app/Models/Article.php index fc66794..6596ac9 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -2,22 +2,17 @@ namespace App\Models; -use App\Events\ArticleApproved; -use App\Events\NewArticleFetched; +use App\Events\ArticleFetched; use Database\Factories\ArticleFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Carbon; /** - * @method static firstOrCreate(array $array) - * @method static where(string $string, string $url) - * @method static create(array $array) + * @method static firstOrCreate(string[] $array) * @property integer $id - * @property int $feed_id - * @property Feed $feed * @property string $url * @property bool|null $is_valid * @property Carbon|null $validated_at @@ -31,25 +26,22 @@ class Article extends Model use HasFactory; protected $fillable = [ - 'feed_id', 'url', 'title', 'description', - 'content', - 'image_url', - 'published_at', - 'author', - 'approval_status', + 'is_valid', + 'is_duplicate', + 'fetched_at', + 'validated_at', ]; - /** - * @return array - */ public function casts(): array { return [ - 'approval_status' => 'string', - 'published_at' => 'datetime', + 'is_valid' => 'boolean', + 'is_duplicate' => 'boolean', + 'fetched_at' => 'datetime', + 'validated_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; @@ -57,83 +49,31 @@ public function casts(): array public function isValid(): bool { - // In the consolidated schema, we only have approval_status - // Consider 'approved' status as valid - return $this->approval_status === 'approved'; - } - - public function isApproved(): bool - { - return $this->approval_status === 'approved'; - } - - public function isPending(): bool - { - return $this->approval_status === 'pending'; - } - - public function isRejected(): bool - { - return $this->approval_status === 'rejected'; - } - - public function approve(string $approvedBy = null): void - { - $this->update([ - 'approval_status' => 'approved', - ]); - - // Fire event to trigger publishing - event(new ArticleApproved($this)); - } - - public function reject(string $rejectedBy = null): void - { - $this->update([ - 'approval_status' => 'rejected', - ]); - } - - public function canBePublished(): bool - { - if (!$this->isValid()) { + if (is_null($this->validated_at)) { return false; } - // If approval system is disabled, auto-approve valid articles - if (!\App\Models\Setting::isPublishingApprovalsEnabled()) { - return true; + if (is_null($this->is_valid)) { + return false; } - // If approval system is enabled, only approved articles can be published - return $this->isApproved(); + return $this->is_valid; } - public function getIsPublishedAttribute(): bool - { - return $this->articlePublication()->exists(); - } - - /** - * @return HasOne - */ public function articlePublication(): HasOne { return $this->hasOne(ArticlePublication::class); } - /** - * @return BelongsTo - */ - public function feed(): BelongsTo + public function articlePublications(): HasMany { - return $this->belongsTo(Feed::class); + return $this->hasMany(ArticlePublication::class); } protected static function booted(): void { static::created(function ($article) { - event(new NewArticleFetched($article)); + event(new ArticleFetched($article)); }); } } diff --git a/app/Models/ArticlePublication.php b/app/Models/ArticlePublication.php index 4f12b9d..52b87cc 100644 --- a/app/Models/ArticlePublication.php +++ b/app/Models/ArticlePublication.php @@ -2,26 +2,21 @@ namespace App\Models; -use Database\Factories\ArticlePublicationFactory; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property integer $article_id - * @property integer $platform_channel_id + * @property integer $community_id * @property integer $post_id * - * @method static create(array $array) + * @method static create(array $array) */ class ArticlePublication extends Model { - /** @use HasFactory */ - use HasFactory; - protected $fillable = [ 'article_id', - 'platform_channel_id', + 'community_id', 'post_id', 'published_at', 'published_by', @@ -34,9 +29,6 @@ class ArticlePublication extends Model 'publication_data' => 'array', ]; - /** - * @return BelongsTo - */ public function article(): BelongsTo { return $this->belongsTo(Article::class); diff --git a/app/Models/Feed.php b/app/Models/Feed.php deleted file mode 100644 index 6fefbec..0000000 --- a/app/Models/Feed.php +++ /dev/null @@ -1,121 +0,0 @@ - $settings - * @property bool $is_active - * @property Carbon|null $last_fetched_at - * @property Carbon $created_at - * @property Carbon $updated_at - * @method static orderBy(string $string, string $string1) - * @method static where(string $string, true $true) - * @method static findOrFail(mixed $feed_id) - */ -class Feed extends Model -{ - /** @use HasFactory */ - use HasFactory; - private const RECENT_FETCH_THRESHOLD_HOURS = 2; - private const DAILY_FETCH_THRESHOLD_HOURS = 24; - - protected $fillable = [ - 'name', - 'url', - 'type', - 'provider', - 'language_id', - 'description', - 'settings', - 'is_active', - 'last_fetched_at' - ]; - - protected $casts = [ - 'settings' => 'array', - 'is_active' => 'boolean', - 'last_fetched_at' => 'datetime' - ]; - - public function getTypeDisplayAttribute(): string - { - return match ($this->type) { - 'website' => 'Website', - 'rss' => 'RSS Feed', - default => 'Unknown' - }; - } - - public function getStatusAttribute(): string - { - if (!$this->is_active) { - return 'Inactive'; - } - - if (!$this->last_fetched_at) { - return 'Never fetched'; - } - - $hoursAgo = $this->last_fetched_at->diffInHours(now()); - - if ($hoursAgo < self::RECENT_FETCH_THRESHOLD_HOURS) { - return 'Recently fetched'; - } elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) { - return "Fetched {$hoursAgo}h ago"; - } else { - return "Fetched " . $this->last_fetched_at->diffForHumans(); - } - } - - /** - * @return BelongsToMany - */ - public function channels(): BelongsToMany - { - return $this->belongsToMany(PlatformChannel::class, 'routes') - ->withPivot(['is_active', 'priority']) - ->withTimestamps(); - } - - /** - * @return BelongsToMany - */ - public function activeChannels(): BelongsToMany - { - return $this->channels() - ->wherePivot('is_active', true) - ->orderByPivot('priority', 'desc'); - } - - /** - * @return HasMany - */ - public function articles(): HasMany - { - return $this->hasMany(Article::class); - } - - /** - * @return BelongsTo - */ - public function language(): BelongsTo - { - return $this->belongsTo(Language::class); - } -} diff --git a/app/Models/Keyword.php b/app/Models/Keyword.php deleted file mode 100644 index 616d184..0000000 --- a/app/Models/Keyword.php +++ /dev/null @@ -1,52 +0,0 @@ - 'boolean' - ]; - - /** - * @return BelongsTo - */ - public function feed(): BelongsTo - { - return $this->belongsTo(Feed::class); - } - - /** - * @return BelongsTo - */ - public function platformChannel(): BelongsTo - { - return $this->belongsTo(PlatformChannel::class); - } - -} diff --git a/app/Models/Language.php b/app/Models/Language.php deleted file mode 100644 index f95f47c..0000000 --- a/app/Models/Language.php +++ /dev/null @@ -1,52 +0,0 @@ - */ - use HasFactory; - - protected $fillable = [ - 'short_code', - 'name', - 'native_name', - 'is_active' - ]; - - protected $casts = [ - 'is_active' => 'boolean' - ]; - - /** - * @return BelongsToMany - */ - public function platformInstances(): BelongsToMany - { - return $this->belongsToMany(PlatformInstance::class) - ->withPivot(['platform_language_id', 'is_default']) - ->withTimestamps(); - } - - /** - * @return HasMany - */ - public function platformChannels(): HasMany - { - return $this->hasMany(PlatformChannel::class); - } - - /** - * @return HasMany - */ - public function feeds(): HasMany - { - return $this->hasMany(Feed::class); - } -} diff --git a/app/Models/Log.php b/app/Models/Log.php index 61de42b..4f3dea5 100644 --- a/app/Models/Log.php +++ b/app/Models/Log.php @@ -2,23 +2,11 @@ namespace App\Models; -use App\Enums\LogLevelEnum; -use Illuminate\Database\Eloquent\Factories\HasFactory; +use App\LogLevelEnum; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Carbon; -/** - * @method static create(array $array) - * @property LogLevelEnum $level - * @property string $message - * @property array $context - * @property Carbon $created_at - * @property Carbon $updated_at - */ class Log extends Model { - use HasFactory; - protected $table = 'logs'; protected $fillable = [ diff --git a/app/Models/PlatformAccount.php b/app/Models/PlatformAccount.php deleted file mode 100644 index ca309e9..0000000 --- a/app/Models/PlatformAccount.php +++ /dev/null @@ -1,139 +0,0 @@ - $activeChannels - * @method static where(string $string, PlatformEnum $platform) - * @method static orderBy(string $string) - * @method static create(array $validated) - */ -class PlatformAccount extends Model -{ - /** @use HasFactory */ - use HasFactory; - - protected $fillable = [ - 'platform', - 'instance_url', - 'username', - 'password', - 'settings', - 'is_active', - 'last_tested_at', - 'status' - ]; - - protected $casts = [ - 'platform' => PlatformEnum::class, - 'settings' => 'array', - 'is_active' => 'boolean', - 'last_tested_at' => 'datetime' - ]; - - // Encrypt password when storing - /** - * @return Attribute - */ - protected function password(): Attribute - { - return Attribute::make( - get: function ($value, array $attributes) { - // Return null if the raw value is null - if (is_null($value)) { - return null; - } - - // Return empty string if value is empty - if (empty($value)) { - return ''; - } - - try { - return Crypt::decryptString($value); - } catch (\Exception $e) { - // If decryption fails, return null to be safe - return null; - } - }, - set: function ($value) { - // Store null if null is passed - if (is_null($value)) { - return null; - } - - // Store empty string as null - if (empty($value)) { - return null; - } - - return Crypt::encryptString($value); - }, - )->withoutObjectCaching(); - } - - - // Get the active accounts for a platform (returns collection) - /** - * @return Collection - */ - public static function getActive(PlatformEnum $platform): Collection - { - return static::where('platform', $platform) - ->where('is_active', true) - ->get(); - } - - // Set this account as active (deactivates others for same platform) - public function setAsActive(): void - { - // Deactivate other accounts for this platform - static::where('platform', $this->platform) - ->where('id', '!=', $this->id) - ->update(['is_active' => false]); - - // Activate this account - $this->update(['is_active' => true]); - } - - /** - * @return BelongsToMany - */ - public function channels(): BelongsToMany - { - return $this->belongsToMany(PlatformChannel::class, 'platform_account_channels') - ->withPivot(['is_active', 'priority']) - ->withTimestamps(); - } - - /** - * @return BelongsToMany - */ - public function activeChannels(): BelongsToMany - { - return $this->channels() - ->wherePivot('is_active', true) - ->orderByPivot('priority', 'desc'); - } -} diff --git a/app/Models/PlatformChannel.php b/app/Models/PlatformChannel.php deleted file mode 100644 index 5179d0f..0000000 --- a/app/Models/PlatformChannel.php +++ /dev/null @@ -1,103 +0,0 @@ - */ - use HasFactory; - - protected $table = 'platform_channels'; - - protected $fillable = [ - 'platform_instance_id', - 'name', - 'display_name', - 'channel_id', - 'description', - 'language_id', - 'is_active' - ]; - - protected $casts = [ - 'is_active' => 'boolean' - ]; - - /** - * @return BelongsTo - */ - public function platformInstance(): BelongsTo - { - return $this->belongsTo(PlatformInstance::class); - } - - /** - * @return BelongsToMany - */ - public function platformAccounts(): BelongsToMany - { - return $this->belongsToMany(PlatformAccount::class, 'platform_account_channels') - ->withPivot(['is_active', 'priority']) - ->withTimestamps(); - } - - /** - * @return BelongsToMany - */ - public function activePlatformAccounts(): BelongsToMany - { - return $this->platformAccounts()->wherePivot('is_active', true); - } - - public function getFullNameAttribute(): string - { - // For Lemmy, use /c/ prefix - return $this->platformInstance->url . '/c/' . $this->name; - } - - /** - * @return BelongsToMany - */ - public function feeds(): BelongsToMany - { - return $this->belongsToMany(Feed::class, 'routes') - ->withPivot(['is_active', 'priority']) - ->withTimestamps(); - } - - /** - * @return BelongsToMany - */ - public function activeFeeds(): BelongsToMany - { - return $this->feeds() - ->wherePivot('is_active', true) - ->orderByPivot('priority', 'desc'); - } - - /** - * @return BelongsTo - */ - public function language(): BelongsTo - { - return $this->belongsTo(Language::class); - } -} diff --git a/app/Models/PlatformChannelPost.php b/app/Models/PlatformChannelPost.php index ef6a21d..700e3ae 100644 --- a/app/Models/PlatformChannelPost.php +++ b/app/Models/PlatformChannelPost.php @@ -3,16 +3,14 @@ namespace App\Models; use App\Enums\PlatformEnum; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; /** * @method static where(string $string, PlatformEnum $platform) - * @method static updateOrCreate(array $array, array $array1) + * @method static updateOrCreate(array $array, array $array1) */ class PlatformChannelPost extends Model { - use HasFactory; protected $fillable = [ 'platform', 'channel_id', @@ -23,9 +21,6 @@ class PlatformChannelPost extends Model 'posted_at', ]; - /** - * @return array - */ protected function casts(): array { return [ diff --git a/app/Models/PlatformInstance.php b/app/Models/PlatformInstance.php deleted file mode 100644 index fa2d242..0000000 --- a/app/Models/PlatformInstance.php +++ /dev/null @@ -1,63 +0,0 @@ - $array, $instanceData) - * @method static where(string $string, mixed $operator) - * @property PlatformEnum $platform - * @property string $url - * @property string $name - * @property string $description - * @property boolean $is_active - */ -class PlatformInstance extends Model -{ - /** @use HasFactory */ - use HasFactory; - - protected $fillable = [ - 'platform', - 'url', - 'name', - 'description', - 'is_active' - ]; - - protected $casts = [ - 'platform' => PlatformEnum::class, - 'is_active' => 'boolean' - ]; - - /** - * @return HasMany - */ - public function channels(): HasMany - { - return $this->hasMany(PlatformChannel::class); - } - - /** - * @return BelongsToMany - */ - public function languages(): BelongsToMany - { - return $this->belongsToMany(Language::class) - ->withPivot(['platform_language_id', 'is_default']) - ->withTimestamps(); - } - - public static function findByUrl(PlatformEnum $platform, string $url): ?self - { - return static::where('platform', $platform) - ->where('url', $url) - ->first(); - } -} diff --git a/app/Models/Route.php b/app/Models/Route.php deleted file mode 100644 index b5ee7d0..0000000 --- a/app/Models/Route.php +++ /dev/null @@ -1,66 +0,0 @@ - */ - use HasFactory; - - protected $table = 'routes'; - - // Laravel doesn't handle composite primary keys well, so we'll use regular queries - protected $primaryKey = null; - public $incrementing = false; - - protected $fillable = [ - 'feed_id', - 'platform_channel_id', - 'is_active', - 'priority' - ]; - - protected $casts = [ - 'is_active' => 'boolean' - ]; - - /** - * @return BelongsTo - */ - public function feed(): BelongsTo - { - return $this->belongsTo(Feed::class); - } - - /** - * @return BelongsTo - */ - public function platformChannel(): BelongsTo - { - return $this->belongsTo(PlatformChannel::class); - } - - /** - * @return HasMany - */ - public function keywords(): HasMany - { - return $this->hasMany(Keyword::class, 'feed_id', 'feed_id') - ->where('platform_channel_id', $this->platform_channel_id); - } -} diff --git a/app/Models/Setting.php b/app/Models/Setting.php deleted file mode 100644 index ccc6c04..0000000 --- a/app/Models/Setting.php +++ /dev/null @@ -1,62 +0,0 @@ -first(); - - return $setting ? $setting->value : $default; - } - - public static function set(string $key, mixed $value): void - { - static::updateOrCreate(['key' => $key], ['value' => $value]); - } - - public static function getBool(string $key, bool $default = false): bool - { - $value = static::get($key, $default); - - return filter_var($value, FILTER_VALIDATE_BOOLEAN); - } - - public static function setBool(string $key, bool $value): void - { - static::set($key, $value ? '1' : '0'); - } - - public static function isArticleProcessingEnabled(): bool - { - return static::getBool('article_processing_enabled', true); - } - - public static function setArticleProcessingEnabled(bool $enabled): void - { - static::setBool('article_processing_enabled', $enabled); - } - - public static function isPublishingApprovalsEnabled(): bool - { - return static::getBool('enable_publishing_approvals', false); - } - - public static function setPublishingApprovalsEnabled(bool $enabled): void - { - static::setBool('enable_publishing_approvals', $enabled); - } -} diff --git a/app/Models/User.php b/app/Models/User.php index a6ab88e..749c7b7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,12 +6,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, HasApiTokens; + use HasFactory, Notifiable; /** * The attributes that are mass assignable. diff --git a/app/Modules/Lemmy/LemmyRequest.php b/app/Modules/Lemmy/LemmyRequest.php index 4df170c..1199a35 100644 --- a/app/Modules/Lemmy/LemmyRequest.php +++ b/app/Modules/Lemmy/LemmyRequest.php @@ -9,74 +9,36 @@ class LemmyRequest { private string $instance; private ?string $token; - private string $scheme = 'https'; public function __construct(string $instance, ?string $token = null) { - // Detect scheme if provided in the instance string - if (preg_match('/^(https?):\/\//i', $instance, $m)) { - $this->scheme = strtolower($m[1]); - } - // Handle both full URLs and just domain names - $this->instance = $this->normalizeInstance($instance); + $this->instance = $instance; $this->token = $token; } - /** - * Normalize instance URL to just the domain name - */ - private function normalizeInstance(string $instance): string - { - // Remove protocol if present - $instance = preg_replace('/^https?:\/\//i', '', $instance); - - // Remove trailing slash if present - $instance = rtrim($instance, '/'); - - return $instance; - } - - /** - * Explicitly set the scheme (http or https) for subsequent requests. - */ - public function withScheme(string $scheme): self - { - $scheme = strtolower($scheme); - if (in_array($scheme, ['http', 'https'], true)) { - $this->scheme = $scheme; - } - return $this; - } - - /** - * @param array $params - */ public function get(string $endpoint, array $params = []): Response { - $url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->instance, $endpoint); - + $url = "https://{$this->instance}/api/v3/{$endpoint}"; + $request = Http::timeout(30); - + if ($this->token) { $request = $request->withToken($this->token); } - + return $request->get($url, $params); } - /** - * @param array $data - */ public function post(string $endpoint, array $data = []): Response { - $url = sprintf('%s://%s/api/v3/%s', $this->scheme, $this->instance, $endpoint); - + $url = "https://{$this->instance}/api/v3/{$endpoint}"; + $request = Http::timeout(30); - + if ($this->token) { $request = $request->withToken($this->token); } - + return $request->post($url, $data); } diff --git a/app/Modules/Lemmy/Services/LemmyApiService.php b/app/Modules/Lemmy/Services/LemmyApiService.php index 3329703..5178eab 100644 --- a/app/Modules/Lemmy/Services/LemmyApiService.php +++ b/app/Modules/Lemmy/Services/LemmyApiService.php @@ -18,67 +18,33 @@ public function __construct(string $instance) public function login(string $username, string $password): ?string { - // Try HTTPS first; on failure, optionally retry with HTTP to support dev instances - $schemesToTry = []; - if (preg_match('/^https?:\/\//i', $this->instance)) { - // Preserve user-provided scheme as first try - $schemesToTry[] = strtolower(str_starts_with($this->instance, 'http://') ? 'http' : 'https'); - } else { - // Default order: https then http - $schemesToTry = ['https', 'http']; - } + try { + $request = new LemmyRequest($this->instance); + $response = $request->post('user/login', [ + 'username_or_email' => $username, + 'password' => $password, + ]); - foreach ($schemesToTry as $idx => $scheme) { - try { - $request = new LemmyRequest($this->instance); - // ensure scheme used matches current attempt - $request = $request->withScheme($scheme); - - $response = $request->post('user/login', [ - 'username_or_email' => $username, - 'password' => $password, + if (!$response->successful()) { + logger()->error('Lemmy login failed', [ + 'status' => $response->status(), + 'body' => $response->body() ]); - - if (!$response->successful()) { - $responseBody = $response->body(); - logger()->error('Lemmy login failed', [ - 'status' => $response->status(), - 'body' => $responseBody, - 'scheme' => $scheme, - ]); - - // Check if it's a rate limit error - if (str_contains($responseBody, 'rate_limit_error')) { - throw new Exception('Rate limited by Lemmy instance. Please wait a moment and try again.'); - } - - // If first attempt failed and there is another scheme to try, continue loop - if ($idx === 0 && count($schemesToTry) > 1) { - continue; - } - - return null; - } - - $data = $response->json(); - return $data['jwt'] ?? null; - } catch (Exception $e) { - logger()->error('Lemmy login exception', ['error' => $e->getMessage(), 'scheme' => $scheme]); - // If this was the first attempt and HTTPS, try HTTP next - if ($idx === 0 && in_array('http', $schemesToTry, true)) { - continue; - } return null; } - } - return null; + $data = $response->json(); + return $data['jwt'] ?? null; + } catch (Exception $e) { + logger()->error('Lemmy login exception', ['error' => $e->getMessage()]); + return null; + } } - public function getCommunityId(string $communityName, string $token): int + public function getCommunityId(string $communityName): int { try { - $request = new LemmyRequest($this->instance, $token); + $request = new LemmyRequest($this->instance); $response = $request->get('community', ['name' => $communityName]); if (!$response->successful()) { @@ -93,12 +59,12 @@ public function getCommunityId(string $communityName, string $token): int } } - public function syncChannelPosts(string $token, int $platformChannelId, string $communityName): void + public function syncChannelPosts(string $token, int $communityId, string $communityName): void { try { $request = new LemmyRequest($this->instance, $token); $response = $request->get('post/list', [ - 'community_id' => $platformChannelId, + 'community_id' => $communityId, 'limit' => 50, 'sort' => 'New' ]); @@ -106,7 +72,7 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $ if (!$response->successful()) { logger()->warning('Failed to sync channel posts', [ 'status' => $response->status(), - 'platform_channel_id' => $platformChannelId + 'community_id' => $communityId ]); return; } @@ -116,10 +82,10 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $ foreach ($posts as $postData) { $post = $postData['post']; - + PlatformChannelPost::storePost( PlatformEnum::LEMMY, - (string) $platformChannelId, + (string) $communityId, $communityName, (string) $post['id'], $post['url'] ?? null, @@ -129,22 +95,19 @@ public function syncChannelPosts(string $token, int $platformChannelId, string $ } logger()->info('Synced channel posts', [ - 'platform_channel_id' => $platformChannelId, + 'community_id' => $communityId, 'posts_count' => count($posts) ]); } catch (Exception $e) { logger()->error('Exception while syncing channel posts', [ 'error' => $e->getMessage(), - 'platform_channel_id' => $platformChannelId + 'community_id' => $communityId ]); } } - /** - * @return array - */ - public function createPost(string $token, string $title, string $body, int $platformChannelId, ?string $url = null, ?string $thumbnail = null, ?int $languageId = null): array + public function createPost(string $token, string $title, string $body, int $communityId, ?string $url = null, ?string $thumbnail = null): array { try { $request = new LemmyRequest($this->instance, $token); @@ -152,7 +115,7 @@ public function createPost(string $token, string $title, string $body, int $plat $postData = [ 'name' => $title, 'body' => $body, - 'community_id' => $platformChannelId, + 'community_id' => $communityId, ]; if ($url) { @@ -163,10 +126,6 @@ public function createPost(string $token, string $title, string $body, int $plat $postData['custom_thumbnail'] = $thumbnail; } - if ($languageId) { - $postData['language_id'] = $languageId; - } - $response = $request->post('post', $postData); if (!$response->successful()) { @@ -179,30 +138,4 @@ public function createPost(string $token, string $title, string $body, int $plat throw $e; } } - - /** - * @return array - */ - public function getLanguages(): array - { - try { - $request = new LemmyRequest($this->instance); - $response = $request->get('site'); - - if (!$response->successful()) { - logger()->warning('Failed to fetch site languages', [ - 'status' => $response->status() - ]); - return []; - } - - $data = $response->json(); - return $data['all_languages'] ?? []; - } catch (Exception $e) { - logger()->error('Exception while fetching languages', [ - 'error' => $e->getMessage() - ]); - return []; - } - } } diff --git a/app/Modules/Lemmy/Services/LemmyPublisher.php b/app/Modules/Lemmy/Services/LemmyPublisher.php index c19262f..a8fd87d 100644 --- a/app/Modules/Lemmy/Services/LemmyPublisher.php +++ b/app/Modules/Lemmy/Services/LemmyPublisher.php @@ -2,51 +2,97 @@ namespace App\Modules\Lemmy\Services; +use App\Enums\PlatformEnum; use App\Exceptions\PlatformAuthException; +use App\Exceptions\PublishException; use App\Models\Article; -use App\Models\PlatformAccount; -use App\Models\PlatformChannel; -use App\Services\Auth\LemmyAuthService; +use App\Models\ArticlePublication; use Exception; +use Illuminate\Support\Facades\Cache; class LemmyPublisher { private LemmyApiService $api; - private PlatformAccount $account; + private string $username; + private string $community; - public function __construct(PlatformAccount $account) + public function __construct(string $instance, string $username, string $community) { - $this->api = new LemmyApiService($account->instance_url); - $this->account = $account; + $this->api = new LemmyApiService($instance); + $this->username = $username; + $this->community = $community; } - /** - * @param array $extractedData - * @return array - * @throws PlatformAuthException - * @throws Exception - */ - public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array + public static function fromConfig(): self { - $token = resolve(LemmyAuthService::class)->getToken($this->account); - - // Use the language ID from extracted data (should be set during validation) - $languageId = $extractedData['language_id'] ?? null; - - // Resolve community name to numeric ID if needed - $communityId = is_numeric($channel->channel_id) - ? (int) $channel->channel_id - : $this->api->getCommunityId($channel->channel_id, $token); - - return $this->api->createPost( - $token, - $extractedData['title'] ?? 'Untitled', - $extractedData['description'] ?? '', - $communityId, - $article->url, - $extractedData['thumbnail'] ?? null, - $languageId + return new self( + config('lemmy.instance'), + config('lemmy.username'), + config('lemmy.community') ); } + /** + * @throws PublishException + */ + public function publish(Article $article, array $extractedData): ArticlePublication + { + try { + $token = $this->getAuthToken(); + $communityId = $this->getCommunityId(); + + $postData = $this->api->createPost( + $token, + $extractedData['title'] ?? 'Untitled', + $extractedData['description'] ?? '', + $communityId, + $article->url, + $extractedData['thumbnail'] ?? null + ); + + return $this->createPublicationRecord($article, $postData, $communityId); + } catch (Exception $e) { + throw new PublishException($article, PlatformEnum::LEMMY, $e); + } + } + + private function getAuthToken(): string + { + return Cache::remember('lemmy_jwt_token', 3600, function () { + $username = config('lemmy.username'); + $password = config('lemmy.password'); + + if (!$username || !$password) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials'); + } + + $token = $this->api->login($username, $password); + + if (!$token) { + throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed'); + } + + return $token; + }); + } + + private function getCommunityId(): int + { + return Cache::remember("lemmy_community_id_{$this->community}", 3600, function () { + return $this->api->getCommunityId($this->community); + }); + } + + private function createPublicationRecord(Article $article, array $postData, int $communityId): ArticlePublication + { + return ArticlePublication::create([ + 'article_id' => $article->id, + 'post_id' => $postData['post_view']['post']['id'], + 'community_id' => $communityId, + 'published_by' => $this->username, + 'published_at' => now(), + 'platform' => 'lemmy', + 'publication_data' => $postData, + ]); + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1ac3ea4..e9dcdba 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,34 +2,41 @@ namespace App\Providers; -use App\Enums\LogLevelEnum; +use App\Events\ArticleFetched; +use App\Events\ArticleReadyToPublish; use App\Events\ExceptionOccurred; +use App\Listeners\ValidateArticle; use App\Listeners\LogExceptionToDatabase; -use Error; +use App\Listeners\PublishArticle; +use App\LogLevelEnum; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; -use InvalidArgumentException; use Throwable; class AppServiceProvider extends ServiceProvider { public function register(): void { + // } public function boot(): void { Event::listen( - ExceptionOccurred::class, - LogExceptionToDatabase::class, + ArticleFetched::class, + ValidateArticle::class, ); Event::listen( - \App\Events\NewArticleFetched::class, - \App\Listeners\ValidateArticleListener::class, + ArticleReadyToPublish::class, + PublishArticle::class, ); + Event::listen( + ExceptionOccurred::class, + LogExceptionToDatabase::class, + ); app()->make(ExceptionHandler::class) ->reportable(function (Throwable $e) { @@ -42,8 +49,9 @@ public function boot(): void private function mapExceptionToLogLevel(Throwable $exception): LogLevelEnum { return match (true) { - $exception instanceof Error => LogLevelEnum::CRITICAL, - $exception instanceof InvalidArgumentException => LogLevelEnum::WARNING, + $exception instanceof \Error => LogLevelEnum::CRITICAL, + $exception instanceof \RuntimeException => LogLevelEnum::ERROR, + $exception instanceof \InvalidArgumentException => LogLevelEnum::WARNING, default => LogLevelEnum::ERROR, }; } diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php deleted file mode 100644 index 8699835..0000000 --- a/app/Providers/HorizonServiceProvider.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - public function getArticlesFromFeed(Feed $feed): Collection - { - if ($feed->type === 'rss') { - return $this->getArticlesFromRssFeed($feed); - } elseif ($feed->type === 'website') { - return $this->getArticlesFromWebsiteFeed($feed); - } - - $this->logSaver->warning("Unsupported feed type", null, [ - 'feed_id' => $feed->id, - 'feed_type' => $feed->type - ]); - - return collect(); - } - - /** - * @return Collection - */ - private function getArticlesFromRssFeed(Feed $feed): Collection - { - // TODO: Implement RSS feed parsing - // For now, return empty collection - return collect(); - } - - /** - * @return Collection - */ - private function getArticlesFromWebsiteFeed(Feed $feed): Collection + public static function getNewArticles(): Collection { try { - // Try to get parser for this feed - $parser = HomepageParserFactory::getParserForFeed($feed); + $allArticles = collect(); - if (! $parser) { - $this->logSaver->warning("No parser available for feed URL", null, [ - 'feed_id' => $feed->id, - 'feed_url' => $feed->url - ]); + foreach (HomepageParserFactory::getAllParsers() as $parser) { + $html = HttpFetcher::fetchHtml($parser->getHomepageUrl()); + $urls = $parser->extractArticleUrls($html); - return collect(); + $articles = collect($urls) + ->map(fn (string $url) => self::saveArticle($url)); + + $allArticles = $allArticles->merge($articles); } - $html = HttpFetcher::fetchHtml($feed->url); - $urls = $parser->extractArticleUrls($html); - - return collect($urls) - ->map(fn (string $url) => $this->saveArticle($url, $feed->id)); - + return $allArticles->filter(); } catch (Exception $e) { - $this->logSaver->error("Failed to fetch articles from website feed", null, [ - 'feed_id' => $feed->id, - 'feed_url' => $feed->url, - 'error' => $e->getMessage() - ]); - - return collect(); + logger()->error("Failed to get new articles", ['error' => $e->getMessage()]); + return new Collection([]); } } - /** - * @return array - */ - public function fetchArticleData(Article $article): array + public static function fetchArticleData(Article $article): array { try { $html = HttpFetcher::fetchHtml($article->url); @@ -92,54 +41,16 @@ public function fetchArticleData(Article $article): array return $parser->extractData($html); } catch (Exception $e) { - $this->logSaver->error('Exception while fetching article data', null, [ + logger()->error('Exception while fetching article data', [ 'url' => $article->url, 'error' => $e->getMessage() ]); - return []; } } - private function saveArticle(string $url, ?int $feedId = null): Article + private static function saveArticle(string $url): Article { - $existingArticle = Article::where('url', $url)->first(); - - if ($existingArticle) { - return $existingArticle; - } - - // Extract a basic title from URL as fallback - $fallbackTitle = $this->generateFallbackTitle($url); - - try { - return Article::create([ - 'url' => $url, - 'feed_id' => $feedId, - 'title' => $fallbackTitle, - ]); - } catch (\Exception $e) { - $this->logSaver->error("Failed to create article - title validation failed", null, [ - 'url' => $url, - 'feed_id' => $feedId, - 'error' => $e->getMessage(), - 'suggestion' => 'Check regex parsing patterns for title extraction' - ]); - throw $e; - } - } - - private function generateFallbackTitle(string $url): string - { - // Extract filename from URL as a basic fallback title - $path = parse_url($url, PHP_URL_PATH); - $filename = basename($path ?: $url); - - // Remove file extension and convert to readable format - $title = preg_replace('/\.[^.]*$/', '', $filename); - $title = str_replace(['-', '_'], ' ', $title); - $title = ucwords($title); - - return $title ?: 'Untitled Article'; + return Article::firstOrCreate(['url' => $url]); } } diff --git a/app/Services/Article/ValidationService.php b/app/Services/Article/ValidationService.php index 271b8d7..c2dee91 100644 --- a/app/Services/Article/ValidationService.php +++ b/app/Services/Article/ValidationService.php @@ -6,62 +6,25 @@ class ValidationService { - public function __construct( - private ArticleFetcher $articleFetcher - ) {} - - public function validate(Article $article): Article + public static function validate(Article $article): Article { logger('Checking keywords for article: ' . $article->id); - $articleData = $this->articleFetcher->fetchArticleData($article); - - // Update article with fetched metadata (title, description) - $updateData = []; + $articleData = ArticleFetcher::fetchArticleData($article); + $validationResult = self::validateByKeywords($articleData['full_article']); - if (!empty($articleData)) { - $updateData['title'] = $articleData['title'] ?? $article->title; - $updateData['description'] = $articleData['description'] ?? $article->description; - $updateData['content'] = $articleData['full_article'] ?? null; - } - - if (!isset($articleData['full_article']) || empty($articleData['full_article'])) { - logger()->warning('Article data missing full_article content', [ - 'article_id' => $article->id, - 'url' => $article->url - ]); - - $updateData['approval_status'] = 'rejected'; - $article->update($updateData); - - return $article->refresh(); - } - - // Validate using extracted content (not stored) - $validationResult = $this->validateByKeywords($articleData['full_article']); - $updateData['approval_status'] = $validationResult ? 'approved' : 'pending'; - - $article->update($updateData); + $article->update([ + 'is_valid' => $validationResult, + 'validated_at' => now(), + ]); return $article->refresh(); } - private function validateByKeywords(string $full_article): bool + private static function validateByKeywords(string $full_article): bool { - // Belgian news content keywords - broader set for Belgian news relevance $keywords = [ - // Political parties and leaders - 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', - 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', - - // Belgian locations and institutions - 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', - 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', - 'parliament', 'government', 'minister', 'policy', 'law', 'legislation', - - // Common Belgian news topics - 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', - 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' + 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', ]; foreach ($keywords as $keyword) { diff --git a/app/Services/Auth/LemmyAuthService.php b/app/Services/Auth/LemmyAuthService.php deleted file mode 100644 index dfd6c11..0000000 --- a/app/Services/Auth/LemmyAuthService.php +++ /dev/null @@ -1,72 +0,0 @@ -username || ! $account->password || ! $account->instance_url) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account: ' . $account->username); - } - - $api = new LemmyApiService($account->instance_url); - $token = $api->login($account->username, $account->password); - - if (!$token) { - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account: ' . $account->username); - } - - return $token; - } - - /** - * Authenticate with Lemmy API and return user data with JWT - * @throws PlatformAuthException - */ - public function authenticate(string $instanceUrl, string $username, string $password): array - { - try { - $api = new LemmyApiService($instanceUrl); - $token = $api->login($username, $password); - - if (!$token) { - // Throw a clean exception that will be caught and handled by the controller - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Invalid credentials'); - } - - // Get user info with the token - // For now, we'll return a basic response structure - // In a real implementation, you might want to fetch user details - return [ - 'jwt' => $token, - 'person_view' => [ - 'person' => [ - 'id' => 0, // Would need API call to get actual user info - 'display_name' => null, - 'bio' => null, - ] - ] - ]; - } catch (PlatformAuthException $e) { - // Re-throw PlatformAuthExceptions as-is to avoid nesting - throw $e; - } catch (Exception $e) { - // Check if it's a rate limit error - if (str_contains($e->getMessage(), 'Rate limited by')) { - throw new PlatformAuthException(PlatformEnum::LEMMY, $e->getMessage()); - } - // For other exceptions, throw a clean PlatformAuthException - throw new PlatformAuthException(PlatformEnum::LEMMY, 'Connection failed'); - } - } -} diff --git a/app/Services/DashboardStatsService.php b/app/Services/DashboardStatsService.php deleted file mode 100644 index a5d8310..0000000 --- a/app/Services/DashboardStatsService.php +++ /dev/null @@ -1,98 +0,0 @@ -getDateRange($period); - - // Get articles fetched for the period - $articlesFetchedQuery = Article::query(); - if ($dateRange) { - $articlesFetchedQuery->whereBetween('created_at', $dateRange); - } - $articlesFetched = $articlesFetchedQuery->count(); - - // Get articles published for the period - $articlesPublishedQuery = ArticlePublication::query() - ->whereNotNull('published_at'); - if ($dateRange) { - $articlesPublishedQuery->whereBetween('published_at', $dateRange); - } - $articlesPublished = $articlesPublishedQuery->count(); - - // Calculate published percentage - $publishedPercentage = $articlesFetched > 0 ? round(($articlesPublished / $articlesFetched) * 100, 1) : 0.0; - - return [ - 'articles_fetched' => $articlesFetched, - 'articles_published' => $articlesPublished, - 'published_percentage' => $publishedPercentage, - ]; - } - - /** - * @return array - */ - public function getAvailablePeriods(): array - { - return [ - 'today' => 'Today', - 'week' => 'This Week', - 'month' => 'This Month', - 'year' => 'This Year', - 'all' => 'All Time', - ]; - } - - /** - * @return array{0: Carbon, 1: Carbon}|null - */ - private function getDateRange(string $period): ?array - { - $now = Carbon::now(); - - return match ($period) { - 'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], - 'week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()], - 'month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()], - 'year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()], - 'all' => null, // No date filtering for all-time stats - default => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], - }; - } - - public function getSystemStats(): array - { - $totalFeeds = Feed::query()->count(); - $activeFeeds = Feed::query()->where('is_active', 1)->count(); - $totalPlatformAccounts = PlatformAccount::query()->count(); - $activePlatformAccounts = PlatformAccount::query()->where('is_active', 1)->count(); - $totalPlatformChannels = PlatformChannel::query()->count(); - $activePlatformChannels = PlatformChannel::query()->where('is_active', 1)->count(); - $totalRoutes = Route::query()->count(); - $activeRoutes = Route::query()->where('is_active', 1)->count(); - - return [ - 'total_feeds' => $totalFeeds, - 'active_feeds' => $activeFeeds, - 'total_platform_accounts' => $totalPlatformAccounts, - 'active_platform_accounts' => $activePlatformAccounts, - 'total_platform_channels' => $totalPlatformChannels, - 'active_platform_channels' => $activePlatformChannels, - 'total_routes' => $totalRoutes, - 'active_routes' => $activeRoutes, - ]; - } -} diff --git a/app/Services/Factories/ArticleParserFactory.php b/app/Services/Factories/ArticleParserFactory.php index 765994a..3496a60 100644 --- a/app/Services/Factories/ArticleParserFactory.php +++ b/app/Services/Factories/ArticleParserFactory.php @@ -3,29 +3,22 @@ namespace App\Services\Factories; use App\Contracts\ArticleParserInterface; -use App\Models\Feed; use App\Services\Parsers\VrtArticleParser; use App\Services\Parsers\BelgaArticleParser; use Exception; class ArticleParserFactory { - /** - * @var array> - */ private static array $parsers = [ VrtArticleParser::class, BelgaArticleParser::class, ]; - /** - * @throws Exception - */ public static function getParser(string $url): ArticleParserInterface { foreach (self::$parsers as $parserClass) { $parser = new $parserClass(); - + if ($parser->canParse($url)) { return $parser; } @@ -34,28 +27,6 @@ public static function getParser(string $url): ArticleParserInterface throw new Exception("No parser found for URL: {$url}"); } - public static function getParserForFeed(Feed $feed, string $parserType = 'article'): ?ArticleParserInterface - { - if (!$feed->provider) { - return null; - } - - $providerConfig = config("feed.providers.{$feed->provider}"); - if (!$providerConfig || !isset($providerConfig['parsers'][$parserType])) { - return null; - } - - $parserClass = $providerConfig['parsers'][$parserType]; - if (!class_exists($parserClass)) { - return null; - } - - return new $parserClass(); - } - - /** - * @return array - */ public static function getSupportedSources(): array { return array_map(function($parserClass) { @@ -64,13 +35,10 @@ public static function getSupportedSources(): array }, self::$parsers); } - /** - * @param class-string $parserClass - */ public static function registerParser(string $parserClass): void { if (!in_array($parserClass, self::$parsers)) { self::$parsers[] = $parserClass; } } -} +} \ No newline at end of file diff --git a/app/Services/Factories/HomepageParserFactory.php b/app/Services/Factories/HomepageParserFactory.php index 7215961..f9d6ffc 100644 --- a/app/Services/Factories/HomepageParserFactory.php +++ b/app/Services/Factories/HomepageParserFactory.php @@ -3,29 +3,22 @@ namespace App\Services\Factories; use App\Contracts\HomepageParserInterface; -use App\Models\Feed; use App\Services\Parsers\VrtHomepageParserAdapter; use App\Services\Parsers\BelgaHomepageParserAdapter; use Exception; class HomepageParserFactory { - /** - * @var array> - */ private static array $parsers = [ VrtHomepageParserAdapter::class, BelgaHomepageParserAdapter::class, ]; - /** - * @throws Exception - */ public static function getParser(string $url): HomepageParserInterface { foreach (self::$parsers as $parserClass) { $parser = new $parserClass(); - + if ($parser->canParse($url)) { return $parser; } @@ -34,22 +27,23 @@ public static function getParser(string $url): HomepageParserInterface throw new Exception("No homepage parser found for URL: {$url}"); } - public static function getParserForFeed(Feed $feed): ?HomepageParserInterface + public static function getAllParsers(): array { - if (!$feed->provider) { - return null; - } - - $providerConfig = config("feed.providers.{$feed->provider}"); - if (!$providerConfig || !isset($providerConfig['parsers']['homepage'])) { - return null; - } - - $parserClass = $providerConfig['parsers']['homepage']; - if (!class_exists($parserClass)) { - return null; - } - - return new $parserClass(); + return array_map(fn($parserClass) => new $parserClass(), self::$parsers); } -} + + public static function getSupportedSources(): array + { + return array_map(function($parserClass) { + $parser = new $parserClass(); + return $parser->getSourceName(); + }, self::$parsers); + } + + public static function registerParser(string $parserClass): void + { + if (!in_array($parserClass, self::$parsers)) { + self::$parsers[] = $parserClass; + } + } +} \ No newline at end of file diff --git a/app/Services/Http/HttpFetcher.php b/app/Services/Http/HttpFetcher.php index 5ef5132..1d263b6 100644 --- a/app/Services/Http/HttpFetcher.php +++ b/app/Services/Http/HttpFetcher.php @@ -7,33 +7,25 @@ class HttpFetcher { - /** - * @throws Exception - */ public static function fetchHtml(string $url): string { try { $response = Http::get($url); - + if (!$response->successful()) { throw new Exception("Failed to fetch URL: {$url} - Status: {$response->status()}"); } - + return $response->body(); } catch (Exception $e) { logger()->error('HTTP fetch failed', [ 'url' => $url, 'error' => $e->getMessage() ]); - throw $e; } } - /** - * @param array $urls - * @return array> - */ public static function fetchMultipleUrls(array $urls): array { try { @@ -44,11 +36,13 @@ public static function fetchMultipleUrls(array $urls): array }); return collect($responses) - ->filter(fn($response, $index) => isset($urls[$index])) - ->reject(fn($response, $index) => $response instanceof Exception) ->map(function ($response, $index) use ($urls) { + if (!isset($urls[$index])) { + return null; + } + $url = $urls[$index]; - + try { if ($response->successful()) { return [ @@ -77,8 +71,7 @@ public static function fetchMultipleUrls(array $urls): array ->toArray(); } catch (Exception $e) { logger()->error('Multiple URL fetch failed', ['error' => $e->getMessage()]); - return []; } } -} +} \ No newline at end of file diff --git a/app/Services/Log/LogSaver.php b/app/Services/Log/LogSaver.php index 5a72fb4..c1a4e4d 100644 --- a/app/Services/Log/LogSaver.php +++ b/app/Services/Log/LogSaver.php @@ -2,64 +2,10 @@ namespace App\Services\Log; -use App\Enums\LogLevelEnum; -use App\Models\Log; -use App\Models\PlatformChannel; - class LogSaver { - /** - * @param array $context - */ - public function info(string $message, ?PlatformChannel $channel = null, array $context = []): void + public function log() { - $this->log(LogLevelEnum::INFO, $message, $channel, $context); - } - /** - * @param array $context - */ - public function error(string $message, ?PlatformChannel $channel = null, array $context = []): void - { - $this->log(LogLevelEnum::ERROR, $message, $channel, $context); - } - - /** - * @param array $context - */ - public function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void - { - $this->log(LogLevelEnum::WARNING, $message, $channel, $context); - } - - /** - * @param array $context - */ - public function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void - { - $this->log(LogLevelEnum::DEBUG, $message, $channel, $context); - } - - /** - * @param array $context - */ - private function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void - { - $logContext = $context; - - if ($channel) { - $logContext = array_merge($logContext, [ - 'channel_id' => $channel->id, - 'channel_name' => $channel->name, - 'platform' => $channel->platformInstance->platform->value, - 'instance_url' => $channel->platformInstance->url, - ]); - } - - Log::create([ - 'level' => $level, - 'message' => $message, - 'context' => $logContext, - ]); } } diff --git a/app/Services/OnboardingService.php b/app/Services/OnboardingService.php deleted file mode 100644 index 2d5dbda..0000000 --- a/app/Services/OnboardingService.php +++ /dev/null @@ -1,46 +0,0 @@ -checkOnboardingStatus(); - }); - } - - public function clearCache(): void - { - Cache::forget('onboarding_needed'); - } - - private function checkOnboardingStatus(): bool - { - $onboardingSkipped = Setting::where('key', 'onboarding_skipped')->value('value') === 'true'; - $onboardingCompleted = Setting::where('key', 'onboarding_completed')->exists(); - - // If skipped or completed, no onboarding needed - if ($onboardingCompleted || $onboardingSkipped) { - return false; - } - - // Check if all components exist - $hasPlatformAccount = PlatformAccount::where('is_active', true)->exists(); - $hasFeed = Feed::where('is_active', true)->exists(); - $hasChannel = PlatformChannel::where('is_active', true)->exists(); - $hasRoute = Route::where('is_active', true)->exists(); - - $hasAllComponents = $hasPlatformAccount && $hasFeed && $hasChannel && $hasRoute; - - return !$hasAllComponents; - } -} \ No newline at end of file diff --git a/app/Services/Parsers/BelgaArticlePageParser.php b/app/Services/Parsers/BelgaArticlePageParser.php index b438d32..14fdca7 100644 --- a/app/Services/Parsers/BelgaArticlePageParser.php +++ b/app/Services/Parsers/BelgaArticlePageParser.php @@ -55,41 +55,15 @@ public static function extractFullArticle(string $html): ?string $cleanHtml = preg_replace('/)<[^<]*)*<\/script>/mi', '', $html); $cleanHtml = preg_replace('/)<[^<]*)*<\/style>/mi', '', $cleanHtml); - // Look for Belga-specific paragraph class - if (preg_match_all('/]*class="[^"]*styles_paragraph__[^"]*"[^>]*>(.*?)<\/p>/is', $cleanHtml, $matches)) { - $paragraphs = array_map(function($paragraph) { - return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); - }, $matches[1]); - - // Filter out empty paragraphs and join with double newlines - $fullText = implode("\n\n", array_filter($paragraphs, function($p) { - return trim($p) !== ''; - })); - - return $fullText ?: null; - } - - // Fallback: Try to extract from prezly-slate-document section + // Try to extract content from Belga-specific document section if (preg_match('/]*class="[^"]*prezly-slate-document[^"]*"[^>]*>(.*?)<\/section>/is', $cleanHtml, $sectionMatches)) { $sectionHtml = $sectionMatches[1]; preg_match_all('/]*>(.*?)<\/p>/is', $sectionHtml, $matches); - - if (!empty($matches[1])) { - $paragraphs = array_map(function($paragraph) { - return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); - }, $matches[1]); - - // Filter out empty paragraphs and join with double newlines - $fullText = implode("\n\n", array_filter($paragraphs, function($p) { - return trim($p) !== ''; - })); - - return $fullText ?: null; - } + } else { + // Fallback: Extract all paragraph content + preg_match_all('/]*>(.*?)<\/p>/is', $cleanHtml, $matches); } - // Final fallback: Extract all paragraph content - preg_match_all('/]*>(.*?)<\/p>/is', $cleanHtml, $matches); if (!empty($matches[1])) { $paragraphs = array_map(function($paragraph) { return html_entity_decode(strip_tags($paragraph), ENT_QUOTES, 'UTF-8'); @@ -121,9 +95,6 @@ public static function extractThumbnail(string $html): ?string return null; } - /** - * @return array - */ public static function extractData(string $html): array { return [ diff --git a/app/Services/Parsers/BelgaHomepageParser.php b/app/Services/Parsers/BelgaHomepageParser.php index 8234582..2606222 100644 --- a/app/Services/Parsers/BelgaHomepageParser.php +++ b/app/Services/Parsers/BelgaHomepageParser.php @@ -4,43 +4,13 @@ class BelgaHomepageParser { - /** - * @return array - */ public static function extractArticleUrls(string $html): array { - // Find all relative article links (most articles use relative paths) - preg_match_all('/]+href="(\/[a-z0-9-]+)"/', $html, $matches); + preg_match_all('/href="https:\/\/www\.belganewsagency\.eu\/([a-z0-9-]+)"/', $html, $matches); - // Blacklist of non-article paths - $blacklistPaths = [ - '/', - '/de', - '/feed', - '/search', - '/category', - '/about', - '/contact', - '/privacy', - '/terms', - ]; - - $urls = collect($matches[1]) + $urls = collect($matches[0] ?? []) ->unique() - ->filter(function ($path) use ($blacklistPaths) { - // Exclude exact matches and paths starting with blacklisted paths - foreach ($blacklistPaths as $blacklistedPath) { - if ($path === $blacklistedPath || str_starts_with($path, $blacklistedPath . '/')) { - return false; - } - } - return true; - }) - ->map(function ($path) { - // Convert relative paths to absolute URLs - return 'https://www.belganewsagency.eu' . $path; - }) - ->values() + ->map(fn ($url) => str_replace('href="', '', str_replace('"', '', $url))) ->toArray(); return $urls; diff --git a/app/Services/Parsers/VrtArticlePageParser.php b/app/Services/Parsers/VrtArticlePageParser.php index 323713d..2a408b1 100644 --- a/app/Services/Parsers/VrtArticlePageParser.php +++ b/app/Services/Parsers/VrtArticlePageParser.php @@ -77,9 +77,6 @@ public static function extractThumbnail(string $html): ?string return null; } - /** - * @return array - */ public static function extractData(string $html): array { return [ diff --git a/app/Services/Parsers/VrtHomepageParser.php b/app/Services/Parsers/VrtHomepageParser.php index 8ca4d96..50dbed9 100644 --- a/app/Services/Parsers/VrtHomepageParser.php +++ b/app/Services/Parsers/VrtHomepageParser.php @@ -4,15 +4,12 @@ class VrtHomepageParser { - /** - * @return array - */ public static function extractArticleUrls(string $html): array { // Extract article links using regex preg_match_all('/href="(\/vrtnws\/en\/\d{4}\/\d{2}\/\d{2}\/[^"]+)"/', $html, $matches); - $urls = collect($matches[1]) + $urls = collect($matches[1] ?? []) ->unique() ->map(fn ($path) => 'https://www.vrt.be' . $path) ->toArray(); diff --git a/app/Services/Publishing/ArticlePublishingService.php b/app/Services/Publishing/ArticlePublishingService.php deleted file mode 100644 index 26c12dc..0000000 --- a/app/Services/Publishing/ArticlePublishingService.php +++ /dev/null @@ -1,143 +0,0 @@ - $extractedData - * @return Collection - * @throws PublishException - */ - public function publishToRoutedChannels(Article $article, array $extractedData): Collection - { - if (! $article->isValid()) { - throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE')); - } - - $feed = $article->feed; - - // Get active routes with keywords instead of just channels - $activeRoutes = Route::where('feed_id', $feed->id) - ->where('is_active', true) - ->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords']) - ->orderBy('priority', 'desc') - ->get(); - - // Filter routes based on keyword matches - $matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) { - return $this->routeMatchesArticle($route, $extractedData); - }); - - return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) { - $channel = $route->platformChannel; - $account = $channel->activePlatformAccounts()->first(); - - if (! $account) { - $this->logSaver->warning('No active account for channel', $channel, [ - 'article_id' => $article->id, - 'route_priority' => $route->priority - ]); - - return null; - } - - return $this->publishToChannel($article, $extractedData, $channel, $account); - }) - ->filter(); - } - - /** - * Check if a route matches an article based on keywords - * @param array $extractedData - */ - private function routeMatchesArticle(Route $route, array $extractedData): bool - { - // Get active keywords for this route - $activeKeywords = $route->keywords->where('is_active', true); - - // If no keywords are defined for this route, the route matches any article - if ($activeKeywords->isEmpty()) { - return true; - } - - // Get article content for keyword matching - $articleContent = ''; - if (isset($extractedData['full_article'])) { - $articleContent = $extractedData['full_article']; - } - if (isset($extractedData['title'])) { - $articleContent .= ' ' . $extractedData['title']; - } - if (isset($extractedData['description'])) { - $articleContent .= ' ' . $extractedData['description']; - } - - // Check if any of the route's keywords match the article content - foreach ($activeKeywords as $keywordModel) { - $keyword = $keywordModel->keyword; - if (stripos($articleContent, $keyword) !== false) { - return true; - } - } - - return false; - } - - /** - * @param array $extractedData - */ - private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication - { - try { - $publisher = $this->makePublisher($account); - $postData = $publisher->publishToChannel($article, $extractedData, $channel); - - $publication = ArticlePublication::create([ - 'article_id' => $article->id, - 'post_id' => $postData['post_view']['post']['id'], - 'platform_channel_id' => $channel->id, - 'published_by' => $account->username, - 'published_at' => now(), - 'platform' => $channel->platformInstance->platform->value, - 'publication_data' => $postData, - ]); - - $this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [ - 'article_id' => $article->id - ]); - - return $publication; - } catch (Exception $e) { - $this->logSaver->warning('Failed to publish to channel', $channel, [ - 'article_id' => $article->id, - 'error' => $e->getMessage() - ]); - - return null; - } - } -} diff --git a/app/Services/RoutingValidationService.php b/app/Services/RoutingValidationService.php deleted file mode 100644 index 7ddff2a..0000000 --- a/app/Services/RoutingValidationService.php +++ /dev/null @@ -1,32 +0,0 @@ - $channels - * @throws RoutingMismatchException - */ - public function validateLanguageCompatibility(Feed $feed, Collection $channels): void - { - if (! $feed->language) { - return; - } - - foreach ($channels as $channel) { - if (! $channel->language) { - continue; - } - - if ($feed->language !== $channel->language) { - throw new RoutingMismatchException($feed, $channel); - } - } - } -} \ No newline at end of file diff --git a/app/Services/SystemStatusService.php b/app/Services/SystemStatusService.php deleted file mode 100644 index 67e67bc..0000000 --- a/app/Services/SystemStatusService.php +++ /dev/null @@ -1,52 +0,0 @@ -} - */ - public function getSystemStatus(): array - { - $reasons = []; - $isEnabled = true; - - if (!Setting::isArticleProcessingEnabled()) { - $isEnabled = false; - $reasons[] = 'Manually disabled by user'; - } - - if (!Feed::where('is_active', true)->exists()) { - $isEnabled = false; - $reasons[] = 'No active feeds configured'; - } - - if (!PlatformChannel::where('is_active', true)->exists()) { - $isEnabled = false; - $reasons[] = 'No active platform channels configured'; - } - - if (!Route::where('is_active', true)->exists()) { - $isEnabled = false; - $reasons[] = 'No active feed-to-channel routes configured'; - } - - return [ - 'is_enabled' => $isEnabled, - 'status' => $isEnabled ? 'Enabled' : 'Disabled', - 'status_class' => $isEnabled ? 'text-green-600' : 'text-red-600', - 'reasons' => $reasons, - ]; - } - - public function canProcessArticles(): bool - { - return $this->getSystemStatus()['is_enabled']; - } -} \ No newline at end of file diff --git a/app/View/Components/AppLayout.php b/app/View/Components/AppLayout.php deleted file mode 100644 index de0d46f..0000000 --- a/app/View/Components/AppLayout.php +++ /dev/null @@ -1,17 +0,0 @@ -withRouting( web: __DIR__.'/../routes/web.php', - api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware) { - $middleware->alias([ - 'onboarding.complete' => EnsureOnboardingComplete::class, - 'onboarding.incomplete' => RedirectIfOnboardingComplete::class, + $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); + + $middleware->web(append: [ + HandleAppearance::class, + HandleInertiaRequests::class, + AddLinkHeadersForPreloadedAssets::class, ]); }) ->withExceptions(function (Exceptions $exceptions) { $exceptions->reportable(function (Throwable $e) { $level = match (true) { - $e instanceof Error => \App\Enums\LogLevelEnum::CRITICAL, - $e instanceof RuntimeException => \App\Enums\LogLevelEnum::ERROR, - $e instanceof InvalidArgumentException => \App\Enums\LogLevelEnum::WARNING, - default => \App\Enums\LogLevelEnum::ERROR, + $e instanceof Error => App\LogLevelEnum::CRITICAL, + $e instanceof RuntimeException => App\LogLevelEnum::ERROR, + $e instanceof InvalidArgumentException => App\LogLevelEnum::WARNING, + default => App\LogLevelEnum::ERROR, }; - + App\Events\ExceptionOccurred::dispatch( $e, $level, diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 4e3b440..38b258d 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,4 @@ return [ App\Providers\AppServiceProvider::class, - App\Providers\HorizonServiceProvider::class, ]; diff --git a/components.json b/components.json new file mode 100644 index 0000000..14024c7 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "resources/css/app.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/composer.json b/composer.json index ad49d34..4992f07 100644 --- a/composer.json +++ b/composer.json @@ -10,25 +10,18 @@ "license": "MIT", "require": { "php": "^8.2", - "blade-ui-kit/blade-heroicons": "^2.6", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", - "laravel/horizon": "^5.29", - "laravel/sanctum": "^4.2", "laravel/tinker": "^2.10.1", - "livewire/livewire": "^4.0", "tightenco/ziggy": "^2.4" }, "require-dev": { "fakerphp/faker": "^1.23", - "larastan/larastan": "^3.5", - "laravel/breeze": "^2.3", "laravel/pail": "^1.2.2", "laravel/pint": "^1.18", "laravel/sail": "^1.43", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", - "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^11.5.3" }, "autoload": { diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..4ccc4d6 --- /dev/null +++ b/composer.lock @@ -0,0 +1,8240 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "3a3b8574ec2625680499045903dfe7df", + "packages": [ + { + "name": "brick/math", + "version": "0.13.1", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", + "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.13.1" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-03-29T13:50:30+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.0.10", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^11.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^8.5 || ^9.5", + "vimeo/psalm": "^4.25 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.0.10" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2024-02-18T20:23:39+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "8c784d071debd117328803d86b2097615b457500" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", + "reference": "8c784d071debd117328803d86b2097615b457500", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0", + "webmozart/assert": "^1.0" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^1.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2024-10-09T13:47:03+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", + "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "symfony/http-foundation": "^4.4|^5.4|^6|^7" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2023-10-12T05:21:21+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.3", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", + "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:45:45+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.9.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:37:11+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:27:01+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-03-27T12:30:47+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", + "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2025-02-03T10:55:03+00:00" + }, + { + "name": "inertiajs/inertia-laravel", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/inertiajs/inertia-laravel.git", + "reference": "b732a5cc33423b2c2366fea38b17dc637d2a0b4f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/b732a5cc33423b2c2366fea38b17dc637d2a0b4f", + "reference": "b732a5cc33423b2c2366fea38b17dc637d2a0b4f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^10.0|^11.0|^12.0", + "php": "^8.1.0", + "symfony/console": "^6.2|^7.0" + }, + "require-dev": { + "laravel/pint": "^1.16", + "mockery/mockery": "^1.3.3", + "orchestra/testbench": "^8.0|^9.2|^10.0", + "phpunit/phpunit": "^10.4|^11.5", + "roave/security-advisories": "dev-master" + }, + "suggest": { + "ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Inertia\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "./helpers.php" + ], + "psr-4": { + "Inertia\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Reinink", + "email": "jonathan@reinink.ca", + "homepage": "https://reinink.ca" + } + ], + "description": "The Laravel adapter for Inertia.js.", + "keywords": [ + "inertia", + "laravel" + ], + "support": { + "issues": "https://github.com/inertiajs/inertia-laravel/issues", + "source": "https://github.com/inertiajs/inertia-laravel/tree/v2.0.3" + }, + "time": "2025-06-20T07:38:21+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.19.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "4e6ec689ef704bb4bd282f29d9dd658dfb4fb262" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/4e6ec689ef704bb4bd282f29d9dd658dfb4fb262", + "reference": "4e6ec689ef704bb4bd282f29d9dd658dfb4fb262", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.7", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.31", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "orchestra/testbench-core": "^10.0.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-06-18T12:56:23+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/57b8f7efe40333cdb925700891c7d7465325d3b1", + "reference": "57b8f7efe40333cdb925700891c7d7465325d3b1", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-mockery": "^1.1" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.5" + }, + "time": "2025-02-11T13:34:40+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2025-03-19T13:51:03+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.10.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3", + "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.10.1" + }, + "time": "2025-01-27T14:24:01+00:00" + }, + { + "name": "league/commonmark", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.8-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2025-05-05T12:20:28+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.30.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e", + "reference": "2203e3151755d874bb2943649dae1eb8533ac93e", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.30.0" + }, + "time": "2025-06-25T13:29:59+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.30.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10", + "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0" + }, + "time": "2025-05-21T10:34:19+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "league/uri", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "81fb5145d2644324614cc532b28efd0215bda430" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.5", + "php": "^8.1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:40:02+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.5.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-factory": "^1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common interfaces and classes for URI representation and interaction", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:18:47+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.9.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", + "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2025-03-24T10:02:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.10.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.75.0", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^10.5.46", + "squizlabs/php_codesniffer": "^3.13.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2025-06-21T15:19:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.2", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", + "reference": "da801d52f0354f70a638673c4a0f04e16529431d", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.4" + }, + "require-dev": { + "nette/tester": "^2.5.2", + "phpstan/phpstan-nette": "^1.0", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.2" + }, + "time": "2024-10-06T23:10:23+00:00" + }, + { + "name": "nette/utils", + "version": "v4.0.7", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "reference": "e67c4061eb40b9c113b218214e42cb5a0dda28f2", + "shasum": "" + }, + "require": { + "php": "8.0 - 8.4" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "dev-master", + "nette/tester": "^2.5", + "phpstan/phpstan": "^1.0", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.0.7" + }, + "time": "2025-06-03T04:55:08+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.5.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/ae59794362fe85e051a58ad36b289443f57be7a9", + "reference": "ae59794362fe85e051a58ad36b289443f57be7a9", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.5.0" + }, + "time": "2025-05-31T08:24:38+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.3.1", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.2.6" + }, + "require-dev": { + "illuminate/console": "^11.44.7", + "laravel/pint": "^1.22.0", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.2", + "phpstan/phpstan": "^1.12.25", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.2.6", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Its like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2025-05-08T08:14:37+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.3", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", + "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2024-07-20T21:41:07+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.9", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "1b801844becfe648985372cb4b12ad6840245ace" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/1b801844becfe648985372cb4b12ad6840245ace", + "reference": "1b801844becfe648985372cb4b12ad6840245ace", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2" + }, + "suggest": { + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-pdo-sqlite": "The doc command requires SQLite to work.", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "http://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.9" + }, + "time": "2025-06-23T02:35:06+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.0", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "shasum": "" + }, + "require": { + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.0" + }, + "time": "2025-06-25T14:20:11+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/console", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/9e27aecde8f506ba0fd1d9989620c04a87697101", + "reference": "9e27aecde8f506ba0fd1d9989620c04a87697101", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/35b55b166f6752d6aaf21aa042fc5ed280fce235", + "reference": "35b55b166f6752d6aaf21aa042fc5ed280fce235", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/var-dumper": "^6.4|^7.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-13T07:48:40+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", + "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-22T09:11:45+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", + "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-30T19:00:26+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/23dd60256610c86a3414575b70c596e5deff6ed9", + "reference": "23dd60256610c86a3414575b70c596e5deff6ed9", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/clock": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/rate-limiter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T15:07:14+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "reference": "1644879a66e4aa29c36fe33dfa6c54b450ce1831", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0", + "symfony/event-dispatcher": "^7.3", + "symfony/http-foundation": "^7.3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/css-selector": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/dom-crawler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^7.1", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^7.1", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-28T08:24:55+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "reference": "b5db5105b290bdbea5ab27b89c69effcf1cb3368", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/mime": "^7.2", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/twig-bridge": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/serializer": "^6.4.3|^7.0.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-02-19T08:51:26+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.32.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/process", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "reference": "40c295f2deb408d5e9d2d32b8ba1dd61e36f05af", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-17T09:11:12+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "8e213820c5fea844ecea29203d2a308019007c15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/8e213820c5fea844ecea29203d2a308019007c15", + "reference": "8e213820c5fea844ecea29203d2a308019007c15", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-05-24T20:43:28+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-25T09:37:31+00:00" + }, + { + "name": "symfony/string", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", + "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-20T20:19:01+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "241d5ac4910d256660238a7ecf250deba4c73063" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/241d5ac4910d256660238a7ecf250deba4c73063", + "reference": "241d5ac4910d256660238a7ecf250deba4c73063", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-27T08:32:26+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb", + "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "reference": "6e209fbe5f5a7b6043baba46fe5735a4b85d0d42", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "ext-iconv": "*", + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "tightenco/ziggy", + "version": "v2.5.3", + "source": { + "type": "git", + "url": "https://github.com/tighten/ziggy.git", + "reference": "0b3b521d2c55fbdb04b6721532f7f5f49d32f52b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tighten/ziggy/zipball/0b3b521d2c55fbdb04b6721532f7f5f49d32f52b", + "reference": "0b3b521d2c55fbdb04b6721532f7f5f49d32f52b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": ">=9.0", + "php": ">=8.1" + }, + "require-dev": { + "laravel/folio": "^1.1", + "orchestra/testbench": "^7.0 || ^8.0 || ^9.0 || ^10.0", + "pestphp/pest": "^2.26|^3.0", + "pestphp/pest-plugin-laravel": "^2.4|^3.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Tighten\\Ziggy\\ZiggyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Tighten\\Ziggy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel Coulbourne", + "email": "daniel@tighten.co" + }, + { + "name": "Jake Bathman", + "email": "jake@tighten.co" + }, + { + "name": "Jacob Baker-Kretzmar", + "email": "jacob@tighten.co" + } + ], + "description": "Use your Laravel named routes in JavaScript.", + "homepage": "https://github.com/tighten/ziggy", + "keywords": [ + "Ziggy", + "javascript", + "laravel", + "routes" + ], + "support": { + "issues": "https://github.com/tighten/ziggy/issues", + "source": "https://github.com/tighten/ziggy/tree/v2.5.3" + }, + "time": "2025-05-17T18:15:19+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.3.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", + "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + }, + "time": "2024-12-21T16:25:41+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.2", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.3", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.3", + "symfony/polyfill-ctype": "^1.24", + "symfony/polyfill-mbstring": "^1.24", + "symfony/polyfill-php80": "^1.24" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2025-04-30T23:37:27+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "shasum": "" + }, + "require": { + "php": ">=7.0.0" + }, + "require-dev": { + "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2024-11-21T01:49:47+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": "^7.2 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<0.12.20", + "vimeo/psalm": "<4.6.1 || 4.6.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.11.0" + }, + "time": "2022-06-03T18:03:27+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.3", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "59a123a3d459c5a23055802237cb317f609867e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/59a123a3d459c5a23055802237cb317f609867e5", + "reference": "59a123a3d459c5a23055802237cb317f609867e5", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.3" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-06-16T00:02:10+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "reference": "8cc3d575c1f0e57eeb923f366a37528c50d2385a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0", + "illuminate/contracts": "^10.24|^11.0|^12.0", + "illuminate/log": "^10.24|^11.0|^12.0", + "illuminate/process": "^10.24|^11.0|^12.0", + "illuminate/support": "^10.24|^11.0|^12.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.0|^10.0", + "pestphp/pest": "^2.20|^3.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2025-06-05T13:55:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "941d1927c5ca420c22710e98420287169c7bcaf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/941d1927c5ca420c22710e98420287169c7bcaf7", + "reference": "941d1927c5ca420c22710e98420287169c7bcaf7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.75.0", + "illuminate/view": "^11.44.7", + "larastan/larastan": "^3.4.0", + "laravel-zero/framework": "^11.36.1", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.1", + "pestphp/pest": "^2.36.0" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2025-05-08T08:38:12+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.43.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "3e7d899232a8c5e3ea4fc6dee7525ad583887e72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/3e7d899232a8c5e3ea4fc6dee7525ad583887e72", + "reference": "3e7d899232a8c5e3ea4fc6dee7525ad583887e72", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0", + "symfony/yaml": "^6.0|^7.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "phpstan/phpstan": "^1.10" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2025-05-19T13:19:21+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.1", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-04-29T12:36:36+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.8.2", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "reference": "60207965f9b7b7a4ce15a0f75d57f9dadb105bdb", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.1", + "nunomaduro/termwind": "^2.3.1", + "php": "^8.2.0", + "symfony/console": "^7.3.0" + }, + "conflict": { + "laravel/framework": "<11.44.2 || >=13.0.0", + "phpunit/phpunit": "<11.5.15 || >=13.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.3", + "larastan/larastan": "^3.4.2", + "laravel/framework": "^11.44.2 || ^12.18", + "laravel/pint": "^1.22.1", + "laravel/sail": "^1.43.1", + "laravel/sanctum": "^4.1.1", + "laravel/tinker": "^2.10.1", + "orchestra/testbench-core": "^9.12.0 || ^10.4", + "pestphp/pest": "^3.8.2", + "sebastian/environment": "^7.2.1 || ^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2025-06-25T02:12:12+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", + "reference": "1a800a7446add2d79cc6b3c01c45381810367d76", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.4.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-06-18T08:56:18+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.25", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "864ab32b3ff52058f917c5b19b3cef821e4a4f1b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/864ab32b3ff52058f917c5b19b3cef821e4a4f1b", + "reference": "864ab32b3ff52058f917c5b19b3cef821e4a4f1b", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.1", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.1", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.0", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.2", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.25" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-06-27T04:36:07+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-07T06:57:01+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-12-05T09:17:50+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:10:34+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-18T13:35:50+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0c3555045a46ab3cd4cc5a69d161225195230edb", + "reference": "0c3555045a46ab3cd4cc5a69d161225195230edb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-03T06:57:57+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.2.3", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:36:25+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/config/feed.php b/config/feed.php deleted file mode 100644 index 2c4288d..0000000 --- a/config/feed.php +++ /dev/null @@ -1,56 +0,0 @@ - [ - 'vrt' => [ - 'code' => 'vrt', - 'name' => 'VRT News', - 'description' => 'Belgian public broadcaster news', - 'type' => 'website', - 'is_active' => true, - 'parsers' => [ - 'homepage' => \App\Services\Parsers\VrtHomepageParserAdapter::class, - 'article' => \App\Services\Parsers\VrtArticleParser::class, - 'article_page' => \App\Services\Parsers\VrtArticlePageParser::class, - ], - ], - 'belga' => [ - 'code' => 'belga', - 'name' => 'Belga News Agency', - 'description' => 'Belgian national news agency', - 'type' => 'rss', - 'is_active' => true, - 'parsers' => [ - 'homepage' => \App\Services\Parsers\BelgaHomepageParserAdapter::class, - 'article' => \App\Services\Parsers\BelgaArticleParser::class, - 'article_page' => \App\Services\Parsers\BelgaArticlePageParser::class, - ], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Default Feed Settings - |-------------------------------------------------------------------------- - | - | Default configuration values for feed processing - | - */ - - 'defaults' => [ - 'fetch_interval' => 3600, // 1 hour in seconds - 'max_articles_per_fetch' => 50, - 'article_retention_days' => 30, - ], -]; \ No newline at end of file diff --git a/config/horizon.php b/config/horizon.php deleted file mode 100644 index 34761ea..0000000 --- a/config/horizon.php +++ /dev/null @@ -1,213 +0,0 @@ - env('HORIZON_DOMAIN'), - - /* - |-------------------------------------------------------------------------- - | Horizon Path - |-------------------------------------------------------------------------- - | - | This is the URI path where Horizon will be accessible from. Feel free - | to change this path to anything you like. Note that the URI will not - | affect the paths of its internal API that aren't exposed to users. - | - */ - - 'path' => env('HORIZON_PATH', 'horizon'), - - /* - |-------------------------------------------------------------------------- - | Horizon Redis Connection - |-------------------------------------------------------------------------- - | - | This is the name of the Redis connection where Horizon will store the - | meta information required for it to function. It includes the list - | of supervisors, failed jobs, job metrics, and other information. - | - */ - - 'use' => 'default', - - /* - |-------------------------------------------------------------------------- - | Horizon Redis Prefix - |-------------------------------------------------------------------------- - | - | This prefix will be used when storing all Horizon data in Redis. You - | may modify the prefix when you are running multiple installations - | of Horizon on the same server so that they don't have problems. - | - */ - - 'prefix' => env( - 'HORIZON_PREFIX', - Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:' - ), - - /* - |-------------------------------------------------------------------------- - | Horizon Route Middleware - |-------------------------------------------------------------------------- - | - | These middleware will get attached onto each Horizon route, giving you - | the chance to add your own middleware to this list or change any of - | the existing middleware. Or, you can simply stick with this list. - | - */ - - 'middleware' => ['web'], - - /* - |-------------------------------------------------------------------------- - | Queue Wait Time Thresholds - |-------------------------------------------------------------------------- - | - | This option allows you to configure when the LongWaitDetected event - | will be fired. Every connection / queue combination may have its - | own, unique threshold (in seconds) before this event is fired. - | - */ - - 'waits' => [ - 'redis:default' => 60, - ], - - /* - |-------------------------------------------------------------------------- - | Job Trimming Times - |-------------------------------------------------------------------------- - | - | Here you can configure for how long (in minutes) you desire Horizon to - | persist the recent and failed jobs. Typically, recent jobs are kept - | for one hour while all failed jobs are stored for an entire week. - | - */ - - 'trim' => [ - 'recent' => 60, - 'pending' => 60, - 'completed' => 60, - 'recent_failed' => 10080, - 'failed' => 10080, - 'monitored' => 10080, - ], - - /* - |-------------------------------------------------------------------------- - | Silenced Jobs - |-------------------------------------------------------------------------- - | - | Silencing a job will instruct Horizon to not place the job in the list - | of completed jobs within the Horizon dashboard. This setting may be - | used to fully remove any noisy jobs from the completed jobs list. - | - */ - - 'silenced' => [ - // App\Jobs\ExampleJob::class, - ], - - /* - |-------------------------------------------------------------------------- - | Metrics - |-------------------------------------------------------------------------- - | - | Here you can configure how many snapshots should be kept to display in - | the metrics graph. This will get used in combination with Horizon's - | `horizon:snapshot` schedule to define how long to retain metrics. - | - */ - - 'metrics' => [ - 'trim_snapshots' => [ - 'job' => 24, - 'queue' => 24, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Fast Termination - |-------------------------------------------------------------------------- - | - | When this option is enabled, Horizon's "terminate" command will not - | wait on all of the workers to terminate unless the --wait option - | is provided. Fast termination can shorten deployment delay by - | allowing a new instance of Horizon to start while the last - | instance will continue to terminate each of its workers. - | - */ - - 'fast_termination' => false, - - /* - |-------------------------------------------------------------------------- - | Memory Limit (MB) - |-------------------------------------------------------------------------- - | - | This value describes the maximum amount of memory the Horizon master - | supervisor may consume before it is terminated and restarted. For - | configuring these limits on your workers, see the next section. - | - */ - - 'memory_limit' => 64, - - /* - |-------------------------------------------------------------------------- - | Queue Worker Configuration - |-------------------------------------------------------------------------- - | - | Here you may define the queue worker settings used by your application - | in all environments. These supervisors and settings handle all your - | queued jobs and will be provisioned by Horizon during deployment. - | - */ - - 'defaults' => [ - 'supervisor-1' => [ - 'connection' => 'redis', - 'queue' => ['default', 'publishing', 'feed-discovery'], - 'balance' => 'auto', - 'autoScalingStrategy' => 'time', - 'maxProcesses' => 1, - 'maxTime' => 0, - 'maxJobs' => 0, - 'memory' => 128, - 'tries' => 1, - 'timeout' => 60, - 'nice' => 0, - ], - ], - - 'environments' => [ - 'production' => [ - 'supervisor-1' => [ - 'maxProcesses' => 10, - 'balanceMaxShift' => 1, - 'balanceCooldown' => 3, - ], - ], - - 'local' => [ - 'supervisor-1' => [ - 'maxProcesses' => 3, - ], - ], - ], -]; diff --git a/config/inertia.php b/config/inertia.php new file mode 100644 index 0000000..f75e7b8 --- /dev/null +++ b/config/inertia.php @@ -0,0 +1,55 @@ + [ + 'enabled' => true, + 'url' => 'http://127.0.0.1:13714', + // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), + + ], + + /* + |-------------------------------------------------------------------------- + | Testing + |-------------------------------------------------------------------------- + | + | The values described here are used to locate Inertia components on the + | filesystem. For instance, when using `assertInertia`, the assertion + | attempts to locate the component as a file relative to the paths. + | + */ + + 'testing' => [ + + 'ensure_pages_exist' => true, + + 'page_paths' => [ + resource_path('js/pages'), + ], + + 'page_extensions' => [ + 'js', + 'jsx', + 'svelte', + 'ts', + 'tsx', + 'vue', + ], + + ], + +]; diff --git a/config/languages.php b/config/languages.php deleted file mode 100644 index d0f8c7f..0000000 --- a/config/languages.php +++ /dev/null @@ -1,51 +0,0 @@ - [ - 'en' => [ - 'short_code' => 'en', - 'name' => 'English', - 'native_name' => 'English', - 'is_active' => true, - ], - 'nl' => [ - 'short_code' => 'nl', - 'name' => 'Dutch', - 'native_name' => 'Nederlands', - 'is_active' => true, - ], - 'fr' => [ - 'short_code' => 'fr', - 'name' => 'French', - 'native_name' => 'Français', - 'is_active' => true, - ], - 'de' => [ - 'short_code' => 'de', - 'name' => 'German', - 'native_name' => 'Deutsch', - 'is_active' => true, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Default Language - |-------------------------------------------------------------------------- - | - | The default language code when no language is specified - | - */ - - 'default' => 'en', -]; \ No newline at end of file diff --git a/config/lemmy.php b/config/lemmy.php new file mode 100644 index 0000000..49b3fc5 --- /dev/null +++ b/config/lemmy.php @@ -0,0 +1,8 @@ + env('LEMMY_USERNAME'), + 'password' => env('LEMMY_PASSWORD'), + 'instance' => env('LEMMY_INSTANCE'), + 'community' => env('LEMMY_COMMUNITY'), +]; diff --git a/config/sanctum.php b/config/sanctum.php deleted file mode 100644 index 44527d6..0000000 --- a/config/sanctum.php +++ /dev/null @@ -1,84 +0,0 @@ - explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( - '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', - Sanctum::currentApplicationUrlWithPort(), - // Sanctum::currentRequestHost(), - ))), - - /* - |-------------------------------------------------------------------------- - | Sanctum Guards - |-------------------------------------------------------------------------- - | - | This array contains the authentication guards that will be checked when - | Sanctum is trying to authenticate a request. If none of these guards - | are able to authenticate the request, Sanctum will use the bearer - | token that's present on an incoming request for authentication. - | - */ - - 'guard' => ['web'], - - /* - |-------------------------------------------------------------------------- - | Expiration Minutes - |-------------------------------------------------------------------------- - | - | This value controls the number of minutes until an issued token will be - | considered expired. This will override any values set in the token's - | "expires_at" attribute, but first-party sessions are not affected. - | - */ - - 'expiration' => null, - - /* - |-------------------------------------------------------------------------- - | Token Prefix - |-------------------------------------------------------------------------- - | - | Sanctum can prefix new tokens in order to take advantage of numerous - | security scanning initiatives maintained by open source platforms - | that notify developers if they commit tokens into repositories. - | - | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning - | - */ - - 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), - - /* - |-------------------------------------------------------------------------- - | Sanctum Middleware - |-------------------------------------------------------------------------- - | - | When authenticating your first-party SPA with Sanctum you may need to - | customize some of the middleware Sanctum uses while processing the - | request. You may change the middleware listed below as required. - | - */ - - 'middleware' => [ - 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, - 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, - 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, - ], - -]; diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index 40e14ee..f62004c 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -17,15 +17,7 @@ class ArticleFactory extends Factory public function definition(): array { return [ - 'feed_id' => \App\Models\Feed::factory(), - 'url' => $this->faker->url(), - 'title' => $this->faker->sentence(), - 'description' => $this->faker->paragraph(), - 'content' => $this->faker->paragraphs(3, true), - 'image_url' => $this->faker->optional()->imageUrl(), - 'published_at' => $this->faker->optional()->dateTimeBetween('-1 month', 'now'), - 'author' => $this->faker->optional()->name(), - 'approval_status' => $this->faker->randomElement(['pending', 'approved', 'rejected']), + // ]; } } diff --git a/database/factories/ArticlePublicationFactory.php b/database/factories/ArticlePublicationFactory.php deleted file mode 100644 index ed59ac4..0000000 --- a/database/factories/ArticlePublicationFactory.php +++ /dev/null @@ -1,35 +0,0 @@ - Article::factory(), - 'platform_channel_id' => PlatformChannel::factory(), - 'post_id' => $this->faker->uuid(), - 'platform' => 'lemmy', - 'published_at' => $this->faker->dateTimeBetween('-1 month', 'now'), - 'published_by' => $this->faker->userName(), - 'publication_data' => null, - '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'), - ]); - } -} \ No newline at end of file diff --git a/database/factories/FeedFactory.php b/database/factories/FeedFactory.php deleted file mode 100644 index d07c3f0..0000000 --- a/database/factories/FeedFactory.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ -class FeedFactory extends Factory -{ - protected $model = Feed::class; - - public function definition(): array - { - return [ - 'name' => $this->faker->words(3, true), - 'url' => $this->faker->url(), - 'type' => $this->faker->randomElement(['website', 'rss']), - 'provider' => $this->faker->randomElement(['vrt', 'belga']), - 'language_id' => null, - 'description' => $this->faker->optional()->sentence(), - 'settings' => [], - 'is_active' => true, - 'last_fetched_at' => null, - ]; - } - - public function inactive(): static - { - return $this->state(fn (array $attributes) => [ - 'is_active' => false, - ]); - } - - public function website(): static - { - return $this->state(fn (array $attributes) => [ - 'type' => 'website', - ]); - } - - public function rss(): static - { - return $this->state(fn (array $attributes) => [ - 'type' => 'rss', - ]); - } - - public function recentlyFetched(): static - { - return $this->state(fn (array $attributes) => [ - 'last_fetched_at' => now()->subHour(), - ]); - } - - public function language(Language $language): static - { - return $this->state(fn (array $attributes) => [ - 'language_id' => $language->id, - ]); - } - - public function vrt(): static - { - return $this->state(fn (array $attributes) => [ - 'provider' => 'vrt', - 'url' => 'https://www.vrt.be/vrtnws/en/', - ]); - } - - public function belga(): static - { - return $this->state(fn (array $attributes) => [ - 'provider' => 'belga', - 'url' => 'https://www.belganewsagency.eu/', - ]); - } -} \ No newline at end of file diff --git a/database/factories/KeywordFactory.php b/database/factories/KeywordFactory.php deleted file mode 100644 index 57e6f0b..0000000 --- a/database/factories/KeywordFactory.php +++ /dev/null @@ -1,51 +0,0 @@ - \App\Models\Feed::factory(), - 'platform_channel_id' => \App\Models\PlatformChannel::factory(), - 'keyword' => $this->faker->word(), - 'is_active' => $this->faker->boolean(70), // 70% chance of being active - 'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'), - 'updated_at' => now(), - ]; - } - - public function forFeed(\App\Models\Feed $feed): static - { - return $this->state(fn (array $attributes) => [ - 'feed_id' => $feed->id, - ]); - } - - public function forChannel(\App\Models\PlatformChannel $channel): static - { - return $this->state(fn (array $attributes) => [ - 'platform_channel_id' => $channel->id, - ]); - } - - 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, - ]); - } -} \ No newline at end of file diff --git a/database/factories/LanguageFactory.php b/database/factories/LanguageFactory.php deleted file mode 100644 index 3e03213..0000000 --- a/database/factories/LanguageFactory.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ -class LanguageFactory extends Factory -{ - protected $model = Language::class; - - public function definition(): array - { - return [ - 'short_code' => $this->faker->unique()->languageCode(), - 'name' => $this->faker->unique()->word(), - 'native_name' => $this->faker->optional()->word(), - 'is_active' => true, - ]; - } - - public function inactive(): static - { - return $this->state(fn (array $attributes) => [ - 'is_active' => false, - ]); - } - - public function english(): static - { - return $this->state(fn (array $attributes) => [ - 'short_code' => 'en', - 'name' => 'English', - 'native_name' => 'English', - ]); - } -} \ No newline at end of file diff --git a/database/factories/LogFactory.php b/database/factories/LogFactory.php deleted file mode 100644 index f9617de..0000000 --- a/database/factories/LogFactory.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ -class LogFactory extends Factory -{ - protected $model = Log::class; - - /** - * Define the model's default state. - * - * @return array - */ - public function definition(): array - { - return [ - 'level' => fake()->randomElement(LogLevelEnum::cases()), - 'message' => fake()->sentence(), - 'context' => fake()->randomElement([ - [], - ['key' => 'value'], - ['error_code' => fake()->numberBetween(1000, 9999)], - ['user_id' => fake()->numberBetween(1, 100)], - ]), - ]; - } -} diff --git a/database/factories/PlatformAccountFactory.php b/database/factories/PlatformAccountFactory.php deleted file mode 100644 index 6c01c17..0000000 --- a/database/factories/PlatformAccountFactory.php +++ /dev/null @@ -1,52 +0,0 @@ - - */ -class PlatformAccountFactory extends Factory -{ - protected $model = PlatformAccount::class; - - public function definition(): array - { - return [ - 'platform' => PlatformEnum::LEMMY, - 'instance_url' => 'https://lemmy.' . $this->faker->domainName(), - 'username' => $this->faker->userName(), - 'password' => 'test-password', - 'settings' => [], - 'is_active' => true, - 'last_tested_at' => null, - 'status' => 'untested', - ]; - } - - public function inactive(): static - { - return $this->state(fn (array $attributes) => [ - 'is_active' => false, - ]); - } - - public function tested(): static - { - return $this->state(fn (array $attributes) => [ - 'last_tested_at' => now()->subHours(2), - 'status' => 'working', - ]); - } - - public function failed(): static - { - return $this->state(fn (array $attributes) => [ - 'last_tested_at' => now()->subHours(2), - 'status' => 'failed', - ]); - } -} \ No newline at end of file diff --git a/database/factories/PlatformChannelFactory.php b/database/factories/PlatformChannelFactory.php deleted file mode 100644 index 3643da2..0000000 --- a/database/factories/PlatformChannelFactory.php +++ /dev/null @@ -1,47 +0,0 @@ - - */ -class PlatformChannelFactory extends Factory -{ - protected $model = PlatformChannel::class; - - public function definition(): array - { - return [ - 'platform_instance_id' => PlatformInstance::factory(), - 'channel_id' => $this->faker->slug(2), - 'name' => $this->faker->words(2, true), - 'display_name' => $this->faker->words(2, true), - 'language_id' => Language::factory(), - 'description' => $this->faker->optional()->sentence(), - 'is_active' => true, - ]; - } - - public function inactive(): static - { - return $this->state(fn (array $attributes) => [ - 'is_active' => false, - ]); - } - - public function community(string $name = null): static - { - $communityName = $name ?: $this->faker->word(); - - return $this->state(fn (array $attributes) => [ - 'channel_id' => strtolower($communityName), - 'name' => $communityName, - 'display_name' => ucfirst($communityName), - ]); - } -} \ No newline at end of file diff --git a/database/factories/PlatformInstanceFactory.php b/database/factories/PlatformInstanceFactory.php deleted file mode 100644 index 182f9dc..0000000 --- a/database/factories/PlatformInstanceFactory.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ -class PlatformInstanceFactory extends Factory -{ - protected $model = PlatformInstance::class; - - public function definition(): array - { - return [ - 'platform' => 'lemmy', - 'name' => $this->faker->words(2, true), - 'url' => $this->faker->url(), - 'is_active' => true, - ]; - } - - public function inactive(): static - { - return $this->state(fn (array $attributes) => [ - 'is_active' => false, - ]); - } - - public function lemmy(): static - { - return $this->state(fn (array $attributes) => [ - 'platform' => 'lemmy', - 'name' => 'Lemmy ' . $this->faker->word(), - 'url' => 'https://lemmy.' . $this->faker->domainName(), - ]); - } -} \ No newline at end of file diff --git a/database/factories/RouteFactory.php b/database/factories/RouteFactory.php deleted file mode 100644 index 53177ac..0000000 --- a/database/factories/RouteFactory.php +++ /dev/null @@ -1,39 +0,0 @@ - Feed::factory(), - 'platform_channel_id' => PlatformChannel::factory(), - 'is_active' => $this->faker->boolean(80), // 80% chance of being active - 'priority' => $this->faker->numberBetween(0, 100), - '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, - ]); - } -} \ No newline at end of file diff --git a/database/factories/SettingFactory.php b/database/factories/SettingFactory.php deleted file mode 100644 index a8aa2a6..0000000 --- a/database/factories/SettingFactory.php +++ /dev/null @@ -1,35 +0,0 @@ - $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, - ]); - } -} \ No newline at end of file diff --git a/database/migrations/2024_01_01_000001_create_articles_and_publications.php b/database/migrations/2024_01_01_000001_create_articles_and_publications.php deleted file mode 100644 index 61cd8fd..0000000 --- a/database/migrations/2024_01_01_000001_create_articles_and_publications.php +++ /dev/null @@ -1,74 +0,0 @@ -id(); - $table->string('title'); - $table->text('description')->nullable(); - $table->longText('content')->nullable(); - $table->string('url')->nullable(); - $table->string('image_url')->nullable(); - $table->timestamp('published_at')->nullable(); - $table->string('author')->nullable(); - $table->unsignedBigInteger('feed_id')->nullable(); - $table->enum('approval_status', ['pending', 'approved', 'rejected'])->default('pending'); - $table->timestamps(); - - $table->index(['published_at', 'approval_status']); - $table->index('feed_id'); - }); - - // Article publications table - Schema::create('article_publications', function (Blueprint $table) { - $table->id(); - $table->foreignId('article_id')->constrained()->onDelete('cascade'); - $table->string('post_id'); - $table->unsignedBigInteger('platform_channel_id'); - $table->string('platform')->default('lemmy'); - $table->json('publication_data')->nullable(); - $table->timestamp('published_at'); - $table->string('published_by'); - $table->timestamps(); - - $table->unique(['article_id', 'platform', 'platform_channel_id'], 'article_pub_unique'); - }); - - // Logs table - Schema::create('logs', function (Blueprint $table) { - $table->id(); - $table->string('level'); // info, warning, error, etc. - $table->string('message'); - $table->json('context')->nullable(); // Additional context data - $table->timestamp('logged_at')->useCurrent(); - $table->timestamps(); - - $table->index(['level', 'logged_at']); - }); - - // Settings table - Schema::create('settings', function (Blueprint $table) { - $table->id(); - $table->string('key')->unique(); - $table->text('value')->nullable(); - $table->string('type')->default('string'); // string, integer, boolean, json - $table->text('description')->nullable(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('article_publications'); - Schema::dropIfExists('articles'); - Schema::dropIfExists('logs'); - Schema::dropIfExists('settings'); - } -}; \ No newline at end of file diff --git a/database/migrations/2024_01_01_000002_create_languages.php b/database/migrations/2024_01_01_000002_create_languages.php deleted file mode 100644 index 0b55878..0000000 --- a/database/migrations/2024_01_01_000002_create_languages.php +++ /dev/null @@ -1,26 +0,0 @@ -id(); - $table->string('short_code', 10)->unique(); // Language code (en, fr, de, en-US, zh-CN, etc.) - $table->string('name'); // English name (English, French, German, etc.) - $table->string('native_name')->nullable(); // Native name (English, Français, Deutsch, etc.) - $table->boolean('is_active')->default(true); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('languages'); - } -}; \ No newline at end of file diff --git a/database/migrations/2024_01_01_000003_create_platforms.php b/database/migrations/2024_01_01_000003_create_platforms.php deleted file mode 100644 index 9a074a9..0000000 --- a/database/migrations/2024_01_01_000003_create_platforms.php +++ /dev/null @@ -1,105 +0,0 @@ -id(); - $table->enum('platform', ['lemmy']); - $table->string('url'); - $table->string('name'); - $table->text('description')->nullable(); - $table->boolean('is_active')->default(true); - $table->timestamps(); - - $table->unique(['platform', 'url']); - }); - - // Platform accounts table - Schema::create('platform_accounts', function (Blueprint $table) { - $table->id(); - $table->enum('platform', ['lemmy']); - $table->string('instance_url'); - $table->string('username'); - $table->string('password'); - $table->json('settings')->nullable(); - $table->boolean('is_active')->default(false); - $table->timestamp('last_tested_at')->nullable(); - $table->string('status')->default('untested'); - $table->timestamps(); - - $table->unique(['username', 'platform', 'is_active']); - }); - - // Platform channels table - Schema::create('platform_channels', function (Blueprint $table) { - $table->id(); - $table->foreignId('platform_instance_id')->constrained()->onDelete('cascade'); - $table->string('name'); // "technology" - $table->string('display_name'); // "Technology" - $table->string('channel_id'); // API ID from platform - $table->text('description')->nullable(); - $table->foreignId('language_id')->nullable()->constrained(); - $table->boolean('is_active')->default(true); - $table->timestamps(); - - $table->unique(['platform_instance_id', 'name']); - }); - - // Platform account channels pivot table - Schema::create('platform_account_channels', function (Blueprint $table) { - $table->id(); - $table->foreignId('platform_account_id')->constrained()->onDelete('cascade'); - $table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); - $table->boolean('is_active')->default(true); - $table->integer('priority')->default(0); - $table->timestamps(); - - $table->unique(['platform_account_id', 'platform_channel_id'], 'account_channel_unique'); - }); - - // Platform channel posts table - Schema::create('platform_channel_posts', function (Blueprint $table) { - $table->id(); - $table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); - $table->string('post_id'); - $table->string('title'); - $table->text('content')->nullable(); - $table->string('url')->nullable(); - $table->timestamp('posted_at'); - $table->string('author'); - $table->json('metadata')->nullable(); - $table->timestamps(); - - $table->unique(['platform_channel_id', 'post_id'], 'channel_post_unique'); - }); - - // Language platform instance pivot table - Schema::create('language_platform_instance', function (Blueprint $table) { - $table->id(); - $table->foreignId('language_id')->constrained()->onDelete('cascade'); - $table->foreignId('platform_instance_id')->constrained()->onDelete('cascade'); - $table->integer('platform_language_id'); // The platform-specific ID (e.g., Lemmy's language ID) - NOT NULL - $table->boolean('is_default')->default(false); // Whether this is the default language for this instance - $table->timestamps(); - - $table->unique(['language_id', 'platform_instance_id'], 'lang_platform_instance_unique'); - }); - } - - public function down(): void - { - Schema::dropIfExists('language_platform_instance'); - Schema::dropIfExists('platform_channel_posts'); - Schema::dropIfExists('platform_account_channels'); - Schema::dropIfExists('platform_channels'); - Schema::dropIfExists('platform_accounts'); - Schema::dropIfExists('platform_instances'); - } -}; \ No newline at end of file diff --git a/database/migrations/2024_01_01_000004_create_feeds_and_routes.php b/database/migrations/2024_01_01_000004_create_feeds_and_routes.php deleted file mode 100644 index 1bf820a..0000000 --- a/database/migrations/2024_01_01_000004_create_feeds_and_routes.php +++ /dev/null @@ -1,63 +0,0 @@ -id(); - $table->string('name'); // "VRT News", "Belga News Agency" - $table->string('url'); // "https://vrt.be" or "https://feeds.example.com/rss.xml" - $table->enum('type', ['website', 'rss']); // Feed type - $table->string('provider'); // Feed provider code (vrt, belga, etc.) - $table->foreignId('language_id')->nullable()->constrained(); - $table->text('description')->nullable(); - $table->json('settings')->nullable(); // Custom settings per feed type - $table->boolean('is_active')->default(true); - $table->timestamp('last_fetched_at')->nullable(); - $table->timestamps(); - - $table->unique('url'); - }); - - // Routes table (pivot between feeds and platform channels) - Schema::create('routes', function (Blueprint $table) { - $table->foreignId('feed_id')->constrained()->onDelete('cascade'); - $table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); - $table->boolean('is_active')->default(true); - $table->integer('priority')->default(0); // for ordering/priority - $table->timestamps(); - - $table->primary(['feed_id', 'platform_channel_id']); - }); - - // Keywords table - Schema::create('keywords', function (Blueprint $table) { - $table->id(); - $table->foreignId('feed_id')->constrained()->onDelete('cascade'); - $table->foreignId('platform_channel_id')->constrained()->onDelete('cascade'); - $table->string('keyword'); - $table->boolean('is_active')->default(true); - $table->timestamps(); - - $table->unique(['feed_id', 'platform_channel_id', 'keyword'], 'keywords_unique'); - }); - - // Add foreign key constraint for articles.feed_id now that feeds table exists - Schema::table('articles', function (Blueprint $table) { - $table->foreign('feed_id')->references('id')->on('feeds')->onDelete('set null'); - }); - } - - public function down(): void - { - Schema::dropIfExists('keywords'); - Schema::dropIfExists('routes'); - Schema::dropIfExists('feeds'); - } -}; \ No newline at end of file diff --git a/database/migrations/2024_01_01_000005_create_personal_access_tokens_table.php b/database/migrations/2024_01_01_000005_create_personal_access_tokens_table.php deleted file mode 100644 index 40ff706..0000000 --- a/database/migrations/2024_01_01_000005_create_personal_access_tokens_table.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->morphs('tokenable'); - $table->text('name'); - $table->string('token', 64)->unique(); - $table->text('abilities')->nullable(); - $table->timestamp('last_used_at')->nullable(); - $table->timestamp('expires_at')->nullable()->index(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('personal_access_tokens'); - } -}; diff --git a/database/migrations/2025_06_29_072202_create_articles_table.php b/database/migrations/2025_06_29_072202_create_articles_table.php new file mode 100644 index 0000000..889bd74 --- /dev/null +++ b/database/migrations/2025_06_29_072202_create_articles_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('url'); + $table->string('title')->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_valid')->nullable(); + $table->boolean('is_duplicate')->default(false); + $table->timestamp('validated_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('articles'); + } +}; diff --git a/database/migrations/2025_06_29_154705_create_logs_table.php b/database/migrations/2025_06_29_154705_create_logs_table.php new file mode 100644 index 0000000..9f4c0ae --- /dev/null +++ b/database/migrations/2025_06_29_154705_create_logs_table.php @@ -0,0 +1,25 @@ +id(); + $table->enum('level', LogLevelEnum::toArray()); + $table->string('message'); + $table->json('context')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('logs'); + } +}; diff --git a/database/migrations/2025_06_29_181847_create_article_publications_table.php b/database/migrations/2025_06_29_181847_create_article_publications_table.php new file mode 100644 index 0000000..04eb974 --- /dev/null +++ b/database/migrations/2025_06_29_181847_create_article_publications_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('article_id')->constrained()->onDelete('cascade'); + $table->string('post_id'); + $table->unsignedBigInteger('community_id'); + $table->string('platform')->default('lemmy'); + $table->json('publication_data')->nullable(); + $table->timestamp('published_at'); + $table->string('published_by'); + $table->timestamps(); + + $table->unique(['article_id', 'platform', 'community_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('article_publications'); + } +}; diff --git a/database/migrations/2025_06_30_163438_create_platform_channel_posts_table.php b/database/migrations/2025_06_30_163438_create_platform_channel_posts_table.php new file mode 100644 index 0000000..d8c1914 --- /dev/null +++ b/database/migrations/2025_06_30_163438_create_platform_channel_posts_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('platform'); + $table->string('channel_id'); + $table->string('channel_name')->nullable(); + $table->string('post_id'); + $table->longText('url')->nullable(); + $table->string('title')->nullable(); + $table->timestamp('posted_at'); + $table->timestamps(); + + $table->index(['platform', 'channel_id']); + $table->index(['platform', 'channel_id', 'posted_at']); + // Will add URL index with prefix after table creation + $table->unique(['platform', 'channel_id', 'post_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('platform_channel_posts'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d074784..d01a0ef 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,15 +2,22 @@ namespace Database\Seeders; +use App\Models\User; +// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { + /** + * Seed the application's database. + */ public function run(): void { - $this->call([ - SettingsSeeder::class, - LanguageSeeder::class, + // User::factory(10)->create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', ]); } } diff --git a/database/seeders/LanguageSeeder.php b/database/seeders/LanguageSeeder.php deleted file mode 100644 index 389d452..0000000 --- a/database/seeders/LanguageSeeder.php +++ /dev/null @@ -1,24 +0,0 @@ -updateOrInsert( - ['short_code' => $language['short_code']], - array_merge($language, [ - 'created_at' => now(), - 'updated_at' => now(), - ]) - ); - } - } -} \ No newline at end of file diff --git a/database/seeders/PlatformInstanceSeeder.php b/database/seeders/PlatformInstanceSeeder.php deleted file mode 100644 index 1b3e841..0000000 --- a/database/seeders/PlatformInstanceSeeder.php +++ /dev/null @@ -1,30 +0,0 @@ - PlatformEnum::LEMMY, - 'url' => 'belgae.social', - 'name' => 'Belgae Social', - 'description' => 'A Belgian Lemmy instance on the fediverse', - ], - ])->each (fn ($instanceData) => - PlatformInstance::updateOrCreate( - [ - 'platform' => $instanceData['platform'], - 'url' => $instanceData['url'], - ], - $instanceData - ) - ); - } -} \ No newline at end of file diff --git a/database/seeders/SettingsSeeder.php b/database/seeders/SettingsSeeder.php deleted file mode 100644 index 8f6b388..0000000 --- a/database/seeders/SettingsSeeder.php +++ /dev/null @@ -1,21 +0,0 @@ - 'article_processing_enabled'], - ['value' => '1'] - ); - } -} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fafcec4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,74 @@ +services: + laravel.test: + build: + context: './vendor/laravel/sail/runtimes/8.4' + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP}' + image: 'sail-8.4/app' + extra_hosts: + - 'host.docker.internal:host-gateway' + ports: + - '${APP_PORT:-80}:80' + - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' + environment: + WWWUSER: '${WWWUSER}' + LARAVEL_SAIL: 1 + XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' + XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' + IGNITION_LOCAL_SITES_PATH: '${PWD}' + volumes: + - '.:/var/www/html' + networks: + - sail + depends_on: + - mysql + scheduler: + build: + context: './vendor/laravel/sail/runtimes/8.4' + dockerfile: Dockerfile + args: + WWWGROUP: '${WWWGROUP}' + image: 'sail-8.4/app' + extra_hosts: + - 'host.docker.internal:host-gateway' + environment: + WWWUSER: '${WWWUSER}' + LARAVEL_SAIL: 1 + volumes: + - '.:/var/www/html' + networks: + - sail + depends_on: + - mysql + command: php artisan schedule:work + mysql: + image: 'mysql/mysql-server:8.0' + ports: + - '${FORWARD_DB_PORT:-3306}:3306' + environment: + MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' + MYSQL_ROOT_HOST: '%' + MYSQL_DATABASE: '${DB_DATABASE}' + MYSQL_USER: '${DB_USERNAME}' + MYSQL_PASSWORD: '${DB_PASSWORD}' + MYSQL_ALLOW_EMPTY_PASSWORD: 1 + volumes: + - 'sail-mysql:/var/lib/mysql' + - './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh' + networks: + - sail + healthcheck: + test: + - CMD + - mysqladmin + - ping + - '-p${DB_PASSWORD}' + retries: 3 + timeout: 5s +networks: + sail: + driver: bridge +volumes: + sail-mysql: + driver: local diff --git a/docker/.env.example b/docker/.env.example deleted file mode 100644 index 6f70e94..0000000 --- a/docker/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# DATABASE SETTINGS -DB_DATABASE=lemmy_poster -DB_USERNAME=lemmy_user -DB_PASSWORD=StrongPassword123! - -# LEMMY SETTINGS -LEMMY_INSTANCE=belgae.social -LEMMY_USERNAME=newsbot -LEMMY_PASSWORD=2Pb5Sypj2sjgwUuU5QJ78KZK -LEMMY_COMMUNITY=newsbottest2 diff --git a/docker/build/entrypoint.sh b/docker/build/entrypoint.sh deleted file mode 100644 index 151124e..0000000 --- a/docker/build/entrypoint.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -# Exit on any error -set -e - -# Check required Lemmy environment variables -if [ -z "$LEMMY_INSTANCE" ] || [ -z "$LEMMY_USERNAME" ] || [ -z "$LEMMY_PASSWORD" ] || [ -z "$LEMMY_COMMUNITY" ]; then - echo "ERROR: Missing required Lemmy configuration variables:" - echo " LEMMY_INSTANCE=${LEMMY_INSTANCE:-'(not set)'}" - echo " LEMMY_USERNAME=${LEMMY_USERNAME:-'(not set)'}" - echo " LEMMY_PASSWORD=${LEMMY_PASSWORD:-'(not set)'}" - echo " LEMMY_COMMUNITY=${LEMMY_COMMUNITY:-'(not set)'}" - echo "Please set all required environment variables before starting the application." - exit 1 -fi - -# Wait for database to be ready -echo "Waiting for database connection..." -until php /docker/wait-for-db.php > /dev/null 2>&1; do - echo "Database not ready, waiting..." - sleep 5 -done -echo "Database connection established." - -# Wait for Redis to be ready -echo "Waiting for Redis connection..." -until php /docker/wait-for-redis.php > /dev/null 2>&1; do - echo "Redis not ready, waiting..." - sleep 2 -done -echo "Redis connection established." - -# Substitute environment variables in .env file -echo "Configuring environment variables..." -envsubst < .env > .env.tmp && mv .env.tmp .env - -# Run migrations and initial setup -echo "Running database migrations..." -php artisan migrate --force - -echo "Dispatching initial sync job..." -php artisan tinker --execute="App\\Jobs\\SyncChannelPostsJob::dispatchForLemmy();" - -# Start all services in single container -echo "Starting web server, scheduler, and Horizon..." -php artisan schedule:work & -php artisan horizon & -php artisan serve --host=0.0.0.0 --port=8000 & - -# Wait for any process to exit -wait \ No newline at end of file diff --git a/docker/build/laravel.env b/docker/build/laravel.env deleted file mode 100644 index fab9ef8..0000000 --- a/docker/build/laravel.env +++ /dev/null @@ -1,59 +0,0 @@ -APP_NAME="Lemmy Poster" -APP_ENV=production -APP_KEY= -APP_DEBUG=true -APP_URL=http://localhost - -APP_LOCALE=en -APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US - -APP_MAINTENANCE_DRIVER=file - -PHP_CLI_SERVER_WORKERS=4 - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -LOG_LEVEL=error - -DB_CONNECTION=mysql -DB_HOST=mysql -DB_PORT=3306 -DB_DATABASE=$DB_DATABASE -DB_USERNAME=$DB_USERNAME -DB_PASSWORD=$DB_PASSWORD - -SESSION_DRIVER=redis -SESSION_LIFETIME=120 -SESSION_ENCRYPT=false -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=redis - -CACHE_STORE=redis - -REDIS_CLIENT=phpredis -REDIS_HOST=redis -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -# LEMMY SETTINGS -LEMMY_INSTANCE= -LEMMY_USERNAME= -LEMMY_PASSWORD= -LEMMY_COMMUNITY= diff --git a/docker/build/wait-for-db.php b/docker/build/wait-for-db.php deleted file mode 100644 index 2427fa8..0000000 --- a/docker/build/wait-for-db.php +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env php -connect('redis', 6379); - echo 'Connected'; - exit(0); -} catch (Exception $e) { - exit(1); -} \ No newline at end of file diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml deleted file mode 100644 index fcb03bb..0000000 --- a/docker/dev/docker-compose.yml +++ /dev/null @@ -1,82 +0,0 @@ -# =================== -# FFR Development Services -# =================== -# Port allocation: -# App: 8000 (frankenphp), 5173 (vite) -# DB: 3307 (mysql) -# Redis: 6380 - -services: - app: - build: - context: ../.. - dockerfile: Dockerfile.dev - container_name: ffr_dev_app - restart: unless-stopped - ports: - - "8000:8000" - - "5173:5173" - volumes: - - ../..:/app - environment: - APP_NAME: "FFR" - APP_ENV: "${APP_ENV:-local}" - APP_DEBUG: "${APP_DEBUG:-true}" - APP_URL: "${APP_URL:-http://localhost:8000}" - DB_CONNECTION: mysql - DB_HOST: db - DB_PORT: 3306 - DB_DATABASE: "${DB_DATABASE:-ffr_dev}" - DB_USERNAME: "${DB_USERNAME:-ffr}" - DB_PASSWORD: "${DB_PASSWORD:-ffr}" - REDIS_HOST: redis - REDIS_PORT: 6379 - SESSION_DRIVER: redis - CACHE_STORE: redis - QUEUE_CONNECTION: redis - VITE_HOST: "0.0.0.0" - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - networks: - - ffr-network - - db: - image: mariadb:11 - container_name: ffr_dev_db - restart: unless-stopped - ports: - - "3307:3306" - environment: - MYSQL_DATABASE: "${DB_DATABASE:-ffr_dev}" - MYSQL_USER: "${DB_USERNAME:-ffr}" - MYSQL_PASSWORD: "${DB_PASSWORD:-ffr}" - MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-ffr_root_dev}" - volumes: - - db_data:/var/lib/mysql - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - networks: - - ffr-network - - redis: - image: redis:7-alpine - container_name: ffr_dev_redis - restart: unless-stopped - ports: - - "6380:6379" - networks: - - ffr-network - -networks: - ffr-network: - driver: bridge - -volumes: - db_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..f432a4f --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh + +# Exit on any error +set -e + +# Wait for database to be ready +until php artisan tinker --execute="DB::connection()->getPdo();" > /dev/null 2>&1; do + echo "Waiting for database connection..." + sleep 2 +done + +# Run migrations on first start (web container only) +if [ "$1" = "web" ]; then + php artisan migrate --force +fi + +# Execute the command based on the argument +case "$1" in + "web") + echo "Starting web server..." + exec php artisan serve --host=0.0.0.0 --port=8000 + ;; + "queue") + echo "Starting queue worker..." + exec php artisan queue:work --tries=3 + ;; + *) + echo "Usage: $0 {web|queue}" + exit 1 + ;; +esac \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf deleted file mode 100644 index 6d5a8da..0000000 --- a/docker/nginx.conf +++ /dev/null @@ -1,54 +0,0 @@ -server { - listen 80; - server_name localhost; - - # Proxy React dev server for development - location / { - proxy_pass http://127.0.0.1:5173; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # WebSocket support for HMR - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_redirect off; - } - - # Proxy API requests to Laravel backend - location /api/ { - proxy_pass http://127.0.0.1:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_redirect off; - } - - # Serve Laravel public assets (images, etc.) - location /images/ { - alias /var/www/html/backend/public/images/; - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # Handle static assets with caching - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - try_files $uri =404; - } - - # Security headers - add_header X-Content-Type-Options nosniff; - add_header X-Frame-Options DENY; - add_header X-XSS-Protection "1; mode=block"; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; -} \ No newline at end of file diff --git a/docker/production/docker-compose.yml b/docker/production/docker-compose.yml deleted file mode 100644 index 20ce7c5..0000000 --- a/docker/production/docker-compose.yml +++ /dev/null @@ -1,69 +0,0 @@ -# =================== -# FFR Production Services -# =================== - -services: - app: - build: - context: ../.. - dockerfile: Dockerfile - image: codeberg.org/lvl0/ffr:latest - container_name: ffr_app - restart: unless-stopped - ports: - - "8000:8000" - environment: - APP_NAME: "${APP_NAME:-FFR}" - APP_KEY: "${APP_KEY}" - APP_URL: "${APP_URL}" - DB_HOST: db - DB_PORT: 3306 - DB_DATABASE: "${DB_DATABASE:-ffr}" - DB_USERNAME: "${DB_USERNAME:-ffr}" - DB_PASSWORD: "${DB_PASSWORD}" - REDIS_HOST: redis - REDIS_PORT: 6379 - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - networks: - - ffr-network - - db: - image: mariadb:11 - container_name: ffr_db - restart: unless-stopped - environment: - MYSQL_DATABASE: "${DB_DATABASE:-ffr}" - MYSQL_USER: "${DB_USERNAME:-ffr}" - MYSQL_PASSWORD: "${DB_PASSWORD}" - MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}" - volumes: - - db_data:/var/lib/mysql - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 30s - networks: - - ffr-network - - redis: - image: redis:7-alpine - container_name: ffr_redis - restart: unless-stopped - volumes: - - redis_data:/data - networks: - - ffr-network - -networks: - ffr-network: - driver: bridge - -volumes: - db_data: - redis_data: diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..a136d22 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,44 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import typescript from 'typescript-eslint'; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + js.configs.recommended, + ...typescript.configs.recommended, + { + ...react.configs.flat.recommended, + ...react.configs.flat['jsx-runtime'], // Required for React 17+ + languageOptions: { + globals: { + ...globals.browser, + }, + }, + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + }, + settings: { + react: { + version: 'detect', + }, + }, + }, + { + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + }, + }, + { + ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'], + }, + prettier, // Turn off all rules that might conflict with Prettier +]; diff --git a/package.json b/package.json index 6519a6a..20229aa 100644 --- a/package.json +++ b/package.json @@ -2,19 +2,63 @@ "private": true, "type": "module", "scripts": { + "build": "vite build", + "build:ssr": "vite build && vite build --ssr", "dev": "vite", - "build": "vite build" + "format": "prettier --write resources/", + "format:check": "prettier --check resources/", + "lint": "eslint . --fix", + "types": "tsc --noEmit" }, "devDependencies": { - "@tailwindcss/postcss": "^4.1.18", - "autoprefixer": "^10.4.20", - "laravel-vite-plugin": "^1.2.0", - "postcss": "^8.5.1", - "tailwindcss": "^4.0.0", - "vite": "^6.2.4" + "@eslint/js": "^9.19.0", + "@types/node": "^22.13.5", + "eslint": "^9.17.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^5.1.0", + "prettier": "^3.4.2", + "prettier-plugin-organize-imports": "^4.1.0", + "prettier-plugin-tailwindcss": "^0.6.11", + "typescript-eslint": "^8.23.0" }, "dependencies": { - "alpinejs": "^3.14.8", - "axios": "^1.8.0" + "@headlessui/react": "^2.2.0", + "@inertiajs/react": "^2.0.0", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@tailwindcss/vite": "^4.0.6", + "@types/react": "^19.0.3", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "concurrently": "^9.0.1", + "globals": "^15.14.0", + "laravel-vite-plugin": "^1.0", + "lucide-react": "^0.475.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^3.0.1", + "tailwindcss": "^4.0.0", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.2", + "vite": "^6.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.9.5", + "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", + "lightningcss-linux-x64-gnu": "^1.29.1" } } diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index ce555e2..0000000 --- a/phpstan.neon +++ /dev/null @@ -1,12 +0,0 @@ -includes: - - vendor/larastan/larastan/extension.neon - -parameters: - level: 7 - paths: - - app/ - - tests/ - - excludePaths: - - bootstrap/*.php - - storage/* diff --git a/phpunit.xml b/phpunit.xml index dda51b4..c09b5bc 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,12 +19,10 @@ - - - + diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 8dc11a1..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; diff --git a/public/images/ffr-logo-600.png b/public/images/ffr-logo-600.png deleted file mode 100644 index 1a4372e..0000000 Binary files a/public/images/ffr-logo-600.png and /dev/null differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index d4b5078..43fdb4a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1 +1,159 @@ @import 'tailwindcss'; + +@plugin 'tailwindcss-animate'; + +@source '../views'; +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --font-sans: + 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +/* + The default border color has changed to `currentColor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.87 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.87 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.985 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@layer base { + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index a8093be..0000000 --- a/resources/js/app.js +++ /dev/null @@ -1,7 +0,0 @@ -import './bootstrap'; - -import Alpine from 'alpinejs'; - -window.Alpine = Alpine; - -Alpine.start(); diff --git a/resources/js/app.tsx b/resources/js/app.tsx new file mode 100644 index 0000000..b8d0c91 --- /dev/null +++ b/resources/js/app.tsx @@ -0,0 +1,24 @@ +import '../css/app.css'; + +import { createInertiaApp } from '@inertiajs/react'; +import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; +import { createRoot } from 'react-dom/client'; +import { initializeTheme } from './hooks/use-appearance'; + +const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; + +createInertiaApp({ + title: (title) => `${title} - ${appName}`, + resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')), + setup({ el, App, props }) { + const root = createRoot(el); + + root.render(); + }, + progress: { + color: '#4B5563', + }, +}); + +// This will set light / dark mode on load... +initializeTheme(); diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js deleted file mode 100644 index 5f1390b..0000000 --- a/resources/js/bootstrap.js +++ /dev/null @@ -1,4 +0,0 @@ -import axios from 'axios'; -window.axios = axios; - -window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/js/components/app-content.tsx b/resources/js/components/app-content.tsx new file mode 100644 index 0000000..e5d04cd --- /dev/null +++ b/resources/js/components/app-content.tsx @@ -0,0 +1,18 @@ +import { SidebarInset } from '@/components/ui/sidebar'; +import * as React from 'react'; + +interface AppContentProps extends React.ComponentProps<'main'> { + variant?: 'header' | 'sidebar'; +} + +export function AppContent({ variant = 'header', children, ...props }: AppContentProps) { + if (variant === 'sidebar') { + return {children}; + } + + return ( +
+ {children} +
+ ); +} diff --git a/resources/js/components/app-header.tsx b/resources/js/components/app-header.tsx new file mode 100644 index 0000000..831f2c3 --- /dev/null +++ b/resources/js/components/app-header.tsx @@ -0,0 +1,182 @@ +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { Icon } from '@/components/icon'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { NavigationMenu, NavigationMenuItem, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { UserMenuContent } from '@/components/user-menu-content'; +import { useInitials } from '@/hooks/use-initials'; +import { cn } from '@/lib/utils'; +import { type BreadcrumbItem, type NavItem, type SharedData } from '@/types'; +import { Link, usePage } from '@inertiajs/react'; +import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-react'; +import AppLogo from './app-logo'; +import AppLogoIcon from './app-logo-icon'; + +const mainNavItems: NavItem[] = [ + { + title: 'Dashboard', + href: '/dashboard', + icon: LayoutGrid, + }, +]; + +const rightNavItems: NavItem[] = [ + { + title: 'Repository', + href: 'https://github.com/laravel/react-starter-kit', + icon: Folder, + }, + { + title: 'Documentation', + href: 'https://laravel.com/docs/starter-kits#react', + icon: BookOpen, + }, +]; + +const activeItemStyles = 'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'; + +interface AppHeaderProps { + breadcrumbs?: BreadcrumbItem[]; +} + +export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) { + const page = usePage(); + const { auth } = page.props; + const getInitials = useInitials(); + return ( + <> +
+
+ {/* Mobile Menu */} +
+ + + + + + Navigation Menu + + + +
+
+
+ {mainNavItems.map((item) => ( + + {item.icon && } + {item.title} + + ))} +
+ +
+ {rightNavItems.map((item) => ( + + {item.icon && } + {item.title} + + ))} +
+
+
+
+
+
+ + + + + + {/* Desktop Navigation */} +
+ + + {mainNavItems.map((item, index) => ( + + + {item.icon && } + {item.title} + + {page.url === item.href && ( +
+ )} +
+ ))} +
+
+
+ +
+
+ +
+ {rightNavItems.map((item) => ( + + + + + {item.title} + {item.icon && } + + + +

{item.title}

+
+
+
+ ))} +
+
+ + + + + + + + +
+
+
+ {breadcrumbs.length > 1 && ( +
+
+ +
+
+ )} + + ); +} diff --git a/resources/js/components/app-logo-icon.tsx b/resources/js/components/app-logo-icon.tsx new file mode 100644 index 0000000..9bd62ad --- /dev/null +++ b/resources/js/components/app-logo-icon.tsx @@ -0,0 +1,13 @@ +import { SVGAttributes } from 'react'; + +export default function AppLogoIcon(props: SVGAttributes) { + return ( + + + + ); +} diff --git a/resources/js/components/app-logo.tsx b/resources/js/components/app-logo.tsx new file mode 100644 index 0000000..69bdcb8 --- /dev/null +++ b/resources/js/components/app-logo.tsx @@ -0,0 +1,14 @@ +import AppLogoIcon from './app-logo-icon'; + +export default function AppLogo() { + return ( + <> +
+ +
+
+ Laravel Starter Kit +
+ + ); +} diff --git a/resources/js/components/app-shell.tsx b/resources/js/components/app-shell.tsx new file mode 100644 index 0000000..0d5cdb9 --- /dev/null +++ b/resources/js/components/app-shell.tsx @@ -0,0 +1,18 @@ +import { SidebarProvider } from '@/components/ui/sidebar'; +import { SharedData } from '@/types'; +import { usePage } from '@inertiajs/react'; + +interface AppShellProps { + children: React.ReactNode; + variant?: 'header' | 'sidebar'; +} + +export function AppShell({ children, variant = 'header' }: AppShellProps) { + const isOpen = usePage().props.sidebarOpen; + + if (variant === 'header') { + return
{children}
; + } + + return {children}; +} diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx new file mode 100644 index 0000000..6a3128b --- /dev/null +++ b/resources/js/components/app-sidebar-header.tsx @@ -0,0 +1,14 @@ +import { Breadcrumbs } from '@/components/breadcrumbs'; +import { SidebarTrigger } from '@/components/ui/sidebar'; +import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; + +export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: BreadcrumbItemType[] }) { + return ( +
+
+ + +
+
+ ); +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx new file mode 100644 index 0000000..c517672 --- /dev/null +++ b/resources/js/components/app-sidebar.tsx @@ -0,0 +1,56 @@ +import { NavFooter } from '@/components/nav-footer'; +import { NavMain } from '@/components/nav-main'; +import { NavUser } from '@/components/nav-user'; +import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; +import { type NavItem } from '@/types'; +import { Link } from '@inertiajs/react'; +import { BookOpen, Folder, LayoutGrid } from 'lucide-react'; +import AppLogo from './app-logo'; + +const mainNavItems: NavItem[] = [ + { + title: 'Dashboard', + href: '/dashboard', + icon: LayoutGrid, + }, +]; + +const footerNavItems: NavItem[] = [ + { + title: 'Repository', + href: 'https://github.com/laravel/react-starter-kit', + icon: Folder, + }, + { + title: 'Documentation', + href: 'https://laravel.com/docs/starter-kits#react', + icon: BookOpen, + }, +]; + +export function AppSidebar() { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/resources/js/components/appearance-dropdown.tsx b/resources/js/components/appearance-dropdown.tsx new file mode 100644 index 0000000..89a4586 --- /dev/null +++ b/resources/js/components/appearance-dropdown.tsx @@ -0,0 +1,53 @@ +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { useAppearance } from '@/hooks/use-appearance'; +import { Monitor, Moon, Sun } from 'lucide-react'; +import { HTMLAttributes } from 'react'; + +export default function AppearanceToggleDropdown({ className = '', ...props }: HTMLAttributes) { + const { appearance, updateAppearance } = useAppearance(); + + const getCurrentIcon = () => { + switch (appearance) { + case 'dark': + return ; + case 'light': + return ; + default: + return ; + } + }; + + return ( +
+ + + + + + updateAppearance('light')}> + + + Light + + + updateAppearance('dark')}> + + + Dark + + + updateAppearance('system')}> + + + System + + + + +
+ ); +} diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/appearance-tabs.tsx new file mode 100644 index 0000000..900b0f2 --- /dev/null +++ b/resources/js/components/appearance-tabs.tsx @@ -0,0 +1,34 @@ +import { Appearance, useAppearance } from '@/hooks/use-appearance'; +import { cn } from '@/lib/utils'; +import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react'; +import { HTMLAttributes } from 'react'; + +export default function AppearanceToggleTab({ className = '', ...props }: HTMLAttributes) { + const { appearance, updateAppearance } = useAppearance(); + + const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ + { value: 'light', icon: Sun, label: 'Light' }, + { value: 'dark', icon: Moon, label: 'Dark' }, + { value: 'system', icon: Monitor, label: 'System' }, + ]; + + return ( +
+ {tabs.map(({ value, icon: Icon, label }) => ( + + ))} +
+ ); +} diff --git a/resources/js/components/breadcrumbs.tsx b/resources/js/components/breadcrumbs.tsx new file mode 100644 index 0000000..cb00f91 --- /dev/null +++ b/resources/js/components/breadcrumbs.tsx @@ -0,0 +1,34 @@ +import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from '@/components/ui/breadcrumb'; +import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; +import { Link } from '@inertiajs/react'; +import { Fragment } from 'react'; + +export function Breadcrumbs({ breadcrumbs }: { breadcrumbs: BreadcrumbItemType[] }) { + return ( + <> + {breadcrumbs.length > 0 && ( + + + {breadcrumbs.map((item, index) => { + const isLast = index === breadcrumbs.length - 1; + return ( + + + {isLast ? ( + {item.title} + ) : ( + + {item.title} + + )} + + {!isLast && } + + ); + })} + + + )} + + ); +} diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx new file mode 100644 index 0000000..e1f8788 --- /dev/null +++ b/resources/js/components/delete-user.tsx @@ -0,0 +1,89 @@ +import { useForm } from '@inertiajs/react'; +import { FormEventHandler, useRef } from 'react'; + +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +import HeadingSmall from '@/components/heading-small'; + +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; + +export default function DeleteUser() { + const passwordInput = useRef(null); + const { data, setData, delete: destroy, processing, reset, errors, clearErrors } = useForm>({ password: '' }); + + const deleteUser: FormEventHandler = (e) => { + e.preventDefault(); + + destroy(route('profile.destroy'), { + preserveScroll: true, + onSuccess: () => closeModal(), + onError: () => passwordInput.current?.focus(), + onFinish: () => reset(), + }); + }; + + const closeModal = () => { + clearErrors(); + reset(); + }; + + return ( +
+ +
+
+

Warning

+

Please proceed with caution, this cannot be undone.

+
+ + + + + + + Are you sure you want to delete your account? + + Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password + to confirm you would like to permanently delete your account. + +
+
+ + + setData('password', e.target.value)} + placeholder="Password" + autoComplete="current-password" + /> + + +
+ + + + + + + + + +
+
+
+
+
+ ); +} diff --git a/resources/js/components/heading-small.tsx b/resources/js/components/heading-small.tsx new file mode 100644 index 0000000..cbd3658 --- /dev/null +++ b/resources/js/components/heading-small.tsx @@ -0,0 +1,8 @@ +export default function HeadingSmall({ title, description }: { title: string; description?: string }) { + return ( +
+

{title}

+ {description &&

{description}

} +
+ ); +} diff --git a/resources/js/components/heading.tsx b/resources/js/components/heading.tsx new file mode 100644 index 0000000..6384e4b --- /dev/null +++ b/resources/js/components/heading.tsx @@ -0,0 +1,8 @@ +export default function Heading({ title, description }: { title: string; description?: string }) { + return ( +
+

{title}

+ {description &&

{description}

} +
+ ); +} diff --git a/resources/js/components/icon.tsx b/resources/js/components/icon.tsx new file mode 100644 index 0000000..0f81d9c --- /dev/null +++ b/resources/js/components/icon.tsx @@ -0,0 +1,11 @@ +import { cn } from '@/lib/utils'; +import { type LucideProps } from 'lucide-react'; +import { type ComponentType } from 'react'; + +interface IconProps extends Omit { + iconNode: ComponentType; +} + +export function Icon({ iconNode: IconComponent, className, ...props }: IconProps) { + return ; +} diff --git a/resources/js/components/input-error.tsx b/resources/js/components/input-error.tsx new file mode 100644 index 0000000..bb48d71 --- /dev/null +++ b/resources/js/components/input-error.tsx @@ -0,0 +1,10 @@ +import { cn } from '@/lib/utils'; +import { type HTMLAttributes } from 'react'; + +export default function InputError({ message, className = '', ...props }: HTMLAttributes & { message?: string }) { + return message ? ( +

+ {message} +

+ ) : null; +} diff --git a/resources/js/components/nav-footer.tsx b/resources/js/components/nav-footer.tsx new file mode 100644 index 0000000..13b6737 --- /dev/null +++ b/resources/js/components/nav-footer.tsx @@ -0,0 +1,34 @@ +import { Icon } from '@/components/icon'; +import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; +import { type NavItem } from '@/types'; +import { type ComponentPropsWithoutRef } from 'react'; + +export function NavFooter({ + items, + className, + ...props +}: ComponentPropsWithoutRef & { + items: NavItem[]; +}) { + return ( + + + + {items.map((item) => ( + + + + {item.icon && } + {item.title} + + + + ))} + + + + ); +} diff --git a/resources/js/components/nav-main.tsx b/resources/js/components/nav-main.tsx new file mode 100644 index 0000000..0dc62c6 --- /dev/null +++ b/resources/js/components/nav-main.tsx @@ -0,0 +1,24 @@ +import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; +import { type NavItem } from '@/types'; +import { Link, usePage } from '@inertiajs/react'; + +export function NavMain({ items = [] }: { items: NavItem[] }) { + const page = usePage(); + return ( + + Platform + + {items.map((item) => ( + + + + {item.icon && } + {item.title} + + + + ))} + + + ); +} diff --git a/resources/js/components/nav-user.tsx b/resources/js/components/nav-user.tsx new file mode 100644 index 0000000..386be8f --- /dev/null +++ b/resources/js/components/nav-user.tsx @@ -0,0 +1,36 @@ +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'; +import { UserInfo } from '@/components/user-info'; +import { UserMenuContent } from '@/components/user-menu-content'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { type SharedData } from '@/types'; +import { usePage } from '@inertiajs/react'; +import { ChevronsUpDown } from 'lucide-react'; + +export function NavUser() { + const { auth } = usePage().props; + const { state } = useSidebar(); + const isMobile = useIsMobile(); + + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/resources/js/components/text-link.tsx b/resources/js/components/text-link.tsx new file mode 100644 index 0000000..1c2ddb8 --- /dev/null +++ b/resources/js/components/text-link.tsx @@ -0,0 +1,19 @@ +import { cn } from '@/lib/utils'; +import { Link } from '@inertiajs/react'; +import { ComponentProps } from 'react'; + +type LinkProps = ComponentProps; + +export default function TextLink({ className = '', children, ...props }: LinkProps) { + return ( + + {children} + + ); +} diff --git a/resources/js/components/ui/alert.tsx b/resources/js/components/ui/alert.tsx new file mode 100644 index 0000000..3b8ee79 --- /dev/null +++ b/resources/js/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "text-destructive-foreground [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-foreground/80", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/resources/js/components/ui/avatar.tsx b/resources/js/components/ui/avatar.tsx new file mode 100644 index 0000000..b7224f0 --- /dev/null +++ b/resources/js/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/resources/js/components/ui/badge.tsx b/resources/js/components/ui/badge.tsx new file mode 100644 index 0000000..268ea77 --- /dev/null +++ b/resources/js/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/resources/js/components/ui/breadcrumb.tsx b/resources/js/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/resources/js/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return + + + + +
+
{children}
+
+
+
+ ); +} diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts new file mode 100644 index 0000000..dd53ea8 --- /dev/null +++ b/resources/js/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/resources/js/pages/auth/confirm-password.tsx b/resources/js/pages/auth/confirm-password.tsx new file mode 100644 index 0000000..bc1ae57 --- /dev/null +++ b/resources/js/pages/auth/confirm-password.tsx @@ -0,0 +1,60 @@ +// Components +import { Head, useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler } from 'react'; + +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AuthLayout from '@/layouts/auth-layout'; + +export default function ConfirmPassword() { + const { data, setData, post, processing, errors, reset } = useForm>({ + password: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + post(route('password.confirm'), { + onFinish: () => reset('password'), + }); + }; + + return ( + + + +
+
+
+ + setData('password', e.target.value)} + /> + + +
+ +
+ +
+
+
+
+ ); +} diff --git a/resources/js/pages/auth/forgot-password.tsx b/resources/js/pages/auth/forgot-password.tsx new file mode 100644 index 0000000..86d1b30 --- /dev/null +++ b/resources/js/pages/auth/forgot-password.tsx @@ -0,0 +1,63 @@ +// Components +import { Head, useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler } from 'react'; + +import InputError from '@/components/input-error'; +import TextLink from '@/components/text-link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AuthLayout from '@/layouts/auth-layout'; + +export default function ForgotPassword({ status }: { status?: string }) { + const { data, setData, post, processing, errors } = useForm>({ + email: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + post(route('password.email')); + }; + + return ( + + + + {status &&
{status}
} + +
+
+
+ + setData('email', e.target.value)} + placeholder="email@example.com" + /> + + +
+ +
+ +
+
+ +
+ Or, return to + log in +
+
+
+ ); +} diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx new file mode 100644 index 0000000..28f76f0 --- /dev/null +++ b/resources/js/pages/auth/login.tsx @@ -0,0 +1,110 @@ +import { Head, useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler } from 'react'; + +import InputError from '@/components/input-error'; +import TextLink from '@/components/text-link'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AuthLayout from '@/layouts/auth-layout'; + +type LoginForm = { + email: string; + password: string; + remember: boolean; +}; + +interface LoginProps { + status?: string; + canResetPassword: boolean; +} + +export default function Login({ status, canResetPassword }: LoginProps) { + const { data, setData, post, processing, errors, reset } = useForm>({ + email: '', + password: '', + remember: false, + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + post(route('login'), { + onFinish: () => reset('password'), + }); + }; + + return ( + + + +
+
+
+ + setData('email', e.target.value)} + placeholder="email@example.com" + /> + +
+ +
+
+ + {canResetPassword && ( + + Forgot password? + + )} +
+ setData('password', e.target.value)} + placeholder="Password" + /> + +
+ +
+ setData('remember', !data.remember)} + tabIndex={3} + /> + +
+ + +
+ +
+ Don't have an account?{' '} + + Sign up + +
+
+ + {status &&
{status}
} +
+ ); +} diff --git a/resources/js/pages/auth/register.tsx b/resources/js/pages/auth/register.tsx new file mode 100644 index 0000000..6b0faa8 --- /dev/null +++ b/resources/js/pages/auth/register.tsx @@ -0,0 +1,119 @@ +import { Head, useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler } from 'react'; + +import InputError from '@/components/input-error'; +import TextLink from '@/components/text-link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AuthLayout from '@/layouts/auth-layout'; + +type RegisterForm = { + name: string; + email: string; + password: string; + password_confirmation: string; +}; + +export default function Register() { + const { data, setData, post, processing, errors, reset } = useForm>({ + name: '', + email: '', + password: '', + password_confirmation: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + post(route('register'), { + onFinish: () => reset('password', 'password_confirmation'), + }); + }; + + return ( + + +
+
+
+ + setData('name', e.target.value)} + disabled={processing} + placeholder="Full name" + /> + +
+ +
+ + setData('email', e.target.value)} + disabled={processing} + placeholder="email@example.com" + /> + +
+ +
+ + setData('password', e.target.value)} + disabled={processing} + placeholder="Password" + /> + +
+ +
+ + setData('password_confirmation', e.target.value)} + disabled={processing} + placeholder="Confirm password" + /> + +
+ + +
+ +
+ Already have an account?{' '} + + Log in + +
+
+
+ ); +} diff --git a/resources/js/pages/auth/reset-password.tsx b/resources/js/pages/auth/reset-password.tsx new file mode 100644 index 0000000..8ea5303 --- /dev/null +++ b/resources/js/pages/auth/reset-password.tsx @@ -0,0 +1,98 @@ +import { Head, useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler } from 'react'; + +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AuthLayout from '@/layouts/auth-layout'; + +interface ResetPasswordProps { + token: string; + email: string; +} + +type ResetPasswordForm = { + token: string; + email: string; + password: string; + password_confirmation: string; +}; + +export default function ResetPassword({ token, email }: ResetPasswordProps) { + const { data, setData, post, processing, errors, reset } = useForm>({ + token: token, + email: email, + password: '', + password_confirmation: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + post(route('password.store'), { + onFinish: () => reset('password', 'password_confirmation'), + }); + }; + + return ( + + + +
+
+
+ + setData('email', e.target.value)} + /> + +
+ +
+ + setData('password', e.target.value)} + placeholder="Password" + /> + +
+ +
+ + setData('password_confirmation', e.target.value)} + placeholder="Confirm password" + /> + +
+ + +
+
+
+ ); +} diff --git a/resources/js/pages/auth/verify-email.tsx b/resources/js/pages/auth/verify-email.tsx new file mode 100644 index 0000000..b4f7846 --- /dev/null +++ b/resources/js/pages/auth/verify-email.tsx @@ -0,0 +1,41 @@ +// Components +import { Head, useForm } from '@inertiajs/react'; +import { LoaderCircle } from 'lucide-react'; +import { FormEventHandler } from 'react'; + +import TextLink from '@/components/text-link'; +import { Button } from '@/components/ui/button'; +import AuthLayout from '@/layouts/auth-layout'; + +export default function VerifyEmail({ status }: { status?: string }) { + const { post, processing } = useForm({}); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + post(route('verification.send')); + }; + + return ( + + + + {status === 'verification-link-sent' && ( +
+ A new verification link has been sent to the email address you provided during registration. +
+ )} + +
+ + + + Log out + +
+
+ ); +} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx new file mode 100644 index 0000000..3f73f02 --- /dev/null +++ b/resources/js/pages/dashboard.tsx @@ -0,0 +1,35 @@ +import { PlaceholderPattern } from '@/components/ui/placeholder-pattern'; +import AppLayout from '@/layouts/app-layout'; +import { type BreadcrumbItem } from '@/types'; +import { Head } from '@inertiajs/react'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Dashboard', + href: '/dashboard', + }, +]; + +export default function Dashboard() { + return ( + + +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/resources/js/pages/settings/appearance.tsx b/resources/js/pages/settings/appearance.tsx new file mode 100644 index 0000000..5099b25 --- /dev/null +++ b/resources/js/pages/settings/appearance.tsx @@ -0,0 +1,30 @@ +import { Head } from '@inertiajs/react'; + +import AppearanceTabs from '@/components/appearance-tabs'; +import HeadingSmall from '@/components/heading-small'; +import { type BreadcrumbItem } from '@/types'; + +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Appearance settings', + href: '/settings/appearance', + }, +]; + +export default function Appearance() { + return ( + + + + +
+ + +
+
+
+ ); +} diff --git a/resources/js/pages/settings/password.tsx b/resources/js/pages/settings/password.tsx new file mode 100644 index 0000000..43540bb --- /dev/null +++ b/resources/js/pages/settings/password.tsx @@ -0,0 +1,128 @@ +import InputError from '@/components/input-error'; +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; +import { type BreadcrumbItem } from '@/types'; +import { Transition } from '@headlessui/react'; +import { Head, useForm } from '@inertiajs/react'; +import { FormEventHandler, useRef } from 'react'; + +import HeadingSmall from '@/components/heading-small'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Password settings', + href: '/settings/password', + }, +]; + +export default function Password() { + const passwordInput = useRef(null); + const currentPasswordInput = useRef(null); + + const { data, setData, errors, put, reset, processing, recentlySuccessful } = useForm({ + current_password: '', + password: '', + password_confirmation: '', + }); + + const updatePassword: FormEventHandler = (e) => { + e.preventDefault(); + + put(route('password.update'), { + preserveScroll: true, + onSuccess: () => reset(), + onError: (errors) => { + if (errors.password) { + reset('password', 'password_confirmation'); + passwordInput.current?.focus(); + } + + if (errors.current_password) { + reset('current_password'); + currentPasswordInput.current?.focus(); + } + }, + }); + }; + + return ( + + + + +
+ + +
+
+ + + setData('current_password', e.target.value)} + type="password" + className="mt-1 block w-full" + autoComplete="current-password" + placeholder="Current password" + /> + + +
+ +
+ + + setData('password', e.target.value)} + type="password" + className="mt-1 block w-full" + autoComplete="new-password" + placeholder="New password" + /> + + +
+ +
+ + + setData('password_confirmation', e.target.value)} + type="password" + className="mt-1 block w-full" + autoComplete="new-password" + placeholder="Confirm password" + /> + + +
+ +
+ + + +

Saved

+
+
+
+
+
+
+ ); +} diff --git a/resources/js/pages/settings/profile.tsx b/resources/js/pages/settings/profile.tsx new file mode 100644 index 0000000..3aeed3a --- /dev/null +++ b/resources/js/pages/settings/profile.tsx @@ -0,0 +1,127 @@ +import { type BreadcrumbItem, type SharedData } from '@/types'; +import { Transition } from '@headlessui/react'; +import { Head, Link, useForm, usePage } from '@inertiajs/react'; +import { FormEventHandler } from 'react'; + +import DeleteUser from '@/components/delete-user'; +import HeadingSmall from '@/components/heading-small'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Profile settings', + href: '/settings/profile', + }, +]; + +type ProfileForm = { + name: string; + email: string; +}; + +export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail: boolean; status?: string }) { + const { auth } = usePage().props; + + const { data, setData, patch, errors, processing, recentlySuccessful } = useForm>({ + name: auth.user.name, + email: auth.user.email, + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + patch(route('profile.update'), { + preserveScroll: true, + }); + }; + + return ( + + + + +
+ + +
+
+ + + setData('name', e.target.value)} + required + autoComplete="name" + placeholder="Full name" + /> + + +
+ +
+ + + setData('email', e.target.value)} + required + autoComplete="username" + placeholder="Email address" + /> + + +
+ + {mustVerifyEmail && auth.user.email_verified_at === null && ( +
+

+ Your email address is unverified.{' '} + + Click here to resend the verification email. + +

+ + {status === 'verification-link-sent' && ( +
+ A new verification link has been sent to your email address. +
+ )} +
+ )} + +
+ + + +

Saved

+
+
+
+
+ + +
+
+ ); +} diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx new file mode 100644 index 0000000..3f3afc1 --- /dev/null +++ b/resources/js/pages/welcome.tsx @@ -0,0 +1,791 @@ +import { type SharedData } from '@/types'; +import { Head, Link, usePage } from '@inertiajs/react'; + +export default function Welcome() { + const { auth } = usePage().props; + + return ( + <> + + + + +
+
+ +
+
+
+
+

Let's get started

+

+ Laravel has an incredibly rich ecosystem. +
+ We suggest starting with the following. +

+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + ); +} diff --git a/resources/js/ssr.tsx b/resources/js/ssr.tsx new file mode 100644 index 0000000..c6cd25a --- /dev/null +++ b/resources/js/ssr.tsx @@ -0,0 +1,30 @@ +import { createInertiaApp } from '@inertiajs/react'; +import createServer from '@inertiajs/react/server'; +import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; +import ReactDOMServer from 'react-dom/server'; +import { type RouteName, route } from 'ziggy-js'; + +const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; + +createServer((page) => + createInertiaApp({ + page, + render: ReactDOMServer.renderToString, + title: (title) => `${title} - ${appName}`, + resolve: (name) => resolvePageComponent(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')), + setup: ({ App, props }) => { + /* eslint-disable */ + // @ts-expect-error + global.route = (name, params, absolute) => + route(name, params as any, absolute, { + // @ts-expect-error + ...page.props.ziggy, + // @ts-expect-error + location: new URL(page.props.ziggy.location), + }); + /* eslint-enable */ + + return ; + }, + }), +); diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts new file mode 100644 index 0000000..b3c9b78 --- /dev/null +++ b/resources/js/types/global.d.ts @@ -0,0 +1,5 @@ +import type { route as routeFn } from 'ziggy-js'; + +declare global { + const route: typeof routeFn; +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts new file mode 100644 index 0000000..1a82d8e --- /dev/null +++ b/resources/js/types/index.d.ts @@ -0,0 +1,43 @@ +import { LucideIcon } from 'lucide-react'; +import type { Config } from 'ziggy-js'; + +export interface Auth { + user: User; +} + +export interface BreadcrumbItem { + title: string; + href: string; +} + +export interface NavGroup { + title: string; + items: NavItem[]; +} + +export interface NavItem { + title: string; + href: string; + icon?: LucideIcon | null; + isActive?: boolean; +} + +export interface SharedData { + name: string; + quote: { message: string; author: string }; + auth: Auth; + ziggy: Config & { location: string }; + sidebarOpen: boolean; + [key: string]: unknown; +} + +export interface User { + id: number; + name: string; + email: string; + avatar?: string; + email_verified_at: string | null; + created_at: string; + updated_at: string; + [key: string]: unknown; // This allows for additional properties... +} diff --git a/resources/js/types/vite-env.d.ts b/resources/js/types/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/resources/js/types/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php new file mode 100644 index 0000000..8218267 --- /dev/null +++ b/resources/views/app.blade.php @@ -0,0 +1,50 @@ + + ($appearance ?? 'system') == 'dark'])> + + + + + {{-- Inline script to detect system dark mode preference and apply it immediately --}} + + + {{-- Inline style to set the HTML background color based on our theme in app.css --}} + + + {{ config('app.name', 'Laravel') }} + + + + + + + + + @routes + @viteReactRefresh + @vite(['resources/js/app.tsx', "resources/js/pages/{$page['component']}.tsx"]) + @inertiaHead + + + @inertia + + diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php deleted file mode 100644 index 3d38186..0000000 --- a/resources/views/auth/confirm-password.blade.php +++ /dev/null @@ -1,27 +0,0 @@ - -
- {{ __('This is a secure area of the application. Please confirm your password before continuing.') }} -
- -
- @csrf - - -
- - - - - -
- -
- - {{ __('Confirm') }} - -
-
-
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php deleted file mode 100644 index cb32e08..0000000 --- a/resources/views/auth/forgot-password.blade.php +++ /dev/null @@ -1,25 +0,0 @@ - -
- {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} -
- - - - -
- @csrf - - -
- - - -
- -
- - {{ __('Email Password Reset Link') }} - -
-
-
diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php deleted file mode 100644 index 78b684f..0000000 --- a/resources/views/auth/login.blade.php +++ /dev/null @@ -1,47 +0,0 @@ - - - - -
- @csrf - - -
- - - -
- - -
- - - - - -
- - -
- -
- -
- @if (Route::has('password.request')) - - {{ __('Forgot your password?') }} - - @endif - - - {{ __('Log in') }} - -
-
-
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php deleted file mode 100644 index a857242..0000000 --- a/resources/views/auth/register.blade.php +++ /dev/null @@ -1,52 +0,0 @@ - -
- @csrf - - -
- - - -
- - -
- - - -
- - -
- - - - - -
- - -
- - - - - -
- -
- - {{ __('Already registered?') }} - - - - {{ __('Register') }} - -
-
-
diff --git a/resources/views/auth/reset-password.blade.php b/resources/views/auth/reset-password.blade.php deleted file mode 100644 index a6494cc..0000000 --- a/resources/views/auth/reset-password.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - -
- @csrf - - - - - -
- - - -
- - -
- - - -
- - -
- - - - - -
- -
- - {{ __('Reset Password') }} - -
-
-
diff --git a/resources/views/auth/verify-email.blade.php b/resources/views/auth/verify-email.blade.php deleted file mode 100644 index eaf811d..0000000 --- a/resources/views/auth/verify-email.blade.php +++ /dev/null @@ -1,31 +0,0 @@ - -
- {{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }} -
- - @if (session('status') == 'verification-link-sent') -
- {{ __('A new verification link has been sent to the email address you provided during registration.') }} -
- @endif - -
-
- @csrf - -
- - {{ __('Resend Verification Email') }} - -
-
- -
- @csrf - - -
-
-
diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php deleted file mode 100644 index 46579cf..0000000 --- a/resources/views/components/application-logo.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php deleted file mode 100644 index c4bd6e2..0000000 --- a/resources/views/components/auth-session-status.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -@props(['status']) - -@if ($status) -
merge(['class' => 'font-medium text-sm text-green-600']) }}> - {{ $status }} -
-@endif diff --git a/resources/views/components/danger-button.blade.php b/resources/views/components/danger-button.blade.php deleted file mode 100644 index d17d288..0000000 --- a/resources/views/components/danger-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/dropdown-link.blade.php b/resources/views/components/dropdown-link.blade.php deleted file mode 100644 index e0f8ce1..0000000 --- a/resources/views/components/dropdown-link.blade.php +++ /dev/null @@ -1 +0,0 @@ -merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}>{{ $slot }} diff --git a/resources/views/components/dropdown.blade.php b/resources/views/components/dropdown.blade.php deleted file mode 100644 index a46f7c8..0000000 --- a/resources/views/components/dropdown.blade.php +++ /dev/null @@ -1,35 +0,0 @@ -@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white']) - -@php -$alignmentClasses = match ($align) { - 'left' => 'ltr:origin-top-left rtl:origin-top-right start-0', - 'top' => 'origin-top', - default => 'ltr:origin-top-right rtl:origin-top-left end-0', -}; - -$width = match ($width) { - '48' => 'w-48', - default => $width, -}; -@endphp - -
-
- {{ $trigger }} -
- - -
diff --git a/resources/views/components/input-error.blade.php b/resources/views/components/input-error.blade.php deleted file mode 100644 index 9e6da21..0000000 --- a/resources/views/components/input-error.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props(['messages']) - -@if ($messages) -
    merge(['class' => 'text-sm text-red-600 space-y-1']) }}> - @foreach ((array) $messages as $message) -
  • {{ $message }}
  • - @endforeach -
-@endif diff --git a/resources/views/components/input-label.blade.php b/resources/views/components/input-label.blade.php deleted file mode 100644 index 1cc65e2..0000000 --- a/resources/views/components/input-label.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -@props(['value']) - - diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php deleted file mode 100644 index 70704c1..0000000 --- a/resources/views/components/modal.blade.php +++ /dev/null @@ -1,78 +0,0 @@ -@props([ - 'name', - 'show' => false, - 'maxWidth' => '2xl' -]) - -@php -$maxWidth = [ - 'sm' => 'sm:max-w-sm', - 'md' => 'sm:max-w-md', - 'lg' => 'sm:max-w-lg', - 'xl' => 'sm:max-w-xl', - '2xl' => 'sm:max-w-2xl', -][$maxWidth]; -@endphp - -
-
-
-
- -
- {{ $slot }} -
-
diff --git a/resources/views/components/nav-link.blade.php b/resources/views/components/nav-link.blade.php deleted file mode 100644 index 5c101a2..0000000 --- a/resources/views/components/nav-link.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -@props(['active']) - -@php -$classes = ($active ?? false) - ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' - : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out'; -@endphp - -merge(['class' => $classes]) }}> - {{ $slot }} - diff --git a/resources/views/components/primary-button.blade.php b/resources/views/components/primary-button.blade.php deleted file mode 100644 index d71f0b6..0000000 --- a/resources/views/components/primary-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/responsive-nav-link.blade.php b/resources/views/components/responsive-nav-link.blade.php deleted file mode 100644 index 43b91e7..0000000 --- a/resources/views/components/responsive-nav-link.blade.php +++ /dev/null @@ -1,11 +0,0 @@ -@props(['active']) - -@php -$classes = ($active ?? false) - ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out' - : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out'; -@endphp - -merge(['class' => $classes]) }}> - {{ $slot }} - diff --git a/resources/views/components/secondary-button.blade.php b/resources/views/components/secondary-button.blade.php deleted file mode 100644 index b32b69f..0000000 --- a/resources/views/components/secondary-button.blade.php +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/resources/views/components/text-input.blade.php b/resources/views/components/text-input.blade.php deleted file mode 100644 index da1b12d..0000000 --- a/resources/views/components/text-input.blade.php +++ /dev/null @@ -1,3 +0,0 @@ -@props(['disabled' => false]) - -merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) }}> diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php deleted file mode 100644 index 66028f2..0000000 --- a/resources/views/dashboard.blade.php +++ /dev/null @@ -1,17 +0,0 @@ - - -

- {{ __('Dashboard') }} -

-
- -
-
-
-
- {{ __("You're logged in!") }} -
-
-
-
-
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 8849a7d..9685ee1 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1,112 +1,10 @@ - - - - - - + + + Lemmy Poster + + + @include('partials.navbar') - {{ config('app.name', 'FFR') }} - - - - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles - - -
- -
- - -
-
-

FFR

- -
- -
- - - - - -
- -
- -
-

FFR

-
-
- -
- {{ $slot }} -
-
-
- - @livewireScripts - + @yield('content') + diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php deleted file mode 100644 index 3628119..0000000 --- a/resources/views/layouts/guest.blade.php +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - {{ config('app.name', 'Laravel') }} - - - - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - @livewireStyles - - - {{ $slot }} - @livewireScripts - - diff --git a/resources/views/layouts/navigation-items.blade.php b/resources/views/layouts/navigation-items.blade.php deleted file mode 100644 index f98d21b..0000000 --- a/resources/views/layouts/navigation-items.blade.php +++ /dev/null @@ -1,53 +0,0 @@ -@php -$navigation = [ - ['name' => 'Dashboard', 'route' => 'dashboard', 'icon' => 'home'], - ['name' => 'Articles', 'route' => 'articles', 'icon' => 'document-text'], - ['name' => 'Feeds', 'route' => 'feeds', 'icon' => 'rss'], - ['name' => 'Channels', 'route' => 'channels', 'icon' => 'hashtag'], - ['name' => 'Routes', 'route' => 'routes', 'icon' => 'arrow-path'], - ['name' => 'Settings', 'route' => 'settings', 'icon' => 'cog-6-tooth'], -]; -@endphp - -@foreach ($navigation as $item) - - @switch($item['icon']) - @case('home') - - - - @break - @case('document-text') - - - - @break - @case('rss') - - - - @break - @case('hashtag') - - - - @break - @case('arrow-path') - - - - @break - @case('cog-6-tooth') - - - - - @break - @endswitch - {{ $item['name'] }} - -@endforeach diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php deleted file mode 100644 index c2d3a65..0000000 --- a/resources/views/layouts/navigation.blade.php +++ /dev/null @@ -1,100 +0,0 @@ - diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php deleted file mode 100644 index 62411ef..0000000 --- a/resources/views/livewire/articles.blade.php +++ /dev/null @@ -1,139 +0,0 @@ -
-
-
-

Articles

-

- Manage and review articles from your feeds -

- @if ($approvalsEnabled) -
- - - - - Approval system enabled -
- @endif -
- -
- -
- @forelse ($articles as $article) -
-
-
-

- {{ $article->title ?? 'Untitled Article' }} -

-

- {{ $article->description ?? 'No description available' }} -

-
- Feed: {{ $article->feed?->name ?? 'Unknown' }} - - {{ $article->created_at->format('M d, Y') }} -
-
-
- @if ($article->is_published) - - - - - Published - - @elseif ($article->approval_status === 'approved') - - - - - Approved - - @elseif ($article->approval_status === 'rejected') - - - - - Rejected - - @else - - - - - Pending - - @endif - - @if ($article->url) - - - - - - @endif -
-
- - @if ($article->approval_status === 'pending' && $approvalsEnabled) -
- - -
- @endif -
- @empty -
- - - -

No articles

-

- No articles have been fetched yet. -

-
- @endforelse - - @if ($articles->hasPages()) -
- {{ $articles->links() }} -
- @endif -
-
diff --git a/resources/views/livewire/channels.blade.php b/resources/views/livewire/channels.blade.php deleted file mode 100644 index ec4691e..0000000 --- a/resources/views/livewire/channels.blade.php +++ /dev/null @@ -1,163 +0,0 @@ -
-
-

Channels

-

- Manage your platform channels and linked accounts -

-
- -
- @forelse ($channels as $channel) -
-
-
-
- - - -
-
-

- {{ $channel->display_name ?? $channel->name }} -

- @if ($channel->platformInstance) - - {{ $channel->platformInstance->name }} - - @endif -
-
- -
- - @if ($channel->description) -

- {{ $channel->description }} -

- @endif - -
-
- - Linked Accounts ({{ $channel->platformAccounts->count() }}) - - -
- - @if ($channel->platformAccounts->isNotEmpty()) -
- @foreach ($channel->platformAccounts->take(3) as $account) -
- {{ $account->username }} - -
- @endforeach - @if ($channel->platformAccounts->count() > 3) - +{{ $channel->platformAccounts->count() - 3 }} more - @endif -
- @else -

No accounts linked

- @endif -
- -
- - {{ $channel->is_active ? 'Active' : 'Inactive' }} - - - {{ $channel->created_at->format('M d, Y') }} - -
-
- @empty -
- - - -

No channels

-

- No platform channels have been configured yet. -

-
- @endforelse -
- - - @if ($managingChannel) - - @endif -
diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php deleted file mode 100644 index 9060411..0000000 --- a/resources/views/livewire/dashboard.blade.php +++ /dev/null @@ -1,153 +0,0 @@ -
-
-

Dashboard

-

- Overview of your feed management system -

-
- - -
-

System Overview

-
- -
-
-
- - - -
-
-

Active Feeds

-

- {{ $systemStats['active_feeds'] }} - /{{ $systemStats['total_feeds'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Platform Accounts

-

- {{ $systemStats['active_platform_accounts'] }} - /{{ $systemStats['total_platform_accounts'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Platform Channels

-

- {{ $systemStats['active_platform_channels'] }} - /{{ $systemStats['total_platform_channels'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Active Routes

-

- {{ $systemStats['active_routes'] }} - /{{ $systemStats['total_routes'] }} -

-
-
-
-
-
- - -
-
-

Article Statistics

- -
-
- -
-
-
- - - -
-
-

Articles Fetched

-

- {{ $articleStats['articles_fetched'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Articles Published

-

- {{ $articleStats['articles_published'] }} -

-
-
-
- - -
-
-
- - - -
-
-

Published Rate

-

- {{ $articleStats['published_percentage'] }}% -

-
-
-
-
-
-
diff --git a/resources/views/livewire/feeds.blade.php b/resources/views/livewire/feeds.blade.php deleted file mode 100644 index 1a2e5f0..0000000 --- a/resources/views/livewire/feeds.blade.php +++ /dev/null @@ -1,87 +0,0 @@ -
-
-

Feeds

-

- Manage your news feed sources -

-
- -
- @forelse ($feeds as $feed) -
-
-
-
- @if ($feed->type === 'rss') - - - - @else - - - - @endif -
-
-

- {{ $feed->name }} -

- @if ($feed->description) -

- {{ $feed->description }} -

- @endif -
-
- -
- -
-
- - {{ $feed->is_active ? 'Active' : 'Inactive' }} - - - {{ strtoupper($feed->type) }} - -
- @if ($feed->url) - - - - - - @endif -
- -
- Created {{ $feed->created_at->format('M d, Y') }} -
-
- @empty -
- - - -

No feeds

-

- No feeds have been configured yet. -

-
- @endforelse -
-
diff --git a/resources/views/livewire/onboarding.blade.php b/resources/views/livewire/onboarding.blade.php deleted file mode 100644 index bba2156..0000000 --- a/resources/views/livewire/onboarding.blade.php +++ /dev/null @@ -1,546 +0,0 @@ -
-
- - {{-- Step 1: Welcome --}} - @if ($step === 1) -
-

Welcome to FFR

-

- Let's get you set up! We'll help you configure your Lemmy account, add your first feed, and create a channel for posting. -

- -
-
-
1
- Connect your Lemmy account -
-
-
2
- Add your first feed -
-
-
3
- Configure a channel -
-
-
4
- Create a route -
-
-
5
- You're ready to go! -
-
- -
- -
-
- @endif - - {{-- Step 2: Platform Account --}} - @if ($step === 2) -
-

Connect Your Lemmy Account

-

- {{ $existingAccount ? 'Your connected Lemmy account' : 'Enter your Lemmy instance details and login credentials' }} -

- - {{-- Progress indicator --}} -
-
1
-
2
-
3
-
4
-
- - @if (!empty($errors['general'])) -
-

{{ $errors['general'] }}

-
- @endif - - @if ($existingAccount) - {{-- Account Card --}} -
-
-
-
-
- - - -
-
-

Account Connected

-
-

Username: {{ $existingAccount['username'] }}

-

Instance: {{ str_replace('https://', '', $existingAccount['instance_url']) }}

-
-
-
- -
-
- -
- - -
-
- @else - {{-- Login Form --}} -
-
- - -

Enter just the domain name (e.g., lemmy.world, belgae.social)

- @error('instanceUrl')

{{ $message }}

@enderror -
- -
- - - @error('username')

{{ $message }}

@enderror -
- -
- - - @error('password')

{{ $message }}

@enderror -
- -
- - -
-
- @endif -
- @endif - - {{-- Step 3: Feed --}} - @if ($step === 3) -
-

Add Your First Feed

-

- Choose from our supported news providers to monitor for new articles -

- - {{-- Progress indicator --}} -
-
-
2
-
3
-
4
-
- -
- @if (!empty($errors['general'])) -
-

{{ $errors['general'] }}

-
- @endif - -
- - - @error('feedName')

{{ $message }}

@enderror -
- -
- - - @error('feedProvider')

{{ $message }}

@enderror -
- -
- - - @error('feedLanguageId')

{{ $message }}

@enderror -
- -
- - - @error('feedDescription')

{{ $message }}

@enderror -
- -
- - -
-
-
- @endif - - {{-- Step 4: Channel --}} - @if ($step === 4) -
-

Configure Your Channel

-

- Set up a Lemmy community where articles will be posted -

- - {{-- Progress indicator --}} -
-
-
-
3
-
4
-
- -
- @if (!empty($errors['general'])) -
-

{{ $errors['general'] }}

-
- @endif - -
- - -

Enter the community name (without the @ or instance)

- @error('channelName')

{{ $message }}

@enderror -
- -
- - - @error('platformInstanceId')

{{ $message }}

@enderror -
- -
- - - @error('channelLanguageId')

{{ $message }}

@enderror -
- -
- - - @error('channelDescription')

{{ $message }}

@enderror -
- -
- - -
-
-
- @endif - - {{-- Step 5: Route --}} - @if ($step === 5) -
-

Create Your First Route

-

- Connect your feed to a channel by creating a route. This tells FFR which articles to post where. -

- - {{-- Progress indicator --}} -
-
-
-
-
4
-
5
-
- -
- @if (!empty($errors['general'])) -
-

{{ $errors['general'] }}

-
- @endif - -
- - - @error('routeFeedId')

{{ $message }}

@enderror -
- -
- - - @if ($channels->isEmpty()) -

- No channels available. Please create a channel first. -

- @endif - @error('routeChannelId')

{{ $message }}

@enderror -
- -
- - -

- Higher priority routes are processed first (default: 50) -

- @error('routePriority')

{{ $message }}

@enderror -
- -
- - -
-
-
- @endif - - {{-- Step 6: Complete --}} - @if ($step === 6) -
-
-
- - - -
-

Setup Complete!

-

- Great! You've successfully configured FFR. Your feeds will now be monitored and articles will be automatically posted to your configured channels. -

-
- - {{-- Progress indicator --}} -
-
-
-
-
-
- -
-
-

What happens next?

-
    -
  • • Your feeds will be checked regularly for new articles
  • -
  • • New articles will be automatically posted to your channels
  • -
  • • You can monitor activity in the Articles and other sections
  • -
-
- -
-

Want more control?

-

- You can add more feeds, channels, and configure settings from the dashboard. -

-
-
- -
- - -
- View Articles - • - Manage Feeds - • - Settings -
-
-
- @endif -
-
diff --git a/resources/views/livewire/routes.blade.php b/resources/views/livewire/routes.blade.php deleted file mode 100644 index 364bbfa..0000000 --- a/resources/views/livewire/routes.blade.php +++ /dev/null @@ -1,380 +0,0 @@ -
-
-
-

Routes

-

- Manage connections between your feeds and channels -

-
- -
- -
- @forelse ($routes as $route) -
-
-
-
-

- {{ $route->feed?->name }} → {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }} -

- @if ($route->is_active) - - - - - Active - - @else - - - - - Inactive - - @endif -
-
- Priority: {{ $route->priority }} - - Feed: {{ $route->feed?->name }} - - Channel: {{ $route->platformChannel?->display_name ?? $route->platformChannel?->name }} - - Created: {{ $route->created_at->format('M d, Y') }} -
- @if ($route->platformChannel?->description) -

- {{ $route->platformChannel->description }} -

- @endif - @if ($route->keywords->isNotEmpty()) -
-
- - - - - Keywords -
-
- @foreach ($route->keywords as $keyword) - - {{ $keyword->keyword }} - - @endforeach -
-
- @else -
- No keyword filters - matches all articles -
- @endif -
-
- - - -
-
-
- @empty -
- - - -

No routes

-

- Get started by creating a new route to connect feeds with channels. -

-
- -
-
- @endforelse -
- - - @if ($showCreateModal) - - @endif - - - @if ($editingRoute) - - @endif -
diff --git a/resources/views/livewire/settings.blade.php b/resources/views/livewire/settings.blade.php deleted file mode 100644 index 96b050c..0000000 --- a/resources/views/livewire/settings.blade.php +++ /dev/null @@ -1,90 +0,0 @@ -
-
-

Settings

-

- Configure your system preferences -

-
- -
- -
-
-

- - - - - Article Processing -

-

- Control how articles are processed and handled -

-
-
-
-
-

- Article Processing Enabled -

-

- Enable automatic fetching and processing of articles from feeds -

-
- -
- -
-
-

- Publishing Approvals Required -

-

- Require manual approval before articles are published to platforms -

-
- -
-
-
- - - @if ($successMessage) -
-

{{ $successMessage }}

-
- @endif - - @if ($errorMessage) -
-

{{ $errorMessage }}

-
- @endif -
-
diff --git a/resources/views/pages/articles/index.blade.php b/resources/views/pages/articles/index.blade.php new file mode 100644 index 0000000..700876e --- /dev/null +++ b/resources/views/pages/articles/index.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app') + +@section('content') +

Articles

+ + + + + + + + + + + @foreach($articles as $article) + + + + + + @endforeach + +
IDURLCreated At
{{ $article->id }}{{ $article->url }}{{ $article->created_at->format('Y-m-d H:i') }}
+@endsection diff --git a/resources/views/pages/logs/index.blade.php b/resources/views/pages/logs/index.blade.php new file mode 100644 index 0000000..3827b03 --- /dev/null +++ b/resources/views/pages/logs/index.blade.php @@ -0,0 +1,26 @@ +@extends('layouts.app') + +@section('content') +

Logs

+ + + + + + + + + + + + @foreach($logs as $log) + + + + + + + @endforeach + +
IDLevelMessageCreated At
{{ $log->id }}{{ ucfirst($log->level->value) }}{{ $log->message }}{{ $log->created_at->format('Y-m-d H:i') }}
+@endsection diff --git a/resources/views/partials/navbar.blade.php b/resources/views/partials/navbar.blade.php new file mode 100644 index 0000000..845964a --- /dev/null +++ b/resources/views/partials/navbar.blade.php @@ -0,0 +1,10 @@ + diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php deleted file mode 100644 index e0e1d38..0000000 --- a/resources/views/profile/edit.blade.php +++ /dev/null @@ -1,29 +0,0 @@ - - -

- {{ __('Profile') }} -

-
- -
-
-
-
- @include('profile.partials.update-profile-information-form') -
-
- -
-
- @include('profile.partials.update-password-form') -
-
- -
-
- @include('profile.partials.delete-user-form') -
-
-
-
-
diff --git a/resources/views/profile/partials/delete-user-form.blade.php b/resources/views/profile/partials/delete-user-form.blade.php deleted file mode 100644 index edeeb4a..0000000 --- a/resources/views/profile/partials/delete-user-form.blade.php +++ /dev/null @@ -1,55 +0,0 @@ -
-
-

- {{ __('Delete Account') }} -

- -

- {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }} -

-
- - {{ __('Delete Account') }} - - -
- @csrf - @method('delete') - -

- {{ __('Are you sure you want to delete your account?') }} -

- -

- {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} -

- -
- - - - - -
- -
- - {{ __('Cancel') }} - - - - {{ __('Delete Account') }} - -
-
-
-
diff --git a/resources/views/profile/partials/update-password-form.blade.php b/resources/views/profile/partials/update-password-form.blade.php deleted file mode 100644 index eaca1ac..0000000 --- a/resources/views/profile/partials/update-password-form.blade.php +++ /dev/null @@ -1,48 +0,0 @@ -
-
-

- {{ __('Update Password') }} -

- -

- {{ __('Ensure your account is using a long, random password to stay secure.') }} -

-
- -
- @csrf - @method('put') - -
- - - -
- -
- - - -
- -
- - - -
- -
- {{ __('Save') }} - - @if (session('status') === 'password-updated') -

{{ __('Saved.') }}

- @endif -
-
-
diff --git a/resources/views/profile/partials/update-profile-information-form.blade.php b/resources/views/profile/partials/update-profile-information-form.blade.php deleted file mode 100644 index 5ae3d35..0000000 --- a/resources/views/profile/partials/update-profile-information-form.blade.php +++ /dev/null @@ -1,64 +0,0 @@ -
-
-

- {{ __('Profile Information') }} -

- -

- {{ __("Update your account's profile information and email address.") }} -

-
- -
- @csrf -
- -
- @csrf - @method('patch') - -
- - - -
- -
- - - - - @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) -
-

- {{ __('Your email address is unverified.') }} - - -

- - @if (session('status') === 'verification-link-sent') -

- {{ __('A new verification link has been sent to your email address.') }} -

- @endif -
- @endif -
- -
- {{ __('Save') }} - - @if (session('status') === 'profile-updated') -

{{ __('Saved.') }}

- @endif -
-
-
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php deleted file mode 100644 index 3fdc575..0000000 --- a/resources/views/welcome.blade.php +++ /dev/null @@ -1,9 +0,0 @@ - - - - FFR - - -

Welcome

- - diff --git a/routes/api.php b/routes/api.php deleted file mode 100644 index b54f639..0000000 --- a/routes/api.php +++ /dev/null @@ -1,117 +0,0 @@ -group(function () { - // Public authentication routes - Route::post('/auth/login', [AuthController::class, 'login'])->name('api.auth.login'); - Route::post('/auth/register', [AuthController::class, 'register'])->name('api.auth.register'); - - // Protected authentication routes - Route::middleware('auth:sanctum')->group(function () { - Route::post('/auth/logout', [AuthController::class, 'logout'])->name('api.auth.logout'); - Route::get('/auth/me', [AuthController::class, 'me'])->name('api.auth.me'); - }); - - // Onboarding - Route::get('/onboarding/status', [OnboardingController::class, 'status'])->name('api.onboarding.status'); - Route::get('/onboarding/options', [OnboardingController::class, 'options'])->name('api.onboarding.options'); - Route::post('/onboarding/platform', [OnboardingController::class, 'createPlatform'])->name('api.onboarding.platform'); - Route::post('/onboarding/feed', [OnboardingController::class, 'createFeed'])->name('api.onboarding.feed'); - Route::post('/onboarding/channel', [OnboardingController::class, 'createChannel'])->name('api.onboarding.channel'); - Route::post('/onboarding/route', [OnboardingController::class, 'createRoute'])->name('api.onboarding.route'); - Route::post('/onboarding/complete', [OnboardingController::class, 'complete'])->name('api.onboarding.complete'); - Route::post('/onboarding/skip', [OnboardingController::class, 'skip'])->name('api.onboarding.skip'); - Route::post('/onboarding/reset-skip', [OnboardingController::class, 'resetSkip'])->name('api.onboarding.reset-skip'); - - // Dashboard stats - Route::get('/dashboard/stats', [DashboardController::class, 'stats'])->name('api.dashboard.stats'); - - // Articles - Route::get('/articles', [ArticlesController::class, 'index'])->name('api.articles.index'); - Route::post('/articles/{article}/approve', [ArticlesController::class, 'approve'])->name('api.articles.approve'); - Route::post('/articles/{article}/reject', [ArticlesController::class, 'reject'])->name('api.articles.reject'); - Route::post('/articles/refresh', [ArticlesController::class, 'refresh'])->name('api.articles.refresh'); - - // Platform Accounts - Route::apiResource('platform-accounts', PlatformAccountsController::class)->names([ - 'index' => 'api.platform-accounts.index', - 'store' => 'api.platform-accounts.store', - 'show' => 'api.platform-accounts.show', - 'update' => 'api.platform-accounts.update', - 'destroy' => 'api.platform-accounts.destroy', - ]); - Route::post('/platform-accounts/{platformAccount}/set-active', [PlatformAccountsController::class, 'setActive']) - ->name('api.platform-accounts.set-active'); - - // Platform Channels - Route::apiResource('platform-channels', PlatformChannelsController::class)->names([ - 'index' => 'api.platform-channels.index', - 'store' => 'api.platform-channels.store', - 'show' => 'api.platform-channels.show', - 'update' => 'api.platform-channels.update', - 'destroy' => 'api.platform-channels.destroy', - ]); - Route::post('/platform-channels/{channel}/toggle', [PlatformChannelsController::class, 'toggle']) - ->name('api.platform-channels.toggle'); - Route::post('/platform-channels/{channel}/accounts', [PlatformChannelsController::class, 'attachAccount']) - ->name('api.platform-channels.attach-account'); - Route::delete('/platform-channels/{channel}/accounts/{account}', [PlatformChannelsController::class, 'detachAccount']) - ->name('api.platform-channels.detach-account'); - Route::put('/platform-channels/{channel}/accounts/{account}', [PlatformChannelsController::class, 'updateAccountRelation']) - ->name('api.platform-channels.update-account-relation'); - - // Feeds - Route::apiResource('feeds', FeedsController::class)->names([ - 'index' => 'api.feeds.index', - 'store' => 'api.feeds.store', - 'show' => 'api.feeds.show', - 'update' => 'api.feeds.update', - 'destroy' => 'api.feeds.destroy', - ]); - Route::post('/feeds/{feed}/toggle', [FeedsController::class, 'toggle'])->name('api.feeds.toggle'); - - // Routing - Route::get('/routing', [RoutingController::class, 'index'])->name('api.routing.index'); - Route::post('/routing', [RoutingController::class, 'store'])->name('api.routing.store'); - Route::get('/routing/{feed}/{channel}', [RoutingController::class, 'show'])->name('api.routing.show'); - Route::put('/routing/{feed}/{channel}', [RoutingController::class, 'update'])->name('api.routing.update'); - Route::delete('/routing/{feed}/{channel}', [RoutingController::class, 'destroy'])->name('api.routing.destroy'); - Route::post('/routing/{feed}/{channel}/toggle', [RoutingController::class, 'toggle'])->name('api.routing.toggle'); - - // Keywords - Route::get('/routing/{feed}/{channel}/keywords', [KeywordsController::class, 'index'])->name('api.keywords.index'); - Route::post('/routing/{feed}/{channel}/keywords', [KeywordsController::class, 'store'])->name('api.keywords.store'); - Route::put('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'update'])->name('api.keywords.update'); - Route::delete('/routing/{feed}/{channel}/keywords/{keyword}', [KeywordsController::class, 'destroy'])->name('api.keywords.destroy'); - Route::post('/routing/{feed}/{channel}/keywords/{keyword}/toggle', [KeywordsController::class, 'toggle'])->name('api.keywords.toggle'); - - // Settings - Route::get('/settings', [SettingsController::class, 'index'])->name('api.settings.index'); - Route::put('/settings', [SettingsController::class, 'update'])->name('api.settings.update'); - - // Logs - Route::get('/logs', [LogsController::class, 'index'])->name('api.logs.index'); -}); diff --git a/routes/auth.php b/routes/auth.php index 3926ecf..7862ed4 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -5,7 +5,6 @@ use App\Http\Controllers\Auth\EmailVerificationNotificationController; use App\Http\Controllers\Auth\EmailVerificationPromptController; use App\Http\Controllers\Auth\NewPasswordController; -use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\PasswordResetLinkController; use App\Http\Controllers\Auth\RegisteredUserController; use App\Http\Controllers\Auth\VerifyEmailController; @@ -52,8 +51,6 @@ Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']); - Route::put('password', [PasswordController::class, 'update'])->name('password.update'); - Route::post('logout', [AuthenticatedSessionController::class, 'destroy']) ->name('logout'); }); diff --git a/routes/console.php b/routes/console.php index 83db0a3..bc6a0cc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,22 +1,61 @@ hourly(); + Schedule::call(function () { - SyncChannelPostsJob::dispatchForAllActiveChannels(); + $article = Article::whereDoesntHave('articlePublications') + ->where('is_valid', true) + ->first(); + + if ($article) { + try { + logger()->info('Publishing article to Lemmy via scheduler', [ + 'article_id' => $article->id, + 'url' => $article->url + ]); + + $extractedData = ArticleFetcher::fetchArticleData($article); + LemmyPublisher::fromConfig()->publish($article, $extractedData); + + logger()->info('Successfully published article to Lemmy', [ + 'article_id' => $article->id + ]); + } catch (Exception $e) { + logger()->error('Failed to publish article to Lemmy via scheduler', [ + 'article_id' => $article->id, + 'error' => $e->getMessage() + ]); + } + } else { + logger()->debug('No unpublished valid articles found for Lemmy publishing'); + } +})->everyFifteenMinutes()->name('publish-to-lemmy'); + +Schedule::call(function () { + $communityId = config('lemmy.community_id'); + $communityName = config('lemmy.community'); + + if ($communityId && $communityName) { + SyncChannelPostsJob::dispatch( + PlatformEnum::LEMMY, + $communityId, + $communityName + ); + + logger()->info('Dispatched channel posts sync job', [ + 'platform' => 'lemmy', + 'community_id' => $communityId, + 'community_name' => $communityName + ]); + } else { + logger()->warning('Missing Lemmy community configuration for sync job'); + } })->everyTenMinutes()->name('sync-lemmy-channel-posts'); - -Schedule::job(new PublishNextArticleJob) - ->everyFiveMinutes() - ->name('publish-next-article') - ->withoutOverlapping() - ->onOneServer(); - -Schedule::job(new ArticleDiscoveryJob) - ->everyThirtyMinutes() - ->name('refresh-articles') - ->withoutOverlapping() - ->onOneServer(); diff --git a/routes/settings.php b/routes/settings.php new file mode 100644 index 0000000..9503137 --- /dev/null +++ b/routes/settings.php @@ -0,0 +1,21 @@ +group(function () { + Route::redirect('settings', 'settings/profile'); + + Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit'); + Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); + Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + + Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit'); + Route::put('settings/password', [PasswordController::class, 'update'])->name('password.update'); + + Route::get('settings/appearance', function () { + return Inertia::render('settings/appearance'); + })->name('appearance'); +}); diff --git a/routes/web.php b/routes/web.php index b7fa3da..3c7b998 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,40 +1,22 @@ route('dashboard'); -}); - -// Onboarding routes (protected by auth, but need incomplete onboarding) -Route::middleware(['auth', 'onboarding.incomplete'])->group(function () { - Route::get('/onboarding', Onboarding::class)->name('onboarding'); -}); - -// Main app routes (protected by auth and require completed onboarding) -Route::middleware(['auth', 'onboarding.complete'])->group(function () { - Route::get('/dashboard', Dashboard::class)->name('dashboard'); - Route::get('/articles', Articles::class)->name('articles'); - Route::get('/feeds', Feeds::class)->name('feeds'); - Route::get('/channels', Channels::class)->name('channels'); - Route::get('/routes', Routes::class)->name('routes'); - Route::get('/settings', Settings::class)->name('settings'); -}); - -// Profile routes (auth protected, no onboarding check needed) -Route::middleware('auth')->group(function () { - Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); - Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); - Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); + return Inertia::render('welcome'); +})->name('home'); + +Route::middleware(['auth', 'verified'])->group(function () { + Route::get('dashboard', function () { + return Inertia::render('dashboard'); + })->name('dashboard'); }); +require __DIR__.'/settings.php'; require __DIR__.'/auth.php'; + +Route::get('/articles', ArticlesController::class)->name('articles'); +Route::get('/logs', LogsController::class)->name('logs'); diff --git a/shell.nix b/shell.nix deleted file mode 100644 index e14efc4..0000000 --- a/shell.nix +++ /dev/null @@ -1,136 +0,0 @@ -{ pkgs ? import {} }: - -pkgs.mkShell { - buildInputs = with pkgs; [ - # PHP and tools - php83 - php83Packages.composer - - # Node.js and npm - nodejs_22 - - # Container tools - podman - podman-compose - - # Database client (for direct DB access) - mariadb.client - - # Redis client - redis - - # Utilities - git - curl - gnumake - ]; - - shellHook = '' - export USER_ID=$(id -u) - export GROUP_ID=$(id -g) - export PODMAN_USERNS=keep-id - - # Compose file location - COMPOSE_FILE="$PWD/docker/dev/docker-compose.yml" - - # =================== - # ALIASES - # =================== - alias pc='podman-compose -f $COMPOSE_FILE' - - # =================== - # DEV COMMANDS - # =================== - dev-up() { - echo "Starting services..." - PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@" - echo "" - podman-compose -f $COMPOSE_FILE ps - echo "" - echo "App available at: http://localhost:8000" - } - - dev-down() { - if [[ "$1" == "-v" ]]; then - echo "Stopping services and removing volumes..." - podman-compose -f $COMPOSE_FILE down -v - else - echo "Stopping services..." - podman-compose -f $COMPOSE_FILE down - fi - } - - dev-restart() { - echo "Restarting services..." - podman-compose -f $COMPOSE_FILE restart "$@" - } - - dev-logs() { - podman-compose -f $COMPOSE_FILE logs -f app "$@" - } - - dev-logs-db() { - podman-compose -f $COMPOSE_FILE logs -f db "$@" - } - - dev-shell() { - podman-compose -f $COMPOSE_FILE exec app sh - } - - dev-artisan() { - podman-compose -f $COMPOSE_FILE exec app php artisan "$@" - } - - # =================== - # PROD COMMANDS - # =================== - prod-build() { - local tag="''${1:-latest}" - local image="codeberg.org/lvl0/ffr:$tag" - - echo "Building production image: $image" - if ! podman build -t "$image" -f Dockerfile .; then - echo "Build failed!" - return 1 - fi - - echo "" - echo "Pushing to registry..." - if ! podman push "$image"; then - echo "" - echo "Push failed! You may need to login first:" - echo " podman login codeberg.org" - return 1 - fi - - echo "" - echo "Done! Image pushed: $image" - } - - # =================== - # WELCOME MESSAGE - # =================== - echo "" - echo "╔═══════════════════════════════════════════════════════════╗" - echo "║ FFR Dev Environment ║" - echo "╚═══════════════════════════════════════════════════════════╝" - echo "" - echo " Podman: $(podman --version | cut -d' ' -f3)" - echo "" - echo "Commands:" - echo " dev-up [services] Start all or specific services" - echo " dev-down [-v] Stop services (-v removes volumes)" - echo " dev-restart Restart services" - echo " dev-logs Tail app logs" - echo " dev-logs-db Tail database logs" - echo " dev-shell Shell into app container" - echo " dev-artisan Run artisan command" - echo " prod-build [tag] Build and push prod image (default: latest)" - echo "" - echo "Services:" - echo " app Laravel + Vite http://localhost:8000" - echo " db MariaDB localhost:3307" - echo " redis Redis localhost:6380" - echo "" - ''; -} diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 94ed352..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - './resources/**/*.blade.php', - './resources/**/*.js', - './app/Livewire/**/*.php', - './vendor/livewire/livewire/dist/livewire.esm.js', - './storage/framework/views/*.php', - ], - theme: { - extend: {}, - }, - plugins: [], -}; diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php deleted file mode 100644 index d0c808b..0000000 --- a/tests/CreatesApplication.php +++ /dev/null @@ -1,21 +0,0 @@ -make(Kernel::class)->bootstrap(); - - return $app; - } -} \ No newline at end of file diff --git a/tests/Feature/ApiAccessTest.php b/tests/Feature/ApiAccessTest.php deleted file mode 100644 index 1ebb64a..0000000 --- a/tests/Feature/ApiAccessTest.php +++ /dev/null @@ -1,55 +0,0 @@ -get('/health'); - $response->assertSuccessful(); - } - - public function test_api_routes_are_publicly_accessible(): void - { - // Test that main API routes are accessible without authentication - $routes = [ - '/api/v1/articles', - '/api/v1/dashboard/stats', - '/api/v1/platform-accounts', - '/api/v1/platform-channels', - '/api/v1/feeds', - '/api/v1/routing', - '/api/v1/settings', - '/api/v1/logs' - ]; - - foreach ($routes as $route) { - $response = $this->get($route); - $this->assertTrue( - $response->isSuccessful(), - "API route {$route} should be publicly accessible" - ); - } - } - - public function test_fallback_route_returns_api_message(): void - { - $response = $this->get('/nonexistent-route'); - $response->assertStatus(404); - $response->assertJson([ - 'message' => 'This is the FFR API backend. Use /api/v1/* endpoints or check the React frontend.', - 'api_base' => '/api/v1' - ]); - } -} \ No newline at end of file diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 13dcb7c..c59d166 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -10,14 +10,14 @@ class AuthenticationTest extends TestCase { use RefreshDatabase; - public function test_login_screen_can_be_rendered(): void + public function test_login_screen_can_be_rendered() { $response = $this->get('/login'); $response->assertStatus(200); } - public function test_users_can_authenticate_using_the_login_screen(): void + public function test_users_can_authenticate_using_the_login_screen() { $user = User::factory()->create(); @@ -30,7 +30,7 @@ public function test_users_can_authenticate_using_the_login_screen(): void $response->assertRedirect(route('dashboard', absolute: false)); } - public function test_users_can_not_authenticate_with_invalid_password(): void + public function test_users_can_not_authenticate_with_invalid_password() { $user = User::factory()->create(); @@ -42,7 +42,7 @@ public function test_users_can_not_authenticate_with_invalid_password(): void $this->assertGuest(); } - public function test_users_can_logout(): void + public function test_users_can_logout() { $user = User::factory()->create(); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 705570b..627fe70 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -13,7 +13,7 @@ class EmailVerificationTest extends TestCase { use RefreshDatabase; - public function test_email_verification_screen_can_be_rendered(): void + public function test_email_verification_screen_can_be_rendered() { $user = User::factory()->unverified()->create(); @@ -22,7 +22,7 @@ public function test_email_verification_screen_can_be_rendered(): void $response->assertStatus(200); } - public function test_email_can_be_verified(): void + public function test_email_can_be_verified() { $user = User::factory()->unverified()->create(); @@ -41,7 +41,7 @@ public function test_email_can_be_verified(): void $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); } - public function test_email_is_not_verified_with_invalid_hash(): void + public function test_email_is_not_verified_with_invalid_hash() { $user = User::factory()->unverified()->create(); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index ff85721..d2072ff 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -10,7 +10,7 @@ class PasswordConfirmationTest extends TestCase { use RefreshDatabase; - public function test_confirm_password_screen_can_be_rendered(): void + public function test_confirm_password_screen_can_be_rendered() { $user = User::factory()->create(); @@ -19,7 +19,7 @@ public function test_confirm_password_screen_can_be_rendered(): void $response->assertStatus(200); } - public function test_password_can_be_confirmed(): void + public function test_password_can_be_confirmed() { $user = User::factory()->create(); @@ -31,7 +31,7 @@ public function test_password_can_be_confirmed(): void $response->assertSessionHasNoErrors(); } - public function test_password_is_not_confirmed_with_invalid_password(): void + public function test_password_is_not_confirmed_with_invalid_password() { $user = User::factory()->create(); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index aa50350..3c7441f 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -12,14 +12,14 @@ class PasswordResetTest extends TestCase { use RefreshDatabase; - public function test_reset_password_link_screen_can_be_rendered(): void + public function test_reset_password_link_screen_can_be_rendered() { $response = $this->get('/forgot-password'); $response->assertStatus(200); } - public function test_reset_password_link_can_be_requested(): void + public function test_reset_password_link_can_be_requested() { Notification::fake(); @@ -30,7 +30,7 @@ public function test_reset_password_link_can_be_requested(): void Notification::assertSentTo($user, ResetPassword::class); } - public function test_reset_password_screen_can_be_rendered(): void + public function test_reset_password_screen_can_be_rendered() { Notification::fake(); @@ -47,7 +47,7 @@ public function test_reset_password_screen_can_be_rendered(): void }); } - public function test_password_can_be_reset_with_valid_token(): void + public function test_password_can_be_reset_with_valid_token() { Notification::fake(); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 1489d0e..d0c3ea2 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -9,14 +9,14 @@ class RegistrationTest extends TestCase { use RefreshDatabase; - public function test_registration_screen_can_be_rendered(): void + public function test_registration_screen_can_be_rendered() { $response = $this->get('/register'); $response->assertStatus(200); } - public function test_new_users_can_register(): void + public function test_new_users_can_register() { $response = $this->post('/register', [ 'name' => 'Test User', diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php new file mode 100644 index 0000000..8585ade --- /dev/null +++ b/tests/Feature/DashboardTest.php @@ -0,0 +1,24 @@ +get('/dashboard')->assertRedirect('/login'); + } + + public function test_authenticated_users_can_visit_the_dashboard() + { + $this->actingAs($user = User::factory()->create()); + + $this->get('/dashboard')->assertOk(); + } +} diff --git a/tests/Feature/DatabaseIntegrationTest.php b/tests/Feature/DatabaseIntegrationTest.php deleted file mode 100644 index e978048..0000000 --- a/tests/Feature/DatabaseIntegrationTest.php +++ /dev/null @@ -1,360 +0,0 @@ -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', - 'short_code' => 'en' - ]); - - $this->assertDatabaseHas('languages', [ - 'name' => 'English', - 'short_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 - { - $account = PlatformAccount::factory()->create([ - 'username' => 'testuser', - 'is_active' => true - ]); - - $this->assertDatabaseHas('platform_accounts', [ - 'username' => 'testuser', - 'is_active' => true - ]); - - $this->assertEquals('testuser', $account->username); - } - - public function test_platform_channel_model_creates_successfully(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id, - 'name' => 'Test Channel', - 'is_active' => true - ]); - - $this->assertDatabaseHas('platform_channels', [ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id, - 'name' => 'Test Channel', - 'is_active' => true - ]); - - $this->assertEquals($instance->id, $channel->platformInstance->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->platform_channel_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 - { - // Test passes individually but has persistent issues in full suite - // Likely due to test pollution that's difficult to isolate - // Commenting out for now since the model works correctly - $this->assertTrue(true); - - // $post = new PlatformChannelPost([ - // 'platform' => PlatformEnum::LEMMY, - // 'channel_id' => 'technology', - // 'post_id' => 'external-post-123', - // 'title' => 'Test Post', - // 'url' => 'https://example.com/post', - // 'posted_at' => now() - // ]); - - // $post->save(); - - // $this->assertDatabaseHas('platform_channel_posts', [ - // 'platform' => PlatformEnum::LEMMY->value, - // 'channel_id' => 'technology', - // 'post_id' => 'external-post-123', - // 'title' => 'Test Post' - // ]); - - // $this->assertEquals('external-post-123', $post->post_id); - } - - public function test_keyword_model_creates_successfully(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::factory() - ->forFeed($feed) - ->forChannel($channel) - ->create([ - 'keyword' => 'test keyword', - 'is_active' => true - ]); - - $this->assertDatabaseHas('keywords', [ - 'keyword' => 'test keyword', - 'is_active' => true, - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id - ]); - } - - 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::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_platform_account_channels_many_to_many_relationship(): void - { - $account = PlatformAccount::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - // Test the pivot table relationship - $account->channels()->attach($channel->id, ['is_active' => true, 'priority' => 1]); - - $this->assertDatabaseHas('platform_account_channels', [ - 'platform_account_id' => $account->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true - ]); - } - - 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, ['platform_language_id' => rand(1, 100)]); - } - - $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); - } - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Console/Commands/FetchNewArticlesCommandTest.php b/tests/Feature/Http/Console/Commands/FetchNewArticlesCommandTest.php deleted file mode 100644 index a74e36b..0000000 --- a/tests/Feature/Http/Console/Commands/FetchNewArticlesCommandTest.php +++ /dev/null @@ -1,93 +0,0 @@ -create(['is_active' => true]); - - // Act & Assert - /** @var PendingCommand $exitCode */ - $exitCode = $this->artisan('article:refresh'); - $exitCode->assertSuccessful(); - - // The command should complete without the "no feeds" message - $exitCode->assertExitCode(0); - } - - public function test_command_does_not_dispatch_jobs_when_no_active_feeds_exist(): void - { - // Arrange - Queue::fake(); - // No active feeds created - - // Act - /** @var PendingCommand $exitCode */ - $exitCode = $this->artisan('article:refresh'); - - // Assert - $exitCode->assertSuccessful(); - Queue::assertNotPushed(ArticleDiscoveryJob::class); - } - - public function test_command_does_not_dispatch_jobs_when_only_inactive_feeds_exist(): void - { - // Arrange - Queue::fake(); - Feed::factory()->create(['is_active' => false]); - - // Act - /** @var PendingCommand $exitCode */ - $exitCode = $this->artisan('article:refresh'); - - // Assert - $exitCode->assertSuccessful(); - Queue::assertNotPushed(ArticleDiscoveryJob::class); - } - - public function test_command_logs_when_no_feeds_available(): void - { - // Arrange - Queue::fake(); - - // Act - /** @var PendingCommand $exitCode */ - $exitCode = $this->artisan('article:refresh'); - - // Assert - $exitCode->assertSuccessful(); - $exitCode->expectsOutput('No active feeds found. Article discovery skipped.'); - } - - public function test_command_skips_when_article_processing_disabled(): void - { - // Arrange - Queue::fake(); - Setting::create([ - 'key' => 'article_processing_enabled', - 'value' => '0' - ]); - - // Act - /** @var PendingCommand $exitCode */ - $exitCode = $this->artisan('article:refresh'); - - // Assert - $exitCode->assertSuccessful(); - $exitCode->expectsOutput('Article processing is disabled. Article discovery skipped.'); - Queue::assertNotPushed(ArticleDiscoveryJob::class); - } -} diff --git a/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php b/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php deleted file mode 100644 index dc1b71f..0000000 --- a/tests/Feature/Http/Console/Commands/SyncChannelPostsCommandTest.php +++ /dev/null @@ -1,55 +0,0 @@ -artisan('channel:sync unsupported'); - - // Assert - $exitCode->assertFailed(); - $exitCode->expectsOutput('Unsupported platform: unsupported'); - } - - public function test_command_returns_failure_exit_code_for_unsupported_platform(): void - { - // Act - /** @var PendingCommand $exitCode */ - $exitCode = $this->artisan('channel:sync invalid'); - - // Assert - $exitCode->assertExitCode(1); - } - - public function test_command_accepts_lemmy_platform_argument(): void - { - // Act - Test that the command accepts lemmy as a valid platform argument - $exitCode = $this->artisan('channel:sync lemmy'); - - // Assert - Command should succeed (not fail with argument validation error) - $exitCode->assertSuccessful(); - $exitCode->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels'); - } - - public function test_command_handles_default_platform(): void - { - // Act - Test that the command works with default platform (should be lemmy) - $exitCode = $this->artisan('channel:sync'); - - // Assert - Command should succeed with default platform - $exitCode->assertSuccessful(); - $exitCode->expectsOutput('Successfully dispatched sync jobs for all active Lemmy channels'); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php deleted file mode 100644 index 0c5c8d2..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/ArticlesControllerTest.php +++ /dev/null @@ -1,173 +0,0 @@ -getJson('/api/v1/articles'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'articles', - 'pagination' => [ - 'current_page', - 'last_page', - 'per_page', - 'total', - 'from', - 'to', - ], - 'settings' => [ - 'publishing_approvals_enabled', - ], - ], - 'message' - ]); - } - - public function test_index_returns_articles_with_pagination(): void - { - $feed = Feed::factory()->create(); - Article::factory()->count(25)->create(['feed_id' => $feed->id]); - - $response = $this->getJson('/api/v1/articles?per_page=10'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'pagination' => [ - 'per_page' => 10, - 'total' => 25, - 'last_page' => 3, - ], - ] - ]); - - $this->assertCount(10, $response->json('data.articles')); - } - - public function test_index_respects_per_page_limit(): void - { - $feed = Feed::factory()->create(); - Article::factory()->count(10)->create(['feed_id' => $feed->id]); - - // Test max limit of 100 - $response = $this->getJson('/api/v1/articles?per_page=150'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'pagination' => [ - 'per_page' => 100, // Should be capped at 100 - ], - ] - ]); - } - - public function test_index_orders_articles_by_created_at_desc(): void - { - $feed = Feed::factory()->create(); - - $firstArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'created_at' => now()->subHours(2), - 'title' => 'First Article' - ]); - - $secondArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'created_at' => now()->subHour(), - 'title' => 'Second Article' - ]); - - $response = $this->getJson('/api/v1/articles'); - - $response->assertStatus(200); - - $articles = $response->json('data.articles'); - $this->assertEquals('Second Article', $articles[0]['title']); - $this->assertEquals('First Article', $articles[1]['title']); - } - - public function test_approve_article_successfully(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending' - ]); - - $response = $this->postJson("/api/v1/articles/{$article->id}/approve"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Article approved and queued for publishing.' - ]); - - $article->refresh(); - $this->assertEquals('approved', $article->approval_status); - } - - public function test_approve_nonexistent_article_returns_404(): void - { - $response = $this->postJson('/api/v1/articles/999/approve'); - - $response->assertStatus(404); - } - - public function test_reject_article_successfully(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending' - ]); - - $response = $this->postJson("/api/v1/articles/{$article->id}/reject"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Article rejected.' - ]); - - $article->refresh(); - $this->assertEquals('rejected', $article->approval_status); - } - - public function test_reject_nonexistent_article_returns_404(): void - { - $response = $this->postJson('/api/v1/articles/999/reject'); - - $response->assertStatus(404); - } - - public function test_index_includes_settings(): void - { - $response = $this->getJson('/api/v1/articles'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'data' => [ - 'settings' => [ - 'publishing_approvals_enabled' - ] - ] - ]); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/DashboardControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/DashboardControllerTest.php deleted file mode 100644 index 6dea95f..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/DashboardControllerTest.php +++ /dev/null @@ -1,132 +0,0 @@ -getJson('/api/v1/dashboard/stats') - ->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'article_stats' => [ - 'articles_fetched', - 'articles_published', - 'published_percentage', - ], - 'system_stats' => [ - 'total_feeds', - 'active_feeds', - 'total_platform_channels', - 'active_platform_channels', - 'total_routes', - 'active_routes', - ], - 'available_periods', - 'current_period', - ], - 'message' - ]); - } - - public function test_stats_with_different_periods(): void - { - $periods = ['today', 'week', 'month', 'year', 'all']; - - foreach ($periods as $period) { - $this - ->getJson("/api/v1/dashboard/stats?period={$period}") - ->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'current_period' => $period, - ] - ]); - } - } - - public function test_stats_with_sample_data(): void - { - // Get initial counts - $initialArticles = Article::count(); - $initialFeeds = Feed::count(); - $initialChannels = PlatformChannel::count(); - $initialRoutes = Route::count(); - $initialPublications = ArticlePublication::count(); - - // Create test data - $feed = Feed::factory()->create(['is_active' => true]); - $channel = PlatformChannel::factory()->create(['is_active' => true]); - $route = Route::factory()->create(['is_active' => true]); - - // Create articles - $articles = Article::factory()->count(3)->create(['feed_id' => $feed->id]); - - // Publish one article - ArticlePublication::factory()->create([ - 'article_id' => $articles->first()->id, - 'platform_channel_id' => $channel->id, - 'published_at' => now() - ]); - - $response = $this->getJson('/api/v1/dashboard/stats?period=all'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'article_stats' => [ - 'articles_fetched' => $initialArticles + 3, - 'articles_published' => $initialPublications + 1, - ], - ] - ]); - - // Just verify structure and that we have more items than we started with - $responseData = $response->json('data'); - $this->assertGreaterThanOrEqual($initialFeeds + 1, $responseData['system_stats']['total_feeds']); - $this->assertGreaterThanOrEqual($initialChannels + 1, $responseData['system_stats']['total_platform_channels']); - $this->assertGreaterThanOrEqual($initialRoutes + 1, $responseData['system_stats']['total_routes']); - } - - public function test_stats_returns_empty_data_with_no_records(): void - { - $this - ->getJson('/api/v1/dashboard/stats') - ->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'article_stats' => [ - 'articles_fetched' => 0, - 'articles_published' => 0, - 'published_percentage' => 0.0, - ], - 'system_stats' => [ - 'total_feeds' => 0, - 'active_feeds' => 0, - 'total_platform_accounts' => 0, - 'active_platform_accounts' => 0, - 'total_platform_channels' => 0, - 'active_platform_channels' => 0, - 'total_routes' => 0, - 'active_routes' => 0, - ], - ] - ]); - } -} diff --git a/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php deleted file mode 100644 index 5b53248..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/FeedsControllerTest.php +++ /dev/null @@ -1,305 +0,0 @@ -getJson('/api/v1/feeds'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'feeds', - 'pagination' - ], - 'message' - ]); - } - - public function test_index_returns_feeds_ordered_by_active_status_then_name(): void - { - Feed::factory()->create(['name' => 'Z Feed', 'is_active' => false]); - Feed::factory()->create(['name' => 'A Feed', 'is_active' => true]); - Feed::factory()->create(['name' => 'B Feed', 'is_active' => true]); - - $response = $this->getJson('/api/v1/feeds'); - - $response->assertStatus(200); - $feeds = $response->json('data.feeds'); - - // First should be active feeds in alphabetical order - $this->assertEquals('A Feed', $feeds[0]['name']); - $this->assertTrue($feeds[0]['is_active']); - $this->assertEquals('B Feed', $feeds[1]['name']); - $this->assertTrue($feeds[1]['is_active']); - // Then inactive feeds - $this->assertEquals('Z Feed', $feeds[2]['name']); - $this->assertFalse($feeds[2]['is_active']); - } - - public function test_store_creates_vrt_feed_successfully(): void - { - $language = Language::factory()->create(); - - $feedData = [ - 'name' => 'VRT Test Feed', - 'provider' => 'vrt', - 'language_id' => $language->id, - 'is_active' => true, - ]; - - $response = $this->postJson('/api/v1/feeds', $feedData); - - $response->assertStatus(201) - ->assertJson([ - 'success' => true, - 'message' => 'Feed created successfully!', - 'data' => [ - 'name' => 'VRT Test Feed', - 'url' => 'https://www.vrt.be/vrtnws/en/', - 'type' => 'website', - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'name' => 'VRT Test Feed', - 'url' => 'https://www.vrt.be/vrtnws/en/', - 'type' => 'website', - ]); - } - - public function test_store_creates_belga_feed_successfully(): void - { - $language = Language::factory()->create(); - - $feedData = [ - 'name' => 'Belga Test Feed', - 'provider' => 'belga', - 'language_id' => $language->id, - 'is_active' => true, - ]; - - $response = $this->postJson('/api/v1/feeds', $feedData); - - $response->assertStatus(201) - ->assertJson([ - 'success' => true, - 'message' => 'Feed created successfully!', - 'data' => [ - 'name' => 'Belga Test Feed', - 'url' => 'https://www.belganewsagency.eu/', - 'type' => 'website', - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'name' => 'Belga Test Feed', - 'url' => 'https://www.belganewsagency.eu/', - 'type' => 'website', - ]); - } - - public function test_store_sets_default_active_status(): void - { - $language = Language::factory()->create(); - - $feedData = [ - 'name' => 'Test Feed', - 'provider' => 'vrt', - 'language_id' => $language->id, - // Not setting is_active - ]; - - $response = $this->postJson('/api/v1/feeds', $feedData); - - $response->assertStatus(201) - ->assertJson([ - 'data' => [ - 'is_active' => true, // Should default to true - ] - ]); - } - - public function test_store_validates_required_fields(): void - { - $response = $this->postJson('/api/v1/feeds', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['name', 'provider', 'language_id']); - } - - public function test_store_rejects_invalid_provider(): void - { - $language = Language::factory()->create(); - - $feedData = [ - 'name' => 'Invalid Feed', - 'provider' => 'invalid', - 'language_id' => $language->id, - ]; - - $response = $this->postJson('/api/v1/feeds', $feedData); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['provider']); - } - - public function test_show_returns_feed_successfully(): void - { - $feed = Feed::factory()->create(); - - $response = $this->getJson("/api/v1/feeds/{$feed->id}"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Feed retrieved successfully.', - 'data' => [ - 'id' => $feed->id, - 'name' => $feed->name, - ] - ]); - } - - public function test_show_returns_404_for_nonexistent_feed(): void - { - $response = $this->getJson('/api/v1/feeds/999'); - - $response->assertStatus(404); - } - - public function test_update_modifies_feed_successfully(): void - { - $language = Language::factory()->create(); - $feed = Feed::factory()->language($language)->create(['name' => 'Original Name']); - - $updateData = [ - 'name' => 'Updated Name', - 'url' => $feed->url, - 'type' => $feed->type, - 'language_id' => $feed->language_id, - ]; - - $response = $this->putJson("/api/v1/feeds/{$feed->id}", $updateData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Feed updated successfully!', - 'data' => [ - 'name' => 'Updated Name', - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'id' => $feed->id, - 'name' => 'Updated Name', - ]); - } - - public function test_update_preserves_active_status_when_not_provided(): void - { - $language = Language::factory()->create(); - $feed = Feed::factory()->language($language)->create(['is_active' => false]); - - $updateData = [ - 'name' => $feed->name, - 'url' => $feed->url, - 'type' => $feed->type, - 'language_id' => $feed->language_id, - // Not providing is_active - ]; - - $response = $this->putJson("/api/v1/feeds/{$feed->id}", $updateData); - - $response->assertStatus(200) - ->assertJson([ - 'data' => [ - 'is_active' => false, // Should preserve original value - ] - ]); - } - - public function test_destroy_deletes_feed_successfully(): void - { - $feed = Feed::factory()->create(); - - $response = $this->deleteJson("/api/v1/feeds/{$feed->id}"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Feed deleted successfully!', - ]); - - $this->assertDatabaseMissing('feeds', ['id' => $feed->id]); - } - - public function test_destroy_returns_404_for_nonexistent_feed(): void - { - $response = $this->deleteJson('/api/v1/feeds/999'); - - $response->assertStatus(404); - } - - public function test_toggle_activates_inactive_feed(): void - { - $feed = Feed::factory()->create(['is_active' => false]); - - $response = $this->postJson("/api/v1/feeds/{$feed->id}/toggle"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Feed activated successfully!', - 'data' => [ - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'id' => $feed->id, - 'is_active' => true, - ]); - } - - public function test_toggle_deactivates_active_feed(): void - { - $feed = Feed::factory()->create(['is_active' => true]); - - $response = $this->postJson("/api/v1/feeds/{$feed->id}/toggle"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Feed deactivated successfully!', - 'data' => [ - 'is_active' => false, - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'id' => $feed->id, - 'is_active' => false, - ]); - } - - public function test_toggle_returns_404_for_nonexistent_feed(): void - { - $response = $this->postJson('/api/v1/feeds/999/toggle'); - - $response->assertStatus(404); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php deleted file mode 100644 index 3934a15..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/KeywordsControllerTest.php +++ /dev/null @@ -1,189 +0,0 @@ -feed = Feed::factory()->create(); - $this->channel = PlatformChannel::factory()->create(); - - $this->route = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - } - - public function test_can_get_keywords_for_route(): void - { - $keyword = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'keyword' => 'test keyword', - 'is_active' => true - ]); - - $response = $this->getJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords"); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - '*' => [ - 'id', - 'keyword', - 'is_active', - 'feed_id', - 'platform_channel_id' - ] - ] - ]) - ->assertJsonPath('data.0.keyword', 'test keyword'); - } - - public function test_can_create_keyword_for_route(): void - { - $keywordData = [ - 'keyword' => 'new keyword', - 'is_active' => true - ]; - - $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", $keywordData); - - $response->assertStatus(201) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'id', - 'keyword', - 'is_active', - 'feed_id', - 'platform_channel_id' - ] - ]) - ->assertJsonPath('data.keyword', 'new keyword') - ->assertJsonPath('data.is_active', true); - - $this->assertDatabaseHas('keywords', [ - 'keyword' => 'new keyword', - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'is_active' => true - ]); - } - - public function test_cannot_create_duplicate_keyword_for_route(): void - { - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'keyword' => 'duplicate keyword' - ]); - - $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", [ - 'keyword' => 'duplicate keyword' - ]); - - $response->assertStatus(409) - ->assertJsonPath('success', false) - ->assertJsonPath('message', 'Keyword already exists for this route.'); - } - - public function test_can_update_keyword(): void - { - $keyword = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'is_active' => true - ]); - - $response = $this->putJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}", [ - 'is_active' => false - ]); - - $response->assertStatus(200) - ->assertJsonPath('data.is_active', false); - - $this->assertDatabaseHas('keywords', [ - 'id' => $keyword->id, - 'is_active' => false - ]); - } - - public function test_can_delete_keyword(): void - { - $keyword = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id - ]); - - $response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}"); - - $response->assertStatus(200); - - $this->assertDatabaseMissing('keywords', [ - 'id' => $keyword->id - ]); - } - - public function test_can_toggle_keyword(): void - { - $keyword = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel->id, - 'is_active' => true - ]); - - $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}/toggle"); - - $response->assertStatus(200) - ->assertJsonPath('data.is_active', false); - - $this->assertDatabaseHas('keywords', [ - 'id' => $keyword->id, - 'is_active' => false - ]); - } - - public function test_cannot_access_keyword_from_different_route(): void - { - $otherFeed = Feed::factory()->create(); - $otherChannel = PlatformChannel::factory()->create(); - - $keyword = Keyword::factory()->create([ - 'feed_id' => $otherFeed->id, - 'platform_channel_id' => $otherChannel->id - ]); - - $response = $this->deleteJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords/{$keyword->id}"); - - $response->assertStatus(404) - ->assertJsonPath('message', 'Keyword not found for this route.'); - } - - public function test_validates_required_fields(): void - { - $response = $this->postJson("/api/v1/routing/{$this->feed->id}/{$this->channel->id}/keywords", []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['keyword']); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/LogsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/LogsControllerTest.php deleted file mode 100644 index de1a42b..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/LogsControllerTest.php +++ /dev/null @@ -1,200 +0,0 @@ -delete(); - } - - public function test_index_returns_successful_response(): void - { - Log::factory()->count(5)->create(); - - $response = $this->getJson('/api/v1/logs'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - 'logs' => [ - '*' => [ - 'id', - 'level', - 'message', - 'context', - 'created_at', - 'updated_at', - ] - ], - 'pagination' => [ - 'current_page', - 'last_page', - 'per_page', - 'total', - 'from', - 'to', - ] - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Logs retrieved successfully.' - ]); - } - - public function test_index_orders_logs_by_created_at_desc(): void - { - $oldLog = Log::factory()->create(['created_at' => now()->subDays(2)]); - $newLog = Log::factory()->create(['created_at' => now()->subDay()]); - $newestLog = Log::factory()->create(['created_at' => now()]); - - $response = $this->getJson('/api/v1/logs'); - - $response->assertStatus(200); - - $logs = $response->json('data.logs'); - - $this->assertEquals($newestLog->id, $logs[0]['id']); - $this->assertEquals($newLog->id, $logs[1]['id']); - $this->assertEquals($oldLog->id, $logs[2]['id']); - } - - public function test_index_filters_by_level(): void - { - Log::factory()->create(['level' => LogLevelEnum::ERROR]); - Log::factory()->create(['level' => LogLevelEnum::WARNING]); - Log::factory()->create(['level' => LogLevelEnum::INFO]); - - $response = $this->getJson('/api/v1/logs?level=error'); - - $response->assertStatus(200); - - $logs = $response->json('data.logs'); - - $this->assertCount(1, $logs); - $this->assertEquals('error', $logs[0]['level']); - } - - public function test_index_respects_per_page_parameter(): void - { - Log::factory()->count(15)->create(); - - $response = $this->getJson('/api/v1/logs?per_page=5'); - - $response->assertStatus(200); - - $logs = $response->json('data.logs'); - $pagination = $response->json('data.pagination'); - - $this->assertCount(5, $logs); - $this->assertEquals(5, $pagination['per_page']); - $this->assertEquals(15, $pagination['total']); - $this->assertEquals(3, $pagination['last_page']); - } - - public function test_index_limits_per_page_to_maximum(): void - { - Log::factory()->count(10)->create(); - - $response = $this->getJson('/api/v1/logs?per_page=150'); - - $response->assertStatus(200); - - $pagination = $response->json('data.pagination'); - - // Should be limited to 100 as per controller logic - $this->assertEquals(100, $pagination['per_page']); - } - - public function test_index_uses_default_per_page_when_not_specified(): void - { - Log::factory()->count(25)->create(); - - $response = $this->getJson('/api/v1/logs'); - - $response->assertStatus(200); - - $pagination = $response->json('data.pagination'); - - // Should use default of 20 - $this->assertEquals(20, $pagination['per_page']); - } - - public function test_index_handles_empty_logs(): void - { - $response = $this->getJson('/api/v1/logs'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Logs retrieved successfully.', - 'data' => [ - 'logs' => [], - 'pagination' => [ - 'total' => 0, - 'current_page' => 1, - 'last_page' => 1, - ] - ] - ]); - } - - public function test_index_pagination_works_correctly(): void - { - Log::factory()->count(25)->create(); - - // Test first page - $response = $this->getJson('/api/v1/logs?per_page=10&page=1'); - $response->assertStatus(200); - - $pagination = $response->json('data.pagination'); - $this->assertEquals(1, $pagination['current_page']); - $this->assertEquals(3, $pagination['last_page']); - $this->assertEquals(1, $pagination['from']); - $this->assertEquals(10, $pagination['to']); - - // Test second page - $response = $this->getJson('/api/v1/logs?per_page=10&page=2'); - $response->assertStatus(200); - - $pagination = $response->json('data.pagination'); - $this->assertEquals(2, $pagination['current_page']); - $this->assertEquals(11, $pagination['from']); - $this->assertEquals(20, $pagination['to']); - } - - public function test_index_with_multiple_log_levels(): void - { - Log::factory()->create(['level' => LogLevelEnum::ERROR, 'message' => 'Error message']); - Log::factory()->create(['level' => LogLevelEnum::WARNING, 'message' => 'Warning message']); - Log::factory()->create(['level' => LogLevelEnum::INFO, 'message' => 'Info message']); - Log::factory()->create(['level' => LogLevelEnum::DEBUG, 'message' => 'Debug message']); - - $response = $this->getJson('/api/v1/logs'); - - $response->assertStatus(200); - - $logs = $response->json('data.logs'); - - $this->assertCount(4, $logs); - - $levels = array_column($logs, 'level'); - $this->assertContains('error', $levels); - $this->assertContains('warning', $levels); - $this->assertContains('info', $levels); - $this->assertContains('debug', $levels); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php deleted file mode 100644 index dfca1c1..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/OnboardingControllerTest.php +++ /dev/null @@ -1,489 +0,0 @@ -create([ - 'id' => 1, - 'short_code' => 'en', - 'name' => 'English', - 'native_name' => 'English', - 'is_active' => true, - ]); - } - - public function test_status_shows_needs_onboarding_when_no_components_exist() - { - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => true, - 'current_step' => 'platform', - 'has_platform_account' => false, - 'has_feed' => false, - 'has_channel' => false, - 'has_route' => false, - 'onboarding_skipped' => false, - ], - ]); - } - - public function test_status_shows_feed_step_when_platform_account_exists() - { - PlatformAccount::factory()->create(['is_active' => true]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => true, - 'current_step' => 'feed', - 'has_platform_account' => true, - 'has_feed' => false, - 'has_channel' => false, - 'has_route' => false, - ], - ]); - } - - public function test_status_shows_channel_step_when_platform_account_and_feed_exist() - { - $language = Language::first(); - PlatformAccount::factory()->create(['is_active' => true]); - Feed::factory()->language($language)->create(['is_active' => true]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => true, - 'current_step' => 'channel', - 'has_platform_account' => true, - 'has_feed' => true, - 'has_channel' => false, - 'has_route' => false, - ], - ]); - } - - public function test_status_shows_route_step_when_platform_account_feed_and_channel_exist() - { - $language = Language::first(); - PlatformAccount::factory()->create(['is_active' => true]); - Feed::factory()->language($language)->create(['is_active' => true]); - PlatformChannel::factory()->create(['is_active' => true]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => true, - 'current_step' => 'route', - 'has_platform_account' => true, - 'has_feed' => true, - 'has_channel' => true, - 'has_route' => false, - ], - ]); - } - - public function test_status_shows_no_onboarding_needed_when_all_components_exist() - { - $language = Language::first(); - PlatformAccount::factory()->create(['is_active' => true]); - Feed::factory()->language($language)->create(['is_active' => true]); - PlatformChannel::factory()->create(['is_active' => true]); - Route::factory()->create(['is_active' => true]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => false, - 'current_step' => null, - 'has_platform_account' => true, - 'has_feed' => true, - 'has_channel' => true, - 'has_route' => true, - ], - ]); - } - - public function test_status_shows_no_onboarding_needed_when_skipped() - { - // No components exist but onboarding is skipped - Setting::create([ - 'key' => 'onboarding_skipped', - 'value' => 'true', - ]); - - $response = $this->getJson('/api/v1/onboarding/status'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'needs_onboarding' => false, - 'current_step' => null, - 'has_platform_account' => false, - 'has_feed' => false, - 'has_channel' => false, - 'has_route' => false, - 'onboarding_skipped' => true, - ], - ]); - } - - public function test_options_returns_languages_and_platform_instances() - { - PlatformInstance::factory()->create([ - 'platform' => 'lemmy', - 'url' => 'https://lemmy.world', - 'name' => 'Lemmy World', - 'is_active' => true, - ]); - - $response = $this->getJson('/api/v1/onboarding/options'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'languages' => [ - '*' => ['id', 'short_code', 'name', 'native_name', 'is_active'] - ], - 'platform_instances' => [ - '*' => ['id', 'platform', 'url', 'name', 'description', 'is_active'] - ] - ] - ]); - } - - public function test_create_feed_validates_required_fields() - { - $response = $this->postJson('/api/v1/onboarding/feed', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['name', 'provider', 'language_id']); - } - - public function test_create_feed_creates_vrt_feed_successfully() - { - $feedData = [ - 'name' => 'VRT Test Feed', - 'provider' => 'vrt', - 'language_id' => 1, - 'description' => 'Test description', - ]; - - $response = $this->postJson('/api/v1/onboarding/feed', $feedData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'name' => 'VRT Test Feed', - 'url' => 'https://www.vrt.be/vrtnws/en/', - 'type' => 'website', - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'name' => 'VRT Test Feed', - 'url' => 'https://www.vrt.be/vrtnws/en/', - 'type' => 'website', - 'language_id' => 1, - 'is_active' => true, - ]); - } - - public function test_create_feed_creates_belga_feed_successfully() - { - $feedData = [ - 'name' => 'Belga Test Feed', - 'provider' => 'belga', - 'language_id' => 1, - 'description' => 'Test description', - ]; - - $response = $this->postJson('/api/v1/onboarding/feed', $feedData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'name' => 'Belga Test Feed', - 'url' => 'https://www.belganewsagency.eu/', - 'type' => 'website', - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('feeds', [ - 'name' => 'Belga Test Feed', - 'url' => 'https://www.belganewsagency.eu/', - 'type' => 'website', - 'language_id' => 1, - 'is_active' => true, - ]); - } - - public function test_create_feed_rejects_invalid_provider() - { - $feedData = [ - 'name' => 'Invalid Feed', - 'provider' => 'invalid', - 'language_id' => 1, - 'description' => 'Test description', - ]; - - $response = $this->postJson('/api/v1/onboarding/feed', $feedData); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['provider']); - } - - public function test_create_channel_validates_required_fields() - { - $response = $this->postJson('/api/v1/onboarding/channel', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['name', 'platform_instance_id', 'language_id']); - } - - public function test_create_channel_creates_channel_successfully() - { - $platformInstance = PlatformInstance::factory()->create(); - $language = Language::factory()->create(); - - // Create a platform account for this instance first - PlatformAccount::factory()->create([ - 'instance_url' => $platformInstance->url, - 'is_active' => true - ]); - - $channelData = [ - 'name' => 'test_community', - 'platform_instance_id' => $platformInstance->id, - 'language_id' => $language->id, - 'description' => 'Test community description', - ]; - - $response = $this->postJson('/api/v1/onboarding/channel', $channelData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'name' => 'test_community', - 'display_name' => 'Test_community', - 'channel_id' => 'test_community', - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('platform_channels', [ - 'name' => 'test_community', - 'channel_id' => 'test_community', - 'platform_instance_id' => $platformInstance->id, - 'language_id' => $language->id, - 'is_active' => true, - ]); - } - - public function test_create_route_validates_required_fields() - { - $response = $this->postJson('/api/v1/onboarding/route', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['feed_id', 'platform_channel_id']); - } - - public function test_create_route_creates_route_successfully() - { - $language = Language::first(); - $feed = Feed::factory()->language($language)->create(); - $platformChannel = PlatformChannel::factory()->create(); - - $routeData = [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $platformChannel->id, - 'priority' => 75, - ]; - - $response = $this->postJson('/api/v1/onboarding/route', $routeData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $platformChannel->id, - 'priority' => 75, - 'is_active' => true, - ] - ]); - - $this->assertDatabaseHas('routes', [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $platformChannel->id, - 'priority' => 75, - 'is_active' => true, - ]); - } - - public function test_complete_onboarding_returns_success() - { - $response = $this->postJson('/api/v1/onboarding/complete'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => ['completed' => true] - ]); - } - - public function test_skip_onboarding_creates_setting() - { - $response = $this->postJson('/api/v1/onboarding/skip'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => ['skipped' => true] - ]); - - $this->assertDatabaseHas('settings', [ - 'key' => 'onboarding_skipped', - 'value' => 'true', - ]); - } - - public function test_skip_onboarding_updates_existing_setting() - { - // Create existing setting with false value - Setting::create([ - 'key' => 'onboarding_skipped', - 'value' => 'false', - ]); - - $response = $this->postJson('/api/v1/onboarding/skip'); - - $response->assertStatus(200); - - $this->assertDatabaseHas('settings', [ - 'key' => 'onboarding_skipped', - 'value' => 'true', - ]); - - // Ensure only one setting exists - $this->assertEquals(1, Setting::where('key', 'onboarding_skipped')->count()); - } - - public function test_reset_skip_removes_setting() - { - // Create skipped setting - Setting::create([ - 'key' => 'onboarding_skipped', - 'value' => 'true', - ]); - - $response = $this->postJson('/api/v1/onboarding/reset-skip'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => ['reset' => true] - ]); - - $this->assertDatabaseMissing('settings', [ - 'key' => 'onboarding_skipped', - ]); - } - - public function test_reset_skip_works_when_no_setting_exists() - { - $response = $this->postJson('/api/v1/onboarding/reset-skip'); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => ['reset' => true] - ]); - } - - public function test_create_platform_validates_instance_url_format() - { - $response = $this->postJson('/api/v1/onboarding/platform', [ - 'instance_url' => 'invalid.domain.with.spaces and symbols!', - 'username' => 'testuser', - 'password' => 'password123', - 'platform' => 'lemmy', - ]); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['instance_url']); - } - - public function test_create_platform_validates_required_fields() - { - $response = $this->postJson('/api/v1/onboarding/platform', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['instance_url', 'username', 'password', 'platform']); - } - - public function test_onboarding_flow_integration() - { - // 1. Initial status - needs onboarding - $response = $this->getJson('/api/v1/onboarding/status'); - $response->assertJson(['data' => ['needs_onboarding' => true, 'current_step' => 'platform']]); - - // 2. Skip onboarding - $response = $this->postJson('/api/v1/onboarding/skip'); - $response->assertJson(['data' => ['skipped' => true]]); - - // 3. Status after skip - no longer needs onboarding - $response = $this->getJson('/api/v1/onboarding/status'); - $response->assertJson(['data' => ['needs_onboarding' => false, 'onboarding_skipped' => true]]); - - // 4. Reset skip - $response = $this->postJson('/api/v1/onboarding/reset-skip'); - $response->assertJson(['data' => ['reset' => true]]); - - // 5. Status after reset - needs onboarding again - $response = $this->getJson('/api/v1/onboarding/status'); - $response->assertJson(['data' => ['needs_onboarding' => true, 'onboarding_skipped' => false]]); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/PlatformAccountsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/PlatformAccountsControllerTest.php deleted file mode 100644 index ae4573a..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/PlatformAccountsControllerTest.php +++ /dev/null @@ -1,204 +0,0 @@ -create(); - PlatformAccount::factory()->count(3)->create(); - - $response = $this->getJson('/api/v1/platform-accounts'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - '*' => [ - 'id', - 'platform', - 'instance_url', - 'username', - 'is_active', - 'created_at', - 'updated_at', - ] - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Platform accounts retrieved successfully.' - ]); - } - - public function test_store_creates_platform_account_successfully(): void - { - $data = [ - 'platform' => 'lemmy', - 'instance_url' => 'https://lemmy.example.com', - 'username' => 'testuser', - 'password' => 'testpass123', - 'settings' => ['key' => 'value'] - ]; - - $response = $this->postJson('/api/v1/platform-accounts', $data); - - $response->assertStatus(201) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - 'id', - 'platform', - 'instance_url', - 'username', - 'is_active', - 'created_at', - 'updated_at', - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Platform account created successfully!' - ]); - - $this->assertDatabaseHas('platform_accounts', [ - 'platform' => 'lemmy', - 'instance_url' => 'https://lemmy.example.com', - 'username' => 'testuser', - ]); - } - - public function test_store_validates_required_fields(): void - { - $response = $this->postJson('/api/v1/platform-accounts', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['platform', 'instance_url', 'username', 'password']); - } - - public function test_show_returns_platform_account_successfully(): void - { - $account = PlatformAccount::factory()->create(); - - $response = $this->getJson("/api/v1/platform-accounts/{$account->id}"); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - 'id', - 'platform', - 'instance_url', - 'username', - 'is_active', - 'created_at', - 'updated_at', - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Platform account retrieved successfully.', - 'data' => [ - 'id' => $account->id, - 'username' => $account->username, - ] - ]); - } - - public function test_update_modifies_platform_account_successfully(): void - { - $account = PlatformAccount::factory()->create(); - - $updateData = [ - 'instance_url' => 'https://updated.example.com', - 'username' => 'updateduser', - 'settings' => ['updated' => 'value'] - ]; - - $response = $this->putJson("/api/v1/platform-accounts/{$account->id}", $updateData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Platform account updated successfully!' - ]); - - $this->assertDatabaseHas('platform_accounts', [ - 'id' => $account->id, - 'instance_url' => 'https://updated.example.com', - 'username' => 'updateduser', - ]); - } - - public function test_destroy_deletes_platform_account_successfully(): void - { - $account = PlatformAccount::factory()->create(); - - $response = $this->deleteJson("/api/v1/platform-accounts/{$account->id}"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Platform account deleted successfully!' - ]); - - $this->assertDatabaseMissing('platform_accounts', [ - 'id' => $account->id - ]); - } - - public function test_set_active_activates_platform_account(): void - { - $account = PlatformAccount::factory()->create(['is_active' => false]); - - $response = $this->postJson("/api/v1/platform-accounts/{$account->id}/set-active"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - ]); - - $this->assertDatabaseHas('platform_accounts', [ - 'id' => $account->id, - 'is_active' => true - ]); - } - - public function test_set_active_deactivates_other_accounts_of_same_platform(): void - { - $activeAccount = PlatformAccount::factory()->create([ - 'platform' => 'lemmy', - 'is_active' => true - ]); - - $newAccount = PlatformAccount::factory()->create([ - 'platform' => 'lemmy', - 'is_active' => false - ]); - - $response = $this->postJson("/api/v1/platform-accounts/{$newAccount->id}/set-active"); - - $response->assertStatus(200); - - $this->assertDatabaseHas('platform_accounts', [ - 'id' => $activeAccount->id, - 'is_active' => false - ]); - - $this->assertDatabaseHas('platform_accounts', [ - 'id' => $newAccount->id, - 'is_active' => true - ]); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php deleted file mode 100644 index 63765ca..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/PlatformChannelsControllerTest.php +++ /dev/null @@ -1,241 +0,0 @@ -create(); - PlatformChannel::factory()->count(3)->create(['platform_instance_id' => $instance->id]); - - $response = $this->getJson('/api/v1/platform-channels'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - '*' => [ - 'id', - 'platform_instance_id', - 'channel_id', - 'name', - 'display_name', - 'description', - 'is_active', - 'created_at', - 'updated_at', - 'platform_instance' - ] - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Platform channels retrieved successfully.' - ]); - } - - public function test_store_creates_platform_channel_successfully(): void - { - $instance = PlatformInstance::factory()->create(); - - // Create a platform account for this instance first - PlatformAccount::factory()->create([ - 'instance_url' => $instance->url, - 'is_active' => true - ]); - - $data = [ - 'platform_instance_id' => $instance->id, - 'channel_id' => 'test_channel', - 'name' => 'Test Channel', - 'display_name' => 'Test Channel Display', - 'description' => 'A test channel', - 'is_active' => true - ]; - - $response = $this->postJson('/api/v1/platform-channels', $data); - - $response->assertStatus(201) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - 'id', - 'platform_instance_id', - 'channel_id', - 'name', - 'display_name', - 'description', - 'is_active', - 'created_at', - 'updated_at', - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Platform channel created successfully and linked to platform account!' - ]); - - $this->assertDatabaseHas('platform_channels', [ - 'platform_instance_id' => $instance->id, - 'channel_id' => 'test_channel', - 'name' => 'Test Channel', - ]); - } - - public function test_store_validates_required_fields(): void - { - $response = $this->postJson('/api/v1/platform-channels', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['platform_instance_id', 'channel_id', 'name']); - } - - public function test_store_validates_platform_instance_exists(): void - { - $data = [ - 'platform_instance_id' => 999, - 'channel_id' => 'test_channel', - 'name' => 'Test Channel' - ]; - - $response = $this->postJson('/api/v1/platform-channels', $data); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['platform_instance_id']); - } - - public function test_show_returns_platform_channel_successfully(): void - { - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - - $response = $this->getJson("/api/v1/platform-channels/{$channel->id}"); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - 'id', - 'platform_instance_id', - 'channel_id', - 'name', - 'display_name', - 'description', - 'is_active', - 'created_at', - 'updated_at', - 'platform_instance' - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Platform channel retrieved successfully.', - 'data' => [ - 'id' => $channel->id, - 'name' => $channel->name, - ] - ]); - } - - public function test_update_modifies_platform_channel_successfully(): void - { - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - - $updateData = [ - 'name' => 'Updated Channel', - 'display_name' => 'Updated Display Name', - 'description' => 'Updated description', - 'is_active' => false - ]; - - $response = $this->putJson("/api/v1/platform-channels/{$channel->id}", $updateData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Platform channel updated successfully!' - ]); - - $this->assertDatabaseHas('platform_channels', [ - 'id' => $channel->id, - 'name' => 'Updated Channel', - 'display_name' => 'Updated Display Name', - 'is_active' => false, - ]); - } - - public function test_destroy_deletes_platform_channel_successfully(): void - { - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - - $response = $this->deleteJson("/api/v1/platform-channels/{$channel->id}"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Platform channel deleted successfully!' - ]); - - $this->assertDatabaseMissing('platform_channels', [ - 'id' => $channel->id - ]); - } - - public function test_toggle_activates_inactive_channel(): void - { - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'is_active' => false - ]); - - $response = $this->postJson("/api/v1/platform-channels/{$channel->id}/toggle"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Platform channel activated successfully!' - ]); - - $this->assertDatabaseHas('platform_channels', [ - 'id' => $channel->id, - 'is_active' => true - ]); - } - - public function test_toggle_deactivates_active_channel(): void - { - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'is_active' => true - ]); - - $response = $this->postJson("/api/v1/platform-channels/{$channel->id}/toggle"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Platform channel deactivated successfully!' - ]); - - $this->assertDatabaseHas('platform_channels', [ - 'id' => $channel->id, - 'is_active' => false - ]); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/RoutingControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/RoutingControllerTest.php deleted file mode 100644 index c8356ea..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/RoutingControllerTest.php +++ /dev/null @@ -1,390 +0,0 @@ -create(); - $instance = PlatformInstance::factory()->create(); - - // Create unique feeds and channels for this test - $feeds = Feed::factory()->count(3)->create(['language_id' => $language->id]); - $channels = PlatformChannel::factory()->count(3)->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - foreach ($feeds as $index => $feed) { - Route::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channels[$index]->id - ]); - } - - $response = $this->getJson('/api/v1/routing'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - '*' => [ - 'feed_id', - 'platform_channel_id', - 'is_active', - 'priority', - 'created_at', - 'updated_at', - ] - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Routing configurations retrieved successfully.' - ]); - } - - public function test_store_creates_routing_configuration_successfully(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $data = [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 5 - ]; - - $response = $this->postJson('/api/v1/routing', $data); - - $response->assertStatus(201) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - 'feed_id', - 'platform_channel_id', - 'is_active', - 'priority', - 'created_at', - 'updated_at', - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Routing configuration created successfully!' - ]); - - $this->assertDatabaseHas('routes', [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 5, - ]); - } - - public function test_store_validates_required_fields(): void - { - $response = $this->postJson('/api/v1/routing', []); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['feed_id', 'platform_channel_id']); - } - - public function test_store_validates_feed_exists(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $data = [ - 'feed_id' => 999, - 'platform_channel_id' => $channel->id - ]; - - $response = $this->postJson('/api/v1/routing', $data); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['feed_id']); - } - - public function test_store_validates_platform_channel_exists(): void - { - $language = Language::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - - $data = [ - 'feed_id' => $feed->id, - 'platform_channel_id' => 999 - ]; - - $response = $this->postJson('/api/v1/routing', $data); - - $response->assertStatus(422) - ->assertJsonValidationErrors(['platform_channel_id']); - } - - public function test_show_returns_routing_configuration_successfully(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $route = Route::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id - ]); - - $response = $this->getJson("/api/v1/routing/{$feed->id}/{$channel->id}"); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'message', - 'data' => [ - 'feed_id', - 'platform_channel_id', - 'is_active', - 'priority', - 'created_at', - 'updated_at', - ] - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Routing configuration retrieved successfully.' - ]); - } - - public function test_show_returns_404_for_nonexistent_routing(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $response = $this->getJson("/api/v1/routing/{$feed->id}/{$channel->id}"); - - $response->assertStatus(404) - ->assertJson([ - 'success' => false, - 'message' => 'Routing configuration not found.' - ]); - } - - public function test_update_modifies_routing_configuration_successfully(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $route = Route::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 1 - ]); - - $updateData = [ - 'is_active' => false, - 'priority' => 10 - ]; - - $response = $this->putJson("/api/v1/routing/{$feed->id}/{$channel->id}", $updateData); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Routing configuration updated successfully!' - ]); - - $this->assertDatabaseHas('routes', [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false, - 'priority' => 10, - ]); - } - - public function test_update_returns_404_for_nonexistent_routing(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $response = $this->putJson("/api/v1/routing/{$feed->id}/{$channel->id}", [ - 'is_active' => false - ]); - - $response->assertStatus(404) - ->assertJson([ - 'success' => false, - 'message' => 'Routing configuration not found.' - ]); - } - - public function test_destroy_deletes_routing_configuration_successfully(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $route = Route::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id - ]); - - $response = $this->deleteJson("/api/v1/routing/{$feed->id}/{$channel->id}"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Routing configuration deleted successfully!' - ]); - - $this->assertDatabaseMissing('routes', [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id - ]); - } - - public function test_destroy_returns_404_for_nonexistent_routing(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $response = $this->deleteJson("/api/v1/routing/{$feed->id}/{$channel->id}"); - - $response->assertStatus(404) - ->assertJson([ - 'success' => false, - 'message' => 'Routing configuration not found.' - ]); - } - - public function test_toggle_activates_inactive_routing(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $route = Route::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false - ]); - - $response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Routing configuration activated successfully!' - ]); - - $this->assertDatabaseHas('routes', [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true - ]); - } - - public function test_toggle_deactivates_active_routing(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $route = Route::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true - ]); - - $response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle"); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Routing configuration deactivated successfully!' - ]); - - $this->assertDatabaseHas('routes', [ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false - ]); - } - - public function test_toggle_returns_404_for_nonexistent_routing(): void - { - $language = Language::factory()->create(); - $instance = PlatformInstance::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'language_id' => $language->id - ]); - - $response = $this->postJson("/api/v1/routing/{$feed->id}/{$channel->id}/toggle"); - - $response->assertStatus(404) - ->assertJson([ - 'success' => false, - 'message' => 'Routing configuration not found.' - ]); - } -} \ No newline at end of file diff --git a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php b/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php deleted file mode 100644 index 7b86f3a..0000000 --- a/tests/Feature/Http/Controllers/Api/V1/SettingsControllerTest.php +++ /dev/null @@ -1,101 +0,0 @@ -getJson('/api/v1/settings'); - - $response->assertStatus(200) - ->assertJsonStructure([ - 'success', - 'data' => [ - 'article_processing_enabled', - 'publishing_approvals_enabled', - ], - 'message' - ]) - ->assertJson([ - 'success' => true, - 'message' => 'Settings retrieved successfully.' - ]); - } - - public function test_update_modifies_article_processing_setting(): void - { - $response = $this->putJson('/api/v1/settings', [ - 'article_processing_enabled' => false, - ]); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Settings updated successfully.', - 'data' => [ - 'article_processing_enabled' => false, - ] - ]); - } - - public function test_update_modifies_publishing_approvals_setting(): void - { - $response = $this->putJson('/api/v1/settings', [ - 'enable_publishing_approvals' => true, - ]); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'message' => 'Settings updated successfully.', - 'data' => [ - 'publishing_approvals_enabled' => true, - ] - ]); - } - - public function test_update_validates_boolean_values(): void - { - $response = $this->putJson('/api/v1/settings', [ - 'article_processing_enabled' => 'not-a-boolean', - 'enable_publishing_approvals' => 'also-not-boolean', - ]); - - $response->assertStatus(422) - ->assertJsonValidationErrors([ - 'article_processing_enabled', - 'enable_publishing_approvals' - ]); - } - - public function test_update_accepts_partial_updates(): void - { - // Update only one setting - $response = $this->putJson('/api/v1/settings', [ - 'article_processing_enabled' => true, - ]); - - $response->assertStatus(200) - ->assertJson([ - 'success' => true, - 'data' => [ - 'article_processing_enabled' => true, - ] - ]); - - // Should still have structure for both settings - $response->assertJsonStructure([ - 'data' => [ - 'article_processing_enabled', - 'publishing_approvals_enabled', - ] - ]); - } -} \ No newline at end of file diff --git a/tests/Feature/JobsAndEventsTest.php b/tests/Feature/JobsAndEventsTest.php deleted file mode 100644 index 7c0c4d6..0000000 --- a/tests/Feature/JobsAndEventsTest.php +++ /dev/null @@ -1,317 +0,0 @@ -create(['is_active' => true]); - - $logSaver = app(LogSaver::class); - $job = new ArticleDiscoveryJob(); - $job->handle($logSaver); - - // 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 - ]); - - // Mock the ArticleFetcher service in the container - $mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class); - $article1 = Article::factory()->create(['url' => 'https://example.com/article1', 'feed_id' => $feed->id]); - $article2 = Article::factory()->create(['url' => 'https://example.com/article2', 'feed_id' => $feed->id]); - $mockFetcher->shouldReceive('getArticlesFromFeed') - ->with($feed) - ->andReturn(collect([$article1, $article2])); - - $this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher); - - $logSaver = app(LogSaver::class); - $articleFetcher = app(ArticleFetcher::class); - $job = new ArticleDiscoveryForFeedJob($feed); - $job->handle($logSaver, $articleFetcher); - - // Should have articles in database (existing articles created by factory) - $this->assertCount(2, Article::all()); - // Note: Events are not fired by ArticleDiscoveryForFeedJob directly - // They would be fired by the Article model when created - } - - public function test_sync_channel_posts_job_processes_successfully(): void - { - $channel = PlatformChannel::factory()->create(); - $job = new SyncChannelPostsJob($channel); - - // Test that job can be constructed and has correct properties - $this->assertEquals('sync', $job->queue); - $this->assertInstanceOf(SyncChannelPostsJob::class, $job); - - // Don't actually run the job to avoid HTTP calls - $this->assertTrue(true); - } - - - public function test_publish_next_article_job_has_correct_configuration(): void - { - $job = new PublishNextArticleJob(); - - $this->assertEquals('publishing', $job->queue); - $this->assertInstanceOf(PublishNextArticleJob::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; - }); - } - - // Test removed - ArticleReadyToPublish class no longer exists - // 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, \App\Enums\LogLevelEnum::ERROR, 'Test 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(); - - $log = Log::factory()->create([ - 'level' => 'error', - 'message' => 'Test error', - 'context' => json_encode(['key' => 'value']) - ]); - - event(new ExceptionLogged($log)); - - Event::assertDispatched(ExceptionLogged::class, function (ExceptionLogged $event) use ($log) { - return $event->log->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, - 'approval_status' => 'pending', - ]); - - // Mock ArticleFetcher to return valid article data - $mockFetcher = \Mockery::mock(\App\Services\Article\ArticleFetcher::class); - $this->app->instance(\App\Services\Article\ArticleFetcher::class, $mockFetcher); - $mockFetcher->shouldReceive('fetchArticleData') - ->with($article) - ->andReturn([ - 'title' => 'Belgian News', - 'description' => 'News from Belgium', - 'full_article' => 'This is a test article about Belgium and Belgian politics.' - ]); - - $validationService = app(ValidationService::class); - $listener = new ValidateArticleListener(); - $event = new NewArticleFetched($article); - - $listener->handle($event, $validationService); - - $article->refresh(); - $this->assertNotEquals('pending', $article->approval_status); - $this->assertContains($article->approval_status, ['approved', 'rejected']); - } - - // Test removed - PublishApprovedArticle and ArticleReadyToPublish classes no longer exist - // public function test_publish_approved_article_listener_queues_job(): void - // { - // Event::fake(); - - // $article = Article::factory()->create([ - // 'approval_status' => 'approved', - // 'approval_status' => 'approved', - // ]); - - // $listener = new PublishApprovedArticle(); - // $event = new ArticleApproved($article); - - // $listener->handle($event); - - // Event::assertDispatched(ArticleReadyToPublish::class); - // } - - // Test removed - PublishArticle and ArticleReadyToPublish classes no longer exist - // public function test_publish_article_listener_queues_publish_job(): void - // { - // Queue::fake(); - - // $article = Article::factory()->create([ - // 'approval_status' => 'approved', - // ]); - - // $listener = new PublishArticle(); - // $event = new ArticleReadyToPublish($article); - - // $listener->handle($event); - - // Queue::assertPushed(PublishNextArticleJob::class); - // } - - public function test_log_exception_to_database_listener_creates_log(): void - { - $log = Log::factory()->create([ - 'level' => 'error', - 'message' => 'Test exception message', - 'context' => json_encode(['error' => 'details']) - ]); - - $listener = new LogExceptionToDatabase(); - $exception = new \Exception('Test exception message'); - $event = new ExceptionOccurred($exception, \App\Enums\LogLevelEnum::ERROR, 'Test exception message'); - - $listener->handle($event); - - $this->assertDatabaseHas('logs', [ - 'level' => 'error', - 'message' => 'Test exception message' - ]); - - $savedLog = Log::where('message', 'Test exception message')->first(); - $this->assertNotNull($savedLog); - $this->assertEquals(\App\Enums\LogLevelEnum::ERROR, $savedLog->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); - - // ArticleApproved event exists but has no listeners after publishing redesign - // $listeners = Event::getListeners(ArticleApproved::class); - // $this->assertNotEmpty($listeners); - - // ArticleReadyToPublish no longer exists - removed this check - // $listeners = Event::getListeners(ArticleReadyToPublish::class); - // $this->assertNotEmpty($listeners); - - $listeners = Event::getListeners(ExceptionOccurred::class); - $this->assertNotEmpty($listeners); - } - - public function test_job_retry_configuration(): void - { - $job = new PublishNextArticleJob(); - - // Test that job has unique configuration - $this->assertObjectHasProperty('uniqueFor', $job); - $this->assertEquals(300, $job->uniqueFor); - } - - public function test_job_queue_configuration(): void - { - $feed = Feed::factory()->create(['url' => 'https://unique-test-feed.com/rss']); - $channel = PlatformChannel::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id]); - - $discoveryJob = new ArticleDiscoveryJob(); - $feedJob = new ArticleDiscoveryForFeedJob($feed); - $publishJob = new PublishNextArticleJob(); - $syncJob = new SyncChannelPostsJob($channel); - - // Test queue assignments - $this->assertEquals('feed-discovery', $discoveryJob->queue ?? 'default'); - $this->assertEquals('feed-discovery', $feedJob->queue ?? 'discovery'); - $this->assertEquals('publishing', $publishJob->queue); - $this->assertEquals('sync', $syncJob->queue ?? 'sync'); - } - - protected function tearDown(): void - { - \Mockery::close(); - parent::tearDown(); - } -} diff --git a/tests/Feature/NewArticleFetchedEventTest.php b/tests/Feature/NewArticleFetchedEventTest.php deleted file mode 100644 index c123e10..0000000 --- a/tests/Feature/NewArticleFetchedEventTest.php +++ /dev/null @@ -1,32 +0,0 @@ -create(); - - $article = Article::create([ - 'url' => 'https://www.google.com', - 'feed_id' => $feed->id, - 'title' => 'Test Article', - ]); - - Event::assertDispatched(NewArticleFetched::class, function (NewArticleFetched $event) use ($article) { - return $event->article->id === $article->id; - }); - } -} diff --git a/tests/Feature/Auth/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php similarity index 70% rename from tests/Feature/Auth/PasswordUpdateTest.php rename to tests/Feature/Settings/PasswordUpdateTest.php index ca28c6c..64e9189 100644 --- a/tests/Feature/Auth/PasswordUpdateTest.php +++ b/tests/Feature/Settings/PasswordUpdateTest.php @@ -1,6 +1,6 @@ create(); $response = $this ->actingAs($user) - ->from('/profile') - ->put('/password', [ + ->from('/settings/password') + ->put('/settings/password', [ 'current_password' => 'password', 'password' => 'new-password', 'password_confirmation' => 'new-password', @@ -26,26 +26,26 @@ public function test_password_can_be_updated(): void $response ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); + ->assertRedirect('/settings/password'); $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); } - public function test_correct_password_must_be_provided_to_update_password(): void + public function test_correct_password_must_be_provided_to_update_password() { $user = User::factory()->create(); $response = $this ->actingAs($user) - ->from('/profile') - ->put('/password', [ + ->from('/settings/password') + ->put('/settings/password', [ 'current_password' => 'wrong-password', 'password' => 'new-password', 'password_confirmation' => 'new-password', ]); $response - ->assertSessionHasErrorsIn('updatePassword', 'current_password') - ->assertRedirect('/profile'); + ->assertSessionHasErrors('current_password') + ->assertRedirect('/settings/password'); } } diff --git a/tests/Feature/ProfileTest.php b/tests/Feature/Settings/ProfileUpdateTest.php similarity index 70% rename from tests/Feature/ProfileTest.php rename to tests/Feature/Settings/ProfileUpdateTest.php index 252fdcc..7d51214 100644 --- a/tests/Feature/ProfileTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -1,40 +1,40 @@ create(); $response = $this ->actingAs($user) - ->get('/profile'); + ->get('/settings/profile'); $response->assertOk(); } - public function test_profile_information_can_be_updated(): void + public function test_profile_information_can_be_updated() { $user = User::factory()->create(); $response = $this ->actingAs($user) - ->patch('/profile', [ + ->patch('/settings/profile', [ 'name' => 'Test User', 'email' => 'test@example.com', ]); $response ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); + ->assertRedirect('/settings/profile'); $user->refresh(); @@ -43,31 +43,31 @@ public function test_profile_information_can_be_updated(): void $this->assertNull($user->email_verified_at); } - public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void + public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged() { $user = User::factory()->create(); $response = $this ->actingAs($user) - ->patch('/profile', [ + ->patch('/settings/profile', [ 'name' => 'Test User', 'email' => $user->email, ]); $response ->assertSessionHasNoErrors() - ->assertRedirect('/profile'); + ->assertRedirect('/settings/profile'); $this->assertNotNull($user->refresh()->email_verified_at); } - public function test_user_can_delete_their_account(): void + public function test_user_can_delete_their_account() { $user = User::factory()->create(); $response = $this ->actingAs($user) - ->delete('/profile', [ + ->delete('/settings/profile', [ 'password' => 'password', ]); @@ -79,20 +79,20 @@ public function test_user_can_delete_their_account(): void $this->assertNull($user->fresh()); } - public function test_correct_password_must_be_provided_to_delete_account(): void + public function test_correct_password_must_be_provided_to_delete_account() { $user = User::factory()->create(); $response = $this ->actingAs($user) - ->from('/profile') - ->delete('/profile', [ + ->from('/settings/profile') + ->delete('/settings/profile', [ 'password' => 'wrong-password', ]); $response - ->assertSessionHasErrorsIn('userDeletion', 'password') - ->assertRedirect('/profile'); + ->assertSessionHasErrors('password') + ->assertRedirect('/settings/profile'); $this->assertNotNull($user->fresh()); } diff --git a/tests/Feature/ValidateArticleListenerTest.php b/tests/Feature/ValidateArticleListenerTest.php deleted file mode 100644 index 9256401..0000000 --- a/tests/Feature/ValidateArticleListenerTest.php +++ /dev/null @@ -1,129 +0,0 @@ - Http::response('Article content', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - $listener = new ValidateArticleListener(); - $event = new NewArticleFetched($article); - - $validationService = app(ValidationService::class); - $listener->handle($event, $validationService); - - $article->refresh(); - - if ($article->isValid()) { - Event::assertDispatched(ArticleReadyToPublish::class, function (ArticleReadyToPublish $event) use ($article) { - return $event->article->id === $article->id; - }); - } else { - Event::assertNotDispatched(ArticleReadyToPublish::class); - } - } - - public function test_listener_skips_already_validated_articles(): void - { - Event::fake([ArticleReadyToPublish::class]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'approved', - ]); - - $listener = new ValidateArticleListener(); - $event = new NewArticleFetched($article); - - $validationService = app(ValidationService::class); - $listener->handle($event, $validationService); - - Event::assertNotDispatched(ArticleReadyToPublish::class); - } - - public function test_listener_skips_articles_with_existing_publication(): void - { - Event::fake([ArticleReadyToPublish::class]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - ArticlePublication::create([ - 'article_id' => $article->id, - 'post_id' => 'test-post-id', - 'platform_channel_id' => 1, - 'published_at' => now(), - 'published_by' => 'test-user', - ]); - - $listener = new ValidateArticleListener(); - $event = new NewArticleFetched($article); - - $validationService = app(ValidationService::class); - $listener->handle($event, $validationService); - - Event::assertNotDispatched(ArticleReadyToPublish::class); - } - - public function test_listener_calls_validation_service(): void - { - Event::fake([ArticleReadyToPublish::class]); - - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending', - ]); - - $listener = new ValidateArticleListener(); - $event = new NewArticleFetched($article); - - $validationService = app(ValidationService::class); - $listener->handle($event, $validationService); - - // Verify that the article was processed by ValidationService - $article->refresh(); - $this->assertNotEquals('pending', $article->approval_status, 'Article should have been validated'); - $this->assertContains($article->approval_status, ['approved', 'rejected'], 'Article should have validation result'); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php index da6d365..fe1ffc2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,41 +3,8 @@ namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Facade; -use Mockery; abstract class TestCase extends BaseTestCase { - use CreatesApplication; - - protected function setUp(): void - { - parent::setUp(); - - // Clean up any existing Mockery instances before each test - if (class_exists(Mockery::class)) { - Mockery::close(); - Mockery::globalHelpers(); - } - - // Prevent any external HTTP requests during tests unless explicitly faked in a test - Http::preventStrayRequests(); - } - - protected function tearDown(): void - { - // Clear HTTP fakes between tests to prevent interference - Http::clearResolvedInstances(); - - // Clear all facade instances to prevent interference - Facade::clearResolvedInstances(); - - // Ensure Mockery is properly closed to prevent facade interference - if (class_exists(Mockery::class)) { - Mockery::close(); - } - - parent::tearDown(); - } + // } diff --git a/tests/Traits/CreatesArticleFetcher.php b/tests/Traits/CreatesArticleFetcher.php deleted file mode 100644 index dcd38eb..0000000 --- a/tests/Traits/CreatesArticleFetcher.php +++ /dev/null @@ -1,36 +0,0 @@ -shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - } - - return new ArticleFetcher($logSaver); - } - - protected function createArticleFetcherWithMockedLogSaver(): array - { - $logSaver = Mockery::mock(LogSaver::class); - $logSaver->shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - - $articleFetcher = new ArticleFetcher($logSaver); - - return [$articleFetcher, $logSaver]; - } -} \ No newline at end of file diff --git a/tests/Unit/Enums/LogLevelEnumTest.php b/tests/Unit/Enums/LogLevelEnumTest.php deleted file mode 100644 index 88084f3..0000000 --- a/tests/Unit/Enums/LogLevelEnumTest.php +++ /dev/null @@ -1,108 +0,0 @@ -assertEquals('debug', LogLevelEnum::DEBUG->value); - $this->assertEquals('info', LogLevelEnum::INFO->value); - $this->assertEquals('warning', LogLevelEnum::WARNING->value); - $this->assertEquals('error', LogLevelEnum::ERROR->value); - $this->assertEquals('critical', LogLevelEnum::CRITICAL->value); - } - - public function test_to_array_returns_all_enum_values(): void - { - $expected = ['debug', 'info', 'warning', 'error', 'critical']; - $actual = LogLevelEnum::toArray(); - - $this->assertEquals($expected, $actual); - $this->assertCount(5, $actual); - } - - public function test_enum_cases_exist(): void - { - $cases = LogLevelEnum::cases(); - - $this->assertCount(5, $cases); - $this->assertContains(LogLevelEnum::DEBUG, $cases); - $this->assertContains(LogLevelEnum::INFO, $cases); - $this->assertContains(LogLevelEnum::WARNING, $cases); - $this->assertContains(LogLevelEnum::ERROR, $cases); - $this->assertContains(LogLevelEnum::CRITICAL, $cases); - } - - public function test_enum_names_are_correct(): void - { - $this->assertEquals('DEBUG', LogLevelEnum::DEBUG->name); - $this->assertEquals('INFO', LogLevelEnum::INFO->name); - $this->assertEquals('WARNING', LogLevelEnum::WARNING->name); - $this->assertEquals('ERROR', LogLevelEnum::ERROR->name); - $this->assertEquals('CRITICAL', LogLevelEnum::CRITICAL->name); - } - - public function test_can_create_enum_from_string(): void - { - $this->assertEquals(LogLevelEnum::DEBUG, LogLevelEnum::from('debug')); - $this->assertEquals(LogLevelEnum::INFO, LogLevelEnum::from('info')); - $this->assertEquals(LogLevelEnum::WARNING, LogLevelEnum::from('warning')); - $this->assertEquals(LogLevelEnum::ERROR, LogLevelEnum::from('error')); - $this->assertEquals(LogLevelEnum::CRITICAL, LogLevelEnum::from('critical')); - } - - public function test_try_from_with_valid_values(): void - { - $this->assertEquals(LogLevelEnum::DEBUG, LogLevelEnum::tryFrom('debug')); - $this->assertEquals(LogLevelEnum::INFO, LogLevelEnum::tryFrom('info')); - $this->assertEquals(LogLevelEnum::WARNING, LogLevelEnum::tryFrom('warning')); - $this->assertEquals(LogLevelEnum::ERROR, LogLevelEnum::tryFrom('error')); - $this->assertEquals(LogLevelEnum::CRITICAL, LogLevelEnum::tryFrom('critical')); - } - - public function test_try_from_with_invalid_value_returns_null(): void - { - $this->assertNull(LogLevelEnum::tryFrom('invalid')); - $this->assertNull(LogLevelEnum::tryFrom('')); - $this->assertNull(LogLevelEnum::tryFrom('CRITICAL')); // case sensitive - } - - public function test_from_throws_exception_for_invalid_value(): void - { - $this->expectException(\ValueError::class); - LogLevelEnum::from('invalid'); - } - - public function test_enum_can_be_compared(): void - { - $debug1 = LogLevelEnum::DEBUG; - $debug2 = LogLevelEnum::DEBUG; - $info = LogLevelEnum::INFO; - - $this->assertTrue($debug1 === $debug2); - $this->assertFalse($debug1 === $info); - } - - public function test_enum_can_be_used_in_match_expression(): void - { - $getMessage = function (LogLevelEnum $level): string { - return match ($level) { - LogLevelEnum::DEBUG => 'Debug message', - LogLevelEnum::INFO => 'Info message', - LogLevelEnum::WARNING => 'Warning message', - LogLevelEnum::ERROR => 'Error message', - LogLevelEnum::CRITICAL => 'Critical message', - }; - }; - - $this->assertEquals('Debug message', $getMessage(LogLevelEnum::DEBUG)); - $this->assertEquals('Info message', $getMessage(LogLevelEnum::INFO)); - $this->assertEquals('Warning message', $getMessage(LogLevelEnum::WARNING)); - $this->assertEquals('Error message', $getMessage(LogLevelEnum::ERROR)); - $this->assertEquals('Critical message', $getMessage(LogLevelEnum::CRITICAL)); - } -} \ No newline at end of file diff --git a/tests/Unit/Enums/PlatformEnumTest.php b/tests/Unit/Enums/PlatformEnumTest.php deleted file mode 100644 index ebd9348..0000000 --- a/tests/Unit/Enums/PlatformEnumTest.php +++ /dev/null @@ -1,89 +0,0 @@ -assertEquals('lemmy', PlatformEnum::LEMMY->value); - } - - public function test_enum_cases_exist(): void - { - $cases = PlatformEnum::cases(); - - $this->assertCount(1, $cases); - $this->assertContains(PlatformEnum::LEMMY, $cases); - } - - public function test_enum_names_are_correct(): void - { - $this->assertEquals('LEMMY', PlatformEnum::LEMMY->name); - } - - public function test_can_create_enum_from_string(): void - { - $this->assertEquals(PlatformEnum::LEMMY, PlatformEnum::from('lemmy')); - } - - public function test_try_from_with_valid_values(): void - { - $this->assertEquals(PlatformEnum::LEMMY, PlatformEnum::tryFrom('lemmy')); - } - - public function test_try_from_with_invalid_value_returns_null(): void - { - $this->assertNull(PlatformEnum::tryFrom('reddit')); - $this->assertNull(PlatformEnum::tryFrom('mastodon')); - $this->assertNull(PlatformEnum::tryFrom('')); - $this->assertNull(PlatformEnum::tryFrom('LEMMY')); // case sensitive - } - - public function test_from_throws_exception_for_invalid_value(): void - { - $this->expectException(\ValueError::class); - PlatformEnum::from('reddit'); - } - - public function test_enum_can_be_compared(): void - { - $lemmy1 = PlatformEnum::LEMMY; - $lemmy2 = PlatformEnum::LEMMY; - - $this->assertTrue($lemmy1 === $lemmy2); - } - - public function test_enum_can_be_used_in_match_expression(): void - { - $getDescription = function (PlatformEnum $platform): string { - return match ($platform) { - PlatformEnum::LEMMY => 'Lemmy is a federated link aggregator', - }; - }; - - $this->assertEquals('Lemmy is a federated link aggregator', $getDescription(PlatformEnum::LEMMY)); - } - - public function test_enum_can_be_used_in_switch_statement(): void - { - $platform = PlatformEnum::LEMMY; - $result = ''; - - switch ($platform) { - case PlatformEnum::LEMMY: - $result = 'lemmy platform'; - break; - } - - $this->assertEquals('lemmy platform', $result); - } - - public function test_enum_value_is_string_backed(): void - { - $this->assertIsString(PlatformEnum::LEMMY->value); - } -} \ No newline at end of file diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php new file mode 100644 index 0000000..2cc6866 --- /dev/null +++ b/tests/Unit/ExampleTest.php @@ -0,0 +1,16 @@ +assertTrue(true); + } +} diff --git a/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php b/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php deleted file mode 100644 index 2b6dd9e..0000000 --- a/tests/Unit/Exceptions/RoutingMismatchExceptionTest.php +++ /dev/null @@ -1,162 +0,0 @@ -create(['short_code' => 'en', 'name' => 'English']); - $frenchLang = Language::factory()->create(['short_code' => 'fr', 'name' => 'French']); - - $feed = new Feed(['name' => 'Test Feed']); - $feed->setRelation('language', $englishLang); - - $channel = new PlatformChannel(['name' => 'Test Channel']); - $channel->setRelation('language', $frenchLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $message = $exception->getMessage(); - $this->assertStringContainsString('Language mismatch:', $message); - $this->assertStringContainsString('Test Feed', $message); - $this->assertStringContainsString('Test Channel', $message); - $this->assertStringContainsString('Feed and channel languages must match', $message); - } - - public function test_exception_extends_routing_exception(): void - { - // Arrange - $englishLang = Language::factory()->create(['short_code' => 'en']); - $frenchLang = Language::factory()->create(['short_code' => 'fr']); - - $feed = new Feed(['name' => 'Test Feed']); - $feed->setRelation('language', $englishLang); - - $channel = new PlatformChannel(['name' => 'Test Channel']); - $channel->setRelation('language', $frenchLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $this->assertInstanceOf(\App\Exceptions\RoutingException::class, $exception); - } - - public function test_exception_with_different_languages(): void - { - // Arrange - $dutchLang = Language::factory()->create(['short_code' => 'nl', 'name' => 'Dutch']); - $germanLang = Language::factory()->create(['short_code' => 'de', 'name' => 'German']); - - $feed = new Feed(['name' => 'Dutch News']); - $feed->setRelation('language', $dutchLang); - - $channel = new PlatformChannel(['name' => 'German Channel']); - $channel->setRelation('language', $germanLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $message = $exception->getMessage(); - $this->assertStringContainsString('Dutch News', $message); - $this->assertStringContainsString('German Channel', $message); - $this->assertStringContainsString('Language mismatch', $message); - } - - public function test_exception_message_contains_all_required_elements(): void - { - // Arrange - $frenchLang = Language::factory()->create(['short_code' => 'fr', 'name' => 'French']); - $spanishLang = Language::factory()->create(['short_code' => 'es', 'name' => 'Spanish']); - - $feed = new Feed(['name' => 'French Feed']); - $feed->setRelation('language', $frenchLang); - - $channel = new PlatformChannel(['name' => 'Spanish Channel']); - $channel->setRelation('language', $spanishLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - $message = $exception->getMessage(); - - // Assert - $this->assertStringContainsString('Language mismatch:', $message); - $this->assertStringContainsString('French Feed', $message); - $this->assertStringContainsString('Spanish Channel', $message); - $this->assertStringContainsString('Feed and channel languages must match', $message); - } - - public function test_exception_with_null_languages(): void - { - // Arrange - $feed = new Feed(['name' => 'No Lang Feed']); - $feed->setRelation('language', null); - - $channel = new PlatformChannel(['name' => 'No Lang Channel']); - $channel->setRelation('language', null); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $message = $exception->getMessage(); - $this->assertStringContainsString('No Lang Feed', $message); - $this->assertStringContainsString('No Lang Channel', $message); - $this->assertIsString($message); - } - - public function test_exception_with_special_characters_in_names(): void - { - // Arrange - $englishLang = Language::factory()->create(['short_code' => 'en']); - $frenchLang = Language::factory()->create(['short_code' => 'fr']); - - $feed = new Feed(['name' => 'Feed with "quotes" & symbols']); - $feed->setRelation('language', $englishLang); - - $channel = new PlatformChannel(['name' => 'Channel with ']); - $channel->setRelation('language', $frenchLang); - - // Act - $exception = new RoutingMismatchException($feed, $channel); - - // Assert - $message = $exception->getMessage(); - $this->assertStringContainsString('Feed with "quotes" & symbols', $message); - $this->assertStringContainsString('Channel with ', $message); - $this->assertIsString($message); - } - - public function test_exception_is_throwable(): void - { - // Arrange - $englishLang = Language::factory()->create(['short_code' => 'en']); - $frenchLang = Language::factory()->create(['short_code' => 'fr']); - - $feed = new Feed(['name' => 'Test Feed']); - $feed->setRelation('language', $englishLang); - - $channel = new PlatformChannel(['name' => 'Test Channel']); - $channel->setRelation('language', $frenchLang); - - // Act & Assert - $this->expectException(RoutingMismatchException::class); - $this->expectExceptionMessage('Language mismatch'); - - throw new RoutingMismatchException($feed, $channel); - } -} \ No newline at end of file diff --git a/tests/Unit/Facades/LogSaverTest.php b/tests/Unit/Facades/LogSaverTest.php deleted file mode 100644 index db240c2..0000000 --- a/tests/Unit/Facades/LogSaverTest.php +++ /dev/null @@ -1,135 +0,0 @@ -getMethod('getFacadeAccessor'); - $method->setAccessible(true); - - $this->assertEquals(\App\Services\Log\LogSaver::class, $method->invoke(null)); - } - - public function test_facade_info_method_works(): void - { - $message = 'Facade info test'; - $context = ['facade' => true]; - - LogSaver::info($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::INFO, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_facade_error_method_works(): void - { - $message = 'Facade error test'; - $context = ['facade' => true, 'error' => 'test_error']; - - LogSaver::error($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::ERROR, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_facade_warning_method_works(): void - { - $message = 'Facade warning test'; - $context = ['facade' => true, 'warning_type' => 'test']; - - LogSaver::warning($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::WARNING, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_facade_debug_method_works(): void - { - $message = 'Facade debug test'; - $context = ['facade' => true, 'debug_info' => 'test']; - - LogSaver::debug($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::DEBUG, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_facade_works_with_channel(): void - { - $platformInstance = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://facade.test.com' - ]); - - $channel = PlatformChannel::factory()->create([ - 'name' => 'Facade Test Channel', - 'platform_instance_id' => $platformInstance->id - ]); - - $message = 'Facade channel test'; - $context = ['facade_test' => true]; - - LogSaver::info($message, $channel, $context); - - $log = Log::first(); - - $expectedContext = array_merge($context, [ - 'channel_id' => $channel->id, - 'channel_name' => 'Facade Test Channel', - 'platform' => PlatformEnum::LEMMY->value, - 'instance_url' => 'https://facade.test.com', - ]); - - $this->assertEquals($expectedContext, $log->context); - $this->assertEquals($message, $log->message); - $this->assertEquals(LogLevelEnum::INFO, $log->level); - } - - public function test_facade_static_calls_resolve_to_service_instance(): void - { - LogSaver::info('Test message 1'); - LogSaver::error('Test message 2'); - - $this->assertDatabaseCount('logs', 2); - - $logs = Log::orderBy('id')->get(); - $this->assertEquals('Test message 1', $logs[0]->message); - $this->assertEquals('Test message 2', $logs[1]->message); - $this->assertEquals(LogLevelEnum::INFO, $logs[0]->level); - $this->assertEquals(LogLevelEnum::ERROR, $logs[1]->level); - } -} \ No newline at end of file diff --git a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php deleted file mode 100644 index e33e3a6..0000000 --- a/tests/Unit/Jobs/ArticleDiscoveryForFeedJobTest.php +++ /dev/null @@ -1,222 +0,0 @@ -make(); - $job = new ArticleDiscoveryForFeedJob($feed); - - $this->assertEquals('feed-discovery', $job->queue); - } - - public function test_job_implements_should_queue(): void - { - $feed = Feed::factory()->make(); - $job = new ArticleDiscoveryForFeedJob($feed); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); - } - - public function test_job_uses_queueable_trait(): void - { - $feed = Feed::factory()->make(); - $job = new ArticleDiscoveryForFeedJob($feed); - - $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, - class_uses($job) - ); - } - - public function test_handle_fetches_articles_and_updates_feed(): void - { - // Arrange - $feed = Feed::factory()->create([ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed', - 'last_fetched_at' => null - ]); - - $mockArticles = collect(['article1', 'article2']); - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('getArticlesFromFeed') - ->once() - ->with($feed) - ->andReturn($mockArticles); - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting feed article fetch', null, [ - 'feed_id' => $feed->id, - 'feed_name' => $feed->name, - 'feed_url' => $feed->url - ]) - ->once(); - - $logSaverMock->shouldReceive('info') - ->with('Feed article fetch completed', null, [ - 'feed_id' => $feed->id, - 'feed_name' => $feed->name, - 'articles_count' => 2 - ]) - ->once(); - - $job = new ArticleDiscoveryForFeedJob($feed); - - // Act - $job->handle($logSaverMock, $articleFetcherMock); - - // Assert - $feed->refresh(); - $this->assertNotNull($feed->last_fetched_at); - $this->assertTrue($feed->last_fetched_at->greaterThan(now()->subMinute())); - } - - public function test_dispatch_for_all_active_feeds_dispatches_jobs_with_delay(): void - { - // Arrange - $feeds = Feed::factory()->count(3)->create(['is_active' => true]); - Feed::factory()->create(['is_active' => false]); // inactive feed should be ignored - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->times(3) // Once for each active feed - ->with('Dispatched feed discovery job', null, Mockery::type('array')); - - $this->app->instance(LogSaver::class, $logSaverMock); - - // Act - ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - - // Assert - Queue::assertPushed(ArticleDiscoveryForFeedJob::class, 3); - - // Verify jobs were dispatched (cannot access private $feed property in test) - } - - public function test_dispatch_for_all_active_feeds_applies_correct_delays(): void - { - // Arrange - Feed::factory()->count(2)->create(['is_active' => true]); - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info')->times(2); - - $this->app->instance(LogSaver::class, $logSaverMock); - - // Act - ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - - // Assert - Queue::assertPushed(ArticleDiscoveryForFeedJob::class, 2); - - // Verify jobs are pushed with delays - Queue::assertPushed(ArticleDiscoveryForFeedJob::class, function ($job) { - return $job->delay !== null; - }); - } - - public function test_dispatch_for_all_active_feeds_with_no_active_feeds(): void - { - // Arrange - Feed::factory()->count(2)->create(['is_active' => false]); - - // Act - ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds(); - - // Assert - Queue::assertNothingPushed(); - } - - public function test_feed_discovery_delay_constant_exists(): void - { - $reflection = new \ReflectionClass(ArticleDiscoveryForFeedJob::class); - $constant = $reflection->getConstant('FEED_DISCOVERY_DELAY_MINUTES'); - - $this->assertEquals(5, $constant); - } - - public function test_job_can_be_serialized(): void - { - $feed = Feed::factory()->create(['name' => 'Test Feed']); - $job = new ArticleDiscoveryForFeedJob($feed); - - $serialized = serialize($job); - $unserialized = unserialize($serialized); - - $this->assertInstanceOf(ArticleDiscoveryForFeedJob::class, $unserialized); - $this->assertEquals($job->queue, $unserialized->queue); - // Note: Cannot test feed property directly as it's private - // but serialization/unserialization working proves the job structure is intact - } - - public function test_handle_logs_start_message_with_correct_context(): void - { - // Arrange - $feed = Feed::factory()->create([ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed' - ]); - - $mockArticles = collect([]); - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('getArticlesFromFeed') - ->once() - ->andReturn($mockArticles); - - // Mock LogSaver with specific expectations - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting feed article fetch', null, [ - 'feed_id' => $feed->id, - 'feed_name' => 'Test Feed', - 'feed_url' => 'https://example.com/feed' - ]) - ->once(); - - $logSaverMock->shouldReceive('info') - ->with('Feed article fetch completed', null, Mockery::type('array')) - ->once(); - - $job = new ArticleDiscoveryForFeedJob($feed); - - // Act - $job->handle($logSaverMock, $articleFetcherMock); - - // Assert - Mockery expectations are verified in tearDown - $this->assertTrue(true); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} \ No newline at end of file diff --git a/tests/Unit/Jobs/ArticleDiscoveryJobTest.php b/tests/Unit/Jobs/ArticleDiscoveryJobTest.php deleted file mode 100644 index 4db26ae..0000000 --- a/tests/Unit/Jobs/ArticleDiscoveryJobTest.php +++ /dev/null @@ -1,146 +0,0 @@ -assertEquals('feed-discovery', $job->queue); - } - - public function test_handle_skips_when_article_processing_disabled(): void - { - // Arrange - Setting::create(['key' => 'article_processing_enabled', 'value' => '0']); - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->once() - ->with('Article processing is disabled. Article discovery skipped.'); - - $job = new ArticleDiscoveryJob(); - - // Act - $job->handle($logSaverMock); - - // Assert - Queue::assertNothingPushed(); - } - - public function test_handle_dispatches_jobs_when_article_processing_enabled(): void - { - // Arrange - Setting::create(['key' => 'article_processing_enabled', 'value' => '1']); - - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting article discovery for all active feeds') - ->once(); - $logSaverMock->shouldReceive('info') - ->with('Article discovery jobs dispatched for all active feeds') - ->once(); - - $job = new ArticleDiscoveryJob(); - - // Act - $job->handle($logSaverMock); - - // Assert - This will test that the static method is called, but we can't easily verify - // the job dispatch without mocking the static method - $this->assertTrue(true); // Job completes without error - } - - public function test_handle_with_default_article_processing_enabled(): void - { - // Arrange - No setting exists, should default to enabled - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting article discovery for all active feeds') - ->once(); - $logSaverMock->shouldReceive('info') - ->with('Article discovery jobs dispatched for all active feeds') - ->once(); - - $job = new ArticleDiscoveryJob(); - - // Act - $job->handle($logSaverMock); - - // Assert - Should complete without skipping - $this->assertTrue(true); // Job completes without error - } - - public function test_job_implements_should_queue(): void - { - // Arrange - $job = new ArticleDiscoveryJob(); - - // Assert - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); - } - - public function test_job_uses_queueable_trait(): void - { - // Arrange - $job = new ArticleDiscoveryJob(); - - // Assert - $this->assertTrue(method_exists($job, 'onQueue')); - $this->assertTrue(method_exists($job, 'onConnection')); - $this->assertTrue(method_exists($job, 'delay')); - } - - public function test_handle_logs_appropriate_messages(): void - { - // This test verifies that the job calls the logging methods - // The actual logging is tested in the LogSaver tests - - // Arrange - // Mock LogSaver - $logSaverMock = Mockery::mock(LogSaver::class); - $logSaverMock->shouldReceive('info') - ->with('Starting article discovery for all active feeds') - ->once(); - $logSaverMock->shouldReceive('info') - ->with('Article discovery jobs dispatched for all active feeds') - ->once(); - - $job = new ArticleDiscoveryJob(); - - // Act - Should not throw any exceptions - $job->handle($logSaverMock); - - // Assert - Job completes successfully - $this->assertTrue(true); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/tests/Unit/Jobs/PublishNextArticleJobTest.php b/tests/Unit/Jobs/PublishNextArticleJobTest.php deleted file mode 100644 index 9e8f6cf..0000000 --- a/tests/Unit/Jobs/PublishNextArticleJobTest.php +++ /dev/null @@ -1,296 +0,0 @@ -assertEquals('publishing', $job->queue); - } - - public function test_job_implements_should_queue(): void - { - $job = new PublishNextArticleJob(); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); - } - - public function test_job_implements_should_be_unique(): void - { - $job = new PublishNextArticleJob(); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job); - } - - public function test_job_has_unique_for_property(): void - { - $job = new PublishNextArticleJob(); - - $this->assertEquals(300, $job->uniqueFor); - } - - public function test_job_uses_queueable_trait(): void - { - $job = new PublishNextArticleJob(); - - $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, - class_uses($job) - ); - } - - public function test_handle_returns_early_when_no_approved_articles(): void - { - // Arrange - No articles exist - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early - - $job = new PublishNextArticleJob(); - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Should complete without error - $this->assertTrue(true); - } - - public function test_handle_returns_early_when_no_unpublished_approved_articles(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved' - ]); - - // Create a publication record to mark it as already published - ArticlePublication::factory()->create(['article_id' => $article->id]); - - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early - - $job = new PublishNextArticleJob(); - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Should complete without error - $this->assertTrue(true); - } - - public function test_handle_skips_non_approved_articles(): void - { - // Arrange - $feed = Feed::factory()->create(); - Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending' - ]); - Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'rejected' - ]); - - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - // No expectations as handle should return early - - $job = new PublishNextArticleJob(); - - // Act - $publishingServiceMock = \Mockery::mock(ArticlePublishingService::class); - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Should complete without error (no approved articles to process) - $this->assertTrue(true); - } - - public function test_handle_publishes_oldest_approved_article(): void - { - // Arrange - $feed = Feed::factory()->create(); - - // Create older article first - $olderArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'created_at' => now()->subHours(2) - ]); - - // Create newer article - $newerArticle = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'created_at' => now()->subHour() - ]); - - $extractedData = ['title' => 'Test Article', 'content' => 'Test content']; - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->with(Mockery::on(function ($article) use ($olderArticle) { - return $article->id === $olderArticle->id; - })) - ->andReturn($extractedData); - - // Mock ArticlePublishingService - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->with( - Mockery::on(function ($article) use ($olderArticle) { - return $article->id === $olderArticle->id; - }), - $extractedData - ); - - $job = new PublishNextArticleJob(); - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Mockery expectations are verified in tearDown - $this->assertTrue(true); - } - - public function test_handle_throws_exception_on_publishing_failure(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = ['title' => 'Test Article']; - $publishException = new PublishException($article, null); - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->with(Mockery::type(Article::class)) - ->andReturn($extractedData); - - // Mock ArticlePublishingService to throw exception - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->andThrow($publishException); - - $job = new PublishNextArticleJob(); - - // Assert - $this->expectException(PublishException::class); - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock); - } - - public function test_handle_logs_publishing_start(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - 'title' => 'Test Article Title', - 'url' => 'https://example.com/article' - ]); - - $extractedData = ['title' => 'Test Article']; - - // Mock ArticleFetcher - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->andReturn($extractedData); - - // Mock ArticlePublishingService - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels')->once(); - - $job = new PublishNextArticleJob(); - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Verify the job completes (logging is verified by observing no exceptions) - $this->assertTrue(true); - } - - public function test_job_can_be_serialized(): void - { - $job = new PublishNextArticleJob(); - - $serialized = serialize($job); - $unserialized = unserialize($serialized); - - $this->assertInstanceOf(PublishNextArticleJob::class, $unserialized); - $this->assertEquals($job->queue, $unserialized->queue); - $this->assertEquals($job->uniqueFor, $unserialized->uniqueFor); - } - - public function test_handle_fetches_article_data_before_publishing(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = ['title' => 'Extracted Title', 'content' => 'Extracted Content']; - - // Mock ArticleFetcher with specific expectations - $articleFetcherMock = Mockery::mock(ArticleFetcher::class); - $articleFetcherMock->shouldReceive('fetchArticleData') - ->once() - ->with(Mockery::type(Article::class)) - ->andReturn($extractedData); - - // Mock publishing service to receive the extracted data - $publishingServiceMock = Mockery::mock(ArticlePublishingService::class); - $publishingServiceMock->shouldReceive('publishToRoutedChannels') - ->once() - ->with(Mockery::type(Article::class), $extractedData); - - $job = new PublishNextArticleJob(); - - // Act - $job->handle($articleFetcherMock, $publishingServiceMock); - - // Assert - Mockery expectations verified in tearDown - $this->assertTrue(true); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} \ No newline at end of file diff --git a/tests/Unit/Jobs/SyncChannelPostsJobTest.php b/tests/Unit/Jobs/SyncChannelPostsJobTest.php deleted file mode 100644 index 6b10a61..0000000 --- a/tests/Unit/Jobs/SyncChannelPostsJobTest.php +++ /dev/null @@ -1,170 +0,0 @@ -make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertEquals('sync', $job->queue); - } - - public function test_job_implements_should_queue(): void - { - $channel = PlatformChannel::factory()->make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class, $job); - } - - public function test_job_implements_should_be_unique(): void - { - $channel = PlatformChannel::factory()->make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertInstanceOf(\Illuminate\Contracts\Queue\ShouldBeUnique::class, $job); - } - - public function test_job_uses_queueable_trait(): void - { - $channel = PlatformChannel::factory()->make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertContains( - \Illuminate\Foundation\Queue\Queueable::class, - class_uses($job) - ); - } - - public function test_dispatch_for_all_active_channels_dispatches_jobs(): void - { - // Arrange - $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 - { - $this->assertTrue(method_exists(SyncChannelPostsJob::class, 'dispatchForAllActiveChannels')); - } - - public function test_job_has_handle_method(): void - { - $channel = PlatformChannel::factory()->make(); - $job = new SyncChannelPostsJob($channel); - - $this->assertTrue(method_exists($job, 'handle')); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/ArticlePublicationTest.php b/tests/Unit/Models/ArticlePublicationTest.php deleted file mode 100644 index 0a0c1de..0000000 --- a/tests/Unit/Models/ArticlePublicationTest.php +++ /dev/null @@ -1,306 +0,0 @@ -assertEquals($fillableFields, $publication->getFillable()); - } - - public function test_table_name(): void - { - $publication = new ArticlePublication(); - - $this->assertEquals('article_publications', $publication->getTable()); - } - - public function test_casts_published_at_to_datetime(): void - { - $timestamp = now()->subHours(2); - $publication = ArticlePublication::factory()->create(['published_at' => $timestamp]); - - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s')); - } - - public function test_casts_publication_data_to_array(): void - { - $publicationData = [ - 'post_url' => 'https://lemmy.world/post/123', - 'platform_response' => [ - 'id' => 123, - 'status' => 'success', - 'metadata' => ['views' => 0, 'votes' => 0] - ], - 'retry_count' => 0 - ]; - - $publication = ArticlePublication::factory()->create(['publication_data' => $publicationData]); - - $this->assertIsArray($publication->publication_data); - $this->assertEquals($publicationData, $publication->publication_data); - } - - public function test_belongs_to_article_relationship(): void - { - $article = Article::factory()->create(); - $publication = ArticlePublication::factory()->create(['article_id' => $article->id]); - - $this->assertInstanceOf(Article::class, $publication->article); - $this->assertEquals($article->id, $publication->article->id); - $this->assertEquals($article->title, $publication->article->title); - } - - public function test_publication_creation_with_factory(): void - { - $publication = ArticlePublication::factory()->create(); - - $this->assertInstanceOf(ArticlePublication::class, $publication); - $this->assertNotNull($publication->article_id); - $this->assertNotNull($publication->platform_channel_id); - $this->assertIsString($publication->post_id); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); - $this->assertIsString($publication->published_by); - } - - public function test_publication_creation_with_explicit_values(): void - { - $article = Article::factory()->create(); - $channel = PlatformChannel::factory()->create(); - $publicationData = ['status' => 'success', 'external_id' => '12345']; - $publishedAt = now()->subHours(1); - - $publication = ArticlePublication::create([ - 'article_id' => $article->id, - 'platform_channel_id' => $channel->id, - 'post_id' => 'post-123', - 'published_at' => $publishedAt, - 'published_by' => 'test_bot', - 'platform' => 'lemmy', - 'publication_data' => $publicationData - ]); - - $this->assertEquals($article->id, $publication->article_id); - $this->assertEquals($channel->id, $publication->platform_channel_id); - $this->assertEquals('post-123', $publication->post_id); - $this->assertEquals($publishedAt->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s')); - $this->assertEquals('test_bot', $publication->published_by); - $this->assertEquals('lemmy', $publication->platform); - $this->assertEquals($publicationData, $publication->publication_data); - } - - public function test_publication_factory_recently_published_state(): void - { - $publication = ArticlePublication::factory()->recentlyPublished()->create(); - - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); - $this->assertTrue($publication->published_at->isAfter(now()->subDay())); - $this->assertTrue($publication->published_at->isBefore(now()->addMinute())); - } - - public function test_publication_update(): void - { - $publication = ArticlePublication::factory()->create([ - 'post_id' => 'original-id', - 'published_by' => 'original_user' - ]); - - $publication->update([ - 'post_id' => 'updated-id', - 'published_by' => 'updated_user' - ]); - - $publication->refresh(); - - $this->assertEquals('updated-id', $publication->post_id); - $this->assertEquals('updated_user', $publication->published_by); - } - - public function test_publication_deletion(): void - { - $publication = ArticlePublication::factory()->create(); - $publicationId = $publication->id; - - $publication->delete(); - - $this->assertDatabaseMissing('article_publications', ['id' => $publicationId]); - } - - public function test_publication_data_can_be_empty_array(): void - { - $publication = ArticlePublication::factory()->create(['publication_data' => []]); - - $this->assertIsArray($publication->publication_data); - $this->assertEmpty($publication->publication_data); - } - - public function test_publication_data_can_be_null(): void - { - $publication = ArticlePublication::factory()->create(['publication_data' => null]); - - $this->assertNull($publication->publication_data); - } - - public function test_publication_data_can_be_complex_structure(): void - { - $complexData = [ - 'platform_response' => [ - 'post_id' => 'abc123', - 'url' => 'https://lemmy.world/post/abc123', - 'created_at' => '2023-01-01T12:00:00Z', - 'author' => [ - 'id' => 456, - 'name' => 'bot_user', - 'display_name' => 'Bot User' - ] - ], - 'metadata' => [ - 'retry_attempts' => 1, - 'processing_time_ms' => 1250, - 'error_log' => [] - ], - 'analytics' => [ - 'initial_views' => 0, - 'initial_votes' => 0, - 'engagement_tracked' => false - ] - ]; - - $publication = ArticlePublication::factory()->create(['publication_data' => $complexData]); - - $this->assertEquals($complexData, $publication->publication_data); - $this->assertEquals('abc123', $publication->publication_data['platform_response']['post_id']); - $this->assertEquals(1, $publication->publication_data['metadata']['retry_attempts']); - $this->assertFalse($publication->publication_data['analytics']['engagement_tracked']); - } - - public function test_publication_with_specific_published_at(): void - { - $timestamp = now()->subHours(3); - $publication = ArticlePublication::factory()->create(['published_at' => $timestamp]); - - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->published_at); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $publication->published_at->format('Y-m-d H:i:s')); - } - - public function test_publication_with_specific_published_by(): void - { - $publication = ArticlePublication::factory()->create(['published_by' => 'custom_bot']); - - $this->assertEquals('custom_bot', $publication->published_by); - } - - public function test_publication_with_specific_platform(): void - { - $publication = ArticlePublication::factory()->create(['platform' => 'lemmy']); - - $this->assertEquals('lemmy', $publication->platform); - } - - public function test_publication_timestamps(): void - { - $publication = ArticlePublication::factory()->create(); - - $this->assertNotNull($publication->created_at); - $this->assertNotNull($publication->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $publication->updated_at); - } - - public function test_multiple_publications_for_same_article(): void - { - $article = Article::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - $publication1 = ArticlePublication::factory()->create([ - 'article_id' => $article->id, - 'platform_channel_id' => $channel1->id, - 'post_id' => 'post-1' - ]); - - $publication2 = ArticlePublication::factory()->create([ - 'article_id' => $article->id, - 'platform_channel_id' => $channel2->id, - 'post_id' => 'post-2' - ]); - - $this->assertEquals($article->id, $publication1->article_id); - $this->assertEquals($article->id, $publication2->article_id); - $this->assertNotEquals($publication1->platform_channel_id, $publication2->platform_channel_id); - $this->assertNotEquals($publication1->post_id, $publication2->post_id); - } - - public function test_publication_with_different_platforms(): void - { - $publication1 = ArticlePublication::factory()->create(['platform' => 'lemmy']); - $publication2 = ArticlePublication::factory()->create(['platform' => 'lemmy']); - - $this->assertEquals('lemmy', $publication1->platform); - $this->assertEquals('lemmy', $publication2->platform); - } - - public function test_publication_post_id_variations(): void - { - $publications = [ - ArticlePublication::factory()->create(['post_id' => 'numeric-123']), - ArticlePublication::factory()->create(['post_id' => 'uuid-' . fake()->uuid()]), - ArticlePublication::factory()->create(['post_id' => 'alphanumeric_post_456']), - ArticlePublication::factory()->create(['post_id' => '12345']), - ]; - - foreach ($publications as $publication) { - $this->assertIsString($publication->post_id); - $this->assertNotEmpty($publication->post_id); - } - } - - public function test_publication_data_with_error_information(): void - { - $errorData = [ - 'status' => 'failed', - 'error' => [ - 'code' => 403, - 'message' => 'Insufficient permissions', - 'details' => 'Bot account lacks posting privileges' - ], - 'retry_info' => [ - 'max_retries' => 3, - 'current_attempt' => 2, - 'next_retry_at' => '2023-01-01T13:00:00Z' - ] - ]; - - $publication = ArticlePublication::factory()->create(['publication_data' => $errorData]); - - $this->assertEquals('failed', $publication->publication_data['status']); - $this->assertEquals(403, $publication->publication_data['error']['code']); - $this->assertEquals(2, $publication->publication_data['retry_info']['current_attempt']); - } - - public function test_publication_relationship_with_article_data(): void - { - $article = Article::factory()->create([ - 'title' => 'Test Article Title', - 'description' => 'Test article description' - ]); - - $publication = ArticlePublication::factory()->create(['article_id' => $article->id]); - - $this->assertEquals('Test Article Title', $publication->article->title); - $this->assertEquals('Test article description', $publication->article->description); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/ArticleTest.php b/tests/Unit/Models/ArticleTest.php deleted file mode 100644 index 8a23dc1..0000000 --- a/tests/Unit/Models/ArticleTest.php +++ /dev/null @@ -1,197 +0,0 @@ - Http::response('', 500) - ]); - - // Don't fake events globally - let individual tests control this - } - - public function test_is_valid_returns_false_when_approval_status_is_pending(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'pending', - ]); - - $this->assertFalse($article->isValid()); - } - - public function test_is_valid_returns_false_when_approval_status_is_rejected(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'rejected', - ]); - - $this->assertFalse($article->isValid()); - } - - public function test_is_valid_returns_true_when_approval_status_is_approved(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'approved', - ]); - - $this->assertTrue($article->isValid()); - } - - public function test_is_approved_returns_true_for_approved_status(): void - { - $article = Article::factory()->make(['approval_status' => 'approved']); - - $this->assertTrue($article->isApproved()); - } - - public function test_is_approved_returns_false_for_non_approved_status(): void - { - $article = Article::factory()->make(['approval_status' => 'pending']); - - $this->assertFalse($article->isApproved()); - } - - public function test_is_pending_returns_true_for_pending_status(): void - { - $article = Article::factory()->make(['approval_status' => 'pending']); - - $this->assertTrue($article->isPending()); - } - - public function test_is_rejected_returns_true_for_rejected_status(): void - { - $article = Article::factory()->make(['approval_status' => 'rejected']); - - $this->assertTrue($article->isRejected()); - } - - public function test_approve_updates_status_and_triggers_event(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - - Event::fake(); - - $article->approve('test_user'); - - $article->refresh(); - $this->assertEquals('approved', $article->approval_status); - - Event::assertDispatched(ArticleApproved::class, function ($event) use ($article) { - return $event->article->id === $article->id; - }); - } - - public function test_approve_without_approved_by_parameter(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - - Event::fake(); - - $article->approve(); - - $article->refresh(); - $this->assertEquals('approved', $article->approval_status); - } - - public function test_reject_updates_status(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'pending', - ]); - - $article->reject('test_user'); - - $article->refresh(); - $this->assertEquals('rejected', $article->approval_status); - } - - public function test_can_be_published_returns_false_for_invalid_article(): void - { - $article = Article::factory()->make([ - 'approval_status' => 'rejected', // rejected = not valid - ]); - - $this->assertFalse($article->canBePublished()); - } - - public function test_can_be_published_requires_approval_when_approvals_enabled(): void - { - // Create a setting that enables approvals - Setting::create(['key' => 'enable_publishing_approvals', 'value' => '1']); - - $pendingArticle = Article::factory()->make([ - 'approval_status' => 'pending', - ]); - - $approvedArticle = Article::factory()->make([ - 'approval_status' => 'approved', - ]); - - $this->assertFalse($pendingArticle->canBePublished()); - $this->assertTrue($approvedArticle->canBePublished()); - } - - public function test_can_be_published_returns_true_when_approvals_disabled(): void - { - // Make sure approvals are disabled (default behavior) - Setting::where('key', 'enable_publishing_approvals')->delete(); - - $article = Article::factory()->make([ - 'approval_status' => 'approved', // Only approved articles can be published - ]); - - $this->assertTrue($article->canBePublished()); - } - - public function test_feed_relationship(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id]); - - $this->assertInstanceOf(Feed::class, $article->feed); - $this->assertEquals($feed->id, $article->feed->id); - } - - public function test_article_creation_fires_new_article_fetched_event(): void - { - $eventFired = false; - - // Listen for the event using a closure - Event::listen(NewArticleFetched::class, function ($event) use (&$eventFired) { - $eventFired = true; - }); - - $feed = Feed::factory()->create(); - Article::factory()->create(['feed_id' => $feed->id]); - - $this->assertTrue($eventFired, 'NewArticleFetched event was not fired'); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/FeedTest.php b/tests/Unit/Models/FeedTest.php deleted file mode 100644 index 94beef8..0000000 --- a/tests/Unit/Models/FeedTest.php +++ /dev/null @@ -1,333 +0,0 @@ -assertEquals($fillableFields, $feed->getFillable()); - } - - public function test_casts_settings_to_array(): void - { - $settings = ['key1' => 'value1', 'key2' => ['nested' => 'value']]; - - $feed = Feed::factory()->create(['settings' => $settings]); - - $this->assertIsArray($feed->settings); - $this->assertEquals($settings, $feed->settings); - } - - public function test_casts_is_active_to_boolean(): void - { - $feed = Feed::factory()->create(['is_active' => '1']); - - $this->assertIsBool($feed->is_active); - $this->assertTrue($feed->is_active); - - $feed->update(['is_active' => '0']); - $feed->refresh(); - - $this->assertIsBool($feed->is_active); - $this->assertFalse($feed->is_active); - } - - public function test_casts_last_fetched_at_to_datetime(): void - { - $timestamp = now()->subHours(2); - $feed = Feed::factory()->create(['last_fetched_at' => $timestamp]); - - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->last_fetched_at); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $feed->last_fetched_at->format('Y-m-d H:i:s')); - } - - public function test_type_display_attribute(): void - { - $websiteFeed = Feed::factory()->create(['type' => 'website']); - $rssFeed = Feed::factory()->create(['type' => 'rss']); - - $this->assertEquals('Website', $websiteFeed->type_display); - $this->assertEquals('RSS Feed', $rssFeed->type_display); - } - - public function test_status_attribute_inactive_feed(): void - { - $feed = Feed::factory()->create(['is_active' => false]); - - $this->assertEquals('Inactive', $feed->status); - } - - public function test_status_attribute_never_fetched(): void - { - $feed = Feed::factory()->create([ - 'is_active' => true, - 'last_fetched_at' => null - ]); - - $this->assertEquals('Never fetched', $feed->status); - } - - public function test_status_attribute_recently_fetched(): void - { - $feed = Feed::factory()->create([ - 'is_active' => true, - 'last_fetched_at' => now()->subHour() - ]); - - $this->assertEquals('Recently fetched', $feed->status); - } - - public function test_status_attribute_fetched_hours_ago(): void - { - $feed = Feed::factory()->create([ - 'is_active' => true, - 'last_fetched_at' => now()->subHours(5)->startOfHour() - ]); - - $this->assertStringContainsString('Fetched', $feed->status); - $this->assertStringContainsString('ago', $feed->status); - } - - public function test_status_attribute_fetched_days_ago(): void - { - $feed = Feed::factory()->create([ - 'is_active' => true, - 'last_fetched_at' => now()->subDays(3) - ]); - - $this->assertStringStartsWith('Fetched', $feed->status); - $this->assertStringContainsString('ago', $feed->status); - } - - public function test_belongs_to_language_relationship(): void - { - $language = Language::factory()->create(); - $feed = Feed::factory()->create(['language_id' => $language->id]); - - $this->assertInstanceOf(Language::class, $feed->language); - $this->assertEquals($language->id, $feed->language->id); - $this->assertEquals($language->name, $feed->language->name); - } - - public function test_has_many_articles_relationship(): void - { - $feed = Feed::factory()->create(); - - $article1 = Article::factory()->create(['feed_id' => $feed->id]); - $article2 = Article::factory()->create(['feed_id' => $feed->id]); - - // Create article for different feed - $otherFeed = Feed::factory()->create(); - Article::factory()->create(['feed_id' => $otherFeed->id]); - - $articles = $feed->articles; - - $this->assertCount(2, $articles); - $this->assertTrue($articles->contains('id', $article1->id)); - $this->assertTrue($articles->contains('id', $article2->id)); - $this->assertInstanceOf(Article::class, $articles->first()); - } - - public function test_belongs_to_many_channels_relationship(): void - { - $feed = Feed::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - // Create routes (which act as pivot records) - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => false, - 'priority' => 50 - ]); - - $channels = $feed->channels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - - // Test pivot data - $channel1FromRelation = $channels->find($channel1->id); - $this->assertEquals(1, $channel1FromRelation->pivot->is_active); - $this->assertEquals(100, $channel1FromRelation->pivot->priority); - } - - public function test_active_channels_relationship(): void - { - $feed = Feed::factory()->create(); - $activeChannel1 = PlatformChannel::factory()->create(); - $activeChannel2 = PlatformChannel::factory()->create(); - $inactiveChannel = PlatformChannel::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $activeChannel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $activeChannel2->id, - 'is_active' => true, - 'priority' => 200 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $inactiveChannel->id, - 'is_active' => false, - 'priority' => 150 - ]); - - $activeChannels = $feed->activeChannels; - - $this->assertCount(2, $activeChannels); - $this->assertTrue($activeChannels->contains('id', $activeChannel1->id)); - $this->assertTrue($activeChannels->contains('id', $activeChannel2->id)); - $this->assertFalse($activeChannels->contains('id', $inactiveChannel->id)); - - // Test ordering by priority descending - $channelIds = $activeChannels->pluck('id')->toArray(); - $this->assertEquals($activeChannel2->id, $channelIds[0]); // Priority 200 - $this->assertEquals($activeChannel1->id, $channelIds[1]); // Priority 100 - } - - public function test_feed_creation_with_factory(): void - { - $feed = Feed::factory()->create(); - - $this->assertInstanceOf(Feed::class, $feed); - $this->assertIsString($feed->name); - $this->assertIsString($feed->url); - $this->assertIsString($feed->type); - // Language ID may be null as it's nullable in the database - $this->assertTrue($feed->language_id === null || is_int($feed->language_id)); - $this->assertIsBool($feed->is_active); - $this->assertIsArray($feed->settings); - } - - public function test_feed_creation_with_explicit_values(): void - { - $language = Language::factory()->create(); - $settings = ['custom' => 'setting', 'nested' => ['key' => 'value']]; - - $feed = Feed::create([ - 'name' => 'Test Feed', - 'url' => 'https://example.com/feed', - 'type' => 'rss', - 'provider' => 'vrt', - 'language_id' => $language->id, - 'description' => 'Test description', - 'settings' => $settings, - 'is_active' => false - ]); - - $this->assertEquals('Test Feed', $feed->name); - $this->assertEquals('https://example.com/feed', $feed->url); - $this->assertEquals('rss', $feed->type); - $this->assertEquals($language->id, $feed->language_id); - $this->assertEquals('Test description', $feed->description); - $this->assertEquals($settings, $feed->settings); - $this->assertFalse($feed->is_active); - } - - public function test_feed_update(): void - { - $feed = Feed::factory()->create([ - 'name' => 'Original Name', - 'is_active' => true - ]); - - $feed->update([ - 'name' => 'Updated Name', - 'is_active' => false - ]); - - $feed->refresh(); - - $this->assertEquals('Updated Name', $feed->name); - $this->assertFalse($feed->is_active); - } - - public function test_feed_deletion(): void - { - $feed = Feed::factory()->create(); - $feedId = $feed->id; - - $feed->delete(); - - $this->assertDatabaseMissing('feeds', ['id' => $feedId]); - } - - public function test_feed_settings_can_be_empty_array(): void - { - $feed = Feed::factory()->create(['settings' => []]); - - $this->assertIsArray($feed->settings); - $this->assertEmpty($feed->settings); - } - - public function test_feed_settings_can_be_complex_structure(): void - { - $complexSettings = [ - 'parsing' => [ - 'selector' => 'article.post', - 'title_selector' => 'h1', - 'content_selector' => '.content' - ], - 'filters' => ['min_length' => 100], - 'schedule' => [ - 'enabled' => true, - 'interval' => 3600 - ] - ]; - - $feed = Feed::factory()->create(['settings' => $complexSettings]); - - $this->assertEquals($complexSettings, $feed->settings); - $this->assertEquals('article.post', $feed->settings['parsing']['selector']); - $this->assertTrue($feed->settings['schedule']['enabled']); - } - - public function test_feed_can_have_null_last_fetched_at(): void - { - $feed = Feed::factory()->create(['last_fetched_at' => null]); - - $this->assertNull($feed->last_fetched_at); - } - - public function test_feed_timestamps(): void - { - $feed = Feed::factory()->create(); - - $this->assertNotNull($feed->created_at); - $this->assertNotNull($feed->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $feed->updated_at); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/KeywordTest.php b/tests/Unit/Models/KeywordTest.php deleted file mode 100644 index e8b4208..0000000 --- a/tests/Unit/Models/KeywordTest.php +++ /dev/null @@ -1,280 +0,0 @@ -assertEquals($fillableFields, $keyword->getFillable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test', - 'is_active' => '1' - ]); - - $this->assertIsBool($keyword->is_active); - $this->assertTrue($keyword->is_active); - - $keyword->update(['is_active' => '0']); - $keyword->refresh(); - - $this->assertIsBool($keyword->is_active); - $this->assertFalse($keyword->is_active); - } - - public function test_belongs_to_feed_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test keyword', - 'is_active' => true - ]); - - $this->assertInstanceOf(Feed::class, $keyword->feed); - $this->assertEquals($feed->id, $keyword->feed->id); - $this->assertEquals($feed->name, $keyword->feed->name); - } - - public function test_belongs_to_platform_channel_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test keyword', - 'is_active' => true - ]); - - $this->assertInstanceOf(PlatformChannel::class, $keyword->platformChannel); - $this->assertEquals($channel->id, $keyword->platformChannel->id); - $this->assertEquals($channel->name, $keyword->platformChannel->name); - } - - public function test_keyword_creation_with_factory(): void - { - $keyword = Keyword::factory()->create(); - - $this->assertInstanceOf(Keyword::class, $keyword); - $this->assertNotNull($keyword->feed_id); - $this->assertNotNull($keyword->platform_channel_id); - $this->assertIsString($keyword->keyword); - $this->assertIsBool($keyword->is_active); - } - - public function test_keyword_creation_with_explicit_values(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'Belgium', - 'is_active' => false - ]); - - $this->assertEquals($feed->id, $keyword->feed_id); - $this->assertEquals($channel->id, $keyword->platform_channel_id); - $this->assertEquals('Belgium', $keyword->keyword); - $this->assertFalse($keyword->is_active); - } - - public function test_keyword_update(): void - { - $keyword = Keyword::factory()->create([ - 'keyword' => 'original', - 'is_active' => true - ]); - - $keyword->update([ - 'keyword' => 'updated', - 'is_active' => false - ]); - - $keyword->refresh(); - - $this->assertEquals('updated', $keyword->keyword); - $this->assertFalse($keyword->is_active); - } - - public function test_keyword_deletion(): void - { - $keyword = Keyword::factory()->create(); - $keywordId = $keyword->id; - - $keyword->delete(); - - $this->assertDatabaseMissing('keywords', ['id' => $keywordId]); - } - - public function test_keyword_with_special_characters(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $specialKeywords = [ - 'België', // Accented characters - 'COVID-19', // Numbers and hyphens - 'U.S.A.', // Periods - 'keyword with spaces', - 'UPPERCASE', - 'lowercase', - 'MixedCase' - ]; - - foreach ($specialKeywords as $keywordText) { - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => $keywordText, - 'is_active' => true - ]); - - $this->assertEquals($keywordText, $keyword->keyword); - $this->assertDatabaseHas('keywords', [ - 'keyword' => $keywordText, - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id - ]); - } - } - - public function test_multiple_keywords_for_same_route(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $keyword1 = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'keyword1', - 'is_active' => true - ]); - - $keyword2 = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'keyword2', - 'is_active' => false - ]); - - $this->assertDatabaseHas('keywords', [ - 'id' => $keyword1->id, - 'keyword' => 'keyword1', - 'is_active' => true - ]); - - $this->assertDatabaseHas('keywords', [ - 'id' => $keyword2->id, - 'keyword' => 'keyword2', - 'is_active' => false - ]); - } - - public function test_keyword_uniqueness_constraint(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - // Create first keyword - Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'unique_keyword', - 'is_active' => true - ]); - - // Attempt to create duplicate should fail - $this->expectException(\Illuminate\Database\QueryException::class); - - Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'unique_keyword', - 'is_active' => true - ]); - } - - public function test_same_keyword_different_routes_allowed(): void - { - $feed1 = Feed::factory()->create(); - $feed2 = Feed::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - // Same keyword for different routes should be allowed - $keyword1 = Keyword::create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel1->id, - 'keyword' => 'common_keyword', - 'is_active' => true - ]); - - $keyword2 = Keyword::create([ - 'feed_id' => $feed2->id, - 'platform_channel_id' => $channel2->id, - 'keyword' => 'common_keyword', - 'is_active' => true - ]); - - $this->assertDatabaseHas('keywords', ['id' => $keyword1->id]); - $this->assertDatabaseHas('keywords', ['id' => $keyword2->id]); - $this->assertNotEquals($keyword1->id, $keyword2->id); - } - - public function test_keyword_timestamps(): void - { - $keyword = Keyword::factory()->create(); - - $this->assertNotNull($keyword->created_at); - $this->assertNotNull($keyword->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $keyword->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $keyword->updated_at); - } - - public function test_keyword_default_active_state(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - // Create without specifying is_active - $keyword = Keyword::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test' - ]); - - // Refresh to get the actual database values including defaults - $keyword->refresh(); - - // Should default to true based on migration default - $this->assertIsBool($keyword->is_active); - $this->assertTrue($keyword->is_active); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/LanguageTest.php b/tests/Unit/Models/LanguageTest.php deleted file mode 100644 index f0a768f..0000000 --- a/tests/Unit/Models/LanguageTest.php +++ /dev/null @@ -1,324 +0,0 @@ -assertEquals($fillableFields, $language->getFillable()); - } - - public function test_table_name(): void - { - $language = new Language(); - - $this->assertEquals('languages', $language->getTable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $language = Language::factory()->create(['is_active' => '1']); - - $this->assertIsBool($language->is_active); - $this->assertTrue($language->is_active); - - $language->update(['is_active' => '0']); - $language->refresh(); - - $this->assertIsBool($language->is_active); - $this->assertFalse($language->is_active); - } - - public function test_belongs_to_many_platform_instances_relationship(): void - { - $language = Language::factory()->create(); - $instance1 = PlatformInstance::factory()->create(); - $instance2 = PlatformInstance::factory()->create(); - - // Attach with required platform_language_id - $language->platformInstances()->attach([ - $instance1->id => ['platform_language_id' => 1], - $instance2->id => ['platform_language_id' => 2] - ]); - - $instances = $language->platformInstances; - - $this->assertCount(2, $instances); - $this->assertTrue($instances->contains('id', $instance1->id)); - $this->assertTrue($instances->contains('id', $instance2->id)); - } - - public function test_has_many_platform_channels_relationship(): void - { - $language = Language::factory()->create(); - - $channel1 = PlatformChannel::factory()->create(['language_id' => $language->id]); - $channel2 = PlatformChannel::factory()->create(['language_id' => $language->id]); - - // Create channel for different language - $otherLanguage = Language::factory()->create(); - PlatformChannel::factory()->create(['language_id' => $otherLanguage->id]); - - $channels = $language->platformChannels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - $this->assertInstanceOf(PlatformChannel::class, $channels->first()); - } - - public function test_has_many_feeds_relationship(): void - { - $language = Language::factory()->create(); - - $feed1 = Feed::factory()->create(['language_id' => $language->id]); - $feed2 = Feed::factory()->create(['language_id' => $language->id]); - - // Create feed for different language - $otherLanguage = Language::factory()->create(); - Feed::factory()->create(['language_id' => $otherLanguage->id]); - - $feeds = $language->feeds; - - $this->assertCount(2, $feeds); - $this->assertTrue($feeds->contains('id', $feed1->id)); - $this->assertTrue($feeds->contains('id', $feed2->id)); - $this->assertInstanceOf(Feed::class, $feeds->first()); - } - - public function test_language_creation_with_factory(): void - { - $language = Language::factory()->create(); - - $this->assertInstanceOf(Language::class, $language); - $this->assertIsString($language->short_code); - $this->assertIsString($language->name); - $this->assertTrue($language->is_active); - } - - public function test_language_creation_with_explicit_values(): void - { - $language = Language::create([ - 'short_code' => 'fr', - 'name' => 'French', - 'native_name' => 'Français', - 'is_active' => false - ]); - - $this->assertEquals('fr', $language->short_code); - $this->assertEquals('French', $language->name); - $this->assertEquals('Français', $language->native_name); - $this->assertFalse($language->is_active); - } - - public function test_language_factory_states(): void - { - $inactiveLanguage = Language::factory()->inactive()->create(); - $this->assertFalse($inactiveLanguage->is_active); - - $englishLanguage = Language::factory()->english()->create(); - $this->assertEquals('en', $englishLanguage->short_code); - $this->assertEquals('English', $englishLanguage->name); - $this->assertEquals('English', $englishLanguage->native_name); - } - - public function test_language_update(): void - { - $language = Language::factory()->create([ - 'name' => 'Original Name', - 'is_active' => true - ]); - - $language->update([ - 'name' => 'Updated Name', - 'is_active' => false - ]); - - $language->refresh(); - - $this->assertEquals('Updated Name', $language->name); - $this->assertFalse($language->is_active); - } - - public function test_language_deletion(): void - { - $language = Language::factory()->create(); - $languageId = $language->id; - - $language->delete(); - - $this->assertDatabaseMissing('languages', ['id' => $languageId]); - } - - public function test_language_can_have_null_native_name(): void - { - $language = Language::factory()->create(['native_name' => null]); - - $this->assertNull($language->native_name); - } - - public function test_language_can_have_empty_native_name(): void - { - $language = Language::factory()->create(['native_name' => '']); - - $this->assertEquals('', $language->native_name); - } - - public function test_language_short_code_variations(): void - { - $shortCodes = ['en', 'fr', 'es', 'de', 'zh', 'pt', 'nl', 'it']; - - foreach ($shortCodes as $code) { - $language = Language::factory()->create(['short_code' => $code]); - $this->assertEquals($code, $language->short_code); - } - } - - public function test_language_timestamps(): void - { - $language = Language::factory()->create(); - - $this->assertNotNull($language->created_at); - $this->assertNotNull($language->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $language->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $language->updated_at); - } - - public function test_language_can_have_multiple_platform_instances(): void - { - $language = Language::factory()->create(); - $instance1 = PlatformInstance::factory()->create(); - $instance2 = PlatformInstance::factory()->create(); - $instance3 = PlatformInstance::factory()->create(); - - // Attach with required platform_language_id values - $language->platformInstances()->attach([ - $instance1->id => ['platform_language_id' => 1], - $instance2->id => ['platform_language_id' => 2], - $instance3->id => ['platform_language_id' => 3] - ]); - - $instances = $language->platformInstances; - - $this->assertCount(3, $instances); - $this->assertTrue($instances->contains('id', $instance1->id)); - $this->assertTrue($instances->contains('id', $instance2->id)); - $this->assertTrue($instances->contains('id', $instance3->id)); - } - - public function test_language_platform_instances_relationship_is_empty_by_default(): void - { - $language = Language::factory()->create(); - - $this->assertCount(0, $language->platformInstances); - } - - public function test_language_platform_channels_relationship_is_empty_by_default(): void - { - $language = Language::factory()->create(); - - $this->assertCount(0, $language->platformChannels); - } - - public function test_language_feeds_relationship_is_empty_by_default(): void - { - $language = Language::factory()->create(); - - $this->assertCount(0, $language->feeds); - } - - public function test_multiple_languages_with_same_name_different_regions(): void - { - $englishUS = Language::factory()->create([ - 'short_code' => 'en-US', - 'name' => 'English (United States)', - 'native_name' => 'English' - ]); - - $englishGB = Language::factory()->create([ - 'short_code' => 'en-GB', - 'name' => 'English (United Kingdom)', - 'native_name' => 'English' - ]); - - $this->assertEquals('English', $englishUS->native_name); - $this->assertEquals('English', $englishGB->native_name); - $this->assertNotEquals($englishUS->short_code, $englishGB->short_code); - $this->assertNotEquals($englishUS->name, $englishGB->name); - } - - public function test_language_with_complex_native_name(): void - { - $complexLanguages = [ - ['short_code' => 'zh-CN', 'name' => 'Chinese (Simplified)', 'native_name' => '简体中文'], - ['short_code' => 'zh-TW', 'name' => 'Chinese (Traditional)', 'native_name' => '繁體中文'], - ['short_code' => 'ar', 'name' => 'Arabic', 'native_name' => 'العربية'], - ['short_code' => 'ru', 'name' => 'Russian', 'native_name' => 'Русский'], - ['short_code' => 'ja', 'name' => 'Japanese', 'native_name' => '日本語'], - ]; - - foreach ($complexLanguages as $langData) { - $language = Language::factory()->create($langData); - - $this->assertEquals($langData['short_code'], $language->short_code); - $this->assertEquals($langData['name'], $language->name); - $this->assertEquals($langData['native_name'], $language->native_name); - } - } - - public function test_language_active_and_inactive_states(): void - { - $activeLanguage = Language::factory()->create(['is_active' => true]); - $inactiveLanguage = Language::factory()->create(['is_active' => false]); - - $this->assertTrue($activeLanguage->is_active); - $this->assertFalse($inactiveLanguage->is_active); - } - - public function test_language_relationships_maintain_referential_integrity(): void - { - $language = Language::factory()->create(); - - // Create related models - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); - $feed = Feed::factory()->create(['language_id' => $language->id]); - - // Attach instance - $language->platformInstances()->attach($instance->id, [ - 'platform_language_id' => 1, - 'is_default' => true - ]); - - // Verify all relationships work - $this->assertCount(1, $language->platformInstances); - $this->assertCount(1, $language->platformChannels); - $this->assertCount(1, $language->feeds); - - $this->assertEquals($language->id, $channel->language_id); - $this->assertEquals($language->id, $feed->language_id); - } - - public function test_language_factory_unique_constraints(): void - { - // The factory should generate unique short codes - $language1 = Language::factory()->create(); - $language2 = Language::factory()->create(); - - $this->assertNotEquals($language1->short_code, $language2->short_code); - $this->assertNotEquals($language1->name, $language2->name); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/PlatformAccountTest.php b/tests/Unit/Models/PlatformAccountTest.php deleted file mode 100644 index 7e71501..0000000 --- a/tests/Unit/Models/PlatformAccountTest.php +++ /dev/null @@ -1,417 +0,0 @@ -assertEquals($fillableFields, $account->getFillable()); - } - - public function test_table_name(): void - { - $account = new PlatformAccount(); - - $this->assertEquals('platform_accounts', $account->getTable()); - } - - public function test_casts_platform_to_enum(): void - { - $account = PlatformAccount::factory()->create(['platform' => PlatformEnum::LEMMY]); - - $this->assertInstanceOf(PlatformEnum::class, $account->platform); - $this->assertEquals(PlatformEnum::LEMMY, $account->platform); - $this->assertEquals('lemmy', $account->platform->value); - } - - public function test_casts_settings_to_array(): void - { - $settings = ['key1' => 'value1', 'nested' => ['key2' => 'value2']]; - - $account = PlatformAccount::factory()->create(['settings' => $settings]); - - $this->assertIsArray($account->settings); - $this->assertEquals($settings, $account->settings); - } - - public function test_casts_is_active_to_boolean(): void - { - $account = PlatformAccount::factory()->create(['is_active' => '1']); - - $this->assertIsBool($account->is_active); - $this->assertTrue($account->is_active); - - $account->update(['is_active' => '0']); - $account->refresh(); - - $this->assertIsBool($account->is_active); - $this->assertFalse($account->is_active); - } - - public function test_casts_last_tested_at_to_datetime(): void - { - $timestamp = now()->subHours(2); - $account = PlatformAccount::factory()->create(['last_tested_at' => $timestamp]); - - $this->assertInstanceOf(\Carbon\Carbon::class, $account->last_tested_at); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $account->last_tested_at->format('Y-m-d H:i:s')); - } - - public function test_password_encryption_and_decryption(): void - { - $plainPassword = 'my-secret-password'; - - $account = PlatformAccount::factory()->create(['password' => $plainPassword]); - - // Password should be decrypted when accessing - $this->assertEquals($plainPassword, $account->password); - - // But encrypted in the database - $this->assertNotEquals($plainPassword, $account->getAttributes()['password']); - $this->assertNotNull($account->getAttributes()['password']); - } - - public function test_password_with_specific_value(): void - { - $password = 'specific-test-password'; - $account = PlatformAccount::factory()->create(['password' => $password]); - - $this->assertEquals($password, $account->password); - $this->assertNotEquals($password, $account->getAttributes()['password']); - } - - public function test_password_encryption_is_different_each_time(): void - { - $password = 'same-password'; - $account1 = PlatformAccount::factory()->create(['password' => $password]); - $account2 = PlatformAccount::factory()->create(['password' => $password]); - - $this->assertEquals($password, $account1->password); - $this->assertEquals($password, $account2->password); - $this->assertNotEquals($account1->getAttributes()['password'], $account2->getAttributes()['password']); - } - - - public function test_password_decryption_handles_corruption(): void - { - $account = PlatformAccount::factory()->create(); - $originalPassword = $account->password; - - // Since the password attribute has special handling, this test verifies the basic functionality - $this->assertNotNull($originalPassword); - $this->assertIsString($originalPassword); - } - - public function test_get_active_static_method(): void - { - // Create active and inactive accounts - $activeAccount1 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => true - ]); - - $activeAccount2 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => true - ]); - - $inactiveAccount = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => false - ]); - - $activeAccounts = PlatformAccount::getActive(PlatformEnum::LEMMY); - - $this->assertCount(2, $activeAccounts); - $this->assertTrue($activeAccounts->contains('id', $activeAccount1->id)); - $this->assertTrue($activeAccounts->contains('id', $activeAccount2->id)); - $this->assertFalse($activeAccounts->contains('id', $inactiveAccount->id)); - } - - public function test_set_as_active_method(): void - { - // Create multiple accounts for same platform - $account1 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => true - ]); - - $account2 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => true - ]); - - $account3 = PlatformAccount::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'is_active' => false - ]); - - // Set account3 as active - $account3->setAsActive(); - - // Refresh all accounts - $account1->refresh(); - $account2->refresh(); - $account3->refresh(); - - // Only account3 should be active - $this->assertFalse($account1->is_active); - $this->assertFalse($account2->is_active); - $this->assertTrue($account3->is_active); - } - - public function test_belongs_to_many_channels_relationship(): void - { - $account = PlatformAccount::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - // Attach channels with pivot data - $account->channels()->attach($channel1->id, [ - 'is_active' => true, - 'priority' => 100 - ]); - - $account->channels()->attach($channel2->id, [ - 'is_active' => false, - 'priority' => 50 - ]); - - $channels = $account->channels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - - // Test pivot data - $channel1FromRelation = $channels->find($channel1->id); - $this->assertEquals(1, $channel1FromRelation->pivot->is_active); - $this->assertEquals(100, $channel1FromRelation->pivot->priority); - - $channel2FromRelation = $channels->find($channel2->id); - $this->assertEquals(0, $channel2FromRelation->pivot->is_active); - $this->assertEquals(50, $channel2FromRelation->pivot->priority); - } - - public function test_active_channels_relationship(): void - { - $account = PlatformAccount::factory()->create(); - $activeChannel1 = PlatformChannel::factory()->create(); - $activeChannel2 = PlatformChannel::factory()->create(); - $inactiveChannel = PlatformChannel::factory()->create(); - - // Attach channels - $account->channels()->attach($activeChannel1->id, [ - 'is_active' => true, - 'priority' => 100 - ]); - - $account->channels()->attach($activeChannel2->id, [ - 'is_active' => true, - 'priority' => 200 - ]); - - $account->channels()->attach($inactiveChannel->id, [ - 'is_active' => false, - 'priority' => 150 - ]); - - $activeChannels = $account->activeChannels; - - $this->assertCount(2, $activeChannels); - $this->assertTrue($activeChannels->contains('id', $activeChannel1->id)); - $this->assertTrue($activeChannels->contains('id', $activeChannel2->id)); - $this->assertFalse($activeChannels->contains('id', $inactiveChannel->id)); - - // Test ordering by priority descending - $channelIds = $activeChannels->pluck('id')->toArray(); - $this->assertEquals($activeChannel2->id, $channelIds[0]); // Priority 200 - $this->assertEquals($activeChannel1->id, $channelIds[1]); // Priority 100 - } - - public function test_account_creation_with_factory(): void - { - $account = PlatformAccount::factory()->create(); - - $this->assertInstanceOf(PlatformAccount::class, $account); - $this->assertInstanceOf(PlatformEnum::class, $account->platform); - $this->assertEquals(PlatformEnum::LEMMY, $account->platform); - $this->assertIsString($account->instance_url); - $this->assertIsString($account->username); - $this->assertEquals('test-password', $account->password); - $this->assertIsBool($account->is_active); - $this->assertTrue($account->is_active); - $this->assertEquals('untested', $account->status); - $this->assertIsArray($account->settings); - } - - public function test_account_creation_with_explicit_values(): void - { - $settings = ['custom' => 'value', 'nested' => ['key' => 'value']]; - $timestamp = now()->subHours(1); - - $account = PlatformAccount::create([ - 'platform' => PlatformEnum::LEMMY, - 'instance_url' => 'https://lemmy.example.com', - 'username' => 'testuser', - 'password' => 'secret123', - 'settings' => $settings, - 'is_active' => false, - 'last_tested_at' => $timestamp, - 'status' => 'working' - ]); - - $this->assertEquals(PlatformEnum::LEMMY, $account->platform); - $this->assertEquals('https://lemmy.example.com', $account->instance_url); - $this->assertEquals('testuser', $account->username); - $this->assertEquals('secret123', $account->password); - $this->assertEquals($settings, $account->settings); - $this->assertFalse($account->is_active); - $this->assertEquals($timestamp->format('Y-m-d H:i:s'), $account->last_tested_at->format('Y-m-d H:i:s')); - $this->assertEquals('working', $account->status); - } - - public function test_account_factory_states(): void - { - $inactiveAccount = PlatformAccount::factory()->inactive()->create(); - $this->assertFalse($inactiveAccount->is_active); - - $testedAccount = PlatformAccount::factory()->tested()->create(); - $this->assertNotNull($testedAccount->last_tested_at); - $this->assertEquals('working', $testedAccount->status); - - $failedAccount = PlatformAccount::factory()->failed()->create(); - $this->assertNotNull($failedAccount->last_tested_at); - $this->assertEquals('failed', $failedAccount->status); - } - - public function test_account_update(): void - { - $account = PlatformAccount::factory()->create([ - 'username' => 'original_user', - 'is_active' => true - ]); - - $account->update([ - 'username' => 'updated_user', - 'is_active' => false - ]); - - $account->refresh(); - - $this->assertEquals('updated_user', $account->username); - $this->assertFalse($account->is_active); - } - - public function test_account_deletion(): void - { - $account = PlatformAccount::factory()->create(); - $accountId = $account->id; - - $account->delete(); - - $this->assertDatabaseMissing('platform_accounts', ['id' => $accountId]); - } - - public function test_account_settings_can_be_empty_array(): void - { - $account = PlatformAccount::factory()->create(['settings' => []]); - - $this->assertIsArray($account->settings); - $this->assertEmpty($account->settings); - } - - public function test_account_settings_can_be_complex_structure(): void - { - $complexSettings = [ - 'authentication' => [ - 'method' => 'jwt', - 'timeout' => 30 - ], - 'features' => ['posting', 'commenting'], - 'rate_limits' => [ - 'posts_per_hour' => 10, - 'comments_per_hour' => 50 - ] - ]; - - $account = PlatformAccount::factory()->create(['settings' => $complexSettings]); - - $this->assertEquals($complexSettings, $account->settings); - $this->assertEquals('jwt', $account->settings['authentication']['method']); - $this->assertEquals(['posting', 'commenting'], $account->settings['features']); - } - - public function test_account_can_have_null_last_tested_at(): void - { - $account = PlatformAccount::factory()->create(['last_tested_at' => null]); - - $this->assertNull($account->last_tested_at); - } - - public function test_account_timestamps(): void - { - $account = PlatformAccount::factory()->create(); - - $this->assertNotNull($account->created_at); - $this->assertNotNull($account->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $account->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $account->updated_at); - } - - public function test_account_can_have_multiple_channels_with_different_priorities(): void - { - $account = PlatformAccount::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - $channel3 = PlatformChannel::factory()->create(); - - // Attach channels with different priorities - $account->channels()->attach([ - $channel1->id => ['is_active' => true, 'priority' => 300], - $channel2->id => ['is_active' => true, 'priority' => 100], - $channel3->id => ['is_active' => false, 'priority' => 200] - ]); - - $allChannels = $account->channels; - $activeChannels = $account->activeChannels; - - $this->assertCount(3, $allChannels); - $this->assertCount(2, $activeChannels); - - // Test that we can access pivot data - foreach ($allChannels as $channel) { - $this->assertNotNull($channel->pivot->priority); - $this->assertIsInt($channel->pivot->is_active); - } - } - - public function test_password_withoutObjectCaching_prevents_caching(): void - { - $account = PlatformAccount::factory()->create(['password' => 'original']); - - // Access password to potentially cache it - $originalPassword = $account->password; - $this->assertEquals('original', $originalPassword); - - // Update password directly in database - $account->update(['password' => 'updated']); - - // Since withoutObjectCaching is used, the new value should be retrieved - $account->refresh(); - $this->assertEquals('updated', $account->password); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/PlatformChannelTest.php b/tests/Unit/Models/PlatformChannelTest.php deleted file mode 100644 index 39e5e5c..0000000 --- a/tests/Unit/Models/PlatformChannelTest.php +++ /dev/null @@ -1,338 +0,0 @@ -assertEquals($fillableFields, $channel->getFillable()); - } - - public function test_table_name(): void - { - $channel = new PlatformChannel(); - - $this->assertEquals('platform_channels', $channel->getTable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $channel = PlatformChannel::factory()->create(['is_active' => '1']); - - $this->assertIsBool($channel->is_active); - $this->assertTrue($channel->is_active); - - $channel->update(['is_active' => '0']); - $channel->refresh(); - - $this->assertIsBool($channel->is_active); - $this->assertFalse($channel->is_active); - } - - public function test_belongs_to_platform_instance_relationship(): void - { - $instance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - - $this->assertInstanceOf(PlatformInstance::class, $channel->platformInstance); - $this->assertEquals($instance->id, $channel->platformInstance->id); - $this->assertEquals($instance->name, $channel->platformInstance->name); - } - - public function test_belongs_to_language_relationship(): void - { - $language = Language::factory()->create(); - $channel = PlatformChannel::factory()->create(['language_id' => $language->id]); - - $this->assertInstanceOf(Language::class, $channel->language); - $this->assertEquals($language->id, $channel->language->id); - $this->assertEquals($language->name, $channel->language->name); - } - - public function test_belongs_to_many_platform_accounts_relationship(): void - { - $channel = PlatformChannel::factory()->create(); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Attach accounts with pivot data - $channel->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 100 - ]); - - $channel->platformAccounts()->attach($account2->id, [ - 'is_active' => false, - 'priority' => 50 - ]); - - $accounts = $channel->platformAccounts; - - $this->assertCount(2, $accounts); - $this->assertTrue($accounts->contains('id', $account1->id)); - $this->assertTrue($accounts->contains('id', $account2->id)); - - // Test pivot data - $account1FromRelation = $accounts->find($account1->id); - $this->assertEquals(1, $account1FromRelation->pivot->is_active); - $this->assertEquals(100, $account1FromRelation->pivot->priority); - - $account2FromRelation = $accounts->find($account2->id); - $this->assertEquals(0, $account2FromRelation->pivot->is_active); - $this->assertEquals(50, $account2FromRelation->pivot->priority); - } - - public function test_active_platform_accounts_relationship(): void - { - $channel = PlatformChannel::factory()->create(); - $activeAccount1 = PlatformAccount::factory()->create(); - $activeAccount2 = PlatformAccount::factory()->create(); - $inactiveAccount = PlatformAccount::factory()->create(); - - // Attach accounts - $channel->platformAccounts()->attach($activeAccount1->id, [ - 'is_active' => true, - 'priority' => 100 - ]); - - $channel->platformAccounts()->attach($activeAccount2->id, [ - 'is_active' => true, - 'priority' => 200 - ]); - - $channel->platformAccounts()->attach($inactiveAccount->id, [ - 'is_active' => false, - 'priority' => 150 - ]); - - $activeAccounts = $channel->activePlatformAccounts; - - $this->assertCount(2, $activeAccounts); - $this->assertTrue($activeAccounts->contains('id', $activeAccount1->id)); - $this->assertTrue($activeAccounts->contains('id', $activeAccount2->id)); - $this->assertFalse($activeAccounts->contains('id', $inactiveAccount->id)); - } - - public function test_full_name_attribute(): void - { - $instance = PlatformInstance::factory()->create(['url' => 'https://lemmy.example.com']); - $channel = PlatformChannel::factory()->create([ - 'platform_instance_id' => $instance->id, - 'name' => 'technology' - ]); - - $this->assertEquals('https://lemmy.example.com/c/technology', $channel->full_name); - } - - public function test_belongs_to_many_feeds_relationship(): void - { - $channel = PlatformChannel::factory()->create(); - $feed1 = Feed::factory()->create(); - $feed2 = Feed::factory()->create(); - - // Create routes (which act as pivot records) - Route::create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed2->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false, - 'priority' => 50 - ]); - - $feeds = $channel->feeds; - - $this->assertCount(2, $feeds); - $this->assertTrue($feeds->contains('id', $feed1->id)); - $this->assertTrue($feeds->contains('id', $feed2->id)); - - // Test pivot data - $feed1FromRelation = $feeds->find($feed1->id); - $this->assertEquals(1, $feed1FromRelation->pivot->is_active); - $this->assertEquals(100, $feed1FromRelation->pivot->priority); - } - - public function test_active_feeds_relationship(): void - { - $channel = PlatformChannel::factory()->create(); - $activeFeed1 = Feed::factory()->create(); - $activeFeed2 = Feed::factory()->create(); - $inactiveFeed = Feed::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $activeFeed1->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $activeFeed2->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 200 - ]); - - Route::create([ - 'feed_id' => $inactiveFeed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false, - 'priority' => 150 - ]); - - $activeFeeds = $channel->activeFeeds; - - $this->assertCount(2, $activeFeeds); - $this->assertTrue($activeFeeds->contains('id', $activeFeed1->id)); - $this->assertTrue($activeFeeds->contains('id', $activeFeed2->id)); - $this->assertFalse($activeFeeds->contains('id', $inactiveFeed->id)); - - // Test ordering by priority descending - $feedIds = $activeFeeds->pluck('id')->toArray(); - $this->assertEquals($activeFeed2->id, $feedIds[0]); // Priority 200 - $this->assertEquals($activeFeed1->id, $feedIds[1]); // Priority 100 - } - - public function test_channel_creation_with_factory(): void - { - $channel = PlatformChannel::factory()->create(); - - $this->assertInstanceOf(PlatformChannel::class, $channel); - $this->assertNotNull($channel->platform_instance_id); - $this->assertIsString($channel->name); - $this->assertIsString($channel->channel_id); - $this->assertIsBool($channel->is_active); - } - - public function test_channel_creation_with_explicit_values(): void - { - $instance = PlatformInstance::factory()->create(); - $language = Language::factory()->create(); - - $channel = PlatformChannel::create([ - 'platform_instance_id' => $instance->id, - 'name' => 'test_channel', - 'display_name' => 'Test Channel', - 'channel_id' => 'channel_123', - 'description' => 'A test channel', - 'language_id' => $language->id, - 'is_active' => false - ]); - - $this->assertEquals($instance->id, $channel->platform_instance_id); - $this->assertEquals('test_channel', $channel->name); - $this->assertEquals('Test Channel', $channel->display_name); - $this->assertEquals('channel_123', $channel->channel_id); - $this->assertEquals('A test channel', $channel->description); - $this->assertEquals($language->id, $channel->language_id); - $this->assertFalse($channel->is_active); - } - - public function test_channel_update(): void - { - $channel = PlatformChannel::factory()->create([ - 'name' => 'original_name', - 'is_active' => true - ]); - - $channel->update([ - 'name' => 'updated_name', - 'is_active' => false - ]); - - $channel->refresh(); - - $this->assertEquals('updated_name', $channel->name); - $this->assertFalse($channel->is_active); - } - - public function test_channel_deletion(): void - { - $channel = PlatformChannel::factory()->create(); - $channelId = $channel->id; - - $channel->delete(); - - $this->assertDatabaseMissing('platform_channels', ['id' => $channelId]); - } - - public function test_channel_with_display_name(): void - { - $channel = PlatformChannel::factory()->create([ - 'name' => 'tech', - 'display_name' => 'Technology Discussion' - ]); - - $this->assertEquals('tech', $channel->name); - $this->assertEquals('Technology Discussion', $channel->display_name); - } - - public function test_channel_without_display_name(): void - { - $channel = PlatformChannel::factory()->create([ - 'name' => 'general', - 'display_name' => 'General' - ]); - - $this->assertEquals('general', $channel->name); - $this->assertEquals('General', $channel->display_name); - } - - public function test_channel_timestamps(): void - { - $channel = PlatformChannel::factory()->create(); - - $this->assertNotNull($channel->created_at); - $this->assertNotNull($channel->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $channel->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $channel->updated_at); - } - - public function test_channel_can_have_multiple_accounts_with_different_priorities(): void - { - $channel = PlatformChannel::factory()->create(); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - $account3 = PlatformAccount::factory()->create(); - - // Attach accounts with different priorities - $channel->platformAccounts()->attach([ - $account1->id => ['is_active' => true, 'priority' => 300], - $account2->id => ['is_active' => true, 'priority' => 100], - $account3->id => ['is_active' => false, 'priority' => 200] - ]); - - $allAccounts = $channel->platformAccounts; - $activeAccounts = $channel->activePlatformAccounts; - - $this->assertCount(3, $allAccounts); - $this->assertCount(2, $activeAccounts); - - // Test that we can access pivot data - foreach ($allAccounts as $account) { - $this->assertNotNull($account->pivot->priority); - $this->assertIsInt($account->pivot->is_active); - } - } -} \ No newline at end of file diff --git a/tests/Unit/Models/PlatformInstanceTest.php b/tests/Unit/Models/PlatformInstanceTest.php deleted file mode 100644 index 9463493..0000000 --- a/tests/Unit/Models/PlatformInstanceTest.php +++ /dev/null @@ -1,325 +0,0 @@ -assertEquals($fillableFields, $instance->getFillable()); - } - - public function test_table_name(): void - { - $instance = new PlatformInstance(); - - $this->assertEquals('platform_instances', $instance->getTable()); - } - - public function test_casts_platform_to_enum(): void - { - $instance = PlatformInstance::factory()->create(['platform' => PlatformEnum::LEMMY]); - - $this->assertInstanceOf(PlatformEnum::class, $instance->platform); - $this->assertEquals(PlatformEnum::LEMMY, $instance->platform); - $this->assertEquals('lemmy', $instance->platform->value); - } - - public function test_casts_is_active_to_boolean(): void - { - $instance = PlatformInstance::factory()->create(['is_active' => '1']); - - $this->assertIsBool($instance->is_active); - $this->assertTrue($instance->is_active); - - $instance->update(['is_active' => '0']); - $instance->refresh(); - - $this->assertIsBool($instance->is_active); - $this->assertFalse($instance->is_active); - } - - public function test_has_many_channels_relationship(): void - { - $instance = PlatformInstance::factory()->create(); - - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $instance->id]); - - // Create channel for different instance - $otherInstance = PlatformInstance::factory()->create(); - PlatformChannel::factory()->create(['platform_instance_id' => $otherInstance->id]); - - $channels = $instance->channels; - - $this->assertCount(2, $channels); - $this->assertTrue($channels->contains('id', $channel1->id)); - $this->assertTrue($channels->contains('id', $channel2->id)); - $this->assertInstanceOf(PlatformChannel::class, $channels->first()); - } - - public function test_belongs_to_many_languages_relationship(): void - { - $instance = PlatformInstance::factory()->create(); - $language1 = Language::factory()->create(); - $language2 = Language::factory()->create(); - - // Attach languages with pivot data - $instance->languages()->attach($language1->id, [ - 'platform_language_id' => 1, - 'is_default' => true - ]); - - $instance->languages()->attach($language2->id, [ - 'platform_language_id' => 2, - 'is_default' => false - ]); - - $languages = $instance->languages; - - $this->assertCount(2, $languages); - $this->assertTrue($languages->contains('id', $language1->id)); - $this->assertTrue($languages->contains('id', $language2->id)); - - // Test pivot data - $language1FromRelation = $languages->find($language1->id); - $this->assertEquals(1, $language1FromRelation->pivot->platform_language_id); - $this->assertEquals(1, $language1FromRelation->pivot->is_default); // Database returns 1 for true - - $language2FromRelation = $languages->find($language2->id); - $this->assertEquals(2, $language2FromRelation->pivot->platform_language_id); - $this->assertEquals(0, $language2FromRelation->pivot->is_default); // Database returns 0 for false - } - - public function test_find_by_url_static_method(): void - { - $url = 'https://lemmy.world'; - - $instance1 = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => $url - ]); - - // Create instance with different URL - PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://lemmy.ml' - ]); - - $foundInstance = PlatformInstance::findByUrl(PlatformEnum::LEMMY, $url); - - $this->assertNotNull($foundInstance); - $this->assertEquals($instance1->id, $foundInstance->id); - $this->assertEquals($url, $foundInstance->url); - $this->assertEquals(PlatformEnum::LEMMY, $foundInstance->platform); - } - - public function test_find_by_url_returns_null_when_not_found(): void - { - $foundInstance = PlatformInstance::findByUrl(PlatformEnum::LEMMY, 'https://nonexistent.lemmy'); - - $this->assertNull($foundInstance); - } - - public function test_find_by_url_filters_by_platform(): void - { - $url = 'https://example.com'; - - // Create instance with same URL but different platform won't be found - PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => $url - ]); - - // Since we only have LEMMY in the enum, this test demonstrates the filtering logic - $foundInstance = PlatformInstance::findByUrl(PlatformEnum::LEMMY, $url); - $this->assertNotNull($foundInstance); - } - - public function test_instance_creation_with_factory(): void - { - $instance = PlatformInstance::factory()->create(); - - $this->assertInstanceOf(PlatformInstance::class, $instance); - $this->assertEquals('lemmy', $instance->platform->value); - $this->assertIsString($instance->name); - $this->assertIsString($instance->url); - $this->assertTrue($instance->is_active); - } - - public function test_instance_creation_with_explicit_values(): void - { - $instance = PlatformInstance::create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://lemmy.world', - 'name' => 'Lemmy World', - 'description' => 'A general purpose Lemmy instance', - 'is_active' => false - ]); - - $this->assertEquals(PlatformEnum::LEMMY, $instance->platform); - $this->assertEquals('https://lemmy.world', $instance->url); - $this->assertEquals('Lemmy World', $instance->name); - $this->assertEquals('A general purpose Lemmy instance', $instance->description); - $this->assertFalse($instance->is_active); - } - - public function test_instance_factory_states(): void - { - $inactiveInstance = PlatformInstance::factory()->inactive()->create(); - $this->assertFalse($inactiveInstance->is_active); - - $lemmyInstance = PlatformInstance::factory()->lemmy()->create(); - $this->assertEquals(PlatformEnum::LEMMY, $lemmyInstance->platform); - $this->assertStringStartsWith('Lemmy ', $lemmyInstance->name); - $this->assertStringStartsWith('https://lemmy.', $lemmyInstance->url); - } - - public function test_instance_update(): void - { - $instance = PlatformInstance::factory()->create([ - 'name' => 'Original Name', - 'is_active' => true - ]); - - $instance->update([ - 'name' => 'Updated Name', - 'is_active' => false - ]); - - $instance->refresh(); - - $this->assertEquals('Updated Name', $instance->name); - $this->assertFalse($instance->is_active); - } - - public function test_instance_deletion(): void - { - $instance = PlatformInstance::factory()->create(); - $instanceId = $instance->id; - - $instance->delete(); - - $this->assertDatabaseMissing('platform_instances', ['id' => $instanceId]); - } - - public function test_instance_can_have_null_description(): void - { - $instance = PlatformInstance::factory()->create(['description' => null]); - - $this->assertNull($instance->description); - } - - public function test_instance_can_have_empty_description(): void - { - $instance = PlatformInstance::factory()->create(['description' => '']); - - $this->assertEquals('', $instance->description); - } - - public function test_instance_url_validation(): void - { - $validUrls = [ - 'https://lemmy.world', - 'https://lemmy.ml', - 'https://beehaw.org', - 'http://localhost:8080', - ]; - - foreach ($validUrls as $url) { - $instance = PlatformInstance::factory()->create(['url' => $url]); - $this->assertEquals($url, $instance->url); - } - } - - public function test_instance_timestamps(): void - { - $instance = PlatformInstance::factory()->create(); - - $this->assertNotNull($instance->created_at); - $this->assertNotNull($instance->updated_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $instance->created_at); - $this->assertInstanceOf(\Carbon\Carbon::class, $instance->updated_at); - } - - public function test_instance_can_have_multiple_languages(): void - { - $instance = PlatformInstance::factory()->create(); - $language1 = Language::factory()->create(); - $language2 = Language::factory()->create(); - $language3 = Language::factory()->create(); - - // Attach multiple languages with different pivot data - $instance->languages()->attach([ - $language1->id => ['platform_language_id' => 1, 'is_default' => true], - $language2->id => ['platform_language_id' => 2, 'is_default' => false], - $language3->id => ['platform_language_id' => 3, 'is_default' => false] - ]); - - $languages = $instance->languages; - - $this->assertCount(3, $languages); - - // Test that we can access pivot data - foreach ($languages as $language) { - $this->assertNotNull($language->pivot->platform_language_id); - $this->assertContains($language->pivot->is_default, [0, 1, true, false]); // Can be int or bool - } - - // Only one should be default - $defaultLanguages = $languages->filter(fn($lang) => $lang->pivot->is_default); - $this->assertCount(1, $defaultLanguages); - } - - public function test_instance_channels_relationship_is_empty_by_default(): void - { - $instance = PlatformInstance::factory()->create(); - - $this->assertCount(0, $instance->channels); - } - - public function test_instance_languages_relationship_is_empty_by_default(): void - { - $instance = PlatformInstance::factory()->create(); - - $this->assertCount(0, $instance->languages); - } - - public function test_multiple_instances_with_same_platform(): void - { - $instance1 = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'name' => 'Lemmy World' - ]); - - $instance2 = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'name' => 'Lemmy ML' - ]); - - $this->assertEquals(PlatformEnum::LEMMY, $instance1->platform); - $this->assertEquals(PlatformEnum::LEMMY, $instance2->platform); - $this->assertNotEquals($instance1->id, $instance2->id); - $this->assertNotEquals($instance1->name, $instance2->name); - } - - public function test_instance_platform_enum_string_value(): void - { - $instance = PlatformInstance::factory()->create(['platform' => 'lemmy']); - - $this->assertEquals('lemmy', $instance->platform->value); - $this->assertEquals(PlatformEnum::LEMMY, $instance->platform); - } -} \ No newline at end of file diff --git a/tests/Unit/Models/RouteTest.php b/tests/Unit/Models/RouteTest.php deleted file mode 100644 index af5ff16..0000000 --- a/tests/Unit/Models/RouteTest.php +++ /dev/null @@ -1,261 +0,0 @@ -assertEquals($fillableFields, $route->getFillable()); - } - - public function test_casts_is_active_to_boolean(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => '1', - 'priority' => 50 - ]); - - $this->assertIsBool($route->is_active); - $this->assertTrue($route->is_active); - - $route->update(['is_active' => '0']); - $route->refresh(); - - $this->assertIsBool($route->is_active); - $this->assertFalse($route->is_active); - } - - public function test_primary_key_configuration(): void - { - $route = new Route(); - - $this->assertNull($route->getKeyName()); - $this->assertFalse($route->getIncrementing()); - } - - public function test_table_name(): void - { - $route = new Route(); - - $this->assertEquals('routes', $route->getTable()); - } - - public function test_belongs_to_feed_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - $this->assertInstanceOf(Feed::class, $route->feed); - $this->assertEquals($feed->id, $route->feed->id); - $this->assertEquals($feed->name, $route->feed->name); - } - - public function test_belongs_to_platform_channel_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - $this->assertInstanceOf(PlatformChannel::class, $route->platformChannel); - $this->assertEquals($channel->id, $route->platformChannel->id); - $this->assertEquals($channel->name, $route->platformChannel->name); - } - - public function test_has_many_keywords_relationship(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Create keywords for this route - $keyword1 = Keyword::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test1' - ]); - - $keyword2 = Keyword::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'test2' - ]); - - // Create keyword for different route (should not be included) - $otherFeed = Feed::factory()->create(); - Keyword::factory()->create([ - 'feed_id' => $otherFeed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'other' - ]); - - $keywords = $route->keywords; - - $this->assertCount(2, $keywords); - $this->assertTrue($keywords->contains('id', $keyword1->id)); - $this->assertTrue($keywords->contains('id', $keyword2->id)); - $this->assertInstanceOf(Keyword::class, $keywords->first()); - } - - public function test_keywords_relationship_filters_by_feed_and_channel(): void - { - $feed1 = Feed::factory()->create(); - $feed2 = Feed::factory()->create(); - $channel1 = PlatformChannel::factory()->create(); - $channel2 = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Create keyword for this exact route - $matchingKeyword = Keyword::factory()->create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel1->id, - 'keyword' => 'matching' - ]); - - // Create keyword for same feed but different channel - Keyword::factory()->create([ - 'feed_id' => $feed1->id, - 'platform_channel_id' => $channel2->id, - 'keyword' => 'different_channel' - ]); - - // Create keyword for same channel but different feed - Keyword::factory()->create([ - 'feed_id' => $feed2->id, - 'platform_channel_id' => $channel1->id, - 'keyword' => 'different_feed' - ]); - - $keywords = $route->keywords; - - $this->assertCount(1, $keywords); - $this->assertEquals($matchingKeyword->id, $keywords->first()->id); - $this->assertEquals('matching', $keywords->first()->keyword); - } - - public function test_route_creation_with_factory(): void - { - $route = Route::factory()->create(); - - $this->assertInstanceOf(Route::class, $route); - $this->assertNotNull($route->feed_id); - $this->assertNotNull($route->platform_channel_id); - $this->assertIsBool($route->is_active); - $this->assertIsInt($route->priority); - } - - public function test_route_creation_with_explicit_values(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => false, - 'priority' => 75 - ]); - - $this->assertEquals($feed->id, $route->feed_id); - $this->assertEquals($channel->id, $route->platform_channel_id); - $this->assertFalse($route->is_active); - $this->assertEquals(75, $route->priority); - } - - public function test_route_update(): void - { - $route = Route::factory()->create([ - 'is_active' => true, - 'priority' => 50 - ]); - - $route->update([ - 'is_active' => false, - 'priority' => 25 - ]); - - $route->refresh(); - - $this->assertFalse($route->is_active); - $this->assertEquals(25, $route->priority); - } - - public function test_route_with_multiple_keywords_active_and_inactive(): void - { - $feed = Feed::factory()->create(); - $channel = PlatformChannel::factory()->create(); - - $route = Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - Keyword::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'active_keyword', - 'is_active' => true - ]); - - Keyword::factory()->create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'keyword' => 'inactive_keyword', - 'is_active' => false - ]); - - $keywords = $route->keywords; - $activeKeywords = $keywords->where('is_active', true); - $inactiveKeywords = $keywords->where('is_active', false); - - $this->assertCount(2, $keywords); - $this->assertCount(1, $activeKeywords); - $this->assertCount(1, $inactiveKeywords); - $this->assertEquals('active_keyword', $activeKeywords->first()->keyword); - $this->assertEquals('inactive_keyword', $inactiveKeywords->first()->keyword); - } -} \ No newline at end of file diff --git a/tests/Unit/Modules/Lemmy/LemmyRequestTest.php b/tests/Unit/Modules/Lemmy/LemmyRequestTest.php deleted file mode 100644 index 18cc955..0000000 --- a/tests/Unit/Modules/Lemmy/LemmyRequestTest.php +++ /dev/null @@ -1,273 +0,0 @@ -assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - $this->assertNull($this->getPrivateProperty($request, 'token')); - } - - public function test_constructor_with_https_url(): void - { - $request = new LemmyRequest('https://lemmy.world'); - - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - } - - public function test_constructor_with_http_url(): void - { - $request = new LemmyRequest('http://lemmy.world'); - - $this->assertEquals('http', $this->getPrivateProperty($request, 'scheme')); - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - } - - public function test_constructor_with_trailing_slash(): void - { - $request = new LemmyRequest('lemmy.world/'); - - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - } - - public function test_constructor_with_full_url_and_trailing_slash(): void - { - $request = new LemmyRequest('https://lemmy.world/'); - - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - $this->assertEquals('lemmy.world', $this->getPrivateProperty($request, 'instance')); - } - - public function test_constructor_with_token(): void - { - $request = new LemmyRequest('lemmy.world', 'test-token'); - - $this->assertEquals('test-token', $this->getPrivateProperty($request, 'token')); - } - - public function test_constructor_preserves_case_in_scheme_detection(): void - { - $request = new LemmyRequest('HTTPS://lemmy.world'); - - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_scheme_sets_https(): void - { - $request = new LemmyRequest('lemmy.world'); - $result = $request->withScheme('https'); - - $this->assertSame($request, $result); - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_scheme_sets_http(): void - { - $request = new LemmyRequest('lemmy.world'); - $result = $request->withScheme('http'); - - $this->assertSame($request, $result); - $this->assertEquals('http', $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_scheme_normalizes_case(): void - { - $request = new LemmyRequest('lemmy.world'); - $request->withScheme('HTTPS'); - - $this->assertEquals('https', $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_scheme_ignores_invalid_schemes(): void - { - $request = new LemmyRequest('lemmy.world'); - $originalScheme = $this->getPrivateProperty($request, 'scheme'); - - $request->withScheme('ftp'); - - $this->assertEquals($originalScheme, $this->getPrivateProperty($request, 'scheme')); - } - - public function test_with_token_sets_token(): void - { - $request = new LemmyRequest('lemmy.world'); - $result = $request->withToken('new-token'); - - $this->assertSame($request, $result); - $this->assertEquals('new-token', $this->getPrivateProperty($request, 'token')); - } - - public function test_get_without_token(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $response = $request->get('site'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/site' - && !$httpRequest->hasHeader('Authorization'); - }); - } - - public function test_get_with_token(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world', 'test-token'); - $response = $request->get('site'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/site' - && $httpRequest->header('Authorization')[0] === 'Bearer test-token'; - }); - } - - public function test_get_with_parameters(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $params = ['limit' => 10, 'page' => 1]; - $response = $request->get('posts', $params); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) use ($params) { - $url = $httpRequest->url(); - return str_contains($url, 'https://lemmy.world/api/v3/posts') - && str_contains($url, 'limit=10') - && str_contains($url, 'page=1'); - }); - } - - public function test_get_with_http_scheme(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $request->withScheme('http'); - $response = $request->get('site'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'http://lemmy.world/api/v3/site'; - }); - } - - public function test_post_without_token(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $response = $request->post('login'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/login' - && $httpRequest->method() === 'POST' - && !$httpRequest->hasHeader('Authorization'); - }); - } - - public function test_post_with_token(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world', 'test-token'); - $response = $request->post('login'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/login' - && $httpRequest->method() === 'POST' - && $httpRequest->header('Authorization')[0] === 'Bearer test-token'; - }); - } - - public function test_post_with_data(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $data = ['username' => 'test', 'password' => 'pass']; - $response = $request->post('login', $data); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) use ($data) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/login' - && $httpRequest->method() === 'POST' - && $httpRequest->data() === $data; - }); - } - - public function test_post_with_http_scheme(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $request->withScheme('http'); - $response = $request->post('login'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'http://lemmy.world/api/v3/login'; - }); - } - - public function test_requests_use_30_second_timeout(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $request->get('site'); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'https://lemmy.world/api/v3/site'; - }); - } - - public function test_chaining_methods(): void - { - Http::fake(['*' => Http::response(['success' => true])]); - - $request = new LemmyRequest('lemmy.world'); - $response = $request->withScheme('http')->withToken('chained-token')->get('site'); - - $this->assertInstanceOf(Response::class, $response); - - Http::assertSent(function ($httpRequest) { - return $httpRequest->url() === 'http://lemmy.world/api/v3/site' - && $httpRequest->header('Authorization')[0] === 'Bearer chained-token'; - }); - } - - private function getPrivateProperty(object $object, string $property): mixed - { - $reflection = new \ReflectionClass($object); - $reflectionProperty = $reflection->getProperty($property); - $reflectionProperty->setAccessible(true); - - return $reflectionProperty->getValue($object); - } -} \ No newline at end of file diff --git a/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php b/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php deleted file mode 100644 index 784b633..0000000 --- a/tests/Unit/Modules/Lemmy/Services/LemmyApiServiceTest.php +++ /dev/null @@ -1,429 +0,0 @@ -getProperty('instance'); - $property->setAccessible(true); - - $this->assertEquals('lemmy.world', $property->getValue($service)); - } - - public function test_login_with_https_success(): void - { - Http::fake([ - 'https://lemmy.world/api/v3/user/login' => Http::response(['jwt' => 'test-token'], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'pass'); - - $this->assertEquals('test-token', $token); - - Http::assertSent(function ($request) { - return $request->url() === 'https://lemmy.world/api/v3/user/login' - && $request['username_or_email'] === 'user' - && $request['password'] === 'pass'; - }); - } - - public function test_login_falls_back_to_http_on_https_failure(): void - { - Http::fake([ - 'https://lemmy.world/api/v3/user/login' => Http::response('', 500), - 'http://lemmy.world/api/v3/user/login' => Http::response(['jwt' => 'http-token'], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'pass'); - - $this->assertEquals('http-token', $token); - - Http::assertSentCount(2); - } - - public function test_login_with_explicit_http_scheme(): void - { - Http::fake([ - 'http://localhost/api/v3/user/login' => Http::response(['jwt' => 'local-token'], 200) - ]); - - $service = new LemmyApiService('http://localhost'); - $token = $service->login('user', 'pass'); - - $this->assertEquals('local-token', $token); - - Http::assertSent(function ($request) { - return $request->url() === 'http://localhost/api/v3/user/login'; - }); - } - - public function test_login_with_explicit_https_scheme(): void - { - Http::fake([ - 'https://secure.lemmy/api/v3/user/login' => Http::response(['jwt' => 'secure-token'], 200) - ]); - - $service = new LemmyApiService('https://secure.lemmy'); - $token = $service->login('user', 'pass'); - - $this->assertEquals('secure-token', $token); - - Http::assertSent(function ($request) { - return $request->url() === 'https://secure.lemmy/api/v3/user/login'; - }); - } - - public function test_login_returns_null_on_unsuccessful_response(): void - { - Http::fake([ - '*' => Http::response(['error' => 'Invalid credentials'], 401) - ]); - - Log::shouldReceive('error')->twice(); // Once for HTTPS, once for HTTP fallback - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'wrong'); - - $this->assertNull($token); - } - - public function test_login_handles_rate_limit_error(): void - { - Http::fake([ - '*' => Http::response('{"error":"rate_limit_error"}', 429) - ]); - - // Expecting 4 error logs: - // 1. 'Lemmy login failed' for HTTPS attempt - // 2. 'Lemmy login exception' for catching the rate limit exception on HTTPS - // 3. 'Lemmy login failed' for HTTP attempt - // 4. 'Lemmy login exception' for catching the rate limit exception on HTTP - Log::shouldReceive('error')->times(4); - - $service = new LemmyApiService('lemmy.world'); - $result = $service->login('user', 'pass'); - - // Since the exception is caught and HTTP is tried, then that also fails, - // the method returns null instead of throwing - $this->assertNull($result); - } - - public function test_login_returns_null_when_jwt_missing_from_response(): void - { - Http::fake([ - '*' => Http::response(['success' => true], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'pass'); - - $this->assertNull($token); - } - - public function test_login_handles_exception_and_returns_null(): void - { - Http::fake(function () { - throw new Exception('Network error'); - }); - - Log::shouldReceive('error')->twice(); - - $service = new LemmyApiService('lemmy.world'); - $token = $service->login('user', 'pass'); - - $this->assertNull($token); - } - - public function test_get_community_id_success(): void - { - Http::fake([ - '*' => Http::response([ - 'community_view' => [ - 'community' => ['id' => 123] - ] - ], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $id = $service->getCommunityId('test-community', 'token'); - - $this->assertEquals(123, $id); - - Http::assertSent(function ($request) { - return str_contains($request->url(), '/api/v3/community') - && str_contains($request->url(), 'name=test-community') - && $request->header('Authorization')[0] === 'Bearer token'; - }); - } - - public function test_get_community_id_throws_on_unsuccessful_response(): void - { - Http::fake([ - '*' => Http::response('Not found', 404) - ]); - - Log::shouldReceive('error')->once(); - - $service = new LemmyApiService('lemmy.world'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Failed to fetch community: 404'); - - $service->getCommunityId('missing', 'token'); - } - - public function test_get_community_id_throws_when_community_not_in_response(): void - { - Http::fake([ - '*' => Http::response(['success' => true], 200) - ]); - - Log::shouldReceive('error')->once(); - - $service = new LemmyApiService('lemmy.world'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Community not found'); - - $service->getCommunityId('test', 'token'); - } - - public function test_sync_channel_posts_success(): void - { - Http::fake([ - '*' => Http::response([ - 'posts' => [ - [ - 'post' => [ - 'id' => 1, - 'url' => 'https://example.com/1', - 'name' => 'Post 1', - 'published' => '2024-01-01T00:00:00Z' - ] - ], - [ - 'post' => [ - 'id' => 2, - 'url' => 'https://example.com/2', - 'name' => 'Post 2', - 'published' => '2024-01-02T00:00:00Z' - ] - ] - ] - ], 200) - ]); - - Log::shouldReceive('info')->once()->with('Synced channel posts', Mockery::any()); - - $mockPost = Mockery::mock('alias:' . PlatformChannelPost::class); - $mockPost->shouldReceive('storePost') - ->twice() - ->with( - PlatformEnum::LEMMY, - Mockery::any(), - 'test-community', - Mockery::any(), - Mockery::any(), - Mockery::any(), - Mockery::any() - ); - - $service = new LemmyApiService('lemmy.world'); - $service->syncChannelPosts('token', 42, 'test-community'); - - Http::assertSent(function ($request) { - return str_contains($request->url(), '/api/v3/post/list') - && str_contains($request->url(), 'community_id=42') - && str_contains($request->url(), 'limit=50') - && str_contains($request->url(), 'sort=New'); - }); - } - - public function test_sync_channel_posts_handles_unsuccessful_response(): void - { - Http::fake([ - '*' => Http::response('Error', 500) - ]); - - Log::shouldReceive('warning')->once()->with('Failed to sync channel posts', Mockery::any()); - - $service = new LemmyApiService('lemmy.world'); - $service->syncChannelPosts('token', 42, 'test-community'); - - Http::assertSentCount(1); - } - - public function test_sync_channel_posts_handles_exception(): void - { - Http::fake(function () { - throw new Exception('Network error'); - }); - - Log::shouldReceive('error')->once()->with('Exception while syncing channel posts', Mockery::any()); - - $service = new LemmyApiService('lemmy.world'); - $service->syncChannelPosts('token', 42, 'test-community'); - - // Assert that the method completes without throwing - $this->assertTrue(true); - } - - public function test_create_post_with_all_parameters(): void - { - Http::fake([ - '*' => Http::response(['post_view' => ['post' => ['id' => 999]]], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $result = $service->createPost( - 'token', - 'Test Title', - 'Test Body', - 42, - 'https://example.com', - 'https://example.com/thumb.jpg', - 5 - ); - - $this->assertEquals(['post_view' => ['post' => ['id' => 999]]], $result); - - Http::assertSent(function ($request) { - $data = $request->data(); - return $request->url() === 'https://lemmy.world/api/v3/post' - && $data['name'] === 'Test Title' - && $data['body'] === 'Test Body' - && $data['community_id'] === 42 - && $data['url'] === 'https://example.com' - && $data['custom_thumbnail'] === 'https://example.com/thumb.jpg' - && $data['language_id'] === 5; - }); - } - - public function test_create_post_with_minimal_parameters(): void - { - Http::fake([ - '*' => Http::response(['post_view' => ['post' => ['id' => 888]]], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $result = $service->createPost( - 'token', - 'Title Only', - 'Body Only', - 42 - ); - - $this->assertEquals(['post_view' => ['post' => ['id' => 888]]], $result); - - Http::assertSent(function ($request) { - $data = $request->data(); - return $request->url() === 'https://lemmy.world/api/v3/post' - && $data['name'] === 'Title Only' - && $data['body'] === 'Body Only' - && $data['community_id'] === 42 - && !isset($data['url']) - && !isset($data['custom_thumbnail']) - && !isset($data['language_id']); - }); - } - - public function test_create_post_throws_on_unsuccessful_response(): void - { - Http::fake([ - '*' => Http::response('Forbidden', 403) - ]); - - Log::shouldReceive('error')->once(); - - $service = new LemmyApiService('lemmy.world'); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Failed to create post: 403'); - - $service->createPost('token', 'Title', 'Body', 42); - } - - public function test_get_languages_success(): void - { - Http::fake([ - '*' => Http::response([ - 'all_languages' => [ - ['id' => 1, 'code' => 'en', 'name' => 'English'], - ['id' => 2, 'code' => 'fr', 'name' => 'French'] - ] - ], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $languages = $service->getLanguages(); - - $this->assertCount(2, $languages); - $this->assertEquals('en', $languages[0]['code']); - $this->assertEquals('fr', $languages[1]['code']); - - Http::assertSent(function ($request) { - return str_contains($request->url(), '/api/v3/site'); - }); - } - - public function test_get_languages_returns_empty_array_on_failure(): void - { - Http::fake([ - '*' => Http::response('Error', 500) - ]); - - Log::shouldReceive('warning')->once(); - - $service = new LemmyApiService('lemmy.world'); - $languages = $service->getLanguages(); - - $this->assertEquals([], $languages); - } - - public function test_get_languages_handles_exception(): void - { - Http::fake(function () { - throw new Exception('Network error'); - }); - - Log::shouldReceive('error')->once(); - - $service = new LemmyApiService('lemmy.world'); - $languages = $service->getLanguages(); - - $this->assertEquals([], $languages); - } - - public function test_get_languages_returns_empty_when_all_languages_missing(): void - { - Http::fake([ - '*' => Http::response(['site_view' => []], 200) - ]); - - $service = new LemmyApiService('lemmy.world'); - $languages = $service->getLanguages(); - - $this->assertEquals([], $languages); - } -} diff --git a/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php b/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php deleted file mode 100644 index 5f17c6b..0000000 --- a/tests/Unit/Modules/Lemmy/Services/LemmyPublisherTest.php +++ /dev/null @@ -1,342 +0,0 @@ -make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $this->assertInstanceOf(LemmyApiService::class, $apiProperty->getValue($publisher)); - - $accountProperty = $reflection->getProperty('account'); - $accountProperty->setAccessible(true); - $this->assertSame($account, $accountProperty->getValue($publisher)); - } - - public function test_publish_to_channel_with_all_data(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => '42' - ]); - - $extractedData = [ - 'title' => 'Test Article', - 'description' => 'Test Description', - 'thumbnail' => 'https://example.com/thumb.jpg', - 'language_id' => 5 - ]; - - // Mock LemmyAuthService via service container - $authMock = Mockery::mock(LemmyAuthService::class); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andReturn('test-token'); - - $this->app->instance(LemmyAuthService::class, $authMock); - - // Mock LemmyApiService - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('createPost') - ->once() - ->with( - 'test-token', - 'Test Article', - 'Test Description', - 42, - 'https://example.com/article', - 'https://example.com/thumb.jpg', - 5 - ) - ->andReturn(['post_view' => ['post' => ['id' => 999]]]); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $result = $publisher->publishToChannel($article, $extractedData, $channel); - - $this->assertEquals(['post_view' => ['post' => ['id' => 999]]], $result); - } - - public function test_publish_to_channel_with_minimal_data(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => '24' - ]); - - $extractedData = []; - - // Mock LemmyAuthService - $authMock = Mockery::mock(LemmyAuthService::class); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andReturn('minimal-token'); - - $this->app->instance(LemmyAuthService::class, $authMock); - - // Mock LemmyApiService - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('createPost') - ->once() - ->with( - 'minimal-token', - 'Untitled', - '', - 24, - 'https://example.com/article', - null, - null - ) - ->andReturn(['post_view' => ['post' => ['id' => 777]]]); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $result = $publisher->publishToChannel($article, $extractedData, $channel); - - $this->assertEquals(['post_view' => ['post' => ['id' => 777]]], $result); - } - - public function test_publish_to_channel_without_thumbnail(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => '33' - ]); - - $extractedData = [ - 'title' => 'No Thumbnail Article', - 'description' => 'Article without thumbnail', - 'language_id' => 2 - ]; - - // Mock LemmyAuthService - $authMock = Mockery::mock(LemmyAuthService::class); - $this->app->instance(LemmyAuthService::class, $authMock); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andReturn('no-thumb-token'); - - // Mock LemmyApiService - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('createPost') - ->once() - ->with( - 'no-thumb-token', - 'No Thumbnail Article', - 'Article without thumbnail', - 33, - 'https://example.com/article', - null, - 2 - ) - ->andReturn(['post_view' => ['post' => ['id' => 555]]]); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $result = $publisher->publishToChannel($article, $extractedData, $channel); - - $this->assertEquals(['post_view' => ['post' => ['id' => 555]]], $result); - } - - public function test_publish_to_channel_throws_platform_auth_exception(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make(); - $channel = PlatformChannel::factory()->make(); - $extractedData = []; - - // Mock LemmyAuthService to throw exception - $authMock = Mockery::mock(LemmyAuthService::class); - $this->app->instance(LemmyAuthService::class, $authMock); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andThrow(new PlatformAuthException(PlatformEnum::LEMMY, 'Auth failed')); - - $publisher = new LemmyPublisher($account); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Auth failed'); - - $publisher->publishToChannel($article, $extractedData, $channel); - } - - public function test_publish_to_channel_throws_api_exception(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => '42' - ]); - - $extractedData = [ - 'title' => 'Test Article' - ]; - - // Mock LemmyAuthService via service container - $authMock = Mockery::mock(LemmyAuthService::class); - $authMock->shouldReceive('getToken') - ->once() - ->with($account) - ->andReturn('test-token'); - - $this->app->instance(LemmyAuthService::class, $authMock); - - // Mock LemmyApiService to throw exception - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('createPost') - ->once() - ->andThrow(new Exception('API Error')); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('API Error'); - - $publisher->publishToChannel($article, $extractedData, $channel); - } - - public function test_publish_to_channel_handles_string_channel_id(): void - { - $account = PlatformAccount::factory()->make([ - 'instance_url' => 'https://lemmy.world' - ]); - - $article = Article::factory()->make([ - 'url' => 'https://example.com/article' - ]); - - $channel = PlatformChannel::factory()->make([ - 'channel_id' => 'string-42' - ]); - - $extractedData = [ - 'title' => 'Test Title' - ]; - - // Mock LemmyAuthService - $authMock = Mockery::mock(LemmyAuthService::class); - $this->app->instance(LemmyAuthService::class, $authMock); - $authMock->shouldReceive('getToken') - ->once() - ->andReturn('token'); - - // Mock LemmyApiService - should call getCommunityId for non-numeric channel_id - $apiMock = Mockery::mock(LemmyApiService::class); - $apiMock->shouldReceive('getCommunityId') - ->once() - ->with('string-42', 'token') - ->andReturn(42); - $apiMock->shouldReceive('createPost') - ->once() - ->with( - 'token', - 'Test Title', - '', - 42, // resolved community ID - 'https://example.com/article', - null, - null - ) - ->andReturn(['success' => true]); - - // Create publisher and inject mocked API using reflection - $publisher = new LemmyPublisher($account); - - $reflection = new \ReflectionClass($publisher); - $apiProperty = $reflection->getProperty('api'); - $apiProperty->setAccessible(true); - $apiProperty->setValue($publisher, $apiMock); - - $result = $publisher->publishToChannel($article, $extractedData, $channel); - - $this->assertEquals(['success' => true], $result); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/ArticleFetcherTest.php b/tests/Unit/Services/ArticleFetcherTest.php deleted file mode 100644 index c67c680..0000000 --- a/tests/Unit/Services/ArticleFetcherTest.php +++ /dev/null @@ -1,274 +0,0 @@ - Http::response('Mock HTML content', 200) - ]); - - // Create ArticleFetcher only when needed - tests will create their own - } - - public function test_get_articles_from_feed_returns_collection(): void - { - $articleFetcher = $this->createArticleFetcher(); - - $feed = Feed::factory()->create([ - 'type' => 'rss', - 'url' => 'https://example.com/feed.rss' - ]); - - $result = $articleFetcher->getArticlesFromFeed($feed); - - $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' - ]); - - $articleFetcher = $this->createArticleFetcher(); - $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/' - ]); - - $articleFetcher = $this->createArticleFetcher(); - $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' - ]); - - $articleFetcher = $this->createArticleFetcher(); - $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' - ]); - - $articleFetcher = $this->createArticleFetcher(); - $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' - ]); - - $articleFetcher = $this->createArticleFetcher(); - $result = $articleFetcher->fetchArticleData($article); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - public function test_get_articles_from_feed_with_null_feed_type(): void - { - // Create feed with valid type first, then manually set to invalid value - $feed = Feed::factory()->create([ - 'type' => 'website', - 'url' => 'https://example.com/feed' - ]); - - // Use reflection to set an invalid type that bypasses enum validation - $reflection = new \ReflectionClass($feed); - $property = $reflection->getProperty('attributes'); - $property->setAccessible(true); - $attributes = $property->getValue($feed); - $attributes['type'] = 'invalid_type'; - $property->setValue($feed, $attributes); - - $articleFetcher = $this->createArticleFetcher(); - $result = $articleFetcher->getArticlesFromFeed($feed); - - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); - $this->assertEmpty($result); - } - - public function test_get_articles_from_website_feed_with_supported_parser(): void - { - // Mock successful HTTP response with sample HTML - Http::fake([ - 'https://www.vrt.be/vrtnws/nl/' => Http::response('Sample VRT content', 200) - ]); - - $feed = Feed::factory()->create([ - 'type' => 'website', - 'url' => 'https://www.vrt.be/vrtnws/nl/' - ]); - - // Test actual behavior - VRT parser should be available - $articleFetcher = $this->createArticleFetcher(); - $result = $articleFetcher->getArticlesFromFeed($feed); - - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); - // VRT parser will process the mocked HTML response - } - - public function test_get_articles_from_website_feed_handles_invalid_url(): void - { - // HTTP mock already set in setUp() to return mock HTML for all requests - - $feed = Feed::factory()->create([ - 'type' => 'website', - 'url' => 'https://invalid-domain-that-does-not-exist-12345.com/' - ]); - - $articleFetcher = $this->createArticleFetcher(); - $result = $articleFetcher->getArticlesFromFeed($feed); - - $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); - $this->assertEmpty($result); - } - - public function test_fetch_article_data_with_supported_parser(): void - { - // Mock successful HTTP response with sample HTML - Http::fake([ - 'https://www.vrt.be/vrtnws/nl/test-article' => Http::response('Sample article content', 200) - ]); - - $article = Article::factory()->create([ - 'url' => 'https://www.vrt.be/vrtnws/nl/test-article' - ]); - - // Test actual behavior - VRT parser should be available - $articleFetcher = $this->createArticleFetcher(); - $result = $articleFetcher->fetchArticleData($article); - - $this->assertIsArray($result); - // VRT parser will process the mocked HTML response - } - - public function test_fetch_article_data_handles_unsupported_domain(): void - { - $article = Article::factory()->create([ - 'url' => 'https://unsupported-domain.com/article' - ]); - - $articleFetcher = $this->createArticleFetcher(); - $result = $articleFetcher->fetchArticleData($article); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } - - public function test_save_article_creates_new_article_when_not_exists(): void - { - $feed = Feed::factory()->create(); - $url = 'https://example.com/unique-article'; - - // Ensure article doesn't exist - $this->assertDatabaseMissing('articles', ['url' => $url]); - - $articleFetcher = $this->createArticleFetcher(); - // Use reflection to access private method for testing - $reflection = new \ReflectionClass($articleFetcher); - $saveArticleMethod = $reflection->getMethod('saveArticle'); - $saveArticleMethod->setAccessible(true); - - $article = $saveArticleMethod->invoke($articleFetcher, $url, $feed->id); - - $this->assertInstanceOf(Article::class, $article); - $this->assertEquals($url, $article->url); - $this->assertEquals($feed->id, $article->feed_id); - $this->assertDatabaseHas('articles', ['url' => $url, 'feed_id' => $feed->id]); - } - - public function test_save_article_returns_existing_article_when_exists(): void - { - $feed = Feed::factory()->create(); - $existingArticle = Article::factory()->create([ - 'url' => 'https://example.com/existing-article', - 'feed_id' => $feed->id - ]); - - // Use reflection to access private method for testing - $reflection = new \ReflectionClass(ArticleFetcher::class); - $saveArticleMethod = $reflection->getMethod('saveArticle'); - $saveArticleMethod->setAccessible(true); - - $articleFetcher = $this->createArticleFetcher(); - $article = $saveArticleMethod->invoke($articleFetcher, $existingArticle->url, $feed->id); - - $this->assertEquals($existingArticle->id, $article->id); - $this->assertEquals($existingArticle->url, $article->url); - - // Ensure no duplicate was created - $this->assertEquals(1, Article::where('url', $existingArticle->url)->count()); - } - - public function test_save_article_without_feed_id(): void - { - $url = 'https://example.com/article-without-feed'; - - // Use reflection to access private method for testing - $reflection = new \ReflectionClass(ArticleFetcher::class); - $saveArticleMethod = $reflection->getMethod('saveArticle'); - $saveArticleMethod->setAccessible(true); - - $articleFetcher = $this->createArticleFetcher(); - $article = $saveArticleMethod->invoke($articleFetcher, $url, null); - - $this->assertInstanceOf(Article::class, $article); - $this->assertEquals($url, $article->url); - $this->assertNull($article->feed_id); - $this->assertDatabaseHas('articles', ['url' => $url, 'feed_id' => null]); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } -} diff --git a/tests/Unit/Services/Auth/LemmyAuthServiceTest.php b/tests/Unit/Services/Auth/LemmyAuthServiceTest.php deleted file mode 100644 index d77f46b..0000000 --- a/tests/Unit/Services/Auth/LemmyAuthServiceTest.php +++ /dev/null @@ -1,162 +0,0 @@ - Http::response(['jwt' => 'jwt-123'], 200), - 'http://lemmy.test/api/v3/user/login' => Http::response(['jwt' => 'jwt-123'], 200) - ]); - - $account = PlatformAccount::factory()->create([ - 'username' => 'testuser', - 'password' => 'testpass', - 'instance_url' => 'https://lemmy.test' - ]); - - $result = app(LemmyAuthService::class)->getToken($account); - - $this->assertEquals('jwt-123', $result); - } - - public function test_get_token_throws_exception_when_username_missing(): void - { - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => null, - 'password' => 'testpass', - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Missing credentials for account: '); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_get_token_throws_exception_when_password_missing(): void - { - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => 'testuser', - 'password' => null, - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Missing credentials for account: testuser'); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_get_token_throws_exception_when_instance_url_missing(): void - { - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => 'testuser', - 'password' => 'testpass', - 'instance_url' => null, - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Missing credentials for account: testuser'); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_get_token_throws_exception_when_login_fails(): void - { - // Mock failed HTTP response for both HTTPS and HTTP - Http::fake([ - 'https://lemmy.test/api/v3/user/login' => Http::response(['error' => 'Invalid credentials'], 401), - 'http://lemmy.test/api/v3/user/login' => Http::response(['error' => 'Invalid credentials'], 401) - ]); - - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => 'failingUser', - 'password' => 'badpass', - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Login failed for account: failingUser'); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_get_token_throws_exception_when_login_returns_false(): void - { - // Mock response with empty/missing JWT for both HTTPS and HTTP - Http::fake([ - 'https://lemmy.test/api/v3/user/login' => Http::response(['success' => false], 200), - 'http://lemmy.test/api/v3/user/login' => Http::response(['success' => false], 200) - ]); - - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => 'emptyUser', - 'password' => 'pass', - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - $this->expectException(PlatformAuthException::class); - $this->expectExceptionMessage('Login failed for account: emptyUser'); - - app(LemmyAuthService::class)->getToken($account); - } - - public function test_platform_auth_exception_contains_correct_platform(): void - { - $account = $this->createMock(PlatformAccount::class); - $account->method('__get')->willReturnCallback(function ($key) { - return match ($key) { - 'username' => null, - 'password' => 'testpass', - 'instance_url' => 'https://lemmy.test', - default => null, - }; - }); - - try { - app(LemmyAuthService::class)->getToken($account); - $this->fail('Expected PlatformAuthException to be thrown'); - } catch (PlatformAuthException $e) { - $this->assertEquals(PlatformEnum::LEMMY, $e->getPlatform()); - } - } -} \ No newline at end of file diff --git a/tests/Unit/Services/DashboardStatsServiceTest.php b/tests/Unit/Services/DashboardStatsServiceTest.php deleted file mode 100644 index d8cfc4b..0000000 --- a/tests/Unit/Services/DashboardStatsServiceTest.php +++ /dev/null @@ -1,42 +0,0 @@ - Http::response('', 500) - ]); - } - - public function test_get_available_periods_returns_correct_options(): void - { - $service = new DashboardStatsService(); - $periods = $service->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_service_instantiation(): void - { - $service = new DashboardStatsService(); - $this->assertInstanceOf(DashboardStatsService::class, $service); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/Factories/ArticleParserFactoryTest.php b/tests/Unit/Services/Factories/ArticleParserFactoryTest.php deleted file mode 100644 index a0a9808..0000000 --- a/tests/Unit/Services/Factories/ArticleParserFactoryTest.php +++ /dev/null @@ -1,225 +0,0 @@ -assertInstanceOf(VrtArticleParser::class, $parser); - $this->assertInstanceOf(ArticleParserInterface::class, $parser); - } - - public function test_get_parser_returns_belga_parser_for_belga_urls(): void - { - $belgaUrl = 'https://www.belganewsagency.eu/nl/nieuws/binnenland/test-article'; - - $parser = ArticleParserFactory::getParser($belgaUrl); - - $this->assertInstanceOf(BelgaArticleParser::class, $parser); - $this->assertInstanceOf(ArticleParserInterface::class, $parser); - } - - public function test_get_parser_throws_exception_for_unsupported_url(): void - { - $unsupportedUrl = 'https://www.example.com/article'; - - $this->expectException(Exception::class); - $this->expectExceptionMessage("No parser found for URL: {$unsupportedUrl}"); - - ArticleParserFactory::getParser($unsupportedUrl); - } - - public function test_get_supported_sources_returns_array_of_source_names(): void - { - $sources = ArticleParserFactory::getSupportedSources(); - - $this->assertIsArray($sources); - $this->assertCount(2, $sources); - $this->assertContains('VRT News', $sources); - $this->assertContains('Belga News Agency', $sources); - } - - public function test_get_supported_sources_returns_sources_in_correct_order(): void - { - $sources = ArticleParserFactory::getSupportedSources(); - - // Based on the factory's parser registration order - $this->assertEquals('VRT News', $sources[0]); - $this->assertEquals('Belga News Agency', $sources[1]); - } - - public function test_register_parser_adds_new_parser_to_list(): void - { - // Create a mock parser class - $mockParserClass = new class implements ArticleParserInterface { - public function canParse(string $url): bool - { - return str_contains($url, 'test-parser.com'); - } - - public function extractData(string $html): array - { - return ['title' => 'Test Title']; - } - - public function getSourceName(): string - { - return 'TestParser'; - } - }; - - $mockParserClassName = get_class($mockParserClass); - - // Register the mock parser - ArticleParserFactory::registerParser($mockParserClassName); - - // Verify it's now included in supported sources - $sources = ArticleParserFactory::getSupportedSources(); - $this->assertContains('TestParser', $sources); - $this->assertCount(3, $sources); // Original 2 + 1 new - - // Verify it can be used to parse URLs - $testUrl = 'https://test-parser.com/article'; - $parser = ArticleParserFactory::getParser($testUrl); - $this->assertInstanceOf($mockParserClassName, $parser); - } - - public function test_register_parser_prevents_duplicate_registration(): void - { - // Get initial source count - $initialSources = ArticleParserFactory::getSupportedSources(); - $initialCount = count($initialSources); - - // Try to register an existing parser - ArticleParserFactory::registerParser(VrtArticleParser::class); - - // Verify count hasn't changed - $newSources = ArticleParserFactory::getSupportedSources(); - $this->assertCount($initialCount, $newSources); - $this->assertEquals($initialSources, $newSources); - } - - public function test_get_parser_uses_first_matching_parser(): void - { - // Create two mock parsers that can parse the same URL - $mockParser1 = new class implements ArticleParserInterface { - public function canParse(string $url): bool - { - return str_contains($url, 'shared-domain.com'); - } - - public function extractData(string $html): array - { - return ['parser' => 'first']; - } - - public function getSourceName(): string - { - return 'FirstParser'; - } - }; - - $mockParser2 = new class implements ArticleParserInterface { - public function canParse(string $url): bool - { - return str_contains($url, 'shared-domain.com'); - } - - public function extractData(string $html): array - { - return ['parser' => 'second']; - } - - public function getSourceName(): string - { - return 'SecondParser'; - } - }; - - $mockParser1Class = get_class($mockParser1); - $mockParser2Class = get_class($mockParser2); - - // Register both parsers - ArticleParserFactory::registerParser($mockParser1Class); - ArticleParserFactory::registerParser($mockParser2Class); - - // The first registered parser should be returned - $testUrl = 'https://shared-domain.com/article'; - $parser = ArticleParserFactory::getParser($testUrl); - - // Should return the first parser since it was registered first - $this->assertInstanceOf($mockParser1Class, $parser); - } - - public function test_factory_maintains_parser_registration_across_calls(): void - { - // Create a mock parser - $mockParser = new class implements ArticleParserInterface { - public function canParse(string $url): bool - { - return str_contains($url, 'persistent-test.com'); - } - - public function extractData(string $html): array - { - return ['title' => 'Persistent Test']; - } - - public function getSourceName(): string - { - return 'PersistentTestParser'; - } - }; - - $mockParserClass = get_class($mockParser); - - // Register the parser - ArticleParserFactory::registerParser($mockParserClass); - - // Make multiple calls to verify persistence - $parser1 = ArticleParserFactory::getParser('https://persistent-test.com/article1'); - $parser2 = ArticleParserFactory::getParser('https://persistent-test.com/article2'); - - $this->assertInstanceOf($mockParserClass, $parser1); - $this->assertInstanceOf($mockParserClass, $parser2); - - // Verify both instances are of the same class but different objects - $this->assertEquals(get_class($parser1), get_class($parser2)); - } - - public function test_get_parser_creates_new_instance_each_time(): void - { - $vrtUrl = 'https://www.vrt.be/vrtnws/nl/test/'; - - $parser1 = ArticleParserFactory::getParser($vrtUrl); - $parser2 = ArticleParserFactory::getParser($vrtUrl); - - // Should be same class but different instances - $this->assertEquals(get_class($parser1), get_class($parser2)); - $this->assertNotSame($parser1, $parser2); - } - - public function test_get_supported_sources_creates_new_instances_for_each_call(): void - { - // This test ensures that getSupportedSources doesn't cause issues - // by creating new instances each time it's called - - $sources1 = ArticleParserFactory::getSupportedSources(); - $sources2 = ArticleParserFactory::getSupportedSources(); - - $this->assertEquals($sources1, $sources2); - $this->assertCount(count($sources1), $sources2); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/Http/HttpFetcherTest.php b/tests/Unit/Services/Http/HttpFetcherTest.php deleted file mode 100644 index be62d67..0000000 --- a/tests/Unit/Services/Http/HttpFetcherTest.php +++ /dev/null @@ -1,281 +0,0 @@ -Test content'; - - Http::fake([ - $url => Http::response($expectedHtml, 200) - ]); - - $result = HttpFetcher::fetchHtml($url); - - $this->assertEquals($expectedHtml, $result); - Http::assertSent(function ($request) use ($url) { - return $request->url() === $url; - }); - } - - public function test_fetch_html_throws_exception_on_unsuccessful_response(): void - { - $url = 'https://example.com'; - $statusCode = 404; - - Http::fake([ - $url => Http::response('Not Found', $statusCode) - ]); - - $this->expectException(Exception::class); - $this->expectExceptionMessage("Failed to fetch URL: {$url} - Status: {$statusCode}"); - - HttpFetcher::fetchHtml($url); - } - - public function test_fetch_html_logs_error_on_exception(): void - { - $url = 'https://example.com'; - - Http::fake([ - $url => Http::response('Server Error', 500) - ]); - - try { - HttpFetcher::fetchHtml($url); - } catch (Exception $e) { - // Expected exception - } - - // Log assertion is complex because service uses logger() function - // Instead, verify the exception was thrown - $this->assertNotNull($e ?? null); - } - - public function test_fetch_html_handles_network_exception(): void - { - $url = 'https://example.com'; - - Http::fake(function () { - throw new Exception('Network error'); - }); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Network error'); - - HttpFetcher::fetchHtml($url); - } - - public function test_fetch_multiple_urls_returns_successful_results(): void - { - $urls = [ - 'https://example.com/page1', - 'https://example.com/page2' - ]; - - $html1 = 'Page 1'; - $html2 = 'Page 2'; - - Http::fake([ - 'https://example.com/page1' => Http::response($html1, 200), - 'https://example.com/page2' => Http::response($html2, 200) - ]); - - $results = HttpFetcher::fetchMultipleUrls($urls); - - $this->assertCount(2, $results); - - $this->assertEquals([ - 'url' => 'https://example.com/page1', - 'html' => $html1, - 'success' => true - ], $results[0]); - - $this->assertEquals([ - 'url' => 'https://example.com/page2', - 'html' => $html2, - 'success' => true - ], $results[1]); - } - - public function test_fetch_multiple_urls_handles_mixed_success_failure(): void - { - $urls = [ - 'https://example.com/success', - 'https://example.com/failure' - ]; - - $successHtml = 'Success'; - - Http::fake([ - 'https://example.com/success' => Http::response($successHtml, 200), - 'https://example.com/failure' => Http::response('Not Found', 404) - ]); - - $results = HttpFetcher::fetchMultipleUrls($urls); - - $this->assertCount(2, $results); - - // First URL should succeed - $this->assertEquals([ - 'url' => 'https://example.com/success', - 'html' => $successHtml, - 'success' => true - ], $results[0]); - - // Second URL should fail - $this->assertEquals([ - 'url' => 'https://example.com/failure', - 'html' => null, - 'success' => false, - 'status' => 404 - ], $results[1]); - } - - public function test_fetch_multiple_urls_returns_empty_array_on_exception(): void - { - $urls = ['https://example.com']; - - Http::fake(function () { - throw new \GuzzleHttp\Exception\ConnectException('Pool request failed', new \GuzzleHttp\Psr7\Request('GET', 'https://example.com')); - }); - - $results = HttpFetcher::fetchMultipleUrls($urls); - - $this->assertEquals([], $results); - - // Skip log assertion as it's complex to test with logger() function - } - - public function test_fetch_multiple_urls_handles_empty_urls_array(): void - { - $urls = []; - - $results = HttpFetcher::fetchMultipleUrls($urls); - - $this->assertEquals([], $results); - } - - public function test_fetch_multiple_urls_handles_response_exception(): void - { - $urls = ['https://example.com']; - - // Mock a response that throws an exception when accessed - Http::fake([ - 'https://example.com' => function () { - $response = Http::response('Success', 200); - // We can't easily mock an exception on the response object itself - // so we'll test this scenario differently - return $response; - } - ]); - - $results = HttpFetcher::fetchMultipleUrls($urls); - - $this->assertCount(1, $results); - $this->assertTrue($results[0]['success']); - } - - public function test_fetch_multiple_urls_filters_null_results(): void - { - // This tests the edge case where URLs array might have gaps - $urls = [ - 'https://example.com/page1', - 'https://example.com/page2' - ]; - - Http::fake([ - 'https://example.com/page1' => Http::response('Page 1', 200), - 'https://example.com/page2' => Http::response('Page 2', 200) - ]); - - $results = HttpFetcher::fetchMultipleUrls($urls); - - $this->assertCount(2, $results); - // All results should be valid (no nulls) - foreach ($results as $result) { - $this->assertNotNull($result); - $this->assertArrayHasKey('url', $result); - $this->assertArrayHasKey('success', $result); - } - } - - #[DataProvider('statusCodesProvider')] - public function test_fetch_html_with_various_status_codes(int $statusCode): void - { - $url = 'https://example.com'; - - Http::fake([ - $url => Http::response('Error', $statusCode) - ]); - - $this->expectException(Exception::class); - $this->expectExceptionMessage("Status: {$statusCode}"); - - HttpFetcher::fetchHtml($url); - } - - public static function statusCodesProvider(): array - { - return [ - [400], [401], [403], [404], [500], [502], [503] - ]; - } - - public function test_fetch_multiple_urls_preserves_url_order(): void - { - $urls = [ - 'https://example.com/first', - 'https://example.com/second', - 'https://example.com/third' - ]; - - Http::fake([ - 'https://example.com/first' => Http::response('First', 200), - 'https://example.com/second' => Http::response('Second', 200), - 'https://example.com/third' => Http::response('Third', 200) - ]); - - $results = HttpFetcher::fetchMultipleUrls($urls); - - $this->assertCount(3, $results); - $this->assertEquals('https://example.com/first', $results[0]['url']); - $this->assertEquals('https://example.com/second', $results[1]['url']); - $this->assertEquals('https://example.com/third', $results[2]['url']); - } - - public function test_fetch_html_logs_correct_error_information(): void - { - $url = 'https://example.com/test-page'; - - Http::fake([ - $url => Http::response('Forbidden', 403) - ]); - - try { - HttpFetcher::fetchHtml($url); - } catch (Exception $e) { - // Expected - } - - // Skip log assertion as service uses logger() function which is harder to test - $this->assertTrue(true); // Just verify we get here - } -} \ No newline at end of file diff --git a/tests/Unit/Services/Log/LogSaverTest.php b/tests/Unit/Services/Log/LogSaverTest.php deleted file mode 100644 index d8dc159..0000000 --- a/tests/Unit/Services/Log/LogSaverTest.php +++ /dev/null @@ -1,282 +0,0 @@ -logSaver = new LogSaver(); - } - - public function test_info_creates_log_record_with_info_level(): void - { - $message = 'Test info message'; - $context = ['key' => 'value']; - - $this->logSaver->info($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::INFO, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_error_creates_log_record_with_error_level(): void - { - $message = 'Test error message'; - $context = ['error_code' => 500]; - - $this->logSaver->error($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::ERROR, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_warning_creates_log_record_with_warning_level(): void - { - $message = 'Test warning message'; - $context = ['warning_type' => 'deprecation']; - - $this->logSaver->warning($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::WARNING, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_debug_creates_log_record_with_debug_level(): void - { - $message = 'Test debug message'; - $context = ['debug_info' => 'trace']; - - $this->logSaver->debug($message, null, $context); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::DEBUG, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals($context, $log->context); - } - - public function test_log_with_channel_includes_channel_information_in_context(): void - { - $platformInstance = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://lemmy.example.com' - ]); - - $channel = PlatformChannel::factory()->create([ - 'name' => 'Test Channel', - 'platform_instance_id' => $platformInstance->id - ]); - - $message = 'Test message with channel'; - $originalContext = ['original_key' => 'original_value']; - - $this->logSaver->info($message, $channel, $originalContext); - - $log = Log::first(); - - $expectedContext = array_merge($originalContext, [ - 'channel_id' => $channel->id, - 'channel_name' => 'Test Channel', - 'platform' => PlatformEnum::LEMMY->value, - 'instance_url' => 'https://lemmy.example.com', - ]); - - $this->assertEquals($expectedContext, $log->context); - $this->assertEquals($message, $log->message); - $this->assertEquals(LogLevelEnum::INFO, $log->level); - } - - public function test_log_without_channel_uses_original_context_only(): void - { - $message = 'Test message without channel'; - $context = ['test_key' => 'test_value']; - - $this->logSaver->info($message, null, $context); - - $log = Log::first(); - - $this->assertEquals($context, $log->context); - $this->assertEquals($message, $log->message); - } - - public function test_log_with_empty_context_creates_minimal_log(): void - { - $message = 'Simple message'; - - $this->logSaver->info($message); - - $this->assertDatabaseHas('logs', [ - 'level' => LogLevelEnum::INFO, - 'message' => $message, - ]); - - $log = Log::first(); - $this->assertEquals([], $log->context); - } - - public function test_log_with_channel_but_empty_context_includes_only_channel_info(): void - { - $platformInstance = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://test.lemmy.com' - ]); - - $channel = PlatformChannel::factory()->create([ - 'name' => 'Empty Context Channel', - 'platform_instance_id' => $platformInstance->id - ]); - - $message = 'Message with channel but no context'; - - $this->logSaver->warning($message, $channel); - - $log = Log::first(); - - $expectedContext = [ - 'channel_id' => $channel->id, - 'channel_name' => 'Empty Context Channel', - 'platform' => PlatformEnum::LEMMY->value, - 'instance_url' => 'https://test.lemmy.com', - ]; - - $this->assertEquals($expectedContext, $log->context); - $this->assertEquals(LogLevelEnum::WARNING, $log->level); - } - - public function test_context_merging_preserves_original_keys_and_adds_channel_info(): void - { - $platformInstance = PlatformInstance::factory()->create([ - 'platform' => PlatformEnum::LEMMY, - 'url' => 'https://merge.lemmy.com' - ]); - - $channel = PlatformChannel::factory()->create([ - 'name' => 'Merge Test Channel', - 'platform_instance_id' => $platformInstance->id - ]); - - $originalContext = [ - 'article_id' => 123, - 'user_action' => 'publish', - 'timestamp' => '2024-01-01 12:00:00' - ]; - - $this->logSaver->error('Context merge test', $channel, $originalContext); - - $log = Log::first(); - - $expectedContext = [ - 'article_id' => 123, - 'user_action' => 'publish', - 'timestamp' => '2024-01-01 12:00:00', - 'channel_id' => $channel->id, - 'channel_name' => 'Merge Test Channel', - 'platform' => PlatformEnum::LEMMY->value, - 'instance_url' => 'https://merge.lemmy.com', - ]; - - $this->assertEquals($expectedContext, $log->context); - } - - public function test_multiple_logs_are_created_independently(): void - { - $this->logSaver->info('First message', null, ['id' => 1]); - $this->logSaver->error('Second message', null, ['id' => 2]); - $this->logSaver->warning('Third message', null, ['id' => 3]); - - $this->assertDatabaseCount('logs', 3); - - $logs = Log::orderBy('id')->get(); - - $this->assertEquals(LogLevelEnum::INFO, $logs[0]->level); - $this->assertEquals('First message', $logs[0]->message); - $this->assertEquals(['id' => 1], $logs[0]->context); - - $this->assertEquals(LogLevelEnum::ERROR, $logs[1]->level); - $this->assertEquals('Second message', $logs[1]->message); - $this->assertEquals(['id' => 2], $logs[1]->context); - - $this->assertEquals(LogLevelEnum::WARNING, $logs[2]->level); - $this->assertEquals('Third message', $logs[2]->message); - $this->assertEquals(['id' => 3], $logs[2]->context); - } - - public function test_log_with_complex_context_data(): void - { - $complexContext = [ - 'nested' => [ - 'array' => ['value1', 'value2'], - 'object' => ['key' => 'value'] - ], - 'numbers' => [1, 2, 3.14], - 'boolean' => true, - 'null_value' => null - ]; - - $this->logSaver->debug('Complex context test', null, $complexContext); - - $log = Log::first(); - $this->assertEquals($complexContext, $log->context); - } - - public function test_each_log_level_method_delegates_to_private_log_method(): void - { - $message = 'Test message'; - $context = ['test' => true]; - - // Test all four log level methods - $this->logSaver->info($message, null, $context); - $this->logSaver->error($message, null, $context); - $this->logSaver->warning($message, null, $context); - $this->logSaver->debug($message, null, $context); - - // Should have 4 log entries - $this->assertDatabaseCount('logs', 4); - - $logs = Log::orderBy('id')->get(); - - // Verify each level was set correctly - $this->assertEquals(LogLevelEnum::INFO, $logs[0]->level); - $this->assertEquals(LogLevelEnum::ERROR, $logs[1]->level); - $this->assertEquals(LogLevelEnum::WARNING, $logs[2]->level); - $this->assertEquals(LogLevelEnum::DEBUG, $logs[3]->level); - - // All should have the same message and context - foreach ($logs as $log) { - $this->assertEquals($message, $log->message); - $this->assertEquals($context, $log->context); - } - } -} \ No newline at end of file diff --git a/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php b/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php deleted file mode 100644 index cb99b5e..0000000 --- a/tests/Unit/Services/Parsers/BelgaArticlePageParserTest.php +++ /dev/null @@ -1,334 +0,0 @@ -'; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertEquals('Test Article Title', $title); - } - - public function test_extract_title_from_h1_tag(): void - { - $html = '

H1 Title Test

'; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertEquals('H1 Title Test', $title); - } - - public function test_extract_title_from_title_tag(): void - { - $html = 'Page Title Test'; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertEquals('Page Title Test', $title); - } - - public function test_extract_title_with_html_entities(): void - { - $html = ''; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertEquals('Test & Article "Title"', $title); - } - - public function test_extract_title_returns_null_when_not_found(): void - { - $html = '

No title here

'; - - $title = BelgaArticlePageParser::extractTitle($html); - - $this->assertNull($title); - } - - public function test_extract_description_from_og_meta_tag(): void - { - $html = ''; - - $description = BelgaArticlePageParser::extractDescription($html); - - $this->assertEquals('Test article description', $description); - } - - public function test_extract_description_from_paragraph(): void - { - $html = '

This is the first paragraph description.

'; - - $description = BelgaArticlePageParser::extractDescription($html); - - $this->assertEquals('This is the first paragraph description.', $description); - } - - public function test_extract_description_with_html_entities(): void - { - $html = ''; - - $description = BelgaArticlePageParser::extractDescription($html); - - $this->assertEquals('Description with & entities ', $description); - } - - public function test_extract_description_returns_null_when_not_found(): void - { - $html = '
No description here
'; - - $description = BelgaArticlePageParser::extractDescription($html); - - $this->assertNull($description); - } - - public function test_extract_full_article_from_belga_paragraph_class(): void - { - $html = ' - - -

First paragraph content.

-

Second paragraph content.

-

This should be ignored.

- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "First paragraph content.\n\nSecond paragraph content."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_filters_empty_paragraphs(): void - { - $html = ' - - -

Content paragraph.

-

-

-

Another content paragraph.

- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "Content paragraph.\n\nAnother content paragraph."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_handles_nested_tags(): void - { - $html = ' - - -

This has bold text and italic text.

-

This has a link inside.

- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "This has bold text and italic text.\n\nThis has a link inside."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_removes_scripts_and_styles(): void - { - $html = ' - - - - - - -

Clean content.

- - - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $this->assertEquals('Clean content.', $fullArticle); - $this->assertStringNotContainsString('console.log', $fullArticle); - $this->assertStringNotContainsString('alert', $fullArticle); - $this->assertStringNotContainsString('color: red', $fullArticle); - } - - public function test_extract_full_article_fallback_to_prezly_document(): void - { - $html = ' - - -
-

Content from prezly section.

-

More prezly content.

-
- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "Content from prezly section.\n\nMore prezly content."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_fallback_to_all_paragraphs(): void - { - $html = ' - - -

First general paragraph.

-

Second general paragraph.

- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $expected = "First general paragraph.\n\nSecond general paragraph."; - $this->assertEquals($expected, $fullArticle); - } - - public function test_extract_full_article_returns_null_when_no_content(): void - { - $html = '
No paragraphs here
'; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $this->assertNull($fullArticle); - } - - public function test_extract_thumbnail_from_og_image(): void - { - $html = ''; - - $thumbnail = BelgaArticlePageParser::extractThumbnail($html); - - $this->assertEquals('https://example.com/image.jpg', $thumbnail); - } - - public function test_extract_thumbnail_from_img_tag(): void - { - $html = 'test'; - - $thumbnail = BelgaArticlePageParser::extractThumbnail($html); - - $this->assertEquals('https://example.com/article-image.png', $thumbnail); - } - - public function test_extract_thumbnail_prefers_og_image(): void - { - $html = ' - - - test - - '; - - $thumbnail = BelgaArticlePageParser::extractThumbnail($html); - - $this->assertEquals('https://example.com/og-image.jpg', $thumbnail); - } - - public function test_extract_thumbnail_returns_null_when_not_found(): void - { - $html = '
No images here
'; - - $thumbnail = BelgaArticlePageParser::extractThumbnail($html); - - $this->assertNull($thumbnail); - } - - public function test_extract_data_returns_all_components(): void - { - $html = ' - - - - - - - -

Full article content here.

- - - '; - - $data = BelgaArticlePageParser::extractData($html); - - $this->assertIsArray($data); - $this->assertArrayHasKey('title', $data); - $this->assertArrayHasKey('description', $data); - $this->assertArrayHasKey('full_article', $data); - $this->assertArrayHasKey('thumbnail', $data); - - $this->assertEquals('Test Article', $data['title']); - $this->assertEquals('Test description', $data['description']); - $this->assertEquals('Full article content here.', $data['full_article']); - $this->assertEquals('https://example.com/image.jpg', $data['thumbnail']); - } - - public function test_extract_data_handles_missing_components_gracefully(): void - { - $html = '
Minimal content
'; - - $data = BelgaArticlePageParser::extractData($html); - - $this->assertIsArray($data); - $this->assertArrayHasKey('title', $data); - $this->assertArrayHasKey('description', $data); - $this->assertArrayHasKey('full_article', $data); - $this->assertArrayHasKey('thumbnail', $data); - - $this->assertNull($data['title']); - $this->assertNull($data['description']); - $this->assertNull($data['full_article']); - $this->assertNull($data['thumbnail']); - } - - /** - * Test based on actual Belga HTML structure from real article - */ - public function test_extract_full_article_with_realistic_belga_html(): void - { - $html = ' - - -
-
-

Around 110,000 people joined the Antwerp Pride Parade on Saturday afternoon, according to police.

-

The event passed without major incidents. Earlier in the day, far-right group Voorpost held a pre-approved protest.

-

Police say they expect no problems with crowd dispersal, as departures will be staggered.

-
-
- - - '; - - $fullArticle = BelgaArticlePageParser::extractFullArticle($html); - - $this->assertNotNull($fullArticle); - $this->assertStringContainsString('110,000 people joined', $fullArticle); - $this->assertStringContainsString('major incidents', $fullArticle); - $this->assertStringContainsString('crowd dispersal', $fullArticle); - - // Should join paragraphs with double newlines - $this->assertStringContainsString("\n\n", $fullArticle); - - // Should strip HTML tags - $this->assertStringNotContainsString('', $fullArticle); - $this->assertStringNotContainsString('', $fullArticle); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php b/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php deleted file mode 100644 index ed58673..0000000 --- a/tests/Unit/Services/Publishing/ArticlePublishingServiceTest.php +++ /dev/null @@ -1,299 +0,0 @@ -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 - { - Mockery::close(); - parent::tearDown(); - } - - public function test_publish_to_routed_channels_throws_exception_for_invalid_article(): void - { - $article = Article::factory()->create(['approval_status' => 'rejected']); - $extractedData = ['title' => 'Test Title']; - - $this->expectException(PublishException::class); - $this->expectExceptionMessage('CANNOT_PUBLISH_INVALID_ARTICLE'); - - $this->service->publishToRoutedChannels($article, $extractedData); - } - - public function test_publish_to_routed_channels_returns_empty_collection_when_no_active_routes(): void - { - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved' - ]); - $extractedData = ['title' => 'Test Title']; - - $result = $this->service->publishToRoutedChannels($article, $extractedData); - - $this->assertInstanceOf(EloquentCollection::class, $result); - $this->assertTrue($result->isEmpty()); - } - - public function test_publish_to_routed_channels_skips_routes_without_active_accounts(): void - { - // Arrange: valid article - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'approval_status' => 'approved', - ]); - - // Create a route with a channel but no active accounts - $channel = PlatformChannel::factory()->create(); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Don't create any platform accounts for the channel - - // Act - $result = $this->service->publishToRoutedChannels($article, ['title' => 'Test']); - - // Assert - $this->assertTrue($result->isEmpty()); - $this->assertDatabaseCount('article_publications', 0); - } - - public function test_publish_to_routed_channels_successfully_publishes_to_channel(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); - - $platformInstance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - // Create route - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Attach account to channel as active - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - - // Mock publisher via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once() - ->andReturn(['post_view' => ['post' => ['id' => 123]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(1, $result); - $this->assertDatabaseHas('article_publications', [ - 'article_id' => $article->id, - 'platform_channel_id' => $channel->id, - 'post_id' => 123, - 'published_by' => $account->username, - ]); - } - - public function test_publish_to_routed_channels_handles_publishing_failure_gracefully(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); - - $platformInstance = PlatformInstance::factory()->create(); - $channel = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account = PlatformAccount::factory()->create(); - - // Create route - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Attach account to channel as active - $channel->platformAccounts()->attach($account->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - - // Publisher throws an exception via service seam - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once() - ->andThrow(new Exception('network error')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertTrue($result->isEmpty()); - $this->assertDatabaseCount('article_publications', 0); - } - - public function test_publish_to_routed_channels_publishes_to_multiple_routes(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 100]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 200]]]); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(2, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 100]); - $this->assertDatabaseHas('article_publications', ['post_id' => 200]); - } - - public function test_publish_to_routed_channels_filters_out_failed_publications(): void - { - // Arrange - $feed = Feed::factory()->create(); - $article = Article::factory()->create(['feed_id' => $feed->id, 'approval_status' => 'approved']); - - $platformInstance = PlatformInstance::factory()->create(); - $channel1 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $channel2 = PlatformChannel::factory()->create(['platform_instance_id' => $platformInstance->id]); - $account1 = PlatformAccount::factory()->create(); - $account2 = PlatformAccount::factory()->create(); - - // Create routes - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - Route::create([ - 'feed_id' => $feed->id, - 'platform_channel_id' => $channel2->id, - 'is_active' => true, - 'priority' => 50 - ]); - - // Attach accounts to channels as active - $channel1->platformAccounts()->attach($account1->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - $channel2->platformAccounts()->attach($account2->id, [ - 'is_active' => true, - 'priority' => 50 - ]); - - $publisherDouble = \Mockery::mock(LemmyPublisher::class); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andReturn(['post_view' => ['post' => ['id' => 300]]]); - $publisherDouble->shouldReceive('publishToChannel') - ->once()->andThrow(new Exception('failed')); - $service = \Mockery::mock(ArticlePublishingService::class, [$this->logSaver])->makePartial(); - $service->shouldAllowMockingProtectedMethods(); - $service->shouldReceive('makePublisher')->andReturn($publisherDouble); - - // Act - $result = $service->publishToRoutedChannels($article, ['title' => 'Hello']); - - // Assert - $this->assertCount(1, $result); - $this->assertDatabaseHas('article_publications', ['post_id' => 300]); - $this->assertDatabaseCount('article_publications', 1); - } -} diff --git a/tests/Unit/Services/Publishing/KeywordFilteringTest.php b/tests/Unit/Services/Publishing/KeywordFilteringTest.php deleted file mode 100644 index 0b98504..0000000 --- a/tests/Unit/Services/Publishing/KeywordFilteringTest.php +++ /dev/null @@ -1,276 +0,0 @@ -shouldReceive('info')->zeroOrMoreTimes(); - $logSaver->shouldReceive('warning')->zeroOrMoreTimes(); - $logSaver->shouldReceive('error')->zeroOrMoreTimes(); - $logSaver->shouldReceive('debug')->zeroOrMoreTimes(); - $this->service = new ArticlePublishingService($logSaver); - $this->feed = Feed::factory()->create(); - $this->channel1 = PlatformChannel::factory()->create(); - $this->channel2 = PlatformChannel::factory()->create(); - - // Create routes - $this->route1 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'is_active' => true, - 'priority' => 100 - ]); - - $this->route2 = Route::create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'is_active' => true, - 'priority' => 50 - ]); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_route_with_no_keywords_matches_all_articles(): void - { - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'Some random article', - 'description' => 'This is about something', - 'full_article' => 'The content talks about various topics' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route with no keywords should match any article'); - } - - public function test_route_with_keywords_matches_article_containing_keyword(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => true - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Route should match article containing keyword "Belgium"'); - } - - public function test_route_with_keywords_does_not_match_article_without_keywords(): void - { - // Add keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'sports', - 'is_active' => true - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'football', - 'is_active' => true - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'Economic news update', - 'description' => 'Markets are doing well', - 'full_article' => 'The economy is showing strong growth this quarter...' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertFalse($result, 'Route should not match article without any keywords'); - } - - public function test_inactive_keywords_are_ignored(): void - { - // Add active and inactive keywords to route1 - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'Belgium', - 'is_active' => false // Inactive - ]); - - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'politics', - 'is_active' => true // Active - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedDataWithInactiveKeyword = [ - 'title' => 'Belgium announces new policy', - 'description' => 'The government makes changes', - 'full_article' => 'The Belgian government announced today...' - ]; - - $extractedDataWithActiveKeyword = [ - 'title' => 'Political changes ahead', - 'description' => 'Politics is changing', - 'full_article' => 'The political landscape is shifting...' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithInactiveKeyword]); - $result2 = $method->invokeArgs($this->service, [$this->route1, $extractedDataWithActiveKeyword]); - - $this->assertFalse($result1, 'Route should not match article with inactive keyword'); - $this->assertTrue($result2, 'Route should match article with active keyword'); - } - - public function test_keyword_matching_is_case_insensitive(): void - { - Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'BELGIUM', - 'is_active' => true - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'belgium news', - 'description' => 'About Belgium', - 'full_article' => 'News from belgium today...' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - - $this->assertTrue($result, 'Keyword matching should be case insensitive'); - } - - public function test_keywords_match_in_title_description_and_content(): void - { - $keywordInTitle = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel1->id, - 'keyword' => 'title-word', - 'is_active' => true - ]); - - $keywordInDescription = Keyword::factory()->create([ - 'feed_id' => $this->feed->id, - 'platform_channel_id' => $this->channel2->id, - 'keyword' => 'desc-word', - 'is_active' => true - ]); - - $article = Article::factory()->create([ - 'feed_id' => $this->feed->id, - 'approval_status' => 'approved' - ]); - - $extractedData = [ - 'title' => 'This contains title-word', - 'description' => 'This has desc-word in it', - 'full_article' => 'The content has no special words' - ]; - - // Use reflection to test private method - $reflection = new \ReflectionClass($this->service); - $method = $reflection->getMethod('routeMatchesArticle'); - $method->setAccessible(true); - - $result1 = $method->invokeArgs($this->service, [$this->route1, $extractedData]); - $result2 = $method->invokeArgs($this->service, [$this->route2, $extractedData]); - - $this->assertTrue($result1, 'Should match keyword in title'); - $this->assertTrue($result2, 'Should match keyword in description'); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/SystemStatusServiceTest.php b/tests/Unit/Services/SystemStatusServiceTest.php deleted file mode 100644 index cd1c863..0000000 --- a/tests/Unit/Services/SystemStatusServiceTest.php +++ /dev/null @@ -1,44 +0,0 @@ - Http::response('', 500) - ]); - } - - public function test_service_instantiation(): void - { - $service = new SystemStatusService(); - $this->assertInstanceOf(SystemStatusService::class, $service); - } - - public function test_get_system_status_returns_correct_structure(): void - { - $service = new SystemStatusService(); - $status = $service->getSystemStatus(); - - $this->assertIsArray($status); - $this->assertArrayHasKey('is_enabled', $status); - $this->assertArrayHasKey('status', $status); - $this->assertArrayHasKey('status_class', $status); - $this->assertArrayHasKey('reasons', $status); - - // Without database setup, system should be disabled - $this->assertFalse($status['is_enabled']); - $this->assertEquals('Disabled', $status['status']); - $this->assertEquals('text-red-600', $status['status_class']); - $this->assertIsArray($status['reasons']); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/ValidationServiceKeywordTest.php b/tests/Unit/Services/ValidationServiceKeywordTest.php deleted file mode 100644 index beaf87c..0000000 --- a/tests/Unit/Services/ValidationServiceKeywordTest.php +++ /dev/null @@ -1,210 +0,0 @@ -createArticleFetcher(); - $this->validationService = new ValidationService($articleFetcher); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * Helper method to access private validateByKeywords method - */ - private function getValidateByKeywordsMethod(): ReflectionMethod - { - $reflection = new ReflectionClass($this->validationService); - $method = $reflection->getMethod('validateByKeywords'); - $method->setAccessible(true); - return $method; - } - - public function test_validates_belgian_political_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This article discusses N-VA party policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Bart De Wever made a statement today.')); - $this->assertTrue($method->invoke($this->validationService, 'Frank Vandenbroucke announced new healthcare policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Alexander De Croo addressed the nation.')); - $this->assertTrue($method->invoke($this->validationService, 'The Vooruit party proposed new legislation.')); - $this->assertTrue($method->invoke($this->validationService, 'Open Vld supports the new budget.')); - $this->assertTrue($method->invoke($this->validationService, 'CD&V members voted on the proposal.')); - $this->assertTrue($method->invoke($this->validationService, 'Vlaams Belang criticized the decision.')); - $this->assertTrue($method->invoke($this->validationService, 'PTB organized a protest yesterday.')); - $this->assertTrue($method->invoke($this->validationService, 'PVDA released a statement.')); - } - - public function test_validates_belgian_location_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This event took place in Belgium.')); - $this->assertTrue($method->invoke($this->validationService, 'The Belgian government announced new policies.')); - $this->assertTrue($method->invoke($this->validationService, 'Flanders saw increased tourism this year.')); - $this->assertTrue($method->invoke($this->validationService, 'The Flemish government supports this initiative.')); - $this->assertTrue($method->invoke($this->validationService, 'Wallonia will receive additional funding.')); - $this->assertTrue($method->invoke($this->validationService, 'Brussels hosted the international conference.')); - $this->assertTrue($method->invoke($this->validationService, 'Antwerp Pride attracted thousands of participants.')); - $this->assertTrue($method->invoke($this->validationService, 'Ghent University published the research.')); - $this->assertTrue($method->invoke($this->validationService, 'Bruges tourism numbers increased.')); - $this->assertTrue($method->invoke($this->validationService, 'Leuven students organized the protest.')); - $this->assertTrue($method->invoke($this->validationService, 'Mechelen city council voted on the proposal.')); - $this->assertTrue($method->invoke($this->validationService, 'Namur hosted the cultural event.')); - $this->assertTrue($method->invoke($this->validationService, 'Liège airport saw increased traffic.')); - $this->assertTrue($method->invoke($this->validationService, 'Charleroi industrial zone expanded.')); - } - - public function test_validates_government_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'Parliament voted on the new legislation.')); - $this->assertTrue($method->invoke($this->validationService, 'The government announced budget cuts.')); - $this->assertTrue($method->invoke($this->validationService, 'The minister addressed concerns about healthcare.')); - $this->assertTrue($method->invoke($this->validationService, 'New policy changes will take effect next month.')); - $this->assertTrue($method->invoke($this->validationService, 'The law was passed with majority support.')); - $this->assertTrue($method->invoke($this->validationService, 'New legislation affects education funding.')); - } - - public function test_validates_news_topic_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'The economy showed signs of recovery.')); - $this->assertTrue($method->invoke($this->validationService, 'Economic indicators improved this quarter.')); - $this->assertTrue($method->invoke($this->validationService, 'Education reforms were announced today.')); - $this->assertTrue($method->invoke($this->validationService, 'Healthcare workers received additional support.')); - $this->assertTrue($method->invoke($this->validationService, 'Transport infrastructure will be upgraded.')); - $this->assertTrue($method->invoke($this->validationService, 'Climate change policies were discussed.')); - $this->assertTrue($method->invoke($this->validationService, 'Energy prices have increased significantly.')); - $this->assertTrue($method->invoke($this->validationService, 'European Union voted on trade agreements.')); - $this->assertTrue($method->invoke($this->validationService, 'EU sanctions were extended.')); - $this->assertTrue($method->invoke($this->validationService, 'Migration policies need urgent review.')); - $this->assertTrue($method->invoke($this->validationService, 'Security measures were enhanced.')); - $this->assertTrue($method->invoke($this->validationService, 'Justice system reforms are underway.')); - $this->assertTrue($method->invoke($this->validationService, 'Culture festivals received government funding.')); - $this->assertTrue($method->invoke($this->validationService, 'Police reported 18 administrative detentions.')); - } - - public function test_case_insensitive_keyword_matching(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertTrue($method->invoke($this->validationService, 'This article mentions ANTWERP in capital letters.')); - $this->assertTrue($method->invoke($this->validationService, 'brussels is mentioned in lowercase.')); - $this->assertTrue($method->invoke($this->validationService, 'BeLgIuM is mentioned in mixed case.')); - $this->assertTrue($method->invoke($this->validationService, 'The FLEMISH government announced policies.')); - $this->assertTrue($method->invoke($this->validationService, 'n-va party policies were discussed.')); - $this->assertTrue($method->invoke($this->validationService, 'EUROPEAN union directives apply.')); - } - - public function test_rejects_content_without_belgian_keywords(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertFalse($method->invoke($this->validationService, 'This article discusses random topics.')); - $this->assertFalse($method->invoke($this->validationService, 'International news from other countries.')); - $this->assertFalse($method->invoke($this->validationService, 'Technology updates and innovations.')); - $this->assertFalse($method->invoke($this->validationService, 'Sports results from around the world.')); - $this->assertFalse($method->invoke($this->validationService, 'Entertainment news and celebrity gossip.')); - $this->assertFalse($method->invoke($this->validationService, 'Weather forecast for next week.')); - $this->assertFalse($method->invoke($this->validationService, 'Stock market analysis and trends.')); - } - - public function test_keyword_matching_in_longer_text(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $longText = ' - This is a comprehensive article about various topics. - It covers international relations, global economics, and regional policies. - However, it specifically mentions that Antwerp hosted a major conference - last week with participants from around the world. The event was - considered highly successful and will likely be repeated next year. - '; - - $this->assertTrue($method->invoke($this->validationService, $longText)); - - $longTextWithoutKeywords = ' - This is a comprehensive article about various topics. - It covers international relations, global finance, and commercial matters. - The conference was held in a major international city and attracted - participants from around the world. The event was considered highly - successful and will likely be repeated next year. - '; - - $this->assertFalse($method->invoke($this->validationService, $longTextWithoutKeywords)); - } - - public function test_empty_content_returns_false(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $this->assertFalse($method->invoke($this->validationService, '')); - $this->assertFalse($method->invoke($this->validationService, ' ')); - $this->assertFalse($method->invoke($this->validationService, "\n\n\t")); - } - - /** - * Test comprehensive keyword coverage to ensure all expected keywords work - */ - public function test_all_keywords_are_functional(): void - { - $method = $this->getValidateByKeywordsMethod(); - - $expectedKeywords = [ - // Political parties and leaders - 'N-VA', 'Bart De Wever', 'Frank Vandenbroucke', 'Alexander De Croo', - 'Vooruit', 'Open Vld', 'CD&V', 'Vlaams Belang', 'PTB', 'PVDA', - - // Belgian locations and institutions - 'Belgium', 'Belgian', 'Flanders', 'Flemish', 'Wallonia', 'Brussels', - 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Mechelen', 'Namur', 'Liège', 'Charleroi', - 'parliament', 'government', 'minister', 'policy', 'law', 'legislation', - - // Common Belgian news topics - 'economy', 'economic', 'education', 'healthcare', 'transport', 'climate', 'energy', - 'European', 'EU', 'migration', 'security', 'justice', 'culture', 'police' - ]; - - foreach ($expectedKeywords as $keyword) { - $testContent = "This article contains the keyword: {$keyword}."; - $result = $method->invoke($this->validationService, $testContent); - - $this->assertTrue($result, "Keyword '{$keyword}' should match but didn't"); - } - } - - public function test_partial_keyword_matches_work(): void - { - $method = $this->getValidateByKeywordsMethod(); - - // Keywords should match when they appear as part of larger words or phrases - $this->assertTrue($method->invoke($this->validationService, 'Anti-government protesters gathered.')); - $this->assertTrue($method->invoke($this->validationService, 'The policeman directed traffic.')); - $this->assertTrue($method->invoke($this->validationService, 'Educational reforms are needed.')); - $this->assertTrue($method->invoke($this->validationService, 'Economic growth accelerated.')); - $this->assertTrue($method->invoke($this->validationService, 'The European directive was implemented.')); - } -} \ No newline at end of file diff --git a/tests/Unit/Services/ValidationServiceTest.php b/tests/Unit/Services/ValidationServiceTest.php deleted file mode 100644 index 97df0b9..0000000 --- a/tests/Unit/Services/ValidationServiceTest.php +++ /dev/null @@ -1,164 +0,0 @@ -createArticleFetcher(); - $this->validationService = new ValidationService($articleFetcher); - } - - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - public function test_validate_returns_article_with_validation_status(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Test content with Belgium news', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending' - ]); - - $result = $this->validationService->validate($article); - - $this->assertInstanceOf(Article::class, $result); - $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); - } - - public function test_validate_marks_article_invalid_when_missing_data(): void - { - // Mock HTTP requests to return HTML without article content - Http::fake([ - 'https://invalid-url-without-parser.com/article' => Http::response('Empty', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://invalid-url-without-parser.com/article', - 'approval_status' => 'pending' - ]); - - $result = $this->validationService->validate($article); - - $this->assertEquals('rejected', $result->approval_status); - } - - public function test_validate_with_supported_article_content(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending' - ]); - - $result = $this->validationService->validate($article); - - // Since we can't fetch real content in tests, it should be marked rejected - $this->assertEquals('rejected', $result->approval_status); - } - - public function test_validate_updates_article_in_database(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'pending' - ]); - - $originalId = $article->id; - - $this->validationService->validate($article); - - // Check that the article was updated in the database - $updatedArticle = Article::find($originalId); - $this->assertContains($updatedArticle->approval_status, ['pending', 'approved', 'rejected']); - } - - public function test_validate_handles_article_with_existing_validation(): void - { - // Mock HTTP requests - Http::fake([ - 'https://example.com/article' => Http::response('Article content', 200) - ]); - - $feed = Feed::factory()->create(); - $article = Article::factory()->create([ - 'feed_id' => $feed->id, - 'url' => 'https://example.com/article', - 'approval_status' => 'approved' - ]); - - $originalApprovalStatus = $article->approval_status; - - $result = $this->validationService->validate($article); - - // Should re-validate - status may change based on content validation - $this->assertContains($result->approval_status, ['pending', 'approved', 'rejected']); - } - - public function test_validate_keyword_checking_logic(): void - { - // Mock HTTP requests with content that contains Belgian keywords - Http::fake([ - 'https://example.com/article-about-bart-de-wever' => Http::response( - '
Article about Bart De Wever and Belgian politics
', - 200 - ) - ]); - - $feed = Feed::factory()->create(); - - // 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', - 'approval_status' => 'pending' - ]); - - $result = $this->validationService->validate($article); - - // The service looks for keywords in the full_article content - // Since we can't fetch real content, it will be marked rejected - $this->assertEquals('rejected', $result->approval_status); - } -} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f905a3e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,122 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ESNext" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "bundler" /* Specify how TypeScript looks up a file from a given module specifier. */, + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + "noEmit": true /* Disable emitting files from a compilation. */, + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + "noImplicitAny": true /* Enable error reporting for expressions and declarations with an implied 'any' type. */, + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "baseUrl": ".", + "paths": { + "@/*": ["./resources/js/*"], + "ziggy-js": ["./vendor/tightenco/ziggy"] + }, + "jsx": "react-jsx" + }, + "include": [ + "resources/js/**/*.ts", + "resources/js/**/*.d.ts", + "resources/js/**/*.tsx", + ] +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 9e105b4..0000000 --- a/vite.config.js +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], - refresh: true, - }), - ], - server: { - host: '0.0.0.0', - port: 5173, - strictPort: true, - cors: true, - hmr: { - host: 'localhost', - }, - }, -}); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..290d90e --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,25 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import laravel from 'laravel-vite-plugin'; +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.tsx'], + ssr: 'resources/js/ssr.tsx', + refresh: true, + }), + react(), + tailwindcss(), + ], + esbuild: { + jsx: 'automatic', + }, + resolve: { + alias: { + 'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'), + }, + }, +});