diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faaf909..b6a9a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,30 +5,32 @@ on: [push] jobs: build-test: runs-on: ubuntu-latest - container: - image: php:8.4 # This forces the job to run in a Docker container steps: - name: Checkout uses: actions/checkout@v3 - - name: Install System Dependencies (Git, Zip, Unzip) - run: | - apt-get update - apt-get install -y unzip git zip - - - name: Install and Enable extensions - run: | - docker-php-ext-install sockets calendar - docker-php-ext-enable sockets calendar - - - name: Install Composer - run: | - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - composer --version + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: pdo, pdo_sqlite, sockets, calendar, pcov + coverage: pcov - name: Install Dependencies - run: composer install --prefer-dist --no-progress - - - name: Run PHPUnit - run: vendor/bin/phpunit tests + run: composer install --prefer-dist --no-progress --no-interaction --optimize-autoloader + + - name: Run Unit Tests with Coverage + run: vendor/bin/phpunit -c tests/phpunit.xml --testsuite=unit --coverage-clover coverage.xml --coverage-filter src + + - name: Run Integration Tests + run: vendor/bin/phpunit -c tests/phpunit.xml --testsuite=integration + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + flags: cms + slug: Neuron-PHP/cms + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index b96832e..e216470 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ cache.properties composer.lock .phpunit.result.cache examples/test.log + +coverage.xml diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..67e2928 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,463 @@ +# Database Migrations Guide + +This document provides comprehensive guidance for working with database migrations in Neuron CMS. + +## Table of Contents + +1. [Core Principles](#core-principles) +2. [Migration Workflow](#migration-workflow) +3. [Common Scenarios](#common-scenarios) +4. [Upgrade Path Considerations](#upgrade-path-considerations) +5. [Best Practices](#best-practices) +6. [Troubleshooting](#troubleshooting) + +## Core Principles + +### Never Modify Existing Migrations + +**CRITICAL RULE: Once a migration has been committed to the repository, NEVER modify it.** + +**Why?** +- Phinx tracks which migrations have been executed using a `phinxlog` table +- Existing installations have already run the original migration +- Modifying an existing migration will NOT update those installations +- This creates schema drift between installations + +**Example of What NOT to Do:** + +```php +// ❌ WRONG: Editing cms/resources/database/migrate/20250111000000_create_users_table.php +// to add a new column after it's already been committed +public function change() +{ + $table = $this->table( 'users' ); + $table->addColumn( 'username', 'string' ) + ->addColumn( 'email', 'string' ) + ->addColumn( 'new_column', 'string' ) // DON'T ADD THIS HERE! + ->create(); +} +``` + +### Always Create New Migrations for Schema Changes + +**Correct Approach:** Create a new migration file with a new timestamp. + +```php +// ✅ CORRECT: Create cms/resources/database/migrate/20251205000000_add_new_column_to_users.php +use Phinx\Migration\AbstractMigration; + +class AddNewColumnToUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->addColumn( 'new_column', 'string', [ 'null' => true ] ) + ->update(); + } +} +``` + +## Migration Workflow + +### Creating a New Migration + +1. **Generate migration file with timestamp:** + ```bash + # Format: YYYYMMDDHHMMSS_description_of_change.php + # Example: 20251205143000_add_timezone_to_users.php + ``` + +2. **Use descriptive names:** + - `add_[column]_to_[table].php` - Adding columns + - `remove_[column]_from_[table].php` - Removing columns + - `create_[table]_table.php` - Creating new tables + - `rename_[old]_to_[new]_in_[table].php` - Renaming columns + +3. **Place migrations in the correct location:** + - CMS component: `cms/resources/database/migrate/` + - Test installations: `testing/*/db/migrate/` + +### Implementing the Migration + +```php +table( 'users' ); + + $table->addColumn( 'timezone', 'string', [ + 'limit' => 50, + 'default' => 'UTC', + 'null' => false, + 'after' => 'last_login_at' // Optional: specify column position + ]) + ->update(); + } +} +``` + +### Testing the Migration + +1. **Test in development environment:** + ```bash + php neuron db:migrate + ``` + +2. **Test rollback (if applicable):** + ```bash + php neuron db:rollback + ``` + +3. **Verify schema changes:** + ```bash + # SQLite + sqlite3 storage/database.sqlite3 "PRAGMA table_info(users);" + + # MySQL + mysql -u user -p -e "DESCRIBE users;" database_name + ``` + +## Common Scenarios + +### Adding a Column to an Existing Table + +```php +class AddRecoveryCodeToUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->addColumn( 'two_factor_recovery_codes', 'text', [ + 'null' => true, + 'comment' => 'JSON-encoded recovery codes for 2FA' + ]) + ->update(); + } +} +``` + +### Adding Multiple Columns + +```php +class AddUserPreferences extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->addColumn( 'timezone', 'string', [ 'limit' => 50, 'default' => 'UTC' ] ) + ->addColumn( 'language', 'string', [ 'limit' => 10, 'default' => 'en' ] ) + ->addColumn( 'theme', 'string', [ 'limit' => 20, 'default' => 'light' ] ) + ->update(); + } +} +``` + +### Renaming a Column + +```php +class RenamePasswordHashInUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->renameColumn( 'password_hash', 'hashed_password' ) + ->update(); + } +} +``` + +### Adding an Index + +```php +class AddTimezoneIndexToUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + $table->addIndex( [ 'timezone' ], [ 'name' => 'idx_users_timezone' ] ) + ->update(); + } +} +``` + +### Modifying a Column (Breaking Change) + +When you need to change a column's type or constraints: + +```php +class ModifyEmailColumnInUsers extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'users' ); + + // Phinx doesn't directly support changeColumn in all cases + // You may need to use raw SQL for complex changes + $table->changeColumn( 'email', 'string', [ + 'limit' => 320, // Changed from 255 to support longer emails + 'null' => false + ]) + ->update(); + } +} +``` + +### Creating a New Table (with Foreign Keys) + +```php +class CreateSessionsTable extends AbstractMigration +{ + public function change() + { + $table = $this->table( 'sessions' ); + + $table->addColumn( 'user_id', 'integer', [ 'null' => false ] ) + ->addColumn( 'token', 'string', [ 'limit' => 64 ] ) + ->addColumn( 'ip_address', 'string', [ 'limit' => 45, 'null' => true ] ) + ->addColumn( 'user_agent', 'string', [ 'limit' => 255, 'null' => true ] ) + ->addColumn( 'expires_at', 'timestamp', [ 'null' => false ] ) + ->addColumn( 'created_at', 'timestamp', [ 'default' => 'CURRENT_TIMESTAMP' ] ) + ->addIndex( [ 'token' ], [ 'unique' => true ] ) + ->addIndex( [ 'user_id' ] ) + ->addForeignKey( 'user_id', 'users', 'id', [ + 'delete' => 'CASCADE', + 'update' => 'CASCADE' + ]) + ->create(); + } +} +``` + +## Upgrade Path Considerations + +### Problem: Schema Drift Between Installations + +When you update the CMS code via `composer update`, the code changes (like Model classes expecting new columns) but the database schema doesn't automatically update. + +**Symptoms:** +- `SQLSTATE[HY000]: General error: 1 no such column: column_name` +- Model methods reference columns that don't exist in older installations + +### Solution: Migration-Based Upgrades + +1. **Update the initial migration for NEW installations:** + - Edit the `create_*_table.php` migration in development + - This ensures new installations get the complete schema + +2. **Create an ALTER migration for EXISTING installations:** + - Create `add_*_to_*.php` migration with the same changes + - This updates installations that already ran the original migration + +**Example Workflow:** + +```bash +# Step 1: User model now needs 'timezone' column +# Don't edit: 20250111000000_create_users_table.php (old installations already ran this) + +# Step 2: Create new migration +touch cms/resources/database/migrate/20251205000000_add_timezone_to_users.php + +# Step 3: Implement the migration +# (see examples above) + +# Step 4: Document in versionlog.md +# Version X.Y.Z +# - Added timezone column to users table (Migration: 20251205000000) + +# Step 5: Users upgrade via composer and run: +php neuron db:migrate +``` + +### cms:install Command Behavior + +The `cms:install` command (`src/Cms/Cli/Commands/Install/InstallCommand.php`): + +1. Copies ALL migration files from `cms/resources/database/migrate/` to project +2. **Skips** migrations that already exist (by filename) +3. Optionally runs migrations + +**Limitation:** When you run `composer update`, new migrations in the CMS package don't automatically copy to existing installations. + +**Workaround:** Manually copy new migrations or run `cms:install` with reinstall option (will overwrite files). + +**Future Enhancement:** Create `cms:upgrade` command to: +- Detect new migrations in CMS package +- Copy them to installation +- Optionally run them + +## Best Practices + +### 1. Use Descriptive Migration Names +``` +✅ 20251205120000_add_two_factor_recovery_codes_to_users.php +❌ 20251205120000_update_users.php +``` + +### 2. Include Comments in Migration Code +```php +/** + * Add two-factor authentication recovery codes to users table + * + * This migration adds support for 2FA recovery codes, allowing users + * to regain access if they lose their authenticator device. + */ +class AddTwoFactorRecoveryCodesToUsers extends AbstractMigration +``` + +### 3. Always Test Rollbacks +```php +// Make migrations reversible when possible +public function change() +{ + // Phinx can automatically reverse addColumn, addIndex, etc. + $table = $this->table( 'users' ); + $table->addColumn( 'timezone', 'string' )->update(); +} + +// For complex migrations, implement up/down explicitly +public function up() +{ + // Migration code +} + +public function down() +{ + // Rollback code +} +``` + +### 4. Handle NULL Values Appropriately + +When adding columns to tables with existing data: + +```php +// Good: Allow NULL or provide default value +$table->addColumn( 'timezone', 'string', [ + 'default' => 'UTC', + 'null' => false +]); + +// Alternative: Allow NULL, update later +$table->addColumn( 'timezone', 'string', [ 'null' => true ] ); +``` + +### 5. Document Breaking Changes + +If a migration requires manual intervention: + +```php +/** + * BREAKING CHANGE: Removes legacy authentication method + * + * BEFORE RUNNING: + * 1. Ensure all users have migrated to new auth system + * 2. Backup the database + * 3. Review docs at: docs/auth-migration.md + */ +class RemoveLegacyAuthColumns extends AbstractMigration +``` + +### 6. Version Documentation + +Update `versionlog.md` with migration information: + +```markdown +## Version 2.1.0 - 2025-12-05 + +### Database Changes +- Added `two_factor_recovery_codes` column to users table +- Added `timezone` column to users table with default 'UTC' +- Migration files: 20251205000000_add_two_factor_and_timezone_to_users.php + +### Upgrade Notes +Run `php neuron db:migrate` to apply schema changes. +``` + +## Troubleshooting + +### Migration Already Exists Error + +**Problem:** Migration file exists in both CMS package and installation, but with different content. + +**Solution:** +- Check which version ran (look at installation's file modification date) +- Create a new migration to reconcile differences +- Never overwrite the existing migration + +### Column Already Exists + +**Problem:** Migration tries to add a column that already exists. + +``` +SQLSTATE[HY000]: General error: 1 duplicate column name +``` + +**Solution:** +```php +public function change() +{ + $table = $this->table( 'users' ); + + // Check if column exists before adding + if( !$table->hasColumn( 'timezone' ) ) + { + $table->addColumn( 'timezone', 'string', [ 'default' => 'UTC' ] ) + ->update(); + } +} +``` + +### Migration Tracking Out of Sync + +**Problem:** Phinx thinks a migration ran, but the schema change isn't present. + +**Solution:** +```bash +# Check migration status +php neuron db:status + +# If needed, manually fix phinxlog table +sqlite3 storage/database.sqlite3 +> DELETE FROM phinxlog WHERE version = '20251205000000'; +> .quit + +# Re-run migration +php neuron db:migrate +``` + +### Data Loss Prevention + +**Always backup before:** +- Dropping columns +- Renaming columns +- Changing column types +- Dropping tables + +```bash +# SQLite backup +cp storage/database.sqlite3 storage/database.sqlite3.backup + +# MySQL backup +mysqldump -u user -p database_name > backup.sql +``` + +## Additional Resources + +- [Phinx Documentation](https://book.cakephp.org/phinx/0/en/migrations.html) +- [Neuron CMS Installation Guide](README.md) diff --git a/UPGRADE_NOTES.md b/UPGRADE_NOTES.md new file mode 100644 index 0000000..77dadeb --- /dev/null +++ b/UPGRADE_NOTES.md @@ -0,0 +1,151 @@ +# Neuron CMS Upgrade Notes + +This file contains version-specific upgrade information, breaking changes, and migration instructions. + +## How to Upgrade + +After running `composer update neuron-php/cms`, follow these steps: + +1. **Run the upgrade command:** + ```bash + ./vendor/bin/neuron cms:upgrade + ``` + +2. **Review and apply migrations:** + The upgrade command will detect new migrations. Review them and run: + ```bash + ./vendor/bin/neuron db:migrate + ``` + +3. **Clear caches:** + ```bash + ./vendor/bin/neuron cache:clear # if applicable + ``` + +4. **Test your application** to ensure compatibility with the new version. + +--- + +## Version 2025.12.5 + +### Database Changes +- **New Migration:** `20251205000000_add_two_factor_and_timezone_to_users.php` + - Adds `two_factor_recovery_codes` column (TEXT, nullable) for storing 2FA backup codes + - Adds `timezone` column (VARCHAR(50), default 'UTC') for user timezone preferences + +### New Features +- Two-factor authentication recovery codes support +- Per-user timezone settings + +### Breaking Changes +- None + +### Action Required +1. Run `php neuron cms:upgrade` to copy new migrations to your installation +2. Run `php neuron db:migrate` to apply the schema changes +3. Existing user records will have `timezone` set to 'UTC' by default + +### Migration Principles Documented +- Added comprehensive migration guidelines to prevent schema drift +- See `MIGRATIONS.md` for detailed migration best practices +- **Important:** Never modify existing migrations; always create new ones for schema changes + +--- + +## Version 2025.11.7 + +### Initial Release Features +- Complete CMS installation system +- User authentication and authorization +- Post, category, and tag management +- Admin dashboard and member areas +- Email verification system +- Password reset functionality +- Maintenance mode +- Job queue integration + +### Database Tables Created +- `users` - User accounts with roles and authentication +- `posts` - Blog posts and content +- `categories` - Content categorization +- `tags` - Content tagging +- `post_categories` - Many-to-many relationship +- `post_tags` - Many-to-many relationship +- `password_reset_tokens` - Password reset token tracking +- `email_verification_tokens` - Email verification tracking +- `jobs` - Job queue +- `failed_jobs` - Failed job tracking + +### Installation +For new installations: +```bash +php neuron cms:install +``` + +--- + +## Upgrade Troubleshooting + +### Missing Column Errors + +**Error:** `SQLSTATE[HY000]: General error: 1 no such column: column_name` + +**Cause:** Your database schema is out of sync with the CMS code. + +**Solution:** +1. Check for new migrations: `php neuron cms:upgrade --check` +2. Copy new migrations: `php neuron cms:upgrade` +3. Run migrations: `php neuron db:migrate` + +### Migration Already Exists + +**Problem:** Migration file exists but wasn't run. + +**Solution:** +```bash +# Check migration status +php neuron db:status + +# If migration shows as pending, run it +php neuron db:migrate + +# If migration isn't tracked, it may need to be marked as run +# See MIGRATIONS.md for details on using --fake flag +``` + +### Customized Views Being Overwritten + +**Problem:** Running `cms:install` with reinstall overwrites customized views. + +**Solution:** +- Use `php neuron cms:upgrade` instead - it only updates new/critical files +- Use `php neuron cms:upgrade --skip-views` to skip view updates entirely +- Manually merge view changes by comparing package views with your customizations + +### Schema Drift After Composer Update + +**Problem:** After `composer update`, application breaks with database errors. + +**Cause:** New CMS code expects columns that don't exist in your database. + +**Prevention:** +1. Always run `neuron cms:upgrade` after `composer update neuron-php/cms` +2. Review and apply any new migrations before deploying to production +3. Test in development/staging environment first + +--- + +## Version History + +| Version | Release Date | Key Changes | +|---------|--------------|-------------| +| 2025.12.5 | 2025-12-05 | Added 2FA recovery codes, user timezones, migration docs | +| 2025.11.7 | 2025-11-07 | Initial CMS release | + +--- + +## Need Help? + +- **Documentation:** See `README.md`, `MIGRATIONS.md` +- **Issues:** Report bugs at [GitHub Issues](https://github.com/neuron-php/cms/issues) +- **Migration Help:** See `MIGRATIONS.md` for a comprehensive migration guide diff --git a/composer.json b/composer.json index f223058..ab0c3a2 100644 --- a/composer.json +++ b/composer.json @@ -12,16 +12,19 @@ "require": { "ext-curl": "*", "ext-json": "*", - "neuron-php/mvc": "^0.9.5", + "neuron-php/mvc": "0.9.*", + "neuron-php/data": "0.9.*", "neuron-php/cli": "0.8.*", "neuron-php/jobs": "0.2.*", "neuron-php/orm": "0.1.*", "neuron-php/dto": "0.0.*", - "phpmailer/phpmailer": "^6.9" + "phpmailer/phpmailer": "^6.9", + "cloudinary/cloudinary_php": "^2.0" }, "require-dev": { "phpunit/phpunit": "9.*", - "mikey179/vfsstream": "^1.6" + "mikey179/vfsstream": "^1.6", + "neuron-php/scaffolding": "0.8.*" }, "autoload": { "psr-4": { @@ -43,5 +46,14 @@ "neuron": { "cli-provider": "Neuron\\Cms\\Cli\\Provider" } + }, + "scripts": { + "post-update-cmd": [ + "@php -r \"echo '\\n╔════════════════════════════════════════════════╗\\n';\"", + "@php -r \"echo '║ Neuron CMS Updated ║\\n';\"", + "@php -r \"echo '╚════════════════════════════════════════════════╝\\n';\"", + "@php -r \"echo '\\n⚠️ Run upgrade command to apply changes:\\n';\"", + "@php -r \"echo ' php neuron cms:upgrade\\n\\n';\"" + ] } } diff --git a/readme.md b/readme.md index 944e2c9..b568067 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,5 @@ [![CI](https://github.com/Neuron-PHP/cms/actions/workflows/ci.yml/badge.svg)](https://github.com/Neuron-PHP/cms/actions) +[![codecov](https://codecov.io/gh/Neuron-PHP/cms/branch/develop/graph/badge.svg)](https://codecov.io/gh/Neuron-PHP/cms) # Neuron-PHP CMS A modern, database-backed Content Management System for PHP 8.4+ built on the Neuron framework. Provides a complete blog platform with user authentication, admin panel, and content management. diff --git a/resources/.cms-manifest.json b/resources/.cms-manifest.json new file mode 100644 index 0000000..5f39cc4 --- /dev/null +++ b/resources/.cms-manifest.json @@ -0,0 +1,41 @@ +{ + "version": "2025.12.5", + "release_date": "2025-12-05", + "migrations": [ + "20250111000000_create_users_table.php", + "20250112000000_create_email_verification_tokens_table.php", + "20250113000000_create_pages_table.php", + "20250114000000_create_categories_table.php", + "20250115000000_create_tags_table.php", + "20250116000000_create_posts_table.php", + "20250117000000_create_post_categories_table.php", + "20250118000000_create_post_tags_table.php", + "20251119224525_add_content_raw_to_posts.php", + "20251205000000_add_two_factor_and_timezone_to_users.php" + ], + "config_files": [ + "auth.yaml", + "event-listeners.yaml", + "neuron.yaml", + "neuron.yaml.example", + "routes.yaml" + ], + "view_directories": [ + "admin", + "auth", + "blog", + "content", + "emails", + "home", + "http_codes", + "layouts", + "member" + ], + "public_assets": [ + "index.php", + ".htaccess" + ], + "breaking_changes": [], + "deprecations": [], + "upgrade_notes": "See UPGRADE_NOTES.md for detailed upgrade instructions" +} diff --git a/resources/app/Initializers/AuthInitializer.php b/resources/app/Initializers/AuthInitializer.php index 48e70af..e40c601 100644 --- a/resources/app/Initializers/AuthInitializer.php +++ b/resources/app/Initializers/AuthInitializer.php @@ -3,7 +3,10 @@ namespace App\Initializers; use Neuron\Cms\Services\Auth\Authentication; +use Neuron\Cms\Services\Auth\CsrfToken; use Neuron\Cms\Auth\Filters\AuthenticationFilter; +use Neuron\Cms\Auth\Filters\CsrfFilter; +use Neuron\Cms\Auth\Filters\AuthCsrfFilter; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Auth\SessionManager; use Neuron\Cms\Repositories\DatabaseUserRepository; @@ -29,7 +32,7 @@ public function run( array $argv = [] ): mixed // Get Settings from Registry $settings = Registry::getInstance()->get( 'Settings' ); - if( !$settings || !$settings instanceof \Neuron\Data\Setting\SettingManager ) + if( !$settings || !$settings instanceof \Neuron\Data\Settings\SettingManager ) { Log::error( "Settings not found in Registry, skipping auth initialization" ); return null; @@ -56,15 +59,21 @@ public function run( array $argv = [] ): mixed $sessionManager = new SessionManager(); $passwordHasher = new PasswordHasher(); $authentication = new Authentication( $userRepository, $sessionManager, $passwordHasher ); + $csrfToken = new CsrfToken(); - // Create authentication filter + // Create filters $authFilter = new AuthenticationFilter( $authentication, '/login' ); + $csrfFilter = new CsrfFilter( $csrfToken ); + $authCsrfFilter = new AuthCsrfFilter( $authentication, $csrfToken, '/login' ); - // Register the auth filter with the Router + // Register filters with the Router $app->getRouter()->registerFilter( 'auth', $authFilter ); + $app->getRouter()->registerFilter( 'csrf', $csrfFilter ); + $app->getRouter()->registerFilter( 'auth-csrf', $authCsrfFilter ); - // Store Authentication in Registry for easy access + // Store services in Registry for easy access Registry::getInstance()->set( 'Authentication', $authentication ); + Registry::getInstance()->set( 'CsrfToken', $csrfToken ); } } catch( \Exception $e ) diff --git a/resources/app/Initializers/MaintenanceInitializer.php b/resources/app/Initializers/MaintenanceInitializer.php index b6e711c..d35e4e4 100644 --- a/resources/app/Initializers/MaintenanceInitializer.php +++ b/resources/app/Initializers/MaintenanceInitializer.php @@ -44,7 +44,7 @@ public function run( array $argv = [] ): mixed $config = null; $settings = Registry::getInstance()->get( 'Settings' ); - if( $settings && $settings instanceof \Neuron\Data\Setting\SettingManager ) + if( $settings && $settings instanceof \Neuron\Data\Settings\SettingManager ) { try { diff --git a/resources/app/Initializers/PasswordResetInitializer.php b/resources/app/Initializers/PasswordResetInitializer.php index ab38cea..8abeaf1 100644 --- a/resources/app/Initializers/PasswordResetInitializer.php +++ b/resources/app/Initializers/PasswordResetInitializer.php @@ -6,7 +6,7 @@ use Neuron\Cms\Services\Auth\PasswordResetter; use Neuron\Cms\Repositories\DatabasePasswordResetTokenRepository; use Neuron\Cms\Repositories\DatabaseUserRepository; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Log\Log; use Neuron\Patterns\Registry; use Neuron\Patterns\IRunnable; diff --git a/resources/app/Initializers/RegistrationInitializer.php b/resources/app/Initializers/RegistrationInitializer.php index 17ad73a..7b1e898 100644 --- a/resources/app/Initializers/RegistrationInitializer.php +++ b/resources/app/Initializers/RegistrationInitializer.php @@ -31,7 +31,7 @@ public function run( array $argv = [] ): mixed // Get Settings from Registry $settings = Registry::getInstance()->get( 'Settings' ); - if( !$settings || !$settings instanceof \Neuron\Data\Setting\SettingManager ) + if( !$settings || !$settings instanceof \Neuron\Data\Settings\SettingManager ) { Log::error( "Settings not found in Registry, skipping registration initialization" ); return null; diff --git a/resources/config/database.yaml.example b/resources/config/database.yaml.example deleted file mode 100644 index 1dea521..0000000 --- a/resources/config/database.yaml.example +++ /dev/null @@ -1,42 +0,0 @@ -# Database Configuration -# -# This file provides database configuration for the CMS component. -# Copy sections to your config/neuron.yaml - -database: - # Database adapter (mysql, pgsql, sqlite) - adapter: mysql - - # Database host - host: localhost - - # Database name - name: neuron_cms - - # Database username - user: root - - # Database password - pass: secret - - # Database port (3306 for MySQL, 5432 for PostgreSQL) - port: 3306 - - # Character set - charset: utf8mb4 - -# Migration Configuration -migrations: - # Path to migrations directory (relative to project root) - path: db/migrate - - # Path to seeds directory (relative to project root) - seeds_path: db/seed - - # Migration tracking table name - table: phinx_log - -# System Configuration (optional) -system: - # Environment name (development, staging, production) - environment: development diff --git a/resources/config/email.yaml.example b/resources/config/email.yaml.example deleted file mode 100644 index 405a8f5..0000000 --- a/resources/config/email.yaml.example +++ /dev/null @@ -1,68 +0,0 @@ -# Email Configuration Example -# -# Copy this file to email.yaml and configure for your environment -# -# This configuration is used by the SendWelcomeEmailListener and other -# email-sending features of the CMS powered by PHPMailer. - -email: - # Test mode - logs emails instead of sending (useful for development) - # When enabled, emails are logged to the log file instead of being sent - test_mode: false - - # Email driver: mail, sendmail, or smtp - # - mail: Uses PHP's mail() function (default, requires server mail setup) - # - sendmail: Uses sendmail binary (Linux/Mac) - # - smtp: Uses SMTP server (recommended for production) - driver: mail - - # From address and name for system emails - from_address: noreply@yourdomain.com - from_name: Your Site Name - - # SMTP Configuration (only required if driver is 'smtp') - # Examples for popular email services: - # - # Gmail: - # host: smtp.gmail.com - # port: 587 - # encryption: tls - # username: your-email@gmail.com - # password: your-app-password (not your regular password!) - # - # SendGrid: - # host: smtp.sendgrid.net - # port: 587 - # encryption: tls - # username: apikey - # password: your-sendgrid-api-key - # - # Mailgun: - # host: smtp.mailgun.org - # port: 587 - # encryption: tls - # username: postmaster@yourdomain.com - # password: your-mailgun-smtp-password - - # host: smtp.gmail.com - # port: 587 - # username: your-email@gmail.com - # password: your-app-password - # encryption: tls # or 'ssl' for port 465 - -# Email Template Customization -# -# Templates are located in: resources/views/emails/ -# -# Available templates: -# - welcome.php - Welcome email sent to new users -# -# To customize, edit the template files directly. They use standard PHP -# templating with variables like $Username, $SiteName, $SiteUrl. -# -# Example customization in welcome.php: -# - Change colors in the diff --git a/resources/views/http_codes/500.php b/resources/views/http_codes/500.php new file mode 100644 index 0000000..2540e78 --- /dev/null +++ b/resources/views/http_codes/500.php @@ -0,0 +1,20 @@ +
+
+

Something went wrong on our end.

+

The server encountered an unexpected error. Please try again later.

+
+
+ + \ No newline at end of file diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 75e83a0..b50706a 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -1,8 +1,8 @@ _authentication = $authentication; + $this->_csrfToken = $csrfToken; + $this->_loginUrl = $loginUrl; + + parent::__construct( + function( RouteMap $route ) { $this->validate( $route ); }, + null + ); + } + + /** + * Validate both authentication and CSRF token + */ + protected function validate( RouteMap $route ): void + { + // 1. Check authentication first + if( !$this->_authentication->check() ) + { + Log::warning( 'Unauthenticated access attempt to protected route: ' . $route->Path ); + $this->redirectToLogin(); + } + + // 2. Check CSRF token for state-changing methods + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + + if( !in_array( strtoupper( $method ), $this->_exemptMethods ) ) + { + $token = $this->getTokenFromRequest(); + + if( !$token ) + { + Log::warning( 'CSRF token missing from authenticated request to: ' . $route->Path ); + $this->respondForbidden( 'CSRF token missing' ); + } + + if( !$this->_csrfToken->validate( $token ) ) + { + Log::warning( 'Invalid CSRF token from authenticated user on: ' . $route->Path ); + $this->respondForbidden( 'Invalid CSRF token' ); + } + } + } + + /** + * Get CSRF token from request + */ + private function getTokenFromRequest(): ?string + { + // Check POST data (filtered) + $token = \Neuron\Data\Filters\Post::filterScalar( 'csrf_token' ); + if( $token ) + { + return $token; + } + + // Check headers (useful for AJAX requests) + if( isset( $_SERVER['HTTP_X_CSRF_TOKEN'] ) ) + { + return $_SERVER['HTTP_X_CSRF_TOKEN']; + } + + return null; + } + + /** + * Redirect to login page + */ + private function redirectToLogin(): void + { + header( 'Location: ' . $this->_loginUrl ); + exit; + } + + /** + * Respond with 403 Forbidden + */ + private function respondForbidden( string $message ): void + { + http_response_code( 403 ); + echo '

403 Forbidden

'; + echo '

' . htmlspecialchars( $message ) . '

'; + exit; + } +} diff --git a/src/Cms/Auth/Filters/CsrfFilter.php b/src/Cms/Auth/Filters/CsrfFilter.php index de7a340..c87c17b 100644 --- a/src/Cms/Auth/Filters/CsrfFilter.php +++ b/src/Cms/Auth/Filters/CsrfFilter.php @@ -65,10 +65,11 @@ protected function validateCsrfToken( RouteMap $route ): void */ private function getTokenFromRequest(): ?string { - // Check POST data - if( isset( $_POST['csrf_token'] ) ) + // Check POST data (filtered) + $token = \Neuron\Data\Filters\Post::filterScalar( 'csrf_token' ); + if( $token ) { - return $_POST['csrf_token']; + return $token; } // Check headers diff --git a/src/Cms/Cli/Commands/Generate/EmailCommand.php b/src/Cms/Cli/Commands/Generate/EmailCommand.php deleted file mode 100644 index 4f5fd5a..0000000 --- a/src/Cms/Cli/Commands/Generate/EmailCommand.php +++ /dev/null @@ -1,156 +0,0 @@ -_projectPath = getcwd(); - $this->_componentPath = dirname( dirname( dirname( dirname( dirname( __DIR__ ) ) ) ) ); - } - - /** - * @inheritDoc - */ - public function getName(): string - { - return 'mail:generate'; - } - - /** - * @inheritDoc - */ - public function getDescription(): string - { - return 'Generate a new email template'; - } - - /** - * Configure the command - */ - public function configure(): void - { - // No additional configuration needed - } - - /** - * Execute the command - */ - public function execute( array $parameters = [] ): int - { - // Get template name from first parameter - $templateName = $parameters[0] ?? null; - - if( !$templateName ) - { - $this->output->error( "Please provide a template name" ); - $this->output->info( "Usage: php neuron mail:generate " ); - $this->output->info( "Example: php neuron mail:generate welcome" ); - return 1; - } - - // Validate template name (should be lowercase with hyphens) - if( !preg_match( '/^[a-z][a-z0-9-]*$/', $templateName ) ) - { - $this->output->error( "Template name must be lowercase and contain only letters, numbers, and hyphens" ); - $this->output->error( "Example: welcome, password-reset, order-confirmation" ); - return 1; - } - - // Create the template file - if( !$this->createTemplate( $templateName ) ) - { - return 1; - } - - $this->output->success( "Email template created successfully!" ); - $this->output->info( "Template: resources/views/emails/{$templateName}.php" ); - $this->output->info( "" ); - $this->output->info( "Usage in code:" ); - $this->output->info( " email()->to('user@example.com')" ); - $this->output->info( " ->subject('Welcome!')" ); - $this->output->info( " ->template('emails/{$templateName}', \$data)" ); - $this->output->info( " ->send();" ); - - return 0; - } - - /** - * Create the template file - */ - private function createTemplate( string $name ): bool - { - $emailsDir = $this->_projectPath . '/resources/views/emails'; - - // Create emails directory if it doesn't exist - if( !is_dir( $emailsDir ) ) - { - if( !mkdir( $emailsDir, 0755, true ) ) - { - $this->output->error( "Failed to create emails directory" ); - return false; - } - } - - $filePath = $emailsDir . '/' . $name . '.php'; - - // Check if file already exists - if( file_exists( $filePath ) ) - { - $this->output->error( "Template already exists: resources/views/emails/{$name}.php" ); - return false; - } - - // Load stub template - $stubPath = $this->_componentPath . '/src/Cms/Cli/Commands/Generate/stubs/email.stub'; - - if( !file_exists( $stubPath ) ) - { - $this->output->error( "Stub template not found: {$stubPath}" ); - return false; - } - - $content = file_get_contents( $stubPath ); - - // Create title from name (e.g., "welcome" -> "Welcome", "password-reset" -> "Password Reset") - $title = ucwords( str_replace( '-', ' ', $name ) ); - - // Replace placeholders - $replacements = [ - 'title' => $title, - 'content' => '

Your email content goes here.

' - ]; - - $content = $this->replacePlaceholders( $content, $replacements ); - - // Write the file - if( file_put_contents( $filePath, $content ) === false ) - { - $this->output->error( "Failed to create template file" ); - return false; - } - - return true; - } - - /** - * Replace placeholders in content - */ - private function replacePlaceholders( string $content, array $replacements ): string - { - foreach( $replacements as $key => $value ) - { - $content = str_replace( '{{' . $key . '}}', $value ?? '', $content ); - } - return $content; - } -} diff --git a/src/Cms/Cli/Commands/Generate/stubs/email.stub b/src/Cms/Cli/Commands/Generate/stubs/email.stub deleted file mode 100644 index 15a922d..0000000 --- a/src/Cms/Cli/Commands/Generate/stubs/email.stub +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - {{title}} - - - -
-

{{title}}

-
- -
-

Hello,

- - {{content}} - -

Best regards,
Your Team

-
- - - - diff --git a/src/Cms/Cli/Commands/Install/InstallCommand.php b/src/Cms/Cli/Commands/Install/InstallCommand.php index f8e6d81..11458c7 100644 --- a/src/Cms/Cli/Commands/Install/InstallCommand.php +++ b/src/Cms/Cli/Commands/Install/InstallCommand.php @@ -6,8 +6,8 @@ use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\DatabaseUserRepository; use Neuron\Cms\Auth\PasswordHasher; -use Neuron\Data\Setting\SettingManager; -use Neuron\Data\Setting\Source\Yaml; +use Neuron\Data\Settings\SettingManager; +use Neuron\Data\Settings\Source\Yaml; use Neuron\Patterns\Registry; /** @@ -182,6 +182,8 @@ private function createDirectories(): bool '/storage', '/storage/logs', '/storage/cache', + '/storage/uploads', + '/storage/uploads/temp', // Database directories '/db', diff --git a/src/Cms/Cli/Commands/Install/UpgradeCommand.php b/src/Cms/Cli/Commands/Install/UpgradeCommand.php new file mode 100644 index 0000000..6e39392 --- /dev/null +++ b/src/Cms/Cli/Commands/Install/UpgradeCommand.php @@ -0,0 +1,471 @@ +_projectPath = getcwd(); + + // Get component path + $this->_componentPath = dirname( dirname( dirname( dirname( dirname( __DIR__ ) ) ) ) ); + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return 'cms:upgrade'; + } + + /** + * @inheritDoc + */ + public function getDescription(): string + { + return 'Upgrade CMS to latest version (copy new migrations, update files)'; + } + + /** + * Configure the command + */ + public function configure(): void + { + $this->addOption( 'check', 'c', false, 'Check for available updates without applying' ); + $this->addOption( 'migrations-only', 'm', false, 'Only copy new migrations' ); + $this->addOption( 'skip-views', null, false, 'Skip updating view files' ); + $this->addOption( 'skip-migrations', null, false, 'Skip copying migrations' ); + $this->addOption( 'run-migrations', 'r', false, 'Run migrations automatically after copying' ); + } + + /** + * Execute the command + */ + public function execute( array $parameters = [] ): int + { + $this->output->writeln( "\n╔═══════════════════════════════════════╗" ); + $this->output->writeln( "║ Neuron CMS - Upgrade ║" ); + $this->output->writeln( "╚═══════════════════════════════════════╝\n" ); + + // Load manifests + if( !$this->loadManifests() ) + { + return 1; + } + + // Check if CMS is installed + if( !$this->isInstalled() ) + { + $this->output->error( "CMS is not installed. Please run 'cms:install' first." ); + return 1; + } + + // Display version information + $this->displayVersionInfo(); + + // Check for updates + $hasUpdates = $this->checkForUpdates(); + + if( !$hasUpdates ) + { + $this->output->success( "✓ CMS is already up to date!" ); + return 0; + } + + // If --check flag, exit after displaying what would be updated + if( $this->input->getOption( 'check' ) ) + { + $this->output->info( "Run 'cms:upgrade' without --check to apply updates" ); + return 0; + } + + // Confirm upgrade + $this->output->writeln( "" ); + if( !$this->input->confirm( "Proceed with upgrade?", true ) ) + { + $this->output->error( "Upgrade cancelled." ); + return 1; + } + + // Perform upgrade steps + $success = true; + + if( !$this->input->getOption( 'skip-migrations' ) ) + { + $this->output->writeln( "\n📦 Copying new migrations..." ); + $success = $success && $this->copyNewMigrations(); + } + + if( !$this->input->getOption( 'migrations-only' ) && !$this->input->getOption( 'skip-views' ) ) + { + $this->output->writeln( "\n🎨 Updating view files..." ); + $success = $success && $this->updateViews(); + } + + if( !$this->input->getOption( 'migrations-only' ) ) + { + $this->output->writeln( "\n⚙️ Updating configuration examples..." ); + $success = $success && $this->updateConfigExamples(); + } + + if( !$success ) + { + $this->output->error( "Upgrade failed!" ); + return 1; + } + + // Update installed manifest + if( !$this->updateInstalledManifest() ) + { + $this->output->warning( "Upgrade completed but manifest update failed. You may need to re-run cms:upgrade." ); + } + + // Display summary + // Display summary + $this->displaySummary(); + + // Optionally run migrations + if( $this->input->getOption( 'run-migrations' ) || + $this->input->confirm( "\nRun database migrations now?", false ) ) + { + $this->output->writeln( "" ); + $this->runMigrations(); + } + else + { + $this->output->info( "\n⚠️ Remember to run: php neuron db:migrate" ); + } + + $this->output->success( "\n✓ Upgrade complete!" ); + + return 0; + } + + /** + * Load package and installed manifests + */ + private function loadManifests(): bool + { + // Load package manifest + $packageManifestPath = $this->_componentPath . '/resources/.cms-manifest.json'; + + if( !file_exists( $packageManifestPath ) ) + { + $this->output->error( "Package manifest not found at: $packageManifestPath" ); + return false; + } + + $packageManifestJson = file_get_contents( $packageManifestPath ); + $this->_packageManifest = json_decode( $packageManifestJson, true ); + + if( json_last_error() !== JSON_ERROR_NONE ) + { + $this->output->error( "Failed to parse package manifest: " . json_last_error_msg() ); + return false; + } + + // Load installed manifest (may not exist on old installations) + $installedManifestPath = $this->_projectPath . '/.cms-manifest.json'; + + if( file_exists( $installedManifestPath ) ) + { + $installedManifestJson = file_get_contents( $installedManifestPath ); + $this->_installedManifest = json_decode( $installedManifestJson, true ); + + if( json_last_error() !== JSON_ERROR_NONE ) + { + $this->output->error( "Failed to parse installed manifest: " . json_last_error_msg() ); + return false; + } + } + else + { + // No manifest = old installation, create minimal one + $this->_installedManifest = [ + 'version' => 'unknown', + 'migrations' => [] + ]; + + // Try to detect installed migrations + $migrateDir = $this->_projectPath . '/db/migrate'; + if( is_dir( $migrateDir ) ) + { + $files = glob( $migrateDir . '/*.php' ); + $this->_installedManifest['migrations'] = array_map( 'basename', $files ); + } + } + + return true; + } + + /** + * Check if CMS is installed + */ + private function isInstalled(): bool + { + // Check for key indicators + $indicators = [ + '/resources/views/admin', + '/config/routes.yaml', + '/db/migrate' + ]; + + foreach( $indicators as $path ) + { + if( !file_exists( $this->_projectPath . $path ) ) + { + return false; + } + } + + return true; + } + + /** + * Display version information + */ + private function displayVersionInfo(): void + { + $installedVersion = $this->_installedManifest['version'] ?? 'unknown'; + $packageVersion = $this->_packageManifest['version'] ?? 'unknown'; + + $this->output->writeln( "Installed Version: $installedVersion" ); + $this->output->writeln( "Package Version: $packageVersion\n" ); + } + + /** + * Check for available updates + */ + private function checkForUpdates(): bool + { + $hasUpdates = false; + + // Check for new migrations + $newMigrations = $this->getNewMigrations(); + + if( !empty( $newMigrations ) ) + { + $hasUpdates = true; + $this->output->writeln( "New Migrations Available:" ); + + foreach( $newMigrations as $migration ) + { + $this->output->writeln( " + $migration" ); + } + } + + // Check version difference + $installedVersion = $this->_installedManifest['version'] ?? '0'; + $packageVersion = $this->_packageManifest['version'] ?? '0'; + + if( $packageVersion !== $installedVersion ) + { + $hasUpdates = true; + + if( empty( $newMigrations ) ) + { + $this->output->writeln( "Version update available (no database changes)" ); + } + } + + return $hasUpdates; + } + + /** + * Get list of new migrations not in installation + */ + private function getNewMigrations(): array + { + $packageMigrations = $this->_packageManifest['migrations'] ?? []; + $installedMigrations = $this->_installedManifest['migrations'] ?? []; + + return array_diff( $packageMigrations, $installedMigrations ); + } + + /** + * Copy new migrations to project + */ + private function copyNewMigrations(): bool + { + $newMigrations = $this->getNewMigrations(); + + if( empty( $newMigrations ) ) + { + $this->output->writeln( " No new migrations to copy" ); + return true; + } + + $migrationsDir = $this->_projectPath . '/db/migrate'; + $componentMigrationsDir = $this->_componentPath . '/resources/database/migrate'; + + // Create migrations directory if it doesn't exist + if( !is_dir( $migrationsDir ) ) + { + if( !mkdir( $migrationsDir, 0755, true ) ) + { + $this->output->error( " Failed to create migrations directory!" ); + return false; + } + } + + $copied = 0; + + foreach( $newMigrations as $migration ) + { + $sourceFile = $componentMigrationsDir . '/' . $migration; + $destFile = $migrationsDir . '/' . $migration; + + if( !file_exists( $sourceFile ) ) + { + $this->output->warning( " Migration file not found: $migration" ); + continue; + } + + if( copy( $sourceFile, $destFile ) ) + { + $this->output->writeln( " ✓ Copied: $migration" ); + $this->_messages[] = "Copied migration: $migration"; + $copied++; + } + else + { + $this->output->error( " ✗ Failed to copy: $migration" ); + return false; + } + } + + if( $copied > 0 ) + { + $this->output->writeln( "\n Copied $copied new migration" . ( $copied > 1 ? 's' : '' ) . "" ); + } + + return true; + } + + /** + * Update view files (conservative - only critical updates) + */ + private function updateViews(): bool + { + // For now, just inform user that views may need manual updates + // In future versions, could implement smart view updates + + $this->output->writeln( " ℹ️ View updates require manual review to preserve customizations" ); + $this->output->writeln( " Compare package views with your installation if needed" ); + $this->output->writeln( " Package views location: " . $this->_componentPath . "/resources/views/" ); + + return true; + } + + /** + * Update configuration example files + */ + private function updateConfigExamples(): bool + { + $configSource = $this->_componentPath . '/resources/config'; + $configDest = $this->_projectPath . '/config'; + + // Only copy .example files + $exampleFiles = glob( $configSource . '/*.example' ); + + if( empty( $exampleFiles ) ) + { + $this->output->writeln( " No configuration examples to update" ); + return true; + } + + foreach( $exampleFiles as $sourceFile ) + { + $fileName = basename( $sourceFile ); + $destFile = $configDest . '/' . $fileName; + + if( copy( $sourceFile, $destFile ) ) + { + $this->output->writeln( " ✓ Updated: $fileName" ); + } + else + { + $this->output->error( " ✗ Failed to copy $fileName from $sourceFile to $destFile" ); + } + } + + return true; + } + + /** + * Update installed manifest + */ + private function updateInstalledManifest(): bool + { + $manifestPath = $this->_projectPath . '/.cms-manifest.json'; + + // Update manifest with package migrations + $packageMigrations = $this->_packageManifest['migrations'] ?? []; + + $this->_installedManifest['version'] = $this->_packageManifest['version']; + $this->_installedManifest['updated_at'] = date( 'Y-m-d H:i:s' ); + $this->_installedManifest['migrations'] = $packageMigrations; + + $json = json_encode( $this->_installedManifest, JSON_PRETTY_PRINT ); + + if( file_put_contents( $manifestPath, $json ) === false ) + { + $this->output->warning( "Failed to update manifest file" ); + return false; + } + + return true; + } + + /** + * Run database migrations + */ + private function runMigrations(): bool + { + $this->output->writeln( "Running migrations...\n" ); + + // For now, instruct user to run migrations manually + // In future, could integrate with MigrationManager + + $this->output->info( "Run: php neuron db:migrate" ); + + return true; + } + + /** + * Display upgrade summary + */ + private function displaySummary(): void + { + if( empty( $this->_messages ) ) + { + return; + } + + $this->output->writeln( "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" ); + $this->output->writeln( "Upgrade Summary:" ); + $this->output->writeln( "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" ); + + foreach( $this->_messages as $message ) + { + $this->output->writeln( " • $message" ); + } + } +} diff --git a/src/Cms/Cli/Commands/Maintenance/EnableCommand.php b/src/Cms/Cli/Commands/Maintenance/EnableCommand.php index 58d3564..63ec944 100644 --- a/src/Cms/Cli/Commands/Maintenance/EnableCommand.php +++ b/src/Cms/Cli/Commands/Maintenance/EnableCommand.php @@ -5,7 +5,7 @@ use Neuron\Cli\Commands\Command; use Neuron\Cms\Maintenance\MaintenanceManager; use Neuron\Cms\Maintenance\MaintenanceConfig; -use Neuron\Data\Setting\Source\Yaml; +use Neuron\Data\Settings\Source\Yaml; /** * CLI command for enabling maintenance mode. diff --git a/src/Cms/Cli/Commands/Queue/InstallCommand.php b/src/Cms/Cli/Commands/Queue/InstallCommand.php deleted file mode 100644 index e6c70ab..0000000 --- a/src/Cms/Cli/Commands/Queue/InstallCommand.php +++ /dev/null @@ -1,413 +0,0 @@ -_projectPath = getcwd(); - } - - /** - * @inheritDoc - */ - public function getName(): string - { - return 'queue:install'; - } - - /** - * @inheritDoc - */ - public function getDescription(): string - { - return 'Install the job queue system'; - } - - /** - * Configure the command - */ - public function configure(): void - { - $this->addOption( 'force', 'f', false, 'Force installation even if already installed' ); - } - - /** - * Execute the command - */ - public function execute( array $parameters = [] ): int - { - $this->output->info( "╔═══════════════════════════════════════╗" ); - $this->output->info( "║ Job Queue Installation ║" ); - $this->output->info( "╚═══════════════════════════════════════╝" ); - $this->output->write( "\n" ); - - // Check if jobs component is available - if( !class_exists( 'Neuron\\Jobs\\Queue\\QueueManager' ) ) - { - $this->output->error( "Job queue component not found." ); - $this->output->info( "Please install it first: composer require neuron-php/jobs" ); - return 1; - } - - $force = $this->input->hasOption( 'force' ); - - // Check if already installed - if( !$force && $this->isAlreadyInstalled() ) - { - $this->output->warning( "Queue system appears to be already installed." ); - $this->output->info( " - Migration exists" ); - $this->output->info( " - Configuration exists" ); - $this->output->write( "\n" ); - - if( !$this->input->confirm( "Do you want to continue anyway?", false ) ) - { - $this->output->info( "Installation cancelled." ); - return 0; - } - } - - // Generate queue migration - $this->output->info( "Generating queue migration..." ); - - if( !$this->generateMigration() ) - { - return 1; - } - - // Add queue configuration - $this->output->info( "Adding queue configuration..." ); - - if( $this->addQueueConfig() ) - { - $this->output->success( "Queue configuration added to neuron.yaml" ); - } - else - { - $this->output->warning( "Could not add queue configuration automatically" ); - $this->output->info( "Please add the following to config/neuron.yaml:" ); - $this->output->write( "\n" ); - $this->output->write( "queue:\n" ); - $this->output->write( " driver: database\n" ); - $this->output->write( " default: default\n" ); - $this->output->write( " retry_after: 90\n" ); - $this->output->write( " max_attempts: 3\n" ); - $this->output->write( " backoff: 0\n" ); - $this->output->write( "\n" ); - } - - // Ask to run migration - $this->output->write( "\n" ); - - if( $this->input->confirm( "Would you like to run the queue migration now?", true ) ) - { - if( !$this->runMigration() ) - { - $this->output->error( "Migration failed!" ); - $this->output->info( "You can run it manually with: php neuron db:migrate" ); - return 1; - } - } - else - { - $this->output->info( "Remember to run migration with: php neuron db:migrate" ); - } - - // Display success and usage info - $this->output->write( "\n" ); - $this->output->success( "Job Queue Installation Complete!" ); - $this->output->write( "\n" ); - $this->output->info( "Queue Configuration:" ); - $this->output->info( " Driver: database" ); - $this->output->info( " Default Queue: default" ); - $this->output->info( " Max Attempts: 3" ); - $this->output->info( " Retry After: 90 seconds" ); - $this->output->write( "\n" ); - - $this->output->info( "Start a worker:" ); - $this->output->info( " php neuron jobs:work" ); - $this->output->write( "\n" ); - - $this->output->info( "Dispatch a job:" ); - $this->output->info( " dispatch(new MyJob(), ['data' => 'value']);" ); - $this->output->write( "\n" ); - - $this->output->info( "For more information, see: vendor/neuron-php/jobs/QUEUE.md" ); - - return 0; - } - - /** - * Check if queue is already installed - */ - private function isAlreadyInstalled(): bool - { - $migrationsDir = $this->_projectPath . '/db/migrate'; - $snakeCaseName = $this->camelToSnake( 'CreateQueueTables' ); - - // Check for existing migration - $existingFiles = glob( $migrationsDir . '/*_' . $snakeCaseName . '.php' ); - - if( empty( $existingFiles ) ) - { - return false; - } - - // Check for queue config - $configFile = $this->_projectPath . '/config/neuron.yaml'; - - if( !file_exists( $configFile ) ) - { - return false; - } - - try - { - $yaml = new Yaml( $configFile ); - $settings = new SettingManager( $yaml ); - $driver = $settings->get( 'queue', 'driver' ); - - return !empty( $driver ); - } - catch( \Exception $e ) - { - return false; - } - } - - /** - * Generate queue migration - */ - private function generateMigration(): bool - { - $migrationName = 'CreateQueueTables'; - $snakeCaseName = $this->camelToSnake( $migrationName ); - $migrationsDir = $this->_projectPath . '/db/migrate'; - - // Create migrations directory if it doesn't exist - if( !is_dir( $migrationsDir ) ) - { - if( !mkdir( $migrationsDir, 0755, true ) ) - { - $this->output->error( "Failed to create migrations directory!" ); - return false; - } - } - - // Check if migration already exists - $existingFiles = glob( $migrationsDir . '/*_' . $snakeCaseName . '.php' ); - - if( !empty( $existingFiles ) ) - { - $existingFile = basename( $existingFiles[0] ); - $this->output->info( "Queue migration already exists: $existingFile" ); - return true; - } - - // Create migration - $timestamp = date( 'YmdHis' ); - $className = $migrationName; - $fileName = $timestamp . '_' . $snakeCaseName . '.php'; - $filePath = $migrationsDir . '/' . $fileName; - - $template = $this->getMigrationTemplate( $className ); - - if( file_put_contents( $filePath, $template ) === false ) - { - $this->output->error( "Failed to create queue migration!" ); - return false; - } - - $this->output->success( "Created: db/migrate/$fileName" ); - return true; - } - - /** - * Get migration template - */ - private function getMigrationTemplate( string $className ): string - { - return <<table( 'jobs', [ 'id' => false, 'primary_key' => [ 'id' ] ] ); - - \$jobs->addColumn( 'id', 'string', [ 'limit' => 255 ] ) - ->addColumn( 'queue', 'string', [ 'limit' => 255 ] ) - ->addColumn( 'payload', 'text' ) - ->addColumn( 'attempts', 'integer', [ 'default' => 0 ] ) - ->addColumn( 'reserved_at', 'integer', [ 'null' => true ] ) - ->addColumn( 'available_at', 'integer' ) - ->addColumn( 'created_at', 'integer' ) - ->addIndex( [ 'queue' ] ) - ->addIndex( [ 'available_at' ] ) - ->addIndex( [ 'reserved_at' ] ) - ->create(); - - // Failed jobs table - \$failedJobs = \$this->table( 'failed_jobs', [ 'id' => false, 'primary_key' => [ 'id' ] ] ); - - \$failedJobs->addColumn( 'id', 'string', [ 'limit' => 255 ] ) - ->addColumn( 'queue', 'string', [ 'limit' => 255 ] ) - ->addColumn( 'payload', 'text' ) - ->addColumn( 'exception', 'text' ) - ->addColumn( 'failed_at', 'integer' ) - ->addIndex( [ 'queue' ] ) - ->addIndex( [ 'failed_at' ] ) - ->create(); - } -} - -PHP; - } - - /** - * Add queue configuration to neuron.yaml - */ - private function addQueueConfig(): bool - { - $configFile = $this->_projectPath . '/config/neuron.yaml'; - - if( !file_exists( $configFile ) ) - { - return false; - } - - try - { - // Read existing config - $yaml = new Yaml( $configFile ); - $settings = new SettingManager( $yaml ); - - // Check if queue config already exists - $existingDriver = $settings->get( 'queue', 'driver' ); - - if( $existingDriver ) - { - return true; // Already configured - } - - // Append queue configuration - $queueConfig = <<output->info( "Running migration..." ); - $this->output->write( "\n" ); - - try - { - // Get the CLI application from the registry - $app = Registry::getInstance()->get( 'cli.application' ); - - if( !$app ) - { - $this->output->error( "CLI application not found in registry!" ); - return false; - } - - // Check if db:migrate command exists - if( !$app->has( 'db:migrate' ) ) - { - $this->output->error( "db:migrate command not found!" ); - return false; - } - - // Get the migrate command class - $commandClass = $app->getRegistry()->get( 'db:migrate' ); - - if( !class_exists( $commandClass ) ) - { - $this->output->error( "Migrate command class not found: {$commandClass}" ); - return false; - } - - // Instantiate the migrate command - $migrateCommand = new $commandClass(); - - // Set input and output on the command - $migrateCommand->setInput( $this->input ); - $migrateCommand->setOutput( $this->output ); - - // Configure the command - $migrateCommand->configure(); - - // Execute the migrate command - $exitCode = $migrateCommand->execute(); - - if( $exitCode !== 0 ) - { - $this->output->error( "Migration failed with exit code: $exitCode" ); - return false; - } - - $this->output->write( "\n" ); - $this->output->success( "Migration completed successfully!" ); - - return true; - } - catch( \Exception $e ) - { - $this->output->error( "Error running migration: " . $e->getMessage() ); - return false; - } - } - - /** - * Convert CamelCase to snake_case - */ - private function camelToSnake( string $input ): string - { - return strtolower( preg_replace( '/(?register( 'cms:install', 'Neuron\\Cms\\Cli\\Commands\\Install\\InstallCommand' ); + $registry->register( + 'cms:upgrade', + 'Neuron\\Cms\\Cli\\Commands\\Install\\UpgradeCommand' + ); + // User management commands $registry->register( 'cms:user:create', @@ -55,17 +60,5 @@ public static function register( Registry $registry ): void 'cms:maintenance:status', 'Neuron\\Cms\\Cli\\Commands\\Maintenance\\StatusCommand' ); - - // Email template generator - $registry->register( - 'mail:generate', - 'Neuron\\Cms\\Cli\\Commands\\Generate\\EmailCommand' - ); - - // Queue installation - $registry->register( - 'queue:install', - 'Neuron\\Cms\\Cli\\Commands\\Queue\\InstallCommand' - ); } } diff --git a/src/Cms/Controllers/Admin/Categories.php b/src/Cms/Controllers/Admin/Categories.php index 268c8e1..746fcd8 100644 --- a/src/Cms/Controllers/Admin/Categories.php +++ b/src/Cms/Controllers/Admin/Categories.php @@ -8,7 +8,7 @@ use Neuron\Cms\Services\Category\Updater; use Neuron\Cms\Services\Category\Deleter; use Neuron\Core\Exceptions\NotFound; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; diff --git a/src/Cms/Controllers/Admin/Media.php b/src/Cms/Controllers/Admin/Media.php new file mode 100644 index 0000000..00509d7 --- /dev/null +++ b/src/Cms/Controllers/Admin/Media.php @@ -0,0 +1,192 @@ +get( 'Settings' ); + + if( !$settings instanceof SettingManager ) + { + throw new \Exception( 'Settings not found in Registry' ); + } + + $this->_uploader = new CloudinaryUploader( $settings ); + $this->_validator = new MediaValidator( $settings ); + } + + /** + * Upload image for Editor.js + * + * Handles POST /admin/upload/image + * Returns JSON in Editor.js format + * + * @return void + */ + public function uploadImage(): void + { + // Set JSON response header + header( 'Content-Type: application/json' ); + + try + { + // Check if file was uploaded + if( !isset( $_FILES['image'] ) ) + { + $this->returnEditorJsError( 'No file was uploaded' ); + return; + } + + $file = $_FILES['image']; + + // Validate file + if( !$this->_validator->validate( $file ) ) + { + $this->returnEditorJsError( $this->_validator->getFirstError() ); + return; + } + + // Upload to Cloudinary + $result = $this->_uploader->upload( $file['tmp_name'] ); + + // Return success response in Editor.js format + $this->returnEditorJsSuccess( $result ); + } + catch( \Exception $e ) + { + $this->returnEditorJsError( $e->getMessage() ); + } + } + + /** + * Upload featured image + * + * Handles POST /admin/upload/featured-image + * Returns JSON with upload result + * + * @return void + */ + public function uploadFeaturedImage(): void + { + // Set JSON response header + header( 'Content-Type: application/json' ); + + try + { + // Check if file was uploaded + if( !isset( $_FILES['image'] ) ) + { + $this->returnError( 'No file was uploaded' ); + return; + } + + $file = $_FILES['image']; + + // Validate file + if( !$this->_validator->validate( $file ) ) + { + $this->returnError( $this->_validator->getFirstError() ); + return; + } + + // Upload to Cloudinary + $result = $this->_uploader->upload( $file['tmp_name'] ); + + // Return success response + $this->returnSuccess( $result ); + } + catch( \Exception $e ) + { + $this->returnError( $e->getMessage() ); + } + } + + /** + * Return Editor.js success response + * + * @param array $result Upload result + * @return void + */ + private function returnEditorJsSuccess( array $result ): void + { + echo json_encode( [ + 'success' => 1, + 'file' => [ + 'url' => $result['url'], + 'width' => $result['width'], + 'height' => $result['height'] + ] + ] ); + exit; + } + + /** + * Return Editor.js error response + * + * @param string $message Error message + * @return void + */ + private function returnEditorJsError( string $message ): void + { + http_response_code( 400 ); + echo json_encode( [ + 'success' => 0, + 'message' => $message + ] ); + exit; + } + + /** + * Return standard success response + * + * @param array $result Upload result + * @return void + */ + private function returnSuccess( array $result ): void + { + echo json_encode( [ + 'success' => true, + 'data' => $result + ] ); + exit; + } + + /** + * Return standard error response + * + * @param string $message Error message + * @return void + */ + private function returnError( string $message ): void + { + http_response_code( 400 ); + echo json_encode( [ + 'success' => false, + 'error' => $message + ] ); + exit; + } +} diff --git a/src/Cms/Controllers/Admin/Profile.php b/src/Cms/Controllers/Admin/Profile.php index 30fc361..74ff398 100644 --- a/src/Cms/Controllers/Admin/Profile.php +++ b/src/Cms/Controllers/Admin/Profile.php @@ -7,7 +7,7 @@ use Neuron\Cms\Services\User\Updater; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Services\Auth\CsrfToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; diff --git a/src/Cms/Controllers/Admin/Users.php b/src/Cms/Controllers/Admin/Users.php index 881af81..5a9ab06 100644 --- a/src/Cms/Controllers/Admin/Users.php +++ b/src/Cms/Controllers/Admin/Users.php @@ -10,7 +10,7 @@ use Neuron\Cms\Services\User\Deleter; use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Services\Auth\CsrfToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; diff --git a/src/Cms/Controllers/Blog.php b/src/Cms/Controllers/Blog.php index 749b10e..eee87a6 100644 --- a/src/Cms/Controllers/Blog.php +++ b/src/Cms/Controllers/Blog.php @@ -6,6 +6,9 @@ use Neuron\Cms\Repositories\DatabasePostRepository; use Neuron\Cms\Repositories\DatabaseCategoryRepository; use Neuron\Cms\Repositories\DatabaseTagRepository; +use Neuron\Cms\Services\Content\EditorJsRenderer; +use Neuron\Cms\Services\Content\ShortcodeParser; +use Neuron\Cms\Services\Widget\WidgetRenderer; use Neuron\Core\Exceptions\NotFound; use Neuron\Mvc\Application; use Neuron\Mvc\Requests\Request; @@ -17,6 +20,7 @@ class Blog extends Content private DatabasePostRepository $_postRepository; private DatabaseCategoryRepository $_categoryRepository; private DatabaseTagRepository $_tagRepository; + private EditorJsRenderer $_renderer; /** * @param Application|null $app @@ -33,6 +37,11 @@ public function __construct( ?Application $app = null ) $this->_postRepository = new DatabasePostRepository( $settings ); $this->_categoryRepository = new DatabaseCategoryRepository( $settings ); $this->_tagRepository = new DatabaseTagRepository( $settings ); + + // Initialize renderer with shortcode support + $widgetRenderer = new WidgetRenderer( $this->_postRepository ); + $shortcodeParser = new ShortcodeParser( $widgetRenderer ); + $this->_renderer = new EditorJsRenderer( $shortcodeParser ); } /** @@ -94,12 +103,17 @@ public function show( Request $request ): string $categories = $this->_categoryRepository->all(); $tags = $this->_tagRepository->all(); + // Render content from Editor.js JSON + $content = $post->getContent(); + $renderedContent = $this->_renderer->render( $content ); + return $this->renderHtml( HttpResponseStatus::OK, [ 'Categories' => $categories, 'Tags' => $tags, 'Post' => $post, + 'renderedContent' => $renderedContent, 'Title' => $post->getTitle() . ' | ' . $this->getName() ], 'show' diff --git a/src/Cms/Controllers/Content.php b/src/Cms/Controllers/Content.php index 1a04ade..bd26a1a 100644 --- a/src/Cms/Controllers/Content.php +++ b/src/Cms/Controllers/Content.php @@ -1,5 +1,6 @@ loadFromFile( "../.version.json" ); + $version = Factories\Version::fromFile( "../.version.json" ); Registry::getInstance()->set( 'version', 'v'.$version->getAsString() ); } @@ -202,7 +202,7 @@ public function markdown( Request $request ): string { $viewData = array(); - $page = $request->getRouteParameter( 'page' ); + $page = $request->getRouteParameter( 'page' ) ?? 'index'; $viewData[ 'Title' ] = $this->getName() . ' | ' . $this->getTitle(); diff --git a/src/Cms/Controllers/Traits/UsesDtos.php b/src/Cms/Controllers/Traits/UsesDtos.php index d6ff25f..69b3460 100644 --- a/src/Cms/Controllers/Traits/UsesDtos.php +++ b/src/Cms/Controllers/Traits/UsesDtos.php @@ -5,13 +5,15 @@ use Neuron\Cms\Services\Dto\DtoFactoryService; use Neuron\Core\Exceptions\Validation; use Neuron\Dto\Dto; +use Neuron\Dto\Mapper\Request as RequestMapper; use Neuron\Mvc\Requests\Request; use Neuron\Patterns\Registry; /** * Trait for using DTOs in controllers * - * Provides helper methods for creating, populating, and validating DTOs. + * Provides helper methods for creating, populating, and validating DTOs + * using proper input filtering through the RequestMapper. * * @package Neuron\Cms\Controllers\Traits */ @@ -38,51 +40,28 @@ protected function getDtoFactory(): DtoFactoryService } /** - * Populate a DTO from request data + * Populate a DTO from request data using RequestMapper * - * Sets DTO properties from POST data. Silently ignores properties - * that don't exist in the DTO. + * Maps filtered POST data to DTO properties using the RequestMapper + * which applies proper input sanitization via Post::filterScalar(). * * @param Dto $dto DTO to populate - * @param Request $request Request containing form data + * @param Request $request Request containing form data (unused - kept for BC) * @param array $fields Array of field names to populate (defaults to all POST data) * @return Dto The populated DTO */ protected function populateDtoFromRequest( Dto $dto, Request $request, array $fields = [] ): Dto { - // If no specific fields provided, use all POST data keys - if( empty( $fields ) ) - { - $fields = array_keys( $_POST ); - } + $mapper = new RequestMapper(); - foreach( $fields as $field ) + // If specific fields provided, pass them to mapper + // Otherwise mapper will use all POST keys + if( !empty( $fields ) ) { - $value = $request->post( $field ); - - // Get the property from the DTO - $property = $dto->getProperty( $field ); - - // Skip if property doesn't exist - if( !$property ) - { - continue; - } - - // Set the value - try - { - // Use magic setter which handles validation per property - $dto->$field = $value; - } - catch( Validation $e ) - { - // Property-level validation errors are collected - // They'll be returned when validate() is called on the DTO - } + return $mapper->mapFiltered( $dto, $fields ); } - return $dto; + return $mapper->map( $dto ); } /** diff --git a/src/Cms/Database/ConnectionFactory.php b/src/Cms/Database/ConnectionFactory.php index 5e423bb..503a772 100644 --- a/src/Cms/Database/ConnectionFactory.php +++ b/src/Cms/Database/ConnectionFactory.php @@ -2,7 +2,7 @@ namespace Neuron\Cms\Database; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; diff --git a/src/Cms/Dtos/CategoryFieldsDto.yaml b/src/Cms/Dtos/CategoryFieldsDto.yaml new file mode 100644 index 0000000..129b499 --- /dev/null +++ b/src/Cms/Dtos/CategoryFieldsDto.yaml @@ -0,0 +1,27 @@ +# Category Fields DTO (Reference Example) +# +# This is a reusable DTO demonstrating the composition feature. +# It defines common category fields that could be referenced by other DTOs. +# +# NOTE: Currently NOT used via composition in CreateCategoryDto/UpdateCategoryDto because +# the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. +# +# Future Enhancement: Once RequestMapper supports nested mapping, category DTOs could +# reference this DTO via composition to eliminate duplicate field definitions. + +dto: + name: + type: string + required: true + length: + min: 2 + max: 100 + slug: + type: string + required: true + length: + min: 2 + max: 100 + description: + type: string + required: false diff --git a/src/Cms/Dtos/CreateCategoryDto.yaml b/src/Cms/Dtos/CreateCategoryDto.yaml index 05c5262..ba89ec6 100644 --- a/src/Cms/Dtos/CreateCategoryDto.yaml +++ b/src/Cms/Dtos/CreateCategoryDto.yaml @@ -1,3 +1,7 @@ +# Create Category DTO +# Note: This DTO uses flat structure instead of composition (referencing CategoryFieldsDto) +# because the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. + dto: name: type: string diff --git a/src/Cms/Dtos/CreatePostDto.yaml b/src/Cms/Dtos/CreatePostDto.yaml index e59ea87..61ab30d 100644 --- a/src/Cms/Dtos/CreatePostDto.yaml +++ b/src/Cms/Dtos/CreatePostDto.yaml @@ -1,3 +1,7 @@ +# Create Post DTO +# Note: This DTO uses flat structure instead of composition (referencing PostFieldsDto) +# because the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. + dto: title: type: string diff --git a/src/Cms/Dtos/CreateUserDto.yaml b/src/Cms/Dtos/CreateUserDto.yaml index 08000d9..ce90670 100644 --- a/src/Cms/Dtos/CreateUserDto.yaml +++ b/src/Cms/Dtos/CreateUserDto.yaml @@ -1,3 +1,7 @@ +# Create User DTO +# Note: This DTO uses flat structure instead of composition (referencing UserCredentialsDto) +# because the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. + dto: username: type: string diff --git a/src/Cms/Dtos/MediaUploadDto.yaml b/src/Cms/Dtos/MediaUploadDto.yaml new file mode 100644 index 0000000..a249d36 --- /dev/null +++ b/src/Cms/Dtos/MediaUploadDto.yaml @@ -0,0 +1,19 @@ +# Media Upload DTO Configuration +# Validation rules for media file uploads + +properties: + file: + type: string + required: true + validators: + - name: NotEmpty + message: "File is required" + + folder: + type: string + required: false + validators: + - name: Length + options: + max: 255 + message: "Folder path must not exceed 255 characters" diff --git a/src/Cms/Dtos/PostFieldsDto.yaml b/src/Cms/Dtos/PostFieldsDto.yaml new file mode 100644 index 0000000..c2e28bc --- /dev/null +++ b/src/Cms/Dtos/PostFieldsDto.yaml @@ -0,0 +1,43 @@ +# Post Fields DTO (Reference Example) +# +# This is a reusable DTO demonstrating the composition feature. +# It defines common post fields that could be referenced by other DTOs. +# +# NOTE: Currently NOT used via composition in CreatePostDto/UpdatePostDto because +# the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. +# +# Future Enhancement: Once RequestMapper supports nested mapping, post DTOs could +# reference this DTO via composition to eliminate duplicate field definitions. + +dto: + title: + type: string + required: true + length: + min: 5 + max: 255 + body: + type: string + required: true + length: + min: 10 + status: + type: string + required: true + slug: + type: string + required: false + excerpt: + type: string + required: false + featuredImage: + type: string + required: false + categoryIds: + type: array + required: false + items: + type: integer + tags: + type: string + required: false diff --git a/src/Cms/Dtos/RegisterUserDto.yaml b/src/Cms/Dtos/RegisterUserDto.yaml index ca4245d..e17199b 100644 --- a/src/Cms/Dtos/RegisterUserDto.yaml +++ b/src/Cms/Dtos/RegisterUserDto.yaml @@ -1,3 +1,9 @@ +# Register User DTO +# Note: This DTO uses flat structure instead of composition (referencing UserCredentialsDto) +# because the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. +# HTML forms submit flat fields (username, email, password) which must map directly to +# top-level DTO properties for RequestMapper compatibility. + dto: username: type: string diff --git a/src/Cms/Dtos/UpdateCategoryDto.yaml b/src/Cms/Dtos/UpdateCategoryDto.yaml index 05c5262..cea85c4 100644 --- a/src/Cms/Dtos/UpdateCategoryDto.yaml +++ b/src/Cms/Dtos/UpdateCategoryDto.yaml @@ -1,3 +1,7 @@ +# Update Category DTO +# Note: This DTO uses flat structure instead of composition (referencing CategoryFieldsDto) +# because the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. + dto: name: type: string diff --git a/src/Cms/Dtos/UpdatePostDto.yaml b/src/Cms/Dtos/UpdatePostDto.yaml index e59ea87..2786630 100644 --- a/src/Cms/Dtos/UpdatePostDto.yaml +++ b/src/Cms/Dtos/UpdatePostDto.yaml @@ -1,3 +1,7 @@ +# Update Post DTO +# Note: This DTO uses flat structure instead of composition (referencing PostFieldsDto) +# because the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. + dto: title: type: string diff --git a/src/Cms/Dtos/UserCredentialsDto.yaml b/src/Cms/Dtos/UserCredentialsDto.yaml new file mode 100644 index 0000000..867dff8 --- /dev/null +++ b/src/Cms/Dtos/UserCredentialsDto.yaml @@ -0,0 +1,34 @@ +# User Credentials DTO (Reference Example) +# +# This is a reusable DTO demonstrating the composition feature. +# It defines common user authentication fields that could be referenced by other DTOs. +# +# NOTE: Currently NOT used via composition in CreateUserDto/RegisterUserDto because +# the RequestMapper doesn't yet support mapping flat POST data to nested DTO properties. +# When forms submit flat fields (username, email, password), they must map directly to +# top-level DTO properties for RequestMapper compatibility. +# +# Future Enhancement: Once RequestMapper supports nested mapping, DTOs like RegisterUserDto +# could reference this DTO via composition: +# credentials: +# type: dto +# ref: 'UserCredentialsDto.yaml' +# +# This would eliminate duplicate field definitions across user-related DTOs. + +dto: + username: + type: string + required: true + length: + min: 3 + max: 50 + email: + type: email + required: true + password: + type: string + required: true + length: + min: 8 + max: 255 diff --git a/src/Cms/Email/helpers.php b/src/Cms/Email/helpers.php index a5392f4..7fea128 100644 --- a/src/Cms/Email/helpers.php +++ b/src/Cms/Email/helpers.php @@ -1,7 +1,7 @@ $enabledBy ?? get_current_user() ]; - return $this->writeMaintenanceFile( $data ); + $result = $this->writeMaintenanceFile( $data ); + + // Emit maintenance mode enabled event + if( $result ) + { + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\MaintenanceModeEnabledEvent( + $data['enabled_by'], + $message + ) ); + } + + return $result; } /** * Disable maintenance mode * + * @param string|null $disabledBy User who disabled maintenance mode * @return bool Success status */ - public function disable(): bool + public function disable( ?string $disabledBy = null ): bool { + // Get who is disabling before we delete the file + $disabledByUser = $disabledBy ?? get_current_user(); + if( file_exists( $this->_maintenanceFilePath ) ) { - return unlink( $this->_maintenanceFilePath ); + $result = unlink( $this->_maintenanceFilePath ); + + // Emit maintenance mode disabled event + if( $result ) + { + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\MaintenanceModeDisabledEvent( + $disabledByUser + ) ); + } + + return $result; } return true; @@ -178,6 +203,11 @@ private function readMaintenanceFile(): array $data = json_decode( $contents, true ); + if( json_last_error() !== JSON_ERROR_NONE ) + { + return []; + } + return is_array( $data ) ? $data : []; } diff --git a/src/Cms/Models/Post.php b/src/Cms/Models/Post.php index 2b4db19..1f524af 100644 --- a/src/Cms/Models/Post.php +++ b/src/Cms/Models/Post.php @@ -18,7 +18,8 @@ class Post extends Model private ?int $_id = null; private string $_title; private string $_slug; - private string $_body; + private string $_body = ''; // Plain text fallback, derived from contentRaw + private string $_contentRaw = '{"blocks":[]}'; // JSON string for Editor.js private ?string $_excerpt = null; private ?string $_featuredImage = null; private int $_authorId; @@ -118,6 +119,55 @@ public function setBody( string $body ): self return $this; } + /** + * Get content as array (decoded Editor.js JSON) + */ + public function getContent(): array + { + return json_decode( $this->_contentRaw, true ) ?? ['blocks' => []]; + } + + /** + * Get raw content JSON string + */ + public function getContentRaw(): string + { + return $this->_contentRaw; + } + + /** + * Set content from Editor.js JSON string + * Also extracts plain text to _body for backward compatibility + */ + public function setContent( string $jsonContent ): self + { + $this->_contentRaw = $jsonContent; + $this->_body = $this->extractPlainText( $jsonContent ); + return $this; + } + + /** + * Set content from array (will be JSON encoded) + * Also extracts plain text to _body for backward compatibility + * @param array $content Content array to encode + * @return self + * @throws \JsonException If JSON encoding fails + */ + public function setContentArray( array $content ): self + { + $encoded = json_encode( $content ); + + if( $encoded === false ) + { + $error = json_last_error_msg(); + throw new \JsonException( "Failed to encode content array to JSON: {$error}" ); + } + + $this->_contentRaw = $encoded; + $this->_body = $this->extractPlainText( $encoded ); + return $this; + } + /** * Get excerpt */ @@ -446,7 +496,54 @@ public static function fromArray( array $data ): static $post->setTitle( $data['title'] ?? '' ); $post->setSlug( $data['slug'] ?? '' ); - $post->setBody( $data['body'] ?? '' ); + + // Handle content_raw first (without extracting plain text to body yet) + if( isset( $data['content_raw'] ) ) + { + if( is_string( $data['content_raw'] ) ) + { + $post->_contentRaw = $data['content_raw']; + } + elseif( is_array( $data['content_raw'] ) ) + { + $encoded = json_encode( $data['content_raw'] ); + if( $encoded === false ) + { + $error = json_last_error_msg(); + throw new \JsonException( "Failed to encode content_raw array to JSON: {$error}" ); + } + $post->_contentRaw = $encoded; + } + } + elseif( isset( $data['content'] ) ) + { + if( is_string( $data['content'] ) ) + { + $post->_contentRaw = $data['content']; + } + elseif( is_array( $data['content'] ) ) + { + $encoded = json_encode( $data['content'] ); + if( $encoded === false ) + { + $error = json_last_error_msg(); + throw new \JsonException( "Failed to encode content array to JSON: {$error}" ); + } + $post->_contentRaw = $encoded; + } + } + + // Set body - if explicitly provided, use it; otherwise extract from content_raw + if( isset( $data['body'] ) && $data['body'] !== '' ) + { + $post->setBody( $data['body'] ); + } + else + { + // Extract plain text from content_raw as fallback + $post->setBody( $post->extractPlainText( $post->_contentRaw ) ); + } + $post->setExcerpt( $data['excerpt'] ?? null ); $post->setFeaturedImage( $data['featured_image'] ?? null ); $post->setAuthorId( (int)($data['author_id'] ?? 0) ); @@ -511,6 +608,7 @@ public function toArray(): array 'title' => $this->_title, 'slug' => $this->_slug, 'body' => $this->_body, + 'content_raw' => $this->_contentRaw, 'excerpt' => $this->_excerpt, 'featured_image' => $this->_featuredImage, 'author_id' => $this->_authorId, @@ -521,4 +619,92 @@ public function toArray(): array 'updated_at' => $this->_updatedAt?->format( 'Y-m-d H:i:s' ), ]; } + + /** + * Extract plain text from Editor.js JSON content + * + * @param string $jsonContent Editor.js JSON string + * @return string Plain text extracted from blocks + */ + private function extractPlainText( string $jsonContent ): string + { + $data = json_decode( $jsonContent, true ); + + if( !$data || !isset( $data['blocks'] ) || !is_array( $data['blocks'] ) ) + { + return ''; + } + + $text = []; + + foreach( $data['blocks'] as $block ) + { + if( !isset( $block['type'] ) || !isset( $block['data'] ) ) + { + continue; + } + + $blockText = match( $block['type'] ) + { + 'paragraph', 'header' => $block['data']['text'] ?? '', + 'list' => isset( $block['data']['items'] ) && is_array( $block['data']['items'] ) + ? $this->extractListText( $block['data']['items'] ) + : '', + 'quote' => $block['data']['text'] ?? '', + 'code' => $block['data']['code'] ?? '', + 'raw' => $block['data']['html'] ?? '', + default => '' + }; + + if( $blockText !== '' ) + { + // Strip HTML tags from text + $blockText = strip_tags( $blockText ); + $text[] = trim( $blockText ); + } + } + + return implode( "\n\n", array_filter( $text ) ); + } + + /** + * Extract text from list items (handles nested structures) + * + * Editor.js List v1.9+ supports nested lists where items can be: + * - Simple strings: "Item text" + * - Objects with nested items: { "content": "Item text", "items": [nested items] } + * + * @param array $items List items array + * @return string Extracted text with newlines between items + */ + private function extractListText( array $items ): string + { + $textItems = []; + + foreach( $items as $item ) + { + // Handle simple string items + if( is_string( $item ) ) + { + $textItems[] = $item; + } + // Handle nested list items (objects with content and items) + elseif( is_array( $item ) && isset( $item['content'] ) ) + { + $textItems[] = $item['content']; + + // Recursively extract nested items + if( isset( $item['items'] ) && is_array( $item['items'] ) ) + { + $nestedText = $this->extractListText( $item['items'] ); + if( $nestedText !== '' ) + { + $textItems[] = $nestedText; + } + } + } + } + + return implode( "\n", $textItems ); + } } diff --git a/src/Cms/Repositories/DatabaseCategoryRepository.php b/src/Cms/Repositories/DatabaseCategoryRepository.php index 419a6bc..7aff64f 100644 --- a/src/Cms/Repositories/DatabaseCategoryRepository.php +++ b/src/Cms/Repositories/DatabaseCategoryRepository.php @@ -4,15 +4,14 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\Category; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; -use DateTimeImmutable; /** - * Database-backed category repository. + * Database-backed category repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ @@ -28,6 +27,7 @@ class DatabaseCategoryRepository implements ICategoryRepository */ public function __construct( SettingManager $settings ) { + // Keep PDO for allWithPostCount() which uses a custom JOIN query $this->_pdo = ConnectionFactory::createFromSettings( $settings ); } @@ -36,12 +36,7 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?Category { - $stmt = $this->_pdo->prepare( "SELECT * FROM categories WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $id ] ); - - $row = $stmt->fetch(); - - return $row ? Category::fromArray( $row ) : null; + return Category::find( $id ); } /** @@ -49,12 +44,7 @@ public function findById( int $id ): ?Category */ public function findBySlug( string $slug ): ?Category { - $stmt = $this->_pdo->prepare( "SELECT * FROM categories WHERE slug = ? LIMIT 1" ); - $stmt->execute( [ $slug ] ); - - $row = $stmt->fetch(); - - return $row ? Category::fromArray( $row ) : null; + return Category::where( 'slug', $slug )->first(); } /** @@ -62,12 +52,7 @@ public function findBySlug( string $slug ): ?Category */ public function findByName( string $name ): ?Category { - $stmt = $this->_pdo->prepare( "SELECT * FROM categories WHERE name = ? LIMIT 1" ); - $stmt->execute( [ $name ] ); - - $row = $stmt->fetch(); - - return $row ? Category::fromArray( $row ) : null; + return Category::where( 'name', $name )->first(); } /** @@ -83,13 +68,7 @@ public function findByIds( array $ids ): array return []; } - $placeholders = implode( ',', array_fill( 0, count( $ids ), '?' ) ); - $stmt = $this->_pdo->prepare( "SELECT * FROM categories WHERE id IN ($placeholders)" ); - $stmt->execute( $ids ); - - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Category::fromArray( $row ), $rows ); + return Category::whereIn( 'id', $ids )->get(); } /** @@ -109,20 +88,11 @@ public function create( Category $category ): Category throw new Exception( 'Category name already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO categories (name, slug, description, created_at, updated_at) - VALUES (?, ?, ?, ?, ?)" - ); - - $stmt->execute([ - $category->getName(), - $category->getSlug(), - $category->getDescription(), - $category->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); + // Use ORM create method + $createdCategory = Category::create( $category->toArray() ); - $category->setId( (int)$this->_pdo->lastInsertId() ); + // Update the original category with the new ID + $category->setId( $createdCategory->getId() ); return $category; } @@ -151,22 +121,8 @@ public function update( Category $category ): bool throw new Exception( 'Category name already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE categories SET - name = ?, - slug = ?, - description = ?, - updated_at = ? - WHERE id = ?" - ); - - return $stmt->execute([ - $category->getName(), - $category->getSlug(), - $category->getDescription(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $category->getId() - ]); + // Use ORM save method + return $category->save(); } /** @@ -175,10 +131,9 @@ public function update( Category $category ): bool public function delete( int $id ): bool { // Foreign key constraints will handle cascade delete of post relationships - $stmt = $this->_pdo->prepare( "DELETE FROM categories WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = Category::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -186,10 +141,7 @@ public function delete( int $id ): bool */ public function all(): array { - $stmt = $this->_pdo->query( "SELECT * FROM categories ORDER BY name ASC" ); - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Category::fromArray( $row ), $rows ); + return Category::orderBy( 'name', 'ASC' )->all(); } /** @@ -197,10 +149,7 @@ public function all(): array */ public function count(): int { - $stmt = $this->_pdo->query( "SELECT COUNT(*) as total FROM categories" ); - $row = $stmt->fetch(); - - return (int)$row['total']; + return Category::query()->count(); } /** @@ -208,6 +157,8 @@ public function count(): int */ public function allWithPostCount(): array { + // This method still uses raw SQL for the JOIN with aggregation + // TODO: Add support for joins and aggregations to ORM $stmt = $this->_pdo->query( "SELECT c.*, COUNT(pc.post_id) as post_count FROM categories c @@ -226,4 +177,21 @@ public function allWithPostCount(): array ]; }, $rows ); } + + /** + * Handle serialization for PHPUnit process isolation + */ + public function __sleep(): array + { + // Don't serialize PDO connection + return []; + } + + /** + * Handle unserialization for PHPUnit process isolation + */ + public function __wakeup(): void + { + // PDO will be re-initialized by test setup + } } diff --git a/src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php b/src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php index 59cca22..a540cf8 100644 --- a/src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php +++ b/src/Cms/Repositories/DatabaseEmailVerificationTokenRepository.php @@ -4,7 +4,7 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\EmailVerificationToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; use DateTimeImmutable; @@ -132,4 +132,21 @@ private function mapRowToToken( array $row ): EmailVerificationToken 'expires_at' => $row['expires_at'] ]); } + + /** + * Handle serialization for PHPUnit process isolation + */ + public function __sleep(): array + { + // Don't serialize PDO connection + return []; + } + + /** + * Handle unserialization for PHPUnit process isolation + */ + public function __wakeup(): void + { + // PDO will be re-initialized by test setup + } } diff --git a/src/Cms/Repositories/DatabasePageRepository.php b/src/Cms/Repositories/DatabasePageRepository.php index fbd65f9..113610f 100644 --- a/src/Cms/Repositories/DatabasePageRepository.php +++ b/src/Cms/Repositories/DatabasePageRepository.php @@ -2,25 +2,19 @@ namespace Neuron\Cms\Repositories; -use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\Page; -use Neuron\Cms\Models\User; -use Neuron\Data\Setting\SettingManager; -use PDO; +use Neuron\Data\Settings\SettingManager; use Exception; -use DateTimeImmutable; /** - * Database-backed page repository. + * Database-backed page repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ class DatabasePageRepository implements IPageRepository { - private PDO $_pdo; - /** * Constructor * @@ -29,7 +23,7 @@ class DatabasePageRepository implements IPageRepository */ public function __construct( SettingManager $settings ) { - $this->_pdo = ConnectionFactory::createFromSettings( $settings ); + // No longer need PDO - ORM is initialized in Bootstrap } /** @@ -37,17 +31,8 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?Page { - $stmt = $this->_pdo->prepare( "SELECT * FROM pages WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $id ] ); - - $row = $stmt->fetch(); - - if( !$row ) - { - return null; - } - - return $this->mapRowToPage( $row ); + // Use eager loading for author + return Page::with( 'author' )->find( $id ); } /** @@ -55,17 +40,8 @@ public function findById( int $id ): ?Page */ public function findBySlug( string $slug ): ?Page { - $stmt = $this->_pdo->prepare( "SELECT * FROM pages WHERE slug = ? LIMIT 1" ); - $stmt->execute( [ $slug ] ); - - $row = $stmt->fetch(); - - if( !$row ) - { - return null; - } - - return $this->mapRowToPage( $row ); + // Use eager loading for author + return Page::with( 'author' )->where( 'slug', $slug )->first(); } /** @@ -79,31 +55,11 @@ public function create( Page $page ): Page throw new Exception( 'Slug already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO pages ( - title, slug, content, template, meta_title, meta_description, - meta_keywords, author_id, status, published_at, view_count, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ); + // Use ORM create method + $createdPage = Page::create( $page->toArray() ); - $stmt->execute([ - $page->getTitle(), - $page->getSlug(), - $page->getContentRaw(), - $page->getTemplate(), - $page->getMetaTitle(), - $page->getMetaDescription(), - $page->getMetaKeywords(), - $page->getAuthorId(), - $page->getStatus(), - $page->getPublishedAt() ? $page->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, - $page->getViewCount(), - $page->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); - - $page->setId( (int)$this->_pdo->lastInsertId() ); + // Update the original page with the new ID + $page->setId( $createdPage->getId() ); return $page; } @@ -125,40 +81,8 @@ public function update( Page $page ): bool throw new Exception( 'Slug already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE pages SET - title = ?, - slug = ?, - content = ?, - template = ?, - meta_title = ?, - meta_description = ?, - meta_keywords = ?, - author_id = ?, - status = ?, - published_at = ?, - view_count = ?, - updated_at = ? - WHERE id = ?" - ); - - $result = $stmt->execute([ - $page->getTitle(), - $page->getSlug(), - $page->getContentRaw(), - $page->getTemplate(), - $page->getMetaTitle(), - $page->getMetaDescription(), - $page->getMetaKeywords(), - $page->getAuthorId(), - $page->getStatus(), - $page->getPublishedAt() ? $page->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, - $page->getViewCount(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $page->getId() - ]); - - return $result; + // Use ORM save method + return $page->save(); } /** @@ -166,10 +90,9 @@ public function update( Page $page ): bool */ public function delete( int $id ): bool { - $stmt = $this->_pdo->prepare( "DELETE FROM pages WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = Page::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -177,29 +100,21 @@ public function delete( int $id ): bool */ public function all( ?string $status = null, int $limit = 0, int $offset = 0 ): array { - $sql = "SELECT * FROM pages"; - $params = []; + $query = Page::query(); if( $status ) { - $sql .= " WHERE status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $sql .= " ORDER BY created_at DESC"; + $query->orderBy( 'created_at', 'DESC' ); if( $limit > 0 ) { - $sql .= " LIMIT ? OFFSET ?"; - $params[] = $limit; - $params[] = $offset; + $query->limit( $limit )->offset( $offset ); } - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToPage' ], $rows ); + return $query->get(); } /** @@ -223,22 +138,14 @@ public function getDrafts(): array */ public function getByAuthor( int $authorId, ?string $status = null ): array { - $sql = "SELECT * FROM pages WHERE author_id = ?"; - $params = [ $authorId ]; + $query = Page::query()->where( 'author_id', $authorId ); if( $status ) { - $sql .= " AND status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $sql .= " ORDER BY created_at DESC"; - - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToPage' ], $rows ); + return $query->orderBy( 'created_at', 'DESC' )->get(); } /** @@ -246,91 +153,28 @@ public function getByAuthor( int $authorId, ?string $status = null ): array */ public function count( ?string $status = null ): int { - $sql = "SELECT COUNT(*) as total FROM pages"; - $params = []; + $query = Page::query(); if( $status ) { - $sql .= " WHERE status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $row = $stmt->fetch(); - - return (int)$row['total']; + return $query->count(); } /** * Increment page view count - */ - public function incrementViewCount( int $id ): bool - { - $stmt = $this->_pdo->prepare( "UPDATE pages SET view_count = view_count + 1 WHERE id = ?" ); - $stmt->execute( [ $id ] ); - - return $stmt->rowCount() > 0; - } - - /** - * Map database row to Page object - * - * @param array $row Database row - * @return Page - */ - private function mapRowToPage( array $row ): Page - { - $data = [ - 'id' => (int)$row['id'], - 'title' => $row['title'], - 'slug' => $row['slug'], - 'content' => $row['content'], - 'template' => $row['template'], - 'meta_title' => $row['meta_title'], - 'meta_description' => $row['meta_description'], - 'meta_keywords' => $row['meta_keywords'], - 'author_id' => (int)$row['author_id'], - 'status' => $row['status'], - 'view_count' => (int)$row['view_count'], - 'published_at' => $row['published_at'] ?? null, - 'created_at' => $row['created_at'], - 'updated_at' => $row['updated_at'] ?? null, - ]; - - $page = Page::fromArray( $data ); - - // Load relationships - $page->setAuthor( $this->loadAuthor( $page->getAuthorId() ) ); - - return $page; - } - - /** - * Load author for a page * - * @param int $authorId - * @return User|null + * Uses atomic UPDATE to avoid race condition under concurrent requests. */ - private function loadAuthor( int $authorId ): ?User + public function incrementViewCount( int $id ): bool { - try - { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $authorId ] ); - $row = $stmt->fetch(); - - if( !$row ) - { - return null; - } + // Use ORM's atomic increment to avoid race condition + $rowsUpdated = Page::query() + ->where( 'id', $id ) + ->increment( 'view_count', 1 ); - return User::fromArray( $row ); - } - catch( \PDOException $e ) - { - // Users table may not exist in test environments - return null; - } + return $rowsUpdated > 0; } } diff --git a/src/Cms/Repositories/DatabasePasswordResetTokenRepository.php b/src/Cms/Repositories/DatabasePasswordResetTokenRepository.php index edfed30..dff22ca 100644 --- a/src/Cms/Repositories/DatabasePasswordResetTokenRepository.php +++ b/src/Cms/Repositories/DatabasePasswordResetTokenRepository.php @@ -4,7 +4,7 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\PasswordResetToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; use DateTimeImmutable; @@ -114,4 +114,21 @@ private function mapRowToToken( array $row ): PasswordResetToken 'expires_at' => $row['expires_at'] ]); } + + /** + * Handle serialization for PHPUnit process isolation + */ + public function __sleep(): array + { + // Don't serialize PDO connection + return []; + } + + /** + * Handle unserialization for PHPUnit process isolation + */ + public function __wakeup(): void + { + // PDO will be re-initialized by test setup + } } diff --git a/src/Cms/Repositories/DatabasePostRepository.php b/src/Cms/Repositories/DatabasePostRepository.php index 2e6b90f..0a57c05 100644 --- a/src/Cms/Repositories/DatabasePostRepository.php +++ b/src/Cms/Repositories/DatabasePostRepository.php @@ -4,18 +4,16 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\Post; -use Neuron\Cms\Models\User; use Neuron\Cms\Models\Category; use Neuron\Cms\Models\Tag; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; -use DateTimeImmutable; /** - * Database-backed post repository. + * Database-backed post repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ @@ -31,6 +29,7 @@ class DatabasePostRepository implements IPostRepository */ public function __construct( SettingManager $settings ) { + // Keep PDO for methods that need raw SQL queries (getByCategory, getByTag) $this->_pdo = ConnectionFactory::createFromSettings( $settings ); } @@ -39,9 +38,8 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?Post { - $stmt = $this->_pdo->prepare( "SELECT * FROM posts WHERE id = ? LIMIT 1" ); + $stmt = $this->_pdo->prepare( "SELECT * FROM posts WHERE id = ?" ); $stmt->execute( [ $id ] ); - $row = $stmt->fetch(); if( !$row ) @@ -49,7 +47,10 @@ public function findById( int $id ): ?Post return null; } - return $this->mapRowToPost( $row ); + $post = Post::fromArray( $row ); + $this->loadRelations( $post ); + + return $post; } /** @@ -57,9 +58,8 @@ public function findById( int $id ): ?Post */ public function findBySlug( string $slug ): ?Post { - $stmt = $this->_pdo->prepare( "SELECT * FROM posts WHERE slug = ? LIMIT 1" ); + $stmt = $this->_pdo->prepare( "SELECT * FROM posts WHERE slug = ?" ); $stmt->execute( [ $slug ] ); - $row = $stmt->fetch(); if( !$row ) @@ -67,7 +67,46 @@ public function findBySlug( string $slug ): ?Post return null; } - return $this->mapRowToPost( $row ); + $post = Post::fromArray( $row ); + $this->loadRelations( $post ); + + return $post; + } + + /** + * Load categories and tags for a post + */ + private function loadRelations( Post $post ): void + { + // Load categories + $stmt = $this->_pdo->prepare( + "SELECT c.* FROM categories c + INNER JOIN post_categories pc ON c.id = pc.category_id + WHERE pc.post_id = ?" + ); + $stmt->execute( [ $post->getId() ] ); + $categoryRows = $stmt->fetchAll(); + + $categories = array_map( + fn( $row ) => Category::fromArray( $row ), + $categoryRows + ); + $post->setCategories( $categories ); + + // Load tags + $stmt = $this->_pdo->prepare( + "SELECT t.* FROM tags t + INNER JOIN post_tags pt ON t.id = pt.tag_id + WHERE pt.post_id = ?" + ); + $stmt->execute( [ $post->getId() ] ); + $tagRows = $stmt->fetchAll(); + + $tags = array_map( + fn( $row ) => Tag::fromArray( $row ), + $tagRows + ); + $post->setTags( $tags ); } /** @@ -81,44 +120,30 @@ public function create( Post $post ): Post throw new Exception( 'Slug already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO posts ( - title, slug, body, excerpt, featured_image, author_id, - status, published_at, view_count, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ); + // Use transaction to ensure atomicity of post creation and relation syncing + return Post::transaction( function() use ( $post ) { + // Use ORM create method - only save the post data without relations + $createdPost = Post::create( $post->toArray() ); - $stmt->execute([ - $post->getTitle(), - $post->getSlug(), - $post->getBody(), - $post->getExcerpt(), - $post->getFeaturedImage(), - $post->getAuthorId(), - $post->getStatus(), - $post->getPublishedAt() ? $post->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, - $post->getViewCount(), - $post->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); - - $post->setId( (int)$this->_pdo->lastInsertId() ); - - // Handle categories - if( count( $post->getCategories() ) > 0 ) - { - $categoryIds = array_map( fn( $c ) => $c->getId(), $post->getCategories() ); - $this->attachCategories( $post->getId(), $categoryIds ); - } + // Update the original post with the new ID + $post->setId( $createdPost->getId() ); - // Handle tags - if( count( $post->getTags() ) > 0 ) - { - $tagIds = array_map( fn( $t ) => $t->getId(), $post->getTags() ); - $this->attachTags( $post->getId(), $tagIds ); - } + // Sync categories using raw SQL (vendor ORM doesn't have relation() method yet) + if( count( $post->getCategories() ) > 0 ) + { + $categoryIds = array_map( fn( $c ) => $c->getId(), $post->getCategories() ); + $this->syncCategories( $post->getId(), $categoryIds ); + } - return $post; + // Sync tags using raw SQL + if( count( $post->getTags() ) > 0 ) + { + $tagIds = array_map( fn( $t ) => $t->getId(), $post->getTags() ); + $this->syncTags( $post->getId(), $tagIds ); + } + + return $post; + } ); } /** @@ -138,52 +163,22 @@ public function update( Post $post ): bool throw new Exception( 'Slug already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE posts SET - title = ?, - slug = ?, - body = ?, - excerpt = ?, - featured_image = ?, - author_id = ?, - status = ?, - published_at = ?, - view_count = ?, - updated_at = ? - WHERE id = ?" - ); + // Use transaction to ensure atomicity of post update and relation syncing + return Post::transaction( function() use ( $post ) { + // Update post using ORM (handles private properties via reflection) + $post->setUpdatedAt( new \DateTimeImmutable() ); + $result = $post->save(); - $result = $stmt->execute([ - $post->getTitle(), - $post->getSlug(), - $post->getBody(), - $post->getExcerpt(), - $post->getFeaturedImage(), - $post->getAuthorId(), - $post->getStatus(), - $post->getPublishedAt() ? $post->getPublishedAt()->format( 'Y-m-d H:i:s' ) : null, - $post->getViewCount(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $post->getId() - ]); - - // Update categories - $this->detachCategories( $post->getId() ); - if( count( $post->getCategories() ) > 0 ) - { + // Sync categories $categoryIds = array_map( fn( $c ) => $c->getId(), $post->getCategories() ); - $this->attachCategories( $post->getId(), $categoryIds ); - } + $this->syncCategories( $post->getId(), $categoryIds ); - // Update tags - $this->detachTags( $post->getId() ); - if( count( $post->getTags() ) > 0 ) - { + // Sync tags $tagIds = array_map( fn( $t ) => $t->getId(), $post->getTags() ); - $this->attachTags( $post->getId(), $tagIds ); - } + $this->syncTags( $post->getId(), $tagIds ); - return $result; + return $result; + } ); } /** @@ -192,10 +187,9 @@ public function update( Post $post ): bool public function delete( int $id ): bool { // Foreign key constraints will handle cascade delete of relationships - $stmt = $this->_pdo->prepare( "DELETE FROM posts WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = Post::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -203,29 +197,21 @@ public function delete( int $id ): bool */ public function all( ?string $status = null, int $limit = 0, int $offset = 0 ): array { - $sql = "SELECT * FROM posts"; - $params = []; + $query = Post::query(); if( $status ) { - $sql .= " WHERE status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $sql .= " ORDER BY created_at DESC"; + $query->orderBy( 'created_at', 'DESC' ); if( $limit > 0 ) { - $sql .= " LIMIT ? OFFSET ?"; - $params[] = $limit; - $params[] = $offset; + $query->limit( $limit )->offset( $offset ); } - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToPost' ], $rows ); + return $query->get(); } /** @@ -233,22 +219,14 @@ public function all( ?string $status = null, int $limit = 0, int $offset = 0 ): */ public function getByAuthor( int $authorId, ?string $status = null ): array { - $sql = "SELECT * FROM posts WHERE author_id = ?"; - $params = [ $authorId ]; + $query = Post::query()->where( 'author_id', $authorId ); if( $status ) { - $sql .= " AND status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $sql .= " ORDER BY created_at DESC"; - - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToPost' ], $rows ); + return $query->orderBy( 'created_at', 'DESC' )->get(); } /** @@ -256,6 +234,8 @@ public function getByAuthor( int $authorId, ?string $status = null ): array */ public function getByCategory( int $categoryId, ?string $status = null ): array { + // This still uses raw SQL for the JOIN + // TODO: Add JOIN support to ORM QueryBuilder $sql = "SELECT p.* FROM posts p INNER JOIN post_categories pc ON p.id = pc.post_id WHERE pc.category_id = ?"; @@ -273,7 +253,7 @@ public function getByCategory( int $categoryId, ?string $status = null ): array $stmt->execute( $params ); $rows = $stmt->fetchAll(); - return array_map( [ $this, 'mapRowToPost' ], $rows ); + return array_map( fn( $row ) => Post::fromArray( $row ), $rows ); } /** @@ -281,6 +261,8 @@ public function getByCategory( int $categoryId, ?string $status = null ): array */ public function getByTag( int $tagId, ?string $status = null ): array { + // This still uses raw SQL for the JOIN + // TODO: Add JOIN support to ORM QueryBuilder $sql = "SELECT p.* FROM posts p INNER JOIN post_tags pt ON p.id = pt.post_id WHERE pt.tag_id = ?"; @@ -298,7 +280,7 @@ public function getByTag( int $tagId, ?string $status = null ): array $stmt->execute( $params ); $rows = $stmt->fetchAll(); - return array_map( [ $this, 'mapRowToPost' ], $rows ); + return array_map( fn( $row ) => Post::fromArray( $row ), $rows ); } /** @@ -330,31 +312,72 @@ public function getScheduled(): array */ public function count( ?string $status = null ): int { - $sql = "SELECT COUNT(*) as total FROM posts"; - $params = []; + $query = Post::query(); if( $status ) { - $sql .= " WHERE status = ?"; - $params[] = $status; + $query->where( 'status', $status ); } - $stmt = $this->_pdo->prepare( $sql ); - $stmt->execute( $params ); - $row = $stmt->fetch(); - - return (int)$row['total']; + return $query->count(); } /** - * Increment post view count + * Increment post view count atomically + * + * Uses atomic SQL UPDATE via ORM to avoid race conditions under concurrent requests. + * The fetch-increment-save pattern would lose increments under high concurrency. */ public function incrementViewCount( int $id ): bool { - $stmt = $this->_pdo->prepare( "UPDATE posts SET view_count = view_count + 1 WHERE id = ?" ); - $stmt->execute( [ $id ] ); + // Use ORM's atomic increment to avoid race condition + $rowsUpdated = Post::query() + ->where( 'id', $id ) + ->increment( 'view_count', 1 ); - return $stmt->rowCount() > 0; + return $rowsUpdated > 0; + } + + /** + * Sync categories for a post (removes old, adds new) + */ + private function syncCategories( int $postId, array $categoryIds ): void + { + // Delete existing categories + $this->_pdo->prepare( "DELETE FROM post_categories WHERE post_id = ?" ) + ->execute( [ $postId ] ); + + // Insert new categories + if( !empty( $categoryIds ) ) + { + $stmt = $this->_pdo->prepare( "INSERT INTO post_categories (post_id, category_id, created_at) VALUES (?, ?, ?)" ); + $now = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); + foreach( $categoryIds as $categoryId ) + { + $stmt->execute( [ $postId, $categoryId, $now ] ); + } + } + } + + /** + * Sync tags for a post (removes old, adds new) + */ + private function syncTags( int $postId, array $tagIds ): void + { + // Delete existing tags + $this->_pdo->prepare( "DELETE FROM post_tags WHERE post_id = ?" ) + ->execute( [ $postId ] ); + + // Insert new tags + if( !empty( $tagIds ) ) + { + $stmt = $this->_pdo->prepare( "INSERT INTO post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?)" ); + $now = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); + foreach( $tagIds as $tagId ) + { + $stmt->execute( [ $postId, $tagId, $now ] ); + } + } } /** @@ -367,17 +390,11 @@ public function attachCategories( int $postId, array $categoryIds ): bool return true; } - $stmt = $this->_pdo->prepare( - "INSERT INTO post_categories (post_id, category_id, created_at) VALUES (?, ?, ?)" - ); - + $stmt = $this->_pdo->prepare( "INSERT INTO post_categories (post_id, category_id, created_at) VALUES (?, ?, ?)" ); + $now = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); foreach( $categoryIds as $categoryId ) { - $stmt->execute([ - $postId, - $categoryId, - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); + $stmt->execute( [ $postId, $categoryId, $now ] ); } return true; @@ -391,7 +408,7 @@ public function detachCategories( int $postId ): bool $stmt = $this->_pdo->prepare( "DELETE FROM post_categories WHERE post_id = ?" ); $stmt->execute( [ $postId ] ); - return true; + return $stmt->rowCount() > 0; } /** @@ -404,17 +421,11 @@ public function attachTags( int $postId, array $tagIds ): bool return true; } - $stmt = $this->_pdo->prepare( - "INSERT INTO post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?)" - ); - + $stmt = $this->_pdo->prepare( "INSERT INTO post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?)" ); + $now = ( new \DateTimeImmutable() )->format( 'Y-m-d H:i:s' ); foreach( $tagIds as $tagId ) { - $stmt->execute([ - $postId, - $tagId, - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); + $stmt->execute( [ $postId, $tagId, $now ] ); } return true; @@ -428,107 +439,23 @@ public function detachTags( int $postId ): bool $stmt = $this->_pdo->prepare( "DELETE FROM post_tags WHERE post_id = ?" ); $stmt->execute( [ $postId ] ); - return true; - } - - /** - * Map database row to Post object - * - * @param array $row Database row - * @return Post - */ - private function mapRowToPost( array $row ): Post - { - $data = [ - 'id' => (int)$row['id'], - 'title' => $row['title'], - 'slug' => $row['slug'], - 'body' => $row['body'], - 'excerpt' => $row['excerpt'], - 'featured_image' => $row['featured_image'], - 'author_id' => (int)$row['author_id'], - 'status' => $row['status'], - 'view_count' => (int)$row['view_count'], - 'published_at' => $row['published_at'] ?? null, - 'created_at' => $row['created_at'], - 'updated_at' => $row['updated_at'] ?? null, - ]; - - $post = Post::fromArray( $data ); - - // Load relationships - $post->setAuthor( $this->loadAuthor( $post->getAuthorId() ) ); - $post->setCategories( $this->loadCategories( $post->getId() ) ); - $post->setTags( $this->loadTags( $post->getId() ) ); - - return $post; + return $stmt->rowCount() > 0; } /** - * Load categories for a post - * - * @param int $postId - * @return Category[] + * Handle serialization for PHPUnit process isolation */ - private function loadCategories( int $postId ): array + public function __sleep(): array { - $stmt = $this->_pdo->prepare( - "SELECT c.* FROM categories c - INNER JOIN post_categories pc ON c.id = pc.category_id - WHERE pc.post_id = ? - ORDER BY c.name ASC" - ); - $stmt->execute( [ $postId ] ); - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Category::fromArray( $row ), $rows ); + // Don't serialize PDO connection + return []; } /** - * Load tags for a post - * - * @param int $postId - * @return Tag[] + * Handle unserialization for PHPUnit process isolation */ - private function loadTags( int $postId ): array + public function __wakeup(): void { - $stmt = $this->_pdo->prepare( - "SELECT t.* FROM tags t - INNER JOIN post_tags pt ON t.id = pt.tag_id - WHERE pt.post_id = ? - ORDER BY t.name ASC" - ); - $stmt->execute( [ $postId ] ); - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Tag::fromArray( $row ), $rows ); - } - - /** - * Load author for a post - * - * @param int $authorId - * @return User|null - */ - private function loadAuthor( int $authorId ): ?User - { - try - { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $authorId ] ); - $row = $stmt->fetch(); - - if( !$row ) - { - return null; - } - - return User::fromArray( $row ); - } - catch( \PDOException $e ) - { - // Users table may not exist in test environments - return null; - } + // PDO will be re-initialized by test setup } } diff --git a/src/Cms/Repositories/DatabaseTagRepository.php b/src/Cms/Repositories/DatabaseTagRepository.php index 851e4c8..c378061 100644 --- a/src/Cms/Repositories/DatabaseTagRepository.php +++ b/src/Cms/Repositories/DatabaseTagRepository.php @@ -4,15 +4,14 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\Tag; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; -use DateTimeImmutable; /** - * Database-backed tag repository. + * Database-backed tag repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ @@ -28,6 +27,7 @@ class DatabaseTagRepository implements ITagRepository */ public function __construct( SettingManager $settings ) { + // Keep PDO for allWithPostCount() which uses a custom JOIN query $this->_pdo = ConnectionFactory::createFromSettings( $settings ); } @@ -36,12 +36,7 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?Tag { - $stmt = $this->_pdo->prepare( "SELECT * FROM tags WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $id ] ); - - $row = $stmt->fetch(); - - return $row ? Tag::fromArray( $row ) : null; + return Tag::find( $id ); } /** @@ -49,12 +44,7 @@ public function findById( int $id ): ?Tag */ public function findBySlug( string $slug ): ?Tag { - $stmt = $this->_pdo->prepare( "SELECT * FROM tags WHERE slug = ? LIMIT 1" ); - $stmt->execute( [ $slug ] ); - - $row = $stmt->fetch(); - - return $row ? Tag::fromArray( $row ) : null; + return Tag::where( 'slug', $slug )->first(); } /** @@ -62,12 +52,7 @@ public function findBySlug( string $slug ): ?Tag */ public function findByName( string $name ): ?Tag { - $stmt = $this->_pdo->prepare( "SELECT * FROM tags WHERE name = ? LIMIT 1" ); - $stmt->execute( [ $name ] ); - - $row = $stmt->fetch(); - - return $row ? Tag::fromArray( $row ) : null; + return Tag::where( 'name', $name )->first(); } /** @@ -87,19 +72,11 @@ public function create( Tag $tag ): Tag throw new Exception( 'Tag name already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO tags (name, slug, created_at, updated_at) - VALUES (?, ?, ?, ?)" - ); - - $stmt->execute([ - $tag->getName(), - $tag->getSlug(), - $tag->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); + // Use ORM create method + $createdTag = Tag::create( $tag->toArray() ); - $tag->setId( (int)$this->_pdo->lastInsertId() ); + // Update the original tag with the new ID + $tag->setId( $createdTag->getId() ); return $tag; } @@ -128,20 +105,8 @@ public function update( Tag $tag ): bool throw new Exception( 'Tag name already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE tags SET - name = ?, - slug = ?, - updated_at = ? - WHERE id = ?" - ); - - return $stmt->execute([ - $tag->getName(), - $tag->getSlug(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $tag->getId() - ]); + // Use ORM save method + return $tag->save(); } /** @@ -150,10 +115,9 @@ public function update( Tag $tag ): bool public function delete( int $id ): bool { // Foreign key constraints will handle cascade delete of post relationships - $stmt = $this->_pdo->prepare( "DELETE FROM tags WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = Tag::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -161,10 +125,7 @@ public function delete( int $id ): bool */ public function all(): array { - $stmt = $this->_pdo->query( "SELECT * FROM tags ORDER BY name ASC" ); - $rows = $stmt->fetchAll(); - - return array_map( fn( $row ) => Tag::fromArray( $row ), $rows ); + return Tag::orderBy( 'name', 'ASC' )->all(); } /** @@ -172,10 +133,7 @@ public function all(): array */ public function count(): int { - $stmt = $this->_pdo->query( "SELECT COUNT(*) as total FROM tags" ); - $row = $stmt->fetch(); - - return (int)$row['total']; + return Tag::query()->count(); } /** @@ -183,6 +141,8 @@ public function count(): int */ public function allWithPostCount(): array { + // This method still uses raw SQL for the JOIN with aggregation + // TODO: Add support for joins and aggregations to ORM $stmt = $this->_pdo->query( "SELECT t.*, COUNT(pt.post_id) as post_count FROM tags t @@ -201,4 +161,21 @@ public function allWithPostCount(): array ]; }, $rows ); } + + /** + * Handle serialization for PHPUnit process isolation + */ + public function __sleep(): array + { + // Don't serialize PDO connection + return []; + } + + /** + * Handle unserialization for PHPUnit process isolation + */ + public function __wakeup(): void + { + // PDO will be re-initialized by test setup + } } diff --git a/src/Cms/Repositories/DatabaseUserRepository.php b/src/Cms/Repositories/DatabaseUserRepository.php index ae8419b..941f918 100644 --- a/src/Cms/Repositories/DatabaseUserRepository.php +++ b/src/Cms/Repositories/DatabaseUserRepository.php @@ -4,21 +4,20 @@ use Neuron\Cms\Database\ConnectionFactory; use Neuron\Cms\Models\User; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use Exception; -use DateTimeImmutable; /** - * Database-backed user repository. + * Database-backed user repository using ORM. * - * Works with SQLite, MySQL, and PostgreSQL via PDO. + * Works with SQLite, MySQL, and PostgreSQL via the Neuron ORM. * * @package Neuron\Cms\Repositories */ class DatabaseUserRepository implements IUserRepository { - private PDO $_pdo; + private ?PDO $_pdo = null; /** * Constructor @@ -28,6 +27,7 @@ class DatabaseUserRepository implements IUserRepository */ public function __construct( SettingManager $settings ) { + // Keep PDO property for backwards compatibility with tests $this->_pdo = ConnectionFactory::createFromSettings( $settings ); } @@ -36,12 +36,7 @@ public function __construct( SettingManager $settings ) */ public function findById( int $id ): ?User { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE id = ? LIMIT 1" ); - $stmt->execute( [ $id ] ); - - $row = $stmt->fetch(); - - return $row ? $this->mapRowToUser( $row ) : null; + return User::find( $id ); } /** @@ -49,12 +44,7 @@ public function findById( int $id ): ?User */ public function findByUsername( string $username ): ?User { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE username = ? LIMIT 1" ); - $stmt->execute( [ $username ] ); - - $row = $stmt->fetch(); - - return $row ? $this->mapRowToUser( $row ) : null; + return User::where( 'username', $username )->first(); } /** @@ -62,12 +52,7 @@ public function findByUsername( string $username ): ?User */ public function findByEmail( string $email ): ?User { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE email = ? LIMIT 1" ); - $stmt->execute( [ $email ] ); - - $row = $stmt->fetch(); - - return $row ? $this->mapRowToUser( $row ) : null; + return User::where( 'email', $email )->first(); } /** @@ -75,12 +60,7 @@ public function findByEmail( string $email ): ?User */ public function findByRememberToken( string $token ): ?User { - $stmt = $this->_pdo->prepare( "SELECT * FROM users WHERE remember_token = ? LIMIT 1" ); - $stmt->execute( [ $token ] ); - - $row = $stmt->fetch(); - - return $row ? $this->mapRowToUser( $row ) : null; + return User::where( 'remember_token', $token )->first(); } /** @@ -100,32 +80,11 @@ public function create( User $user ): User throw new Exception( 'Email already exists' ); } - $stmt = $this->_pdo->prepare( - "INSERT INTO users ( - username, email, password_hash, role, status, email_verified, - two_factor_secret, remember_token, failed_login_attempts, - locked_until, last_login_at, timezone, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ); + // Use ORM create method + $createdUser = User::create( $user->toArray() ); - $stmt->execute([ - $user->getUsername(), - $user->getEmail(), - $user->getPasswordHash(), - $user->getRole(), - $user->getStatus(), - $user->isEmailVerified() ? 1 : 0, - $user->getTwoFactorSecret(), - $user->getRememberToken(), - $user->getFailedLoginAttempts(), - $user->getLockedUntil() ? $user->getLockedUntil()->format( 'Y-m-d H:i:s' ) : null, - $user->getLastLoginAt() ? $user->getLastLoginAt()->format( 'Y-m-d H:i:s' ) : null, - $user->getTimezone(), - $user->getCreatedAt()->format( 'Y-m-d H:i:s' ), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ) - ]); - - $user->setId( (int)$this->_pdo->lastInsertId() ); + // Update the original user with the new ID + $user->setId( $createdUser->getId() ); return $user; } @@ -154,40 +113,8 @@ public function update( User $user ): bool throw new Exception( 'Email already exists' ); } - $stmt = $this->_pdo->prepare( - "UPDATE users SET - username = ?, - email = ?, - password_hash = ?, - role = ?, - status = ?, - email_verified = ?, - two_factor_secret = ?, - remember_token = ?, - failed_login_attempts = ?, - locked_until = ?, - last_login_at = ?, - timezone = ?, - updated_at = ? - WHERE id = ?" - ); - - return $stmt->execute([ - $user->getUsername(), - $user->getEmail(), - $user->getPasswordHash(), - $user->getRole(), - $user->getStatus(), - $user->isEmailVerified() ? 1 : 0, - $user->getTwoFactorSecret(), - $user->getRememberToken(), - $user->getFailedLoginAttempts(), - $user->getLockedUntil() ? $user->getLockedUntil()->format( 'Y-m-d H:i:s' ) : null, - $user->getLastLoginAt() ? $user->getLastLoginAt()->format( 'Y-m-d H:i:s' ) : null, - $user->getTimezone(), - (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), - $user->getId() - ]); + // Use ORM save method + return $user->save(); } /** @@ -195,10 +122,9 @@ public function update( User $user ): bool */ public function delete( int $id ): bool { - $stmt = $this->_pdo->prepare( "DELETE FROM users WHERE id = ?" ); - $stmt->execute( [ $id ] ); + $deletedCount = User::query()->where( 'id', $id )->delete(); - return $stmt->rowCount() > 0; + return $deletedCount > 0; } /** @@ -206,10 +132,7 @@ public function delete( int $id ): bool */ public function all(): array { - $stmt = $this->_pdo->query( "SELECT * FROM users ORDER BY created_at DESC" ); - $rows = $stmt->fetchAll(); - - return array_map( [ $this, 'mapRowToUser' ], $rows ); + return User::orderBy( 'created_at', 'DESC' )->all(); } /** @@ -217,47 +140,90 @@ public function all(): array */ public function count(): int { - $stmt = $this->_pdo->query( "SELECT COUNT(*) as total FROM users" ); - $row = $stmt->fetch(); + return User::query()->count(); + } + + /** + * Atomically increment failed login attempts for a user + * + * Uses atomic UPDATE to avoid race condition under concurrent login attempts. + * + * @param int $userId User ID + * @return int New failed login attempts count, or -1 if user not found + */ + public function incrementFailedLoginAttempts( int $userId ): int + { + // Use atomic increment + $rowsUpdated = User::query() + ->where( 'id', $userId ) + ->increment( 'failed_login_attempts', 1 ); - return (int)$row['total']; + if( $rowsUpdated === 0 ) + { + return -1; // User not found + } + + // Fetch and return the new count + $user = $this->findById( $userId ); + return $user ? $user->getFailedLoginAttempts() : -1; + } + + /** + * Atomically reset failed login attempts and unlock account + * + * Uses atomic UPDATE to avoid race condition. + * + * @param int $userId User ID + * @return bool True if successful, false if user not found + */ + public function resetFailedLoginAttempts( int $userId ): bool + { + // Use ORM's atomic update to avoid race condition + $rowsUpdated = User::query() + ->where( 'id', $userId ) + ->update([ + 'failed_login_attempts' => 0, + 'locked_until' => null + ]); + + return $rowsUpdated > 0; } /** - * Map database row to User object + * Atomically set account lockout until specified time * - * @param array $row Database row - * @return User + * Uses atomic UPDATE to avoid race condition. + * + * @param int $userId User ID + * @param \DateTimeImmutable|null $lockedUntil Locked until time, or null to unlock + * @return bool True if successful, false if user not found */ - private function mapRowToUser( array $row ): User + public function setLockedUntil( int $userId, ?\DateTimeImmutable $lockedUntil ): bool { - $emailVerifiedRaw = $row['email_verified'] ?? null; - $emailVerified = is_bool( $emailVerifiedRaw ) - ? $emailVerifiedRaw - : in_array( - strtolower( (string)$emailVerifiedRaw ), - [ '1', 'true', 't', 'yes', 'on' ], - true - ); + $lockedUntilString = $lockedUntil ? $lockedUntil->format( 'Y-m-d H:i:s' ) : null; - $data = [ - 'id' => (int)$row['id'], - 'username' => $row['username'], - 'email' => $row['email'], - 'password_hash' => $row['password_hash'], - 'role' => $row['role'], - 'status' => $row['status'], - 'email_verified' => $emailVerified, - 'two_factor_secret' => $row['two_factor_secret'], - 'remember_token' => $row['remember_token'], - 'failed_login_attempts' => (int)$row['failed_login_attempts'], - 'locked_until' => $row['locked_until'] ?? null, - 'last_login_at' => $row['last_login_at'] ?? null, - 'timezone' => $row['timezone'] ?? 'UTC', - 'created_at' => $row['created_at'], - 'updated_at' => $row['updated_at'] ?? null, - ]; + // Use ORM's atomic update to avoid race condition + $rowsUpdated = User::query() + ->where( 'id', $userId ) + ->update([ 'locked_until' => $lockedUntilString ]); - return User::fromArray( $data ); + return $rowsUpdated > 0; + } + + /** + * Handle serialization for PHPUnit process isolation + */ + public function __sleep(): array + { + // Don't serialize PDO connection + return []; + } + + /** + * Handle unserialization for PHPUnit process isolation + */ + public function __wakeup(): void + { + // PDO will be re-initialized by test setup } } diff --git a/src/Cms/Repositories/IUserRepository.php b/src/Cms/Repositories/IUserRepository.php index bfc59af..a56a072 100644 --- a/src/Cms/Repositories/IUserRepository.php +++ b/src/Cms/Repositories/IUserRepository.php @@ -55,4 +55,29 @@ public function all(): array; * Count total users */ public function count(): int; + + /** + * Atomically increment failed login attempts for a user + * + * @param int $userId User ID + * @return int New failed login attempts count, or -1 if user not found + */ + public function incrementFailedLoginAttempts( int $userId ): int; + + /** + * Atomically reset failed login attempts and unlock account + * + * @param int $userId User ID + * @return bool True if successful, false if user not found + */ + public function resetFailedLoginAttempts( int $userId ): bool; + + /** + * Atomically set account lockout until specified time + * + * @param int $userId User ID + * @param \DateTimeImmutable|null $lockedUntil Locked until time, or null to unlock + * @return bool True if successful, false if user not found + */ + public function setLockedUntil( int $userId, ?\DateTimeImmutable $lockedUntil ): bool; } diff --git a/src/Cms/Services/Auth/Authentication.php b/src/Cms/Services/Auth/Authentication.php index b2d00bd..3fa74d4 100644 --- a/src/Cms/Services/Auth/Authentication.php +++ b/src/Cms/Services/Auth/Authentication.php @@ -46,40 +46,75 @@ public function attempt( string $username, string $password, bool $remember = fa { // Perform dummy hash to normalize timing $this->_passwordHasher->verify( $password, '$2y$10$dummyhashtopreventtimingattack1234567890' ); + + // Emit login failed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginFailedEvent( + $username, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ), + 'user_not_found' + ) ); + return false; } // Check if account is locked if( $user->isLockedOut() ) { + // Emit login failed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginFailedEvent( + $username, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ), + 'account_locked' + ) ); return false; } // Check if account is active if( !$user->isActive() ) { + // Emit login failed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginFailedEvent( + $username, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ), + 'account_inactive' + ) ); return false; } // Verify password if( !$this->validateCredentials( $user, $password ) ) { - // Increment failed login attempts - $user->incrementFailedLoginAttempts(); + // Atomically increment failed login attempts to avoid race condition + $newFailedAttempts = $this->_userRepository->incrementFailedLoginAttempts( $user->getId() ); // Lock account if too many failed attempts - if( $user->getFailedLoginAttempts() >= $this->_maxLoginAttempts ) + if( $newFailedAttempts >= $this->_maxLoginAttempts ) { $lockedUntil = (new DateTimeImmutable())->add( new DateInterval( "PT{$this->_lockoutDuration}M" ) ); - $user->setLockedUntil( $lockedUntil ); + $this->_userRepository->setLockedUntil( $user->getId(), $lockedUntil ); } - $this->_userRepository->update( $user ); + // Emit login failed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginFailedEvent( + $username, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ), + 'invalid_credentials' + ) ); + return false; } - // Successful login - reset failed attempts - $user->resetFailedLoginAttempts(); + // Successful login - atomically reset failed attempts + $this->_userRepository->resetFailedLoginAttempts( $user->getId() ); + + // Refresh user from database to get updated failed_login_attempts + $user = $this->_userRepository->findById( $user->getId() ); + + // Update last login time and potentially rehash password $user->setLastLoginAt( new DateTimeImmutable() ); // Check if password needs rehashing @@ -107,12 +142,20 @@ public function login( User $user, bool $remember = false ): void // Store user ID in session $this->_sessionManager->set( 'user_id', $user->getId() ); $this->_sessionManager->set( 'user_role', $user->getRole() ); + $this->_sessionManager->set( 'login_time', microtime( true ) ); // Handle remember me if( $remember ) { $this->setRememberToken( $user ); } + + // Emit user login event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLoginEvent( + $user, + $_SERVER['REMOTE_ADDR'] ?? 'unknown', + microtime( true ) + ) ); } /** @@ -120,6 +163,9 @@ public function login( User $user, bool $remember = false ): void */ public function logout(): void { + $user = null; + $sessionDuration = 0.0; + // Clear remember token if exists if( $this->check() ) { @@ -128,6 +174,13 @@ public function logout(): void { $user->setRememberToken( null ); $this->_userRepository->update( $user ); + + // Calculate session duration + $loginTime = $this->_sessionManager->get( 'login_time' ); + if( $loginTime ) + { + $sessionDuration = microtime( true ) - $loginTime; + } } } @@ -139,6 +192,15 @@ public function logout(): void { setcookie( 'remember_token', '', time() - 3600, '/', '', true, true ); } + + // Emit user logout event + if( $user ) + { + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\UserLogoutEvent( + $user, + $sessionDuration + ) ); + } } /** diff --git a/src/Cms/Services/Auth/CsrfToken.php b/src/Cms/Services/Auth/CsrfToken.php index af847f6..0921be8 100644 --- a/src/Cms/Services/Auth/CsrfToken.php +++ b/src/Cms/Services/Auth/CsrfToken.php @@ -3,6 +3,8 @@ namespace Neuron\Cms\Services\Auth; use Neuron\Cms\Auth\SessionManager; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; /** * CSRF token service. @@ -16,10 +18,12 @@ class CsrfToken { private SessionManager $_sessionManager; private string $_tokenKey = 'csrf_token'; + private IRandom $random; - public function __construct( SessionManager $sessionManager ) + public function __construct( SessionManager $sessionManager, ?IRandom $random = null ) { $this->_sessionManager = $sessionManager; + $this->random = $random ?? new RealRandom(); } /** @@ -27,7 +31,7 @@ public function __construct( SessionManager $sessionManager ) */ public function generate(): string { - $token = bin2hex( random_bytes( 32 ) ); + $token = $this->random->string( 64, 'hex' ); $this->_sessionManager->set( $this->_tokenKey, $token ); return $token; } diff --git a/src/Cms/Services/Auth/EmailVerifier.php b/src/Cms/Services/Auth/EmailVerifier.php index 03073ac..7b9837c 100644 --- a/src/Cms/Services/Auth/EmailVerifier.php +++ b/src/Cms/Services/Auth/EmailVerifier.php @@ -7,7 +7,9 @@ use Neuron\Cms\Repositories\IEmailVerificationTokenRepository; use Neuron\Cms\Repositories\IUserRepository; use Neuron\Cms\Services\Email\Sender; -use Neuron\Data\Setting\SettingManager; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; +use Neuron\Data\Settings\SettingManager; use Neuron\Log\Log; use Exception; @@ -23,6 +25,7 @@ class EmailVerifier private IEmailVerificationTokenRepository $_tokenRepository; private IUserRepository $_userRepository; private SettingManager $_settings; + private IRandom $_random; private string $_basePath; private string $_verificationUrl; private int $_tokenExpirationMinutes = 60; @@ -35,13 +38,15 @@ class EmailVerifier * @param SettingManager $settings Settings manager with email configuration * @param string $basePath Base path for template loading * @param string $verificationUrl Base URL for email verification (token will be appended) + * @param IRandom|null $random Random generator (defaults to cryptographically secure) */ public function __construct( IEmailVerificationTokenRepository $tokenRepository, IUserRepository $userRepository, SettingManager $settings, string $basePath, - string $verificationUrl + string $verificationUrl, + ?IRandom $random = null ) { $this->_tokenRepository = $tokenRepository; @@ -49,6 +54,7 @@ public function __construct( $this->_settings = $settings; $this->_basePath = $basePath; $this->_verificationUrl = $verificationUrl; + $this->_random = $random ?? new RealRandom(); } /** @@ -74,8 +80,8 @@ public function sendVerificationEmail( User $user ): bool // Delete any existing tokens for this user $this->_tokenRepository->deleteByUserId( $user->getId() ); - // Generate secure random token - $plainToken = bin2hex( random_bytes( 32 ) ); + // Generate secure random token (64 hex characters = 32 bytes) + $plainToken = $this->_random->string( 64, 'hex' ); $hashedToken = hash( 'sha256', $plainToken ); // Create and store token @@ -160,6 +166,9 @@ public function verifyEmail( string $plainToken ): bool Log::info( "Email verified for user: {$user->getUsername()}" ); + // Emit email verified event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\EmailVerifiedEvent( $user ) ); + return true; } diff --git a/src/Cms/Services/Auth/PasswordResetter.php b/src/Cms/Services/Auth/PasswordResetter.php index bc3ffcd..fc491a3 100644 --- a/src/Cms/Services/Auth/PasswordResetter.php +++ b/src/Cms/Services/Auth/PasswordResetter.php @@ -7,7 +7,9 @@ use Neuron\Cms\Repositories\IPasswordResetTokenRepository; use Neuron\Cms\Repositories\IUserRepository; use Neuron\Cms\Services\Email\Sender; -use Neuron\Data\Setting\SettingManager; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; +use Neuron\Data\Settings\SettingManager; use Neuron\Log\Log; use Exception; @@ -24,6 +26,7 @@ class PasswordResetter private IUserRepository $_userRepository; private PasswordHasher $_passwordHasher; private SettingManager $_settings; + private IRandom $_random; private string $_basePath; private string $_resetUrl; private int $_tokenExpirationMinutes = 60; @@ -37,6 +40,7 @@ class PasswordResetter * @param SettingManager $settings Settings manager with email configuration * @param string $basePath Base path for template loading * @param string $resetUrl Base URL for password reset (token will be appended) + * @param IRandom|null $random Random generator (defaults to cryptographically secure) */ public function __construct( IPasswordResetTokenRepository $tokenRepository, @@ -44,7 +48,8 @@ public function __construct( PasswordHasher $passwordHasher, SettingManager $settings, string $basePath, - string $resetUrl + string $resetUrl, + ?IRandom $random = null ) { $this->_tokenRepository = $tokenRepository; @@ -53,6 +58,7 @@ public function __construct( $this->_settings = $settings; $this->_basePath = $basePath; $this->_resetUrl = $resetUrl; + $this->_random = $random ?? new RealRandom(); } /** @@ -87,8 +93,8 @@ public function requestReset( string $email ): bool // Delete any existing tokens for this email $this->_tokenRepository->deleteByEmail( $email ); - // Generate secure random token - $plainToken = bin2hex( random_bytes( 32 ) ); + // Generate secure random token (64 hex characters = 32 bytes) + $plainToken = $this->_random->string( 64, 'hex' ); $hashedToken = hash( 'sha256', $plainToken ); // Create and store token @@ -103,6 +109,12 @@ public function requestReset( string $email ): bool // Send reset email $this->sendResetEmail( $email, $plainToken ); + // Emit password reset requested event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\PasswordResetRequestedEvent( + $user, + $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ) ); + return true; } @@ -166,6 +178,12 @@ public function resetPassword( string $plainToken, string $newPassword ): bool // Delete the token $this->_tokenRepository->deleteByToken( hash( 'sha256', $plainToken ) ); + // Emit password reset completed event + \Neuron\Application\CrossCutting\Event::emit( new \Neuron\Cms\Events\PasswordResetCompletedEvent( + $user, + $_SERVER['REMOTE_ADDR'] ?? 'unknown' + ) ); + return true; } diff --git a/src/Cms/Services/Category/Creator.php b/src/Cms/Services/Category/Creator.php index b8a1cdb..f753505 100644 --- a/src/Cms/Services/Category/Creator.php +++ b/src/Cms/Services/Category/Creator.php @@ -5,6 +5,8 @@ use Neuron\Cms\Models\Category; use Neuron\Cms\Repositories\ICategoryRepository; use Neuron\Cms\Events\CategoryCreatedEvent; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; use Neuron\Patterns\Registry; use DateTimeImmutable; @@ -18,10 +20,12 @@ class Creator { private ICategoryRepository $_categoryRepository; + private IRandom $_random; - public function __construct( ICategoryRepository $categoryRepository ) + public function __construct( ICategoryRepository $categoryRepository, ?IRandom $random = null ) { $this->_categoryRepository = $categoryRepository; + $this->_random = $random ?? new RealRandom(); } /** @@ -67,7 +71,7 @@ public function create( * Generate URL-friendly slug from name * * For names with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $name * @return string @@ -82,7 +86,7 @@ private function generateSlug( string $name ): string // Fallback for names with no ASCII characters if( $slug === '' ) { - $slug = 'category-' . uniqid(); + $slug = 'category-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Category/Updater.php b/src/Cms/Services/Category/Updater.php index cd80348..bd9d653 100644 --- a/src/Cms/Services/Category/Updater.php +++ b/src/Cms/Services/Category/Updater.php @@ -5,6 +5,8 @@ use Neuron\Cms\Models\Category; use Neuron\Cms\Repositories\ICategoryRepository; use Neuron\Cms\Events\CategoryUpdatedEvent; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; use Neuron\Patterns\Registry; use DateTimeImmutable; @@ -18,10 +20,12 @@ class Updater { private ICategoryRepository $_categoryRepository; + private IRandom $_random; - public function __construct( ICategoryRepository $categoryRepository ) + public function __construct( ICategoryRepository $categoryRepository, ?IRandom $random = null ) { $this->_categoryRepository = $categoryRepository; + $this->_random = $random ?? new RealRandom(); } /** @@ -68,7 +72,7 @@ public function update( * Generate URL-friendly slug from name * * For names with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $name * @return string @@ -83,7 +87,7 @@ private function generateSlug( string $name ): string // Fallback for names with no ASCII characters if( $slug === '' ) { - $slug = 'category-' . uniqid(); + $slug = 'category-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Content/EditorJsRenderer.php b/src/Cms/Services/Content/EditorJsRenderer.php index 2e48aa6..42ea044 100644 --- a/src/Cms/Services/Content/EditorJsRenderer.php +++ b/src/Cms/Services/Content/EditorJsRenderer.php @@ -98,13 +98,60 @@ private function renderList( array $data ): string $html = "<{$tag} class='mb-3'>\n"; foreach( $items as $item ) { - $html .= "
  • " . $this->parseInlineContent( $item ) . "
  • \n"; + $html .= $this->renderListItem( $item, $style ); } $html .= "\n"; return $html; } + /** + * Render a single list item (handles both strings and nested structures) + * + * Editor.js List v1.9+ supports nested lists where items can be: + * - Simple strings: "Item text" + * - Objects with nested items: { "content": "Item text", "items": [nested items] } + * + * @param mixed $item The list item (string or array) + * @param string $style List style (ordered/unordered) + * @return string Rendered HTML + */ + private function renderListItem( mixed $item, string $style ): string + { + // Handle simple string items (legacy format and leaf items) + if( is_string( $item ) ) + { + return "
  • " . $this->parseInlineContent( $item ) . "
  • \n"; + } + + // Handle nested list items (objects with content and items) + if( is_array( $item ) && isset( $item['content'] ) ) + { + $html = "
  • \n"; + $html .= " " . $this->parseInlineContent( $item['content'] ) . "\n"; + + // Recursively render nested items + if( isset( $item['items'] ) && is_array( $item['items'] ) && !empty( $item['items'] ) ) + { + $tag = $style === 'ordered' ? 'ol' : 'ul'; + $html .= " <{$tag}>\n"; + foreach( $item['items'] as $nestedItem ) + { + // Indent nested items + $nestedHtml = $this->renderListItem( $nestedItem, $style ); + $html .= " " . $nestedHtml; + } + $html .= " \n"; + } + + $html .= "
  • \n"; + return $html; + } + + // Fallback for unknown item types (shouldn't happen in valid Editor.js data) + return "
  • \n"; + } + /** * Render image block */ diff --git a/src/Cms/Services/Email/Sender.php b/src/Cms/Services/Email/Sender.php index 8045b9f..68f7e5e 100644 --- a/src/Cms/Services/Email/Sender.php +++ b/src/Cms/Services/Email/Sender.php @@ -4,7 +4,7 @@ use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\Exception as PHPMailerException; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Log\Log; /** diff --git a/src/Cms/Services/Media/CloudinaryUploader.php b/src/Cms/Services/Media/CloudinaryUploader.php new file mode 100644 index 0000000..fc72a12 --- /dev/null +++ b/src/Cms/Services/Media/CloudinaryUploader.php @@ -0,0 +1,326 @@ +_settings = $settings; + $this->_cloudinary = $this->initializeCloudinary(); + } + + /** + * Initialize Cloudinary instance + * + * @return Cloudinary + * @throws \Exception If configuration is invalid + */ + private function initializeCloudinary(): Cloudinary + { + $cloudName = $this->_settings->get( 'cloudinary', 'cloud_name' ); + $apiKey = $this->_settings->get( 'cloudinary', 'api_key' ); + $apiSecret = $this->_settings->get( 'cloudinary', 'api_secret' ); + + if( !$cloudName || !$apiKey || !$apiSecret ) + { + throw new \Exception( 'Cloudinary configuration is incomplete. Please set cloud_name, api_key, and api_secret in config/neuron.yaml' ); + } + + return new Cloudinary( [ + 'cloud' => [ + 'cloud_name' => $cloudName, + 'api_key' => $apiKey, + 'api_secret' => $apiSecret + ] + ] ); + } + + /** + * Upload a file from local filesystem + * + * @param string $filePath Path to the file to upload + * @param array $options Upload options (folder, transformation, etc.) + * @return array Upload result with keys: url, public_id, width, height, format + * @throws \Exception If upload fails + */ + public function upload( string $filePath, array $options = [] ): array + { + if( !file_exists( $filePath ) ) + { + throw new \Exception( "File not found: {$filePath}" ); + } + + // Merge with default options from config + $uploadOptions = $this->buildUploadOptions( $options ); + + try + { + $uploadApi = $this->_cloudinary->uploadApi(); + $result = $uploadApi->upload( $filePath, $uploadOptions ); + + return $this->formatResult( $result ); + } + catch( \Exception $e ) + { + throw new \Exception( "Cloudinary upload failed: " . $e->getMessage(), 0, $e ); + } + } + + /** + * Upload a file from URL + * + * @param string $url URL of the file to upload + * @param array $options Upload options (folder, transformation, etc.) + * @return array Upload result with keys: url, public_id, width, height, format + * @throws \Exception If upload fails or URL is unsafe + */ + public function uploadFromUrl( string $url, array $options = [] ): array + { + // Validate URL against SSRF attacks + $this->validateUrlAgainstSsrf( $url ); + + // Merge with default options from config + $uploadOptions = $this->buildUploadOptions( $options ); + + try + { + $uploadApi = $this->_cloudinary->uploadApi(); + $result = $uploadApi->upload( $url, $uploadOptions ); + + return $this->formatResult( $result ); + } + catch( \Exception $e ) + { + throw new \Exception( "Cloudinary upload from URL failed: " . $e->getMessage(), 0, $e ); + } + } + + /** + * Delete a file by its public ID + * + * @param string $publicId The public ID of the file to delete + * @return bool True if deletion was successful + * @throws \Exception If deletion fails + */ + public function delete( string $publicId ): bool + { + try + { + $uploadApi = $this->_cloudinary->uploadApi(); + $result = $uploadApi->destroy( $publicId ); + + return isset( $result['result'] ) && $result['result'] === 'ok'; + } + catch( \Exception $e ) + { + throw new \Exception( "Cloudinary deletion failed: " . $e->getMessage(), 0, $e ); + } + } + + /** + * Validate URL against SSRF (Server-Side Request Forgery) attacks + * + * Ensures the URL: + * - Is a valid URL + * - Uses HTTPS protocol only + * - Does not resolve to private/internal IP addresses + * - Does not target loopback, link-local, or cloud metadata addresses + * + * @param string $url The URL to validate + * @return void + * @throws \Exception If URL is invalid or unsafe + */ + private function validateUrlAgainstSsrf( string $url ): void + { + // Basic URL validation + if( !filter_var( $url, FILTER_VALIDATE_URL ) ) + { + throw new \Exception( "Invalid URL format: {$url}" ); + } + + // Parse URL components + $parsedUrl = parse_url( $url ); + + if( $parsedUrl === false || !isset( $parsedUrl['scheme'] ) || !isset( $parsedUrl['host'] ) ) + { + throw new \Exception( "Failed to parse URL: {$url}" ); + } + + // Require HTTPS only + if( strtolower( $parsedUrl['scheme'] ) !== 'https' ) + { + throw new \Exception( "Only HTTPS URLs are allowed for security reasons. Provided: {$parsedUrl['scheme']}" ); + } + + $host = $parsedUrl['host']; + + // Check if host is already an IP address + if( filter_var( $host, FILTER_VALIDATE_IP ) !== false ) + { + // Host is an IP address, validate it directly + if( $this->isPrivateOrReservedIp( $host ) ) + { + throw new \Exception( "URL uses a private or reserved IP address ({$host}). Access denied for security reasons." ); + } + $ips = [ $host ]; + } + else + { + // Host is a hostname, resolve to IP addresses + $ips = $this->resolveHostnameToIps( $host ); + + if( empty( $ips ) ) + { + throw new \Exception( "Unable to resolve hostname: {$host}" ); + } + + // Check each resolved IP against blocked ranges + foreach( $ips as $ip ) + { + if( $this->isPrivateOrReservedIp( $ip ) ) + { + throw new \Exception( "URL resolves to a private or reserved IP address ({$ip}). Access denied for security reasons." ); + } + } + } + } + + /** + * Resolve hostname to IP addresses (both IPv4 and IPv6) + * + * @param string $hostname The hostname to resolve + * @return array Array of IP addresses + */ + private function resolveHostnameToIps( string $hostname ): array + { + $ips = []; + + // Get IPv4 addresses + $ipv4Records = @dns_get_record( $hostname, DNS_A ); + if( $ipv4Records !== false ) + { + foreach( $ipv4Records as $record ) + { + if( isset( $record['ip'] ) ) + { + $ips[] = $record['ip']; + } + } + } + + // Get IPv6 addresses + $ipv6Records = @dns_get_record( $hostname, DNS_AAAA ); + if( $ipv6Records !== false ) + { + foreach( $ipv6Records as $record ) + { + if( isset( $record['ipv6'] ) ) + { + $ips[] = $record['ipv6']; + } + } + } + + return $ips; + } + + /** + * Check if an IP address is private, loopback, link-local, or reserved + * + * Blocks the following ranges: + * - Private IPv4: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + * - Loopback IPv4: 127.0.0.0/8 + * - Link-local IPv4: 169.254.0.0/16 + * - Loopback IPv6: ::1 + * - Private IPv6: fc00::/7 + * - Link-local IPv6: fe80::/10 + * - IPv4-mapped IPv6: ::ffff:0:0/96 + * + * @param string $ip The IP address to check + * @return bool True if IP is private/reserved, false otherwise + */ + private function isPrivateOrReservedIp( string $ip ): bool + { + // Use filter_var with FILTER_VALIDATE_IP and appropriate flags + // This checks for private and reserved ranges + $flags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE; + + // If filter_var returns false, the IP is in a private or reserved range + return filter_var( $ip, FILTER_VALIDATE_IP, $flags ) === false; + } + + /** + * Build upload options by merging user options with config defaults + * + * @param array $options User-provided options + * @return array Complete upload options + */ + private function buildUploadOptions( array $options ): array + { + $defaultFolder = $this->_settings->get( 'cloudinary', 'folder' ) ?? 'neuron-cms/images'; + + $uploadOptions = [ + 'folder' => $options['folder'] ?? $defaultFolder, + 'resource_type' => 'image' + ]; + + // Add any additional options passed by the user + if( isset( $options['public_id'] ) ) + { + $uploadOptions['public_id'] = $options['public_id']; + } + + if( isset( $options['transformation'] ) ) + { + $uploadOptions['transformation'] = $options['transformation']; + } + + if( isset( $options['tags'] ) ) + { + $uploadOptions['tags'] = $options['tags']; + } + + return $uploadOptions; + } + + /** + * Format Cloudinary result into standardized array + * + * @param array $result Cloudinary upload result + * @return array Formatted result + */ + private function formatResult( array $result ): array + { + return [ + 'url' => $result['secure_url'] ?? $result['url'] ?? '', + 'public_id' => $result['public_id'] ?? '', + 'width' => $result['width'] ?? 0, + 'height' => $result['height'] ?? 0, + 'format' => $result['format'] ?? '', + 'bytes' => $result['bytes'] ?? 0, + 'resource_type' => $result['resource_type'] ?? 'image', + 'created_at' => $result['created_at'] ?? '' + ]; + } +} diff --git a/src/Cms/Services/Media/IMediaUploader.php b/src/Cms/Services/Media/IMediaUploader.php new file mode 100644 index 0000000..eb7b603 --- /dev/null +++ b/src/Cms/Services/Media/IMediaUploader.php @@ -0,0 +1,42 @@ +_settings = $settings; + } + + /** + * Validate an uploaded file + * + * @param array $file PHP $_FILES array entry + * @return bool True if valid, false otherwise + */ + public function validate( array $file ): bool + { + $this->_errors = []; + + // Check if file was uploaded + if( !isset( $file['error'] ) || !isset( $file['tmp_name'] ) ) + { + $this->_errors[] = 'No file was uploaded'; + return false; + } + + // Check for upload errors + if( $file['error'] !== UPLOAD_ERR_OK ) + { + $this->_errors[] = $this->getUploadErrorMessage( $file['error'] ); + return false; + } + + // Check if file exists + if( !file_exists( $file['tmp_name'] ) ) + { + $this->_errors[] = 'Uploaded file not found'; + return false; + } + + // Validate file size + if( !$this->validateFileSize( $file['size'] ) ) + { + return false; + } + + // Validate file type + if( !$this->validateFileType( $file['tmp_name'], $file['name'] ) ) + { + return false; + } + + return true; + } + + /** + * Validate file size + * + * @param int $size File size in bytes + * @return bool True if valid + */ + private function validateFileSize( int $size ): bool + { + $maxSize = $this->_settings->get( 'cloudinary', 'max_file_size' ) ?? 5242880; // 5MB default + + if( $size > $maxSize ) + { + $maxSizeMB = round( $maxSize / 1048576, 2 ); + $this->_errors[] = "File size exceeds maximum allowed size of {$maxSizeMB}MB"; + return false; + } + + if( $size === 0 ) + { + $this->_errors[] = 'File is empty'; + return false; + } + + return true; + } + + /** + * Validate file type + * + * @param string $filePath Path to the file + * @param string $fileName Original filename + * @return bool True if valid + */ + private function validateFileType( string $filePath, string $fileName ): bool + { + $allowedFormats = $this->_settings->get( 'cloudinary', 'allowed_formats' ) + ?? ['jpg', 'jpeg', 'png', 'gif', 'webp']; + + // Get file extension + $extension = strtolower( pathinfo( $fileName, PATHINFO_EXTENSION ) ); + + if( !in_array( $extension, $allowedFormats ) ) + { + $this->_errors[] = 'File type not allowed. Allowed types: ' . implode( ', ', $allowedFormats ); + return false; + } + + // Verify MIME type + $finfo = finfo_open( FILEINFO_MIME_TYPE ); + $mimeType = finfo_file( $finfo, $filePath ); + finfo_close( $finfo ); + + $allowedMimeTypes = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/webp' + ]; + + if( !in_array( $mimeType, $allowedMimeTypes ) ) + { + $this->_errors[] = 'Invalid file type. Must be a valid image file.'; + return false; + } + + // Additional security check: verify it's actually an image + $imageInfo = @getimagesize( $filePath ); + if( $imageInfo === false ) + { + $this->_errors[] = 'File is not a valid image'; + return false; + } + + return true; + } + + /** + * Get upload error message + * + * @param int $error PHP upload error code + * @return string Error message + */ + private function getUploadErrorMessage( int $error ): string + { + return match( $error ) + { + UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive in php.ini', + UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive in HTML form', + UPLOAD_ERR_PARTIAL => 'File was only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', + UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload', + default => 'Unknown upload error' + }; + } + + /** + * Get validation errors + * + * @return array Array of error messages + */ + public function getErrors(): array + { + return $this->_errors; + } + + /** + * Get first validation error + * + * @return string|null First error message or null if no errors + */ + public function getFirstError(): ?string + { + return $this->_errors[0] ?? null; + } +} diff --git a/src/Cms/Services/Member/RegistrationService.php b/src/Cms/Services/Member/RegistrationService.php index 236e83d..45497b4 100644 --- a/src/Cms/Services/Member/RegistrationService.php +++ b/src/Cms/Services/Member/RegistrationService.php @@ -6,7 +6,7 @@ use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\IUserRepository; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use Neuron\Dto\Dto; use Neuron\Events\Emitter; use Neuron\Cms\Events\UserCreatedEvent; diff --git a/src/Cms/Services/Page/Creator.php b/src/Cms/Services/Page/Creator.php index 4465b1d..229f5b1 100644 --- a/src/Cms/Services/Page/Creator.php +++ b/src/Cms/Services/Page/Creator.php @@ -4,6 +4,8 @@ use Neuron\Cms\Models\Page; use Neuron\Cms\Repositories\IPageRepository; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; use DateTimeImmutable; /** @@ -16,10 +18,12 @@ class Creator { private IPageRepository $_pageRepository; + private IRandom $_random; - public function __construct( IPageRepository $pageRepository ) + public function __construct( IPageRepository $pageRepository, ?IRandom $random = null ) { $this->_pageRepository = $pageRepository; + $this->_random = $random ?? new RealRandom(); } /** @@ -73,7 +77,7 @@ public function create( * Generate URL-friendly slug from title * * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $title * @return string @@ -88,7 +92,7 @@ private function generateSlug( string $title ): string // Fallback for titles with no ASCII characters if( $slug === '' ) { - $slug = 'page-' . uniqid(); + $slug = 'page-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Post/Creator.php b/src/Cms/Services/Post/Creator.php index afe0df6..52765a0 100644 --- a/src/Cms/Services/Post/Creator.php +++ b/src/Cms/Services/Post/Creator.php @@ -6,6 +6,8 @@ use Neuron\Cms\Repositories\IPostRepository; use Neuron\Cms\Repositories\ICategoryRepository; use Neuron\Cms\Services\Tag\Resolver as TagResolver; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; use DateTimeImmutable; /** @@ -20,23 +22,26 @@ class Creator private IPostRepository $_postRepository; private ICategoryRepository $_categoryRepository; private TagResolver $_tagResolver; + private IRandom $_random; public function __construct( IPostRepository $postRepository, ICategoryRepository $categoryRepository, - TagResolver $tagResolver + TagResolver $tagResolver, + ?IRandom $random = null ) { $this->_postRepository = $postRepository; $this->_categoryRepository = $categoryRepository; $this->_tagResolver = $tagResolver; + $this->_random = $random ?? new RealRandom(); } /** * Create a new post * * @param string $title Post title - * @param string $body Post body content + * @param string $content Editor.js JSON content * @param int $authorId Author user ID * @param string $status Post status (draft, published, scheduled) * @param string|null $slug Optional custom slug (auto-generated if not provided) @@ -48,7 +53,7 @@ public function __construct( */ public function create( string $title, - string $body, + string $content, int $authorId, string $status, ?string $slug = null, @@ -61,7 +66,7 @@ public function create( $post = new Post(); $post->setTitle( $title ); $post->setSlug( $slug ?: $this->generateSlug( $title ) ); - $post->setBody( $body ); + $post->setContent( $content ); $post->setExcerpt( $excerpt ); $post->setFeaturedImage( $featuredImage ); $post->setAuthorId( $authorId ); @@ -89,7 +94,7 @@ public function create( * Generate URL-friendly slug from title * * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $title * @return string @@ -104,7 +109,7 @@ private function generateSlug( string $title ): string // Fallback for titles with no ASCII characters if( $slug === '' ) { - $slug = 'post-' . uniqid(); + $slug = 'post-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Post/Updater.php b/src/Cms/Services/Post/Updater.php index 3ed0eb5..c811685 100644 --- a/src/Cms/Services/Post/Updater.php +++ b/src/Cms/Services/Post/Updater.php @@ -6,6 +6,8 @@ use Neuron\Cms\Repositories\IPostRepository; use Neuron\Cms\Repositories\ICategoryRepository; use Neuron\Cms\Services\Tag\Resolver as TagResolver; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; /** * Post update service. @@ -19,16 +21,19 @@ class Updater private IPostRepository $_postRepository; private ICategoryRepository $_categoryRepository; private TagResolver $_tagResolver; + private IRandom $_random; public function __construct( IPostRepository $postRepository, ICategoryRepository $categoryRepository, - TagResolver $tagResolver + TagResolver $tagResolver, + ?IRandom $random = null ) { $this->_postRepository = $postRepository; $this->_categoryRepository = $categoryRepository; $this->_tagResolver = $tagResolver; + $this->_random = $random ?? new RealRandom(); } /** @@ -36,7 +41,7 @@ public function __construct( * * @param Post $post The post to update * @param string $title Post title - * @param string $body Post body content + * @param string $content Editor.js JSON content * @param string $status Post status * @param string|null $slug Custom slug * @param string|null $excerpt Excerpt @@ -48,7 +53,7 @@ public function __construct( public function update( Post $post, string $title, - string $body, + string $content, string $status, ?string $slug = null, ?string $excerpt = null, @@ -59,7 +64,7 @@ public function update( { $post->setTitle( $title ); $post->setSlug( $slug ?: $this->generateSlug( $title ) ); - $post->setBody( $body ); + $post->setContent( $content ); $post->setExcerpt( $excerpt ); $post->setFeaturedImage( $featuredImage ); $post->setStatus( $status ); @@ -86,7 +91,7 @@ public function update( * Generate URL-friendly slug from title * * For titles with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $title * @return string @@ -101,7 +106,7 @@ private function generateSlug( string $title ): string // Fallback for titles with no ASCII characters if( $slug === '' ) { - $slug = 'post-' . uniqid(); + $slug = 'post-' . $this->_random->uniqueId(); } return $slug; diff --git a/src/Cms/Services/Tag/Creator.php b/src/Cms/Services/Tag/Creator.php index 6135088..a50e618 100644 --- a/src/Cms/Services/Tag/Creator.php +++ b/src/Cms/Services/Tag/Creator.php @@ -4,6 +4,8 @@ use Neuron\Cms\Models\Tag; use Neuron\Cms\Repositories\ITagRepository; +use Neuron\Core\System\IRandom; +use Neuron\Core\System\RealRandom; /** * Tag creation service. @@ -15,10 +17,12 @@ class Creator { private ITagRepository $_tagRepository; + private IRandom $_random; - public function __construct( ITagRepository $tagRepository ) + public function __construct( ITagRepository $tagRepository, ?IRandom $random = null ) { $this->_tagRepository = $tagRepository; + $this->_random = $random ?? new RealRandom(); } /** @@ -41,7 +45,7 @@ public function create( string $name, ?string $slug = null ): Tag * Generate URL-friendly slug from name * * For names with only non-ASCII characters (e.g., "你好", "مرحبا"), - * generates a fallback slug using uniqid(). + * generates a fallback slug using a unique identifier. * * @param string $name * @return string @@ -56,7 +60,7 @@ private function generateSlug( string $name ): string // Fallback for names with no ASCII characters if( $slug === '' ) { - $slug = 'tag-' . uniqid(); + $slug = 'tag-' . $this->_random->uniqueId(); } return $slug; diff --git a/tests/Integration/CategoryTagRelationshipTest.php b/tests/Integration/CategoryTagRelationshipTest.php new file mode 100644 index 0000000..76eb45f --- /dev/null +++ b/tests/Integration/CategoryTagRelationshipTest.php @@ -0,0 +1,538 @@ +pdo->prepare( + "INSERT INTO categories (name, slug, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)" + ); + + $stmt->execute( ['Technology', 'technology', 'Tech articles', $now, $now] ); + $parentId = (int)$this->pdo->lastInsertId(); + + // Create child category + $stmt->execute( ['PHP', 'php', 'PHP programming', $now, $now] ); + $childId = (int)$this->pdo->lastInsertId(); + + // Verify categories exist + $stmt = $this->pdo->prepare( "SELECT * FROM categories ORDER BY name" ); + $stmt->execute(); + $categories = $stmt->fetchAll(); + + $this->assertCount( 2, $categories ); + $this->assertEquals( 'PHP', $categories[0]['name'] ); + $this->assertEquals( 'Technology', $categories[1]['name'] ); + } + + /** + * Test category slug uniqueness + */ + public function testCategorySlugUniqueness(): void + { + $now = date( 'Y-m-d H:i:s' ); + + $stmt = $this->pdo->prepare( + "INSERT INTO categories (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['Category 1', 'unique-slug', $now, $now] ); + + // Try to create another category with same slug + $this->expectException( \PDOException::class ); + + $stmt->execute( ['Category 2', 'unique-slug', $now, $now] ); + } + + /** + * Test attaching multiple categories to a post + */ + public function testPostMultipleCategoryAttachment(): void + { + // Create user + $userId = $this->createTestUser([ + 'username' => 'catuser', + 'email' => 'cat@example.com' + ]); + + // Create categories + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO categories (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['Web Development', 'web-dev', $now, $now] ); + $webDevId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['Backend', 'backend', $now, $now] ); + $backendId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['Frontend', 'frontend', $now, $now] ); + $frontendId = (int)$this->pdo->lastInsertId(); + + // Create post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Full Stack Development', + 'full-stack-dev', + 'Learn full stack', + '{"blocks":[]}', + $userId, + 'published', + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Attach categories to post + $stmt = $this->pdo->prepare( + "INSERT INTO post_categories (post_id, category_id, created_at) + VALUES (?, ?, ?)" + ); + + $stmt->execute( [$postId, $webDevId, $now] ); + $stmt->execute( [$postId, $backendId, $now] ); + $stmt->execute( [$postId, $frontendId, $now] ); + + // Query post categories + $stmt = $this->pdo->prepare( + "SELECT c.* FROM categories c + INNER JOIN post_categories pc ON c.id = pc.category_id + WHERE pc.post_id = ? + ORDER BY c.name" + ); + + $stmt->execute( [$postId] ); + $postCategories = $stmt->fetchAll(); + + $this->assertCount( 3, $postCategories ); + $this->assertEquals( 'Backend', $postCategories[0]['name'] ); + $this->assertEquals( 'Frontend', $postCategories[1]['name'] ); + $this->assertEquals( 'Web Development', $postCategories[2]['name'] ); + } + + /** + * Test querying posts by category + */ + public function testQueryPostsByCategory(): void + { + $userId = $this->createTestUser([ + 'username' => 'postcat', + 'email' => 'postcat@example.com' + ]); + + // Create category + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO categories (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['PHP', 'php', $now, $now] ); + $phpCategoryId = (int)$this->pdo->lastInsertId(); + + // Create posts in PHP category + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $postIds = []; + for( $i = 1; $i <= 3; $i++ ) + { + $stmt->execute([ + "PHP Article {$i}", + "php-article-{$i}", + "Content {$i}", + '{"blocks":[]}', + $userId, + 'published', + $now + ]); + $postIds[] = (int)$this->pdo->lastInsertId(); + } + + // Attach posts to category + $stmt = $this->pdo->prepare( + "INSERT INTO post_categories (post_id, category_id, created_at) + VALUES (?, ?, ?)" + ); + + foreach( $postIds as $postId ) + { + $stmt->execute( [$postId, $phpCategoryId, $now] ); + } + + // Query posts by category + $stmt = $this->pdo->prepare( + "SELECT p.* FROM posts p + INNER JOIN post_categories pc ON p.id = pc.post_id + WHERE pc.category_id = ? + ORDER BY p.title" + ); + + $stmt->execute( [$phpCategoryId] ); + $categoryPosts = $stmt->fetchAll(); + + $this->assertCount( 3, $categoryPosts ); + $this->assertEquals( 'PHP Article 1', $categoryPosts[0]['title'] ); + $this->assertEquals( 'PHP Article 2', $categoryPosts[1]['title'] ); + $this->assertEquals( 'PHP Article 3', $categoryPosts[2]['title'] ); + } + + /** + * Test creating and managing tags + */ + public function testTagCreationAndRetrieval(): void + { + $now = date( 'Y-m-d H:i:s' ); + + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $tagNames = ['tutorial', 'beginner', 'advanced', 'guide']; + + foreach( $tagNames as $tag ) + { + $stmt->execute( [$tag, $tag, $now, $now] ); + } + + // Verify all tags created + $stmt = $this->pdo->prepare( "SELECT * FROM tags ORDER BY name" ); + $stmt->execute(); + $tags = $stmt->fetchAll(); + + $this->assertCount( 4, $tags ); + $this->assertEquals( 'advanced', $tags[0]['name'] ); + $this->assertEquals( 'beginner', $tags[1]['name'] ); + $this->assertEquals( 'guide', $tags[2]['name'] ); + $this->assertEquals( 'tutorial', $tags[3]['name'] ); + } + + /** + * Test tag slug uniqueness + */ + public function testTagSlugUniqueness(): void + { + $now = date( 'Y-m-d H:i:s' ); + + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['Tutorial', 'tutorial-tag', $now, $now] ); + + // Try to create another tag with same slug + $this->expectException( \PDOException::class ); + + $stmt->execute( ['Another Tutorial', 'tutorial-tag', $now, $now] ); + } + + /** + * Test attaching multiple tags to a post + */ + public function testPostMultipleTagAttachment(): void + { + $userId = $this->createTestUser([ + 'username' => 'taguser', + 'email' => 'tag@example.com' + ]); + + // Create tags + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['php', 'php', $now, $now] ); + $phpTagId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['tutorial', 'tutorial', $now, $now] ); + $tutorialTagId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['beginner', 'beginner', $now, $now] ); + $beginnerTagId = (int)$this->pdo->lastInsertId(); + + // Create post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'PHP Tutorial for Beginners', + 'php-tutorial-beginners', + 'Learn PHP basics', + '{"blocks":[]}', + $userId, + 'published', + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Attach tags to post + $stmt = $this->pdo->prepare( + "INSERT INTO post_tags (post_id, tag_id, created_at) + VALUES (?, ?, ?)" + ); + + $stmt->execute( [$postId, $phpTagId, $now] ); + $stmt->execute( [$postId, $tutorialTagId, $now] ); + $stmt->execute( [$postId, $beginnerTagId, $now] ); + + // Query post tags + $stmt = $this->pdo->prepare( + "SELECT t.* FROM tags t + INNER JOIN post_tags pt ON t.id = pt.tag_id + WHERE pt.post_id = ? + ORDER BY t.name" + ); + + $stmt->execute( [$postId] ); + $postTags = $stmt->fetchAll(); + + $this->assertCount( 3, $postTags ); + $this->assertEquals( 'beginner', $postTags[0]['name'] ); + $this->assertEquals( 'php', $postTags[1]['name'] ); + $this->assertEquals( 'tutorial', $postTags[2]['name'] ); + } + + /** + * Test querying posts by tag + */ + public function testQueryPostsByTag(): void + { + $userId = $this->createTestUser([ + 'username' => 'posttag', + 'email' => 'posttag@example.com' + ]); + + // Create tag + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['laravel', 'laravel', $now, $now] ); + $laravelTagId = (int)$this->pdo->lastInsertId(); + + // Create posts with Laravel tag + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $postIds = []; + for( $i = 1; $i <= 3; $i++ ) + { + $stmt->execute([ + "Laravel Tutorial {$i}", + "laravel-tutorial-{$i}", + "Content {$i}", + '{"blocks":[]}', + $userId, + 'published', + $now + ]); + $postIds[] = (int)$this->pdo->lastInsertId(); + } + + // Attach posts to tag + $stmt = $this->pdo->prepare( + "INSERT INTO post_tags (post_id, tag_id, created_at) + VALUES (?, ?, ?)" + ); + + foreach( $postIds as $postId ) + { + $stmt->execute( [$postId, $laravelTagId, $now] ); + } + + // Query posts by tag + $stmt = $this->pdo->prepare( + "SELECT p.* FROM posts p + INNER JOIN post_tags pt ON p.id = pt.post_id + WHERE pt.tag_id = ? + ORDER BY p.title" + ); + + $stmt->execute( [$laravelTagId] ); + $tagPosts = $stmt->fetchAll(); + + $this->assertCount( 3, $tagPosts ); + $this->assertEquals( 'Laravel Tutorial 1', $tagPosts[0]['title'] ); + $this->assertEquals( 'Laravel Tutorial 2', $tagPosts[1]['title'] ); + $this->assertEquals( 'Laravel Tutorial 3', $tagPosts[2]['title'] ); + } + + /** + * Test removing category detaches from posts + */ + public function testCategoryDeletionDetachesFromPosts(): void + { + $userId = $this->createTestUser([ + 'username' => 'detachcat', + 'email' => 'detachcat@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + + // Create category + $stmt = $this->pdo->prepare( + "INSERT INTO categories (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + $stmt->execute( ['Temporary', 'temporary', $now, $now] ); + $categoryId = (int)$this->pdo->lastInsertId(); + + // Create post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->execute( ['Test Post', 'test-post', 'Content', '{"blocks":[]}', $userId, 'draft', $now] ); + $postId = (int)$this->pdo->lastInsertId(); + + // Attach category to post + $stmt = $this->pdo->prepare( + "INSERT INTO post_categories (post_id, category_id, created_at) + VALUES (?, ?, ?)" + ); + $stmt->execute( [$postId, $categoryId, $now] ); + + // Verify attachment exists + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM post_categories WHERE post_id = ? AND category_id = ?" + ); + $stmt->execute( [$postId, $categoryId] ); + $this->assertEquals( 1, (int)$stmt->fetchColumn() ); + + // Delete category + $stmt = $this->pdo->prepare( "DELETE FROM categories WHERE id = ?" ); + $stmt->execute( [$categoryId] ); + + // Verify pivot entry was also deleted (cascade or manual cleanup) + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM post_categories WHERE category_id = ?" + ); + $stmt->execute( [$categoryId] ); + $this->assertEquals( 0, (int)$stmt->fetchColumn(), 'Category deletion should remove pivot entries' ); + } + + /** + * Test post with both categories and tags + */ + public function testPostWithCategoriesAndTags(): void + { + $userId = $this->createTestUser([ + 'username' => 'fullpost', + 'email' => 'fullpost@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + + // Create categories + $stmt = $this->pdo->prepare( + "INSERT INTO categories (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + $stmt->execute( ['Programming', 'programming', $now, $now] ); + $progCatId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['Web', 'web', $now, $now] ); + $webCatId = (int)$this->pdo->lastInsertId(); + + // Create tags + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + $stmt->execute( ['vue', 'vue', $now, $now] ); + $vueTagId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['javascript', 'javascript', $now, $now] ); + $jsTagId = (int)$this->pdo->lastInsertId(); + + // Create post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + $stmt->execute([ + 'Vue.js Web Development', + 'vue-web-dev', + 'Learn Vue', + '{"blocks":[]}', + $userId, + 'published', + $now + ]); + $postId = (int)$this->pdo->lastInsertId(); + + // Attach categories + $stmt = $this->pdo->prepare( + "INSERT INTO post_categories (post_id, category_id, created_at) + VALUES (?, ?, ?)" + ); + $stmt->execute( [$postId, $progCatId, $now] ); + $stmt->execute( [$postId, $webCatId, $now] ); + + // Attach tags + $stmt = $this->pdo->prepare( + "INSERT INTO post_tags (post_id, tag_id, created_at) + VALUES (?, ?, ?)" + ); + $stmt->execute( [$postId, $vueTagId, $now] ); + $stmt->execute( [$postId, $jsTagId, $now] ); + + // Verify post has categories + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM post_categories WHERE post_id = ?" + ); + $stmt->execute( [$postId] ); + $this->assertEquals( 2, (int)$stmt->fetchColumn() ); + + // Verify post has tags + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM post_tags WHERE post_id = ?" + ); + $stmt->execute( [$postId] ); + $this->assertEquals( 2, (int)$stmt->fetchColumn() ); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..0172747 --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,285 @@ +pdo is available - real database connection + * // All migrations have been run + * } + * } + * + * @package Tests\Integration + */ +abstract class IntegrationTestCase extends TestCase +{ + protected PDO $pdo; + protected Manager $migrationManager; + private static bool $migrationsRun = false; + + /** + * Set up test database before each test class + */ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + } + + /** + * Set up test database and run migrations + */ + protected function setUp(): void + { + parent::setUp(); + + // Create test database connection + $this->pdo = $this->createTestDatabase(); + + // Run migrations only once per test run + if( !self::$migrationsRun ) + { + $this->runMigrations(); + self::$migrationsRun = true; + } + + // Start a transaction for test isolation + $this->pdo->beginTransaction(); + } + + /** + * Rollback transaction after each test for isolation + */ + protected function tearDown(): void + { + // Rollback transaction to clean up test data + if( $this->pdo->inTransaction() ) + { + $this->pdo->rollBack(); + } + + parent::tearDown(); + } + + /** + * Create test database connection + * + * Uses environment variables or defaults to SQLite file for testing. + * Set TEST_DB_DRIVER, TEST_DB_HOST, TEST_DB_NAME, etc. to use MySQL/PostgreSQL. + * + * @return PDO + */ + private function createTestDatabase(): PDO + { + $driver = getenv( 'TEST_DB_DRIVER' ) ?: 'sqlite'; + + if( $driver === 'sqlite' ) + { + // Use SQLite file for persistence across test methods + $dbPath = sys_get_temp_dir() . '/cms_test_' . getmypid() . '.db'; + + // Remove old test database if exists + if( file_exists( $dbPath ) && !self::$migrationsRun ) + { + unlink( $dbPath ); + } + + $pdo = new PDO( + "sqlite:{$dbPath}", + null, + null, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC + ] + ); + + // Enable foreign keys for SQLite + $pdo->exec( 'PRAGMA foreign_keys = ON' ); + + return $pdo; + } + + if( $driver === 'mysql' ) + { + $host = getenv( 'TEST_DB_HOST' ) ?: 'localhost'; + $port = getenv( 'TEST_DB_PORT' ) ?: '3306'; + $dbname = getenv( 'TEST_DB_NAME' ) ?: 'cms_test'; + $user = getenv( 'TEST_DB_USER' ) ?: 'root'; + $pass = getenv( 'TEST_DB_PASSWORD' ) ?: ''; + + // Create database if it doesn't exist + $pdo = new PDO( + "mysql:host={$host};port={$port}", + $user, + $pass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + $pdo->exec( "CREATE DATABASE IF NOT EXISTS `{$dbname}`" ); + $pdo->exec( "USE `{$dbname}`" ); + + return $pdo; + } + + if( $driver === 'pgsql' ) + { + $host = getenv( 'TEST_DB_HOST' ) ?: 'localhost'; + $port = getenv( 'TEST_DB_PORT' ) ?: '5432'; + $dbname = getenv( 'TEST_DB_NAME' ) ?: 'cms_test'; + $user = getenv( 'TEST_DB_USER' ) ?: 'postgres'; + $pass = getenv( 'TEST_DB_PASSWORD' ) ?: ''; + + return new PDO( + "pgsql:host={$host};port={$port};dbname={$dbname}", + $user, + $pass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] + ); + } + + throw new \RuntimeException( "Unsupported database driver: {$driver}" ); + } + + /** + * Run Phinx migrations + * + * This runs the ACTUAL migration files from resources/database/migrate/ + * exactly as they would run in production. + */ + private function runMigrations(): void + { + $migrationsPath = dirname( __DIR__, 2 ) . '/resources/database/migrate'; + + if( !is_dir( $migrationsPath ) ) + { + throw new \RuntimeException( "Migrations directory not found: {$migrationsPath}" ); + } + + // Configure Phinx + $config = new Config([ + 'paths' => [ + 'migrations' => $migrationsPath + ], + 'environments' => [ + 'default_migration_table' => 'phinxlog', + 'default_environment' => 'test', + 'test' => [ + 'name' => $this->getDatabaseName(), + 'connection' => $this->pdo + ] + ] + ]); + + // Create migration manager + $this->migrationManager = new Manager( $config, new StringInput( '' ), new NullOutput() ); + + // Run all migrations + $this->migrationManager->migrate( 'test' ); + } + + /** + * Get database name from PDO connection + */ + private function getDatabaseName(): string + { + $driver = $this->pdo->getAttribute( PDO::ATTR_DRIVER_NAME ); + + if( $driver === 'sqlite' ) + { + return 'test'; + } + + if( $driver === 'mysql' ) + { + $result = $this->pdo->query( 'SELECT DATABASE()' )->fetchColumn(); + return $result ?: 'cms_test'; + } + + if( $driver === 'pgsql' ) + { + $result = $this->pdo->query( 'SELECT current_database()' )->fetchColumn(); + return $result ?: 'cms_test'; + } + + return 'test'; + } + + /** + * Rollback all migrations (for cleanup) + * + * WARNING: This drops all tables. Only use in test teardown. + */ + protected function rollbackMigrations(): void + { + if( $this->migrationManager ) + { + $this->migrationManager->rollback( 'test', 0 ); + } + } + + /** + * Helper: Insert a test user and return the ID + */ + protected function createTestUser( array $data = [] ): int + { + $defaults = [ + 'username' => 'testuser_' . uniqid(), + 'email' => 'test_' . uniqid() . '@example.com', + 'password_hash' => password_hash( 'password', PASSWORD_DEFAULT ), + 'role' => 'subscriber', + 'status' => 'active', + 'email_verified' => 1, + 'created_at' => date( 'Y-m-d H:i:s' ), + 'updated_at' => date( 'Y-m-d H:i:s' ) + ]; + + $userData = array_merge( $defaults, $data ); + + $fields = implode( ', ', array_keys( $userData ) ); + $placeholders = implode( ', ', array_fill( 0, count( $userData ), '?' ) ); + + $stmt = $this->pdo->prepare( + "INSERT INTO users ({$fields}) VALUES ({$placeholders})" + ); + $stmt->execute( array_values( $userData ) ); + + return (int)$this->pdo->lastInsertId(); + } + + /** + * Helper: Truncate a table for cleanup + */ + protected function truncateTable( string $table ): void + { + $driver = $this->pdo->getAttribute( PDO::ATTR_DRIVER_NAME ); + + if( $driver === 'sqlite' ) + { + $this->pdo->exec( "DELETE FROM {$table}" ); + } + else + { + $this->pdo->exec( "TRUNCATE TABLE {$table}" ); + } + } +} diff --git a/tests/Integration/PageManagementFlowTest.php b/tests/Integration/PageManagementFlowTest.php new file mode 100644 index 0000000..9060543 --- /dev/null +++ b/tests/Integration/PageManagementFlowTest.php @@ -0,0 +1,506 @@ +createTestUser([ + 'username' => 'pageauthor', + 'email' => 'pageauthor@example.com' + ]); + + // Create page + $content = json_encode([ + 'blocks' => [ + ['type' => 'header', 'data' => ['text' => 'About Us', 'level' => 1]], + ['type' => 'paragraph', 'data' => ['text' => 'We are a company...']] + ] + ]); + + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, template, meta_title, meta_description, meta_keywords, author_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $now = date( 'Y-m-d H:i:s' ); + $stmt->execute([ + 'About Us', + 'about-us', + $content, + 'default', + 'About Our Company', + 'Learn more about our company history and values', + 'about, company, history', + $userId, + 'draft', + $now, + $now + ]); + + $pageId = (int)$this->pdo->lastInsertId(); + $this->assertGreaterThan( 0, $pageId ); + + // Verify page was created correctly + $stmt = $this->pdo->prepare( "SELECT * FROM pages WHERE id = ?" ); + $stmt->execute( [$pageId] ); + $page = $stmt->fetch(); + + $this->assertEquals( 'About Us', $page['title'] ); + $this->assertEquals( 'about-us', $page['slug'] ); + $this->assertEquals( 'default', $page['template'] ); + $this->assertEquals( 'About Our Company', $page['meta_title'] ); + $this->assertEquals( 'draft', $page['status'] ); + $this->assertEquals( $userId, $page['author_id'] ); + $this->assertNull( $page['published_at'] ); + } + + /** + * Test page update flow + */ + public function testPageUpdateFlow(): void + { + $userId = $this->createTestUser([ + 'username' => 'updateuser', + 'email' => 'update@example.com' + ]); + + // Create page + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, author_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Original Title', + 'original-slug', + '{"blocks":[]}', + $userId, + 'draft', + $now, + $now + ]); + + $pageId = (int)$this->pdo->lastInsertId(); + + // Update page + sleep(1); // Ensure updated_at is different + $updatedContent = json_encode([ + 'blocks' => [ + ['type' => 'paragraph', 'data' => ['text' => 'Updated content']] + ] + ]); + + $stmt = $this->pdo->prepare( + "UPDATE pages + SET title = ?, content = ?, template = ?, meta_title = ?, updated_at = ? + WHERE id = ?" + ); + + $newNow = date( 'Y-m-d H:i:s' ); + $stmt->execute([ + 'Updated Title', + $updatedContent, + 'custom', + 'Updated Meta Title', + $newNow, + $pageId + ]); + + // Verify updates + $stmt = $this->pdo->prepare( "SELECT * FROM pages WHERE id = ?" ); + $stmt->execute( [$pageId] ); + $page = $stmt->fetch(); + + $this->assertEquals( 'Updated Title', $page['title'] ); + $this->assertEquals( 'custom', $page['template'] ); + $this->assertEquals( 'Updated Meta Title', $page['meta_title'] ); + $this->assertNotEquals( $now, $page['updated_at'] ); + } + + /** + * Test page publishing workflow + */ + public function testPagePublishingWorkflow(): void + { + $userId = $this->createTestUser([ + 'username' => 'publisher', + 'email' => 'publisher@example.com' + ]); + + // Create draft page + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, author_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Draft Page', + 'draft-page', + '{"blocks":[]}', + $userId, + 'draft', + $now, + $now + ]); + + $pageId = (int)$this->pdo->lastInsertId(); + + // Verify it's draft + $stmt = $this->pdo->prepare( "SELECT status, published_at FROM pages WHERE id = ?" ); + $stmt->execute( [$pageId] ); + $page = $stmt->fetch(); + + $this->assertEquals( 'draft', $page['status'] ); + $this->assertNull( $page['published_at'] ); + + // Publish page + $publishedAt = new DateTimeImmutable(); + $stmt = $this->pdo->prepare( + "UPDATE pages SET status = ?, published_at = ?, updated_at = ? WHERE id = ?" + ); + + $stmt->execute([ + 'published', + $publishedAt->format( 'Y-m-d H:i:s' ), + date( 'Y-m-d H:i:s' ), + $pageId + ]); + + // Verify published + $stmt = $this->pdo->prepare( "SELECT status, published_at FROM pages WHERE id = ?" ); + $stmt->execute( [$pageId] ); + $page = $stmt->fetch(); + + $this->assertEquals( 'published', $page['status'] ); + $this->assertNotNull( $page['published_at'] ); + + // Unpublish (revert to draft) + $stmt = $this->pdo->prepare( + "UPDATE pages SET status = ?, published_at = NULL WHERE id = ?" + ); + + $stmt->execute( ['draft', $pageId] ); + + // Verify unpublished + $stmt = $this->pdo->prepare( "SELECT status, published_at FROM pages WHERE id = ?" ); + $stmt->execute( [$pageId] ); + $page = $stmt->fetch(); + + $this->assertEquals( 'draft', $page['status'] ); + $this->assertNull( $page['published_at'] ); + } + + /** + * Test page slug uniqueness constraint + */ + public function testPageSlugUniqueness(): void + { + $userId = $this->createTestUser([ + 'username' => 'sluguser', + 'email' => 'slug@example.com' + ]); + + // Create first page + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, author_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Page One', + 'unique-page-slug', + '{"blocks":[]}', + $userId, + 'draft', + $now, + $now + ]); + + // Try to create second page with same slug + $this->expectException( \PDOException::class ); + + $stmt->execute([ + 'Page Two', + 'unique-page-slug', // Duplicate + '{"blocks":[]}', + $userId, + 'draft', + $now, + $now + ]); + } + + /** + * Test page templates + */ + public function testPageTemplates(): void + { + $userId = $this->createTestUser([ + 'username' => 'templateuser', + 'email' => 'template@example.com' + ]); + + $templates = ['default', 'full-width', 'sidebar-left', 'sidebar-right', 'landing']; + $now = date( 'Y-m-d H:i:s' ); + + foreach( $templates as $template ) + { + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, template, author_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + "Page with {$template}", + "page-{$template}", + '{"blocks":[]}', + $template, + $userId, + 'draft', + $now, + $now + ]); + } + + // Query pages by template + $stmt = $this->pdo->prepare( "SELECT title, template FROM pages WHERE author_id = ? ORDER BY template" ); + $stmt->execute( [$userId] ); + $pages = $stmt->fetchAll(); + + $this->assertCount( 5, $pages ); + $this->assertEquals( 'default', $pages[0]['template'] ); + $this->assertEquals( 'full-width', $pages[1]['template'] ); + } + + /** + * Test page view count tracking + */ + public function testPageViewCountTracking(): void + { + $userId = $this->createTestUser([ + 'username' => 'viewuser', + 'email' => 'view@example.com' + ]); + + // Create page + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, author_id, status, view_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Popular Page', + 'popular-page', + '{"blocks":[]}', + $userId, + 'published', + 0, + $now, + $now + ]); + + $pageId = (int)$this->pdo->lastInsertId(); + + // Simulate 5 page views + for( $i = 1; $i <= 5; $i++ ) + { + $stmt = $this->pdo->prepare( + "UPDATE pages SET view_count = view_count + 1 WHERE id = ?" + ); + $stmt->execute( [$pageId] ); + + // Verify count incremented + $stmt = $this->pdo->prepare( "SELECT view_count FROM pages WHERE id = ?" ); + $stmt->execute( [$pageId] ); + $count = (int)$stmt->fetchColumn(); + + $this->assertEquals( $i, $count ); + } + + // Verify final count + $stmt = $this->pdo->prepare( "SELECT view_count FROM pages WHERE id = ?" ); + $stmt->execute( [$pageId] ); + $finalCount = (int)$stmt->fetchColumn(); + + $this->assertEquals( 5, $finalCount ); + } + + /** + * Test SEO metadata fields + */ + public function testSeoMetadata(): void + { + $userId = $this->createTestUser([ + 'username' => 'seouser', + 'email' => 'seo@example.com' + ]); + + // Create page with full SEO metadata + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, meta_title, meta_description, meta_keywords, author_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'SEO Optimized Page', + 'seo-page', + '{"blocks":[]}', + 'Best SEO Page - My Company', + 'This is a comprehensive SEO-optimized page with all metadata fields properly filled out', + 'seo, optimization, metadata, keywords', + $userId, + 'published', + $now, + $now + ]); + + $pageId = (int)$this->pdo->lastInsertId(); + + // Verify SEO metadata + $stmt = $this->pdo->prepare( + "SELECT meta_title, meta_description, meta_keywords FROM pages WHERE id = ?" + ); + $stmt->execute( [$pageId] ); + $seo = $stmt->fetch(); + + $this->assertEquals( 'Best SEO Page - My Company', $seo['meta_title'] ); + $this->assertStringContainsString( 'comprehensive SEO-optimized', $seo['meta_description'] ); + $this->assertStringContainsString( 'seo', $seo['meta_keywords'] ); + $this->assertStringContainsString( 'optimization', $seo['meta_keywords'] ); + } + + /** + * Test user deletion cascades to pages + */ + public function testUserDeletionCascadesToPages(): void + { + // Create user + $userId = $this->createTestUser([ + 'username' => 'cascadeuser', + 'email' => 'cascade@example.com' + ]); + + // Create pages for user + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, author_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + for( $i = 1; $i <= 3; $i++ ) + { + $stmt->execute([ + "Page {$i}", + "page-{$i}-cascade", + '{"blocks":[]}', + $userId, + 'draft', + $now, + $now + ]); + } + + // Verify pages exist + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM pages WHERE author_id = ?" ); + $stmt->execute( [$userId] ); + $count = (int)$stmt->fetchColumn(); + $this->assertEquals( 3, $count ); + + // Delete user + $stmt = $this->pdo->prepare( "DELETE FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + + // Verify pages were cascade deleted + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM pages WHERE author_id = ?" ); + $stmt->execute( [$userId] ); + $count = (int)$stmt->fetchColumn(); + $this->assertEquals( 0, $count, 'Pages should be cascade deleted when author is deleted' ); + } + + /** + * Test querying published pages + */ + public function testQueryPublishedPages(): void + { + $userId = $this->createTestUser([ + 'username' => 'pubuser', + 'email' => 'pub@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO pages (title, slug, content, author_id, status, published_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + // Create 3 published pages + for( $i = 1; $i <= 3; $i++ ) + { + $stmt->execute([ + "Published Page {$i}", + "published-{$i}", + '{"blocks":[]}', + $userId, + 'published', + $now, + $now, + $now + ]); + } + + // Create 2 draft pages + for( $i = 1; $i <= 2; $i++ ) + { + $stmt->execute([ + "Draft Page {$i}", + "draft-{$i}-pub", + '{"blocks":[]}', + $userId, + 'draft', + null, + $now, + $now + ]); + } + + // Query only published pages + $stmt = $this->pdo->prepare( + "SELECT * FROM pages WHERE status = ? ORDER BY title" + ); + $stmt->execute( ['published'] ); + $publishedPages = $stmt->fetchAll(); + + $this->assertCount( 3, $publishedPages ); + $this->assertEquals( 'Published Page 1', $publishedPages[0]['title'] ); + $this->assertEquals( 'Published Page 2', $publishedPages[1]['title'] ); + $this->assertEquals( 'Published Page 3', $publishedPages[2]['title'] ); + } +} diff --git a/tests/Integration/PostPublishingFlowTest.php b/tests/Integration/PostPublishingFlowTest.php new file mode 100644 index 0000000..1c0c087 --- /dev/null +++ b/tests/Integration/PostPublishingFlowTest.php @@ -0,0 +1,388 @@ +createTestUser([ + 'username' => 'testauthor', + 'email' => 'author@example.com', + 'role' => 'editor' + ]); + + // 2. Create a draft post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, excerpt, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $content = json_encode([ + 'blocks' => [ + ['type' => 'paragraph', 'data' => ['text' => 'This is the first paragraph']] + ] + ]); + + $stmt->execute([ + 'My First Post', + 'my-first-post', + 'This is the first paragraph', + $content, + 'A short excerpt', + $userId, + 'draft', + date( 'Y-m-d H:i:s' ) + ]); + + $postId = (int)$this->pdo->lastInsertId(); + $this->assertGreaterThan( 0, $postId ); + + // 3. Verify post was created as draft + $stmt = $this->pdo->prepare( "SELECT * FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertEquals( 'My First Post', $post['title'] ); + $this->assertEquals( 'my-first-post', $post['slug'] ); + $this->assertEquals( 'draft', $post['status'] ); + $this->assertEquals( $userId, $post['author_id'] ); + $this->assertNull( $post['published_at'] ); + + // 4. Update post content + $stmt = $this->pdo->prepare( + "UPDATE posts + SET title = ?, body = ?, content_raw = ?, updated_at = ? + WHERE id = ?" + ); + + $newContent = json_encode([ + 'blocks' => [ + ['type' => 'header', 'data' => ['text' => 'Updated Title', 'level' => 1]], + ['type' => 'paragraph', 'data' => ['text' => 'Updated content with more details']] + ] + ]); + + $stmt->execute([ + 'My Updated Post', + 'Updated Title Updated content with more details', + $newContent, + date( 'Y-m-d H:i:s' ), + $postId + ]); + + // 5. Verify update + $stmt = $this->pdo->prepare( "SELECT * FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertEquals( 'My Updated Post', $post['title'] ); + $this->assertEquals( 'draft', $post['status'] ); // Still draft + + // 6. Publish the post + $publishedAt = new DateTimeImmutable(); + $stmt = $this->pdo->prepare( + "UPDATE posts + SET status = ?, published_at = ?, updated_at = ? + WHERE id = ?" + ); + + $stmt->execute([ + 'published', + $publishedAt->format( 'Y-m-d H:i:s' ), + date( 'Y-m-d H:i:s' ), + $postId + ]); + + // 7. Verify post is published + $stmt = $this->pdo->prepare( "SELECT * FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertEquals( 'published', $post['status'] ); + $this->assertNotNull( $post['published_at'] ); + + // 8. Delete the post + $stmt = $this->pdo->prepare( "DELETE FROM posts WHERE id = ?" ); + $result = $stmt->execute( [$postId] ); + + $this->assertTrue( $result ); + + // 9. Verify post is deleted + $stmt = $this->pdo->prepare( "SELECT * FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertFalse( $post ); + } + + /** + * Test post with categories relationship + */ + public function testPostWithCategories(): void + { + // Create user + $userId = $this->createTestUser([ + 'username' => 'categoryuser', + 'email' => 'catuser@example.com' + ]); + + // Create categories + $stmt = $this->pdo->prepare( + "INSERT INTO categories (name, slug, created_at, updated_at) VALUES (?, ?, ?, ?)" + ); + + $now = date( 'Y-m-d H:i:s' ); + $stmt->execute( ['Technology', 'technology', $now, $now] ); + $techCategoryId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['PHP', 'php', $now, $now] ); + $phpCategoryId = (int)$this->pdo->lastInsertId(); + + // Create post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'PHP Programming', + 'php-programming', + 'Learn PHP', + '{"blocks":[]}', + $userId, + 'draft', + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Attach categories to post + $stmt = $this->pdo->prepare( + "INSERT INTO post_categories (post_id, category_id, created_at) VALUES (?, ?, ?)" + ); + + $stmt->execute( [$postId, $techCategoryId, $now] ); + $stmt->execute( [$postId, $phpCategoryId, $now] ); + + // Verify relationships + $stmt = $this->pdo->prepare( + "SELECT c.* FROM categories c + INNER JOIN post_categories pc ON c.id = pc.category_id + WHERE pc.post_id = ? + ORDER BY c.name" + ); + + $stmt->execute( [$postId] ); + $categories = $stmt->fetchAll(); + + $this->assertCount( 2, $categories ); + $this->assertEquals( 'PHP', $categories[0]['name'] ); + $this->assertEquals( 'Technology', $categories[1]['name'] ); + } + + /** + * Test post with tags relationship + */ + public function testPostWithTags(): void + { + // Create user + $userId = $this->createTestUser([ + 'username' => 'taguser', + 'email' => 'taguser@example.com' + ]); + + // Create tags + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) VALUES (?, ?, ?, ?)" + ); + + $now = date( 'Y-m-d H:i:s' ); + $stmt->execute( ['tutorial', 'tutorial', $now, $now] ); + $tutorialTagId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['beginner', 'beginner', $now, $now] ); + $beginnerTagId = (int)$this->pdo->lastInsertId(); + + // Create post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Getting Started', + 'getting-started', + 'Tutorial for beginners', + '{"blocks":[]}', + $userId, + 'published', + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Attach tags to post + $stmt = $this->pdo->prepare( + "INSERT INTO post_tags (post_id, tag_id, created_at) VALUES (?, ?, ?)" + ); + + $stmt->execute( [$postId, $tutorialTagId, $now] ); + $stmt->execute( [$postId, $beginnerTagId, $now] ); + + // Verify relationships + $stmt = $this->pdo->prepare( + "SELECT t.* FROM tags t + INNER JOIN post_tags pt ON t.id = pt.tag_id + WHERE pt.post_id = ? + ORDER BY t.name" + ); + + $stmt->execute( [$postId] ); + $tags = $stmt->fetchAll(); + + $this->assertCount( 2, $tags ); + $this->assertEquals( 'beginner', $tags[0]['name'] ); + $this->assertEquals( 'tutorial', $tags[1]['name'] ); + } + + /** + * Test foreign key constraint - deleting user cascades to posts + */ + public function testUserDeletionCascadesToPosts(): void + { + // Create user + $userId = $this->createTestUser([ + 'username' => 'cascadeuser', + 'email' => 'cascade@example.com' + ]); + + // Create post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Test Post', + 'test-post-cascade', + 'Content', + '{"blocks":[]}', + $userId, + 'draft', + date( 'Y-m-d H:i:s' ) + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Verify post exists + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM posts WHERE author_id = ?" ); + $stmt->execute( [$userId] ); + $count = (int)$stmt->fetchColumn(); + $this->assertEquals( 1, $count ); + + // Delete user + $stmt = $this->pdo->prepare( "DELETE FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + + // Verify post was cascade deleted + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $count = (int)$stmt->fetchColumn(); + $this->assertEquals( 0, $count, 'Post should be cascade deleted when user is deleted' ); + } + + /** + * Test slug uniqueness constraint + */ + public function testSlugUniquenessConstraint(): void + { + $userId = $this->createTestUser([ + 'username' => 'sluguser', + 'email' => 'slug@example.com' + ]); + + // Create first post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'First Post', + 'unique-slug', + 'Content', + '{"blocks":[]}', + $userId, + 'draft', + date( 'Y-m-d H:i:s' ) + ]); + + // Try to create second post with same slug - should fail + $this->expectException( \PDOException::class ); + + $stmt->execute([ + 'Second Post', + 'unique-slug', // Duplicate slug + 'Content', + '{"blocks":[]}', + $userId, + 'draft', + date( 'Y-m-d H:i:s' ) + ]); + } + + /** + * Test transaction isolation - changes in one test don't affect another + */ + public function testTransactionIsolation(): void + { + // Create post in this test + $userId = $this->createTestUser([ + 'username' => 'isolationuser', + 'email' => 'isolation@example.com' + ]); + + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Isolation Test', + 'isolation-test', + 'Content', + '{"blocks":[]}', + $userId, + 'draft', + date( 'Y-m-d H:i:s' ) + ]); + + $postId = (int)$this->pdo->lastInsertId(); + $this->assertGreaterThan( 0, $postId ); + + // This will be rolled back after test completes + // Next test should not see this data + } +} diff --git a/tests/Integration/PostPublishingSchedulingTest.php b/tests/Integration/PostPublishingSchedulingTest.php new file mode 100644 index 0000000..4701d08 --- /dev/null +++ b/tests/Integration/PostPublishingSchedulingTest.php @@ -0,0 +1,536 @@ +createTestUser([ + 'username' => 'publisher', + 'email' => 'pub@example.com' + ]); + + // Create draft post + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Draft Article', + 'draft-article', + 'Content', + '{"blocks":[]}', + $userId, + 'draft', + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Verify it's draft without published date + $stmt = $this->pdo->prepare( "SELECT status, published_at FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertEquals( 'draft', $post['status'] ); + $this->assertNull( $post['published_at'] ); + + // Publish the post + $publishedAt = new DateTimeImmutable(); + $stmt = $this->pdo->prepare( + "UPDATE posts SET status = ?, published_at = ? WHERE id = ?" + ); + + $stmt->execute([ + 'published', + $publishedAt->format( 'Y-m-d H:i:s' ), + $postId + ]); + + // Verify published + $stmt = $this->pdo->prepare( "SELECT status, published_at FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertEquals( 'published', $post['status'] ); + $this->assertNotNull( $post['published_at'] ); + $this->assertGreaterThanOrEqual( $now, $post['published_at'] ); + } + + /** + * Test unpublishing a post (revert to draft) + */ + public function testUnpublishPost(): void + { + $userId = $this->createTestUser([ + 'username' => 'unpublisher', + 'email' => 'unpub@example.com' + ]); + + // Create published post + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, published_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Published Article', + 'published-article-unpub', + 'Content', + '{"blocks":[]}', + $userId, + 'published', + $now, + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Verify it's published + $stmt = $this->pdo->prepare( "SELECT status, published_at FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertEquals( 'published', $post['status'] ); + $this->assertNotNull( $post['published_at'] ); + + // Unpublish (revert to draft) + $stmt = $this->pdo->prepare( + "UPDATE posts SET status = ?, published_at = NULL WHERE id = ?" + ); + + $stmt->execute( ['draft', $postId] ); + + // Verify unpublished + $stmt = $this->pdo->prepare( "SELECT status, published_at FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertEquals( 'draft', $post['status'] ); + $this->assertNull( $post['published_at'] ); + } + + /** + * Test scheduling a post for future publication + */ + public function testSchedulePostForFuturePublication(): void + { + $userId = $this->createTestUser([ + 'username' => 'scheduler', + 'email' => 'sched@example.com' + ]); + + // Create draft post + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Future Article', + 'future-article', + 'Content', + '{"blocks":[]}', + $userId, + 'draft', + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Schedule for 1 hour in the future + $scheduledTime = (new DateTimeImmutable())->modify( '+1 hour' ); + + $stmt = $this->pdo->prepare( + "UPDATE posts SET status = ?, published_at = ? WHERE id = ?" + ); + + $stmt->execute([ + 'scheduled', + $scheduledTime->format( 'Y-m-d H:i:s' ), + $postId + ]); + + // Verify scheduled + $stmt = $this->pdo->prepare( "SELECT status, published_at FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $post = $stmt->fetch(); + + $this->assertEquals( 'scheduled', $post['status'] ); + $this->assertNotNull( $post['published_at'] ); + $this->assertGreaterThan( date( 'Y-m-d H:i:s' ), $post['published_at'] ); + } + + /** + * Test finding scheduled posts that should be published + */ + public function testFindScheduledPostsReadyToPublish(): void + { + $userId = $this->createTestUser([ + 'username' => 'autopub', + 'email' => 'autopub@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, published_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + // Create scheduled post in the past (should be published) + $pastTime = (new DateTimeImmutable())->modify( '-1 hour' ); + $stmt->execute([ + 'Past Scheduled', + 'past-scheduled', + 'Content', + '{"blocks":[]}', + $userId, + 'scheduled', + $pastTime->format( 'Y-m-d H:i:s' ), + $now + ]); + + $pastPostId = (int)$this->pdo->lastInsertId(); + + // Create scheduled post in the future (should NOT be published yet) + $futureTime = (new DateTimeImmutable())->modify( '+1 hour' ); + $stmt->execute([ + 'Future Scheduled', + 'future-scheduled', + 'Content', + '{"blocks":[]}', + $userId, + 'scheduled', + $futureTime->format( 'Y-m-d H:i:s' ), + $now + ]); + + $futurePostId = (int)$this->pdo->lastInsertId(); + + // Find scheduled posts ready to publish + $stmt = $this->pdo->prepare( + "SELECT id, title FROM posts + WHERE status = ? AND published_at <= ? + ORDER BY published_at" + ); + + $stmt->execute( ['scheduled', date( 'Y-m-d H:i:s' )] ); + $readyPosts = $stmt->fetchAll(); + + $this->assertCount( 1, $readyPosts ); + $this->assertEquals( $pastPostId, $readyPosts[0]['id'] ); + $this->assertEquals( 'Past Scheduled', $readyPosts[0]['title'] ); + + // Auto-publish the ready post + $stmt = $this->pdo->prepare( + "UPDATE posts SET status = ? WHERE id = ?" + ); + + $stmt->execute( ['published', $pastPostId] ); + + // Verify it's now published + $stmt = $this->pdo->prepare( "SELECT status FROM posts WHERE id = ?" ); + $stmt->execute( [$pastPostId] ); + $status = $stmt->fetchColumn(); + + $this->assertEquals( 'published', $status ); + + // Verify future post is still scheduled + $stmt->execute( [$futurePostId] ); + $status = $stmt->fetchColumn(); + + $this->assertEquals( 'scheduled', $status ); + } + + /** + * Test view count tracking + */ + public function testPostViewCountIncrement(): void + { + $userId = $this->createTestUser([ + 'username' => 'viewtracker', + 'email' => 'viewtrack@example.com' + ]); + + // Create published post with initial view count + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, view_count, published_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Popular Post', + 'popular-post-views', + 'Content', + '{"blocks":[]}', + $userId, + 'published', + 0, + $now, + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Simulate 10 page views + for( $i = 1; $i <= 10; $i++ ) + { + $stmt = $this->pdo->prepare( + "UPDATE posts SET view_count = view_count + 1 WHERE id = ?" + ); + $stmt->execute( [$postId] ); + } + + // Verify view count + $stmt = $this->pdo->prepare( "SELECT view_count FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $viewCount = (int)$stmt->fetchColumn(); + + $this->assertEquals( 10, $viewCount ); + } + + /** + * Test querying posts by status + */ + public function testQueryPostsByStatus(): void + { + $userId = $this->createTestUser([ + 'username' => 'statusquery', + 'email' => 'statusq@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, published_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + // Create posts with different statuses + $statuses = [ + 'draft' => 3, + 'published' => 5, + 'scheduled' => 2 + ]; + + foreach( $statuses as $status => $count ) + { + for( $i = 1; $i <= $count; $i++ ) + { + $publishedAt = in_array( $status, ['published', 'scheduled'] ) ? $now : null; + + $stmt->execute([ + "{$status} Post {$i}", + "{$status}-post-{$i}", + 'Content', + '{"blocks":[]}', + $userId, + $status, + $publishedAt, + $now + ]); + } + } + + // Query each status + foreach( $statuses as $status => $expectedCount ) + { + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM posts WHERE author_id = ? AND status = ?" + ); + $stmt->execute( [$userId, $status] ); + $count = (int)$stmt->fetchColumn(); + + $this->assertEquals( $expectedCount, $count, "Should have {$expectedCount} {$status} posts" ); + } + } + + /** + * Test most viewed posts query + */ + public function testMostViewedPostsQuery(): void + { + $userId = $this->createTestUser([ + 'username' => 'viewranking', + 'email' => 'viewrank@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, view_count, published_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + // Create posts with different view counts + $viewCounts = [100, 500, 250, 750, 50]; + + foreach( $viewCounts as $index => $views ) + { + $stmt->execute([ + "Post with {$views} views", + "post-views-{$index}", + 'Content', + '{"blocks":[]}', + $userId, + 'published', + $views, + $now, + $now + ]); + } + + // Query top 3 most viewed posts + $stmt = $this->pdo->prepare( + "SELECT title, view_count FROM posts + WHERE author_id = ? AND status = ? + ORDER BY view_count DESC + LIMIT 3" + ); + + $stmt->execute( [$userId, 'published'] ); + $topPosts = $stmt->fetchAll(); + + $this->assertCount( 3, $topPosts ); + $this->assertEquals( 750, $topPosts[0]['view_count'] ); + $this->assertEquals( 500, $topPosts[1]['view_count'] ); + $this->assertEquals( 250, $topPosts[2]['view_count'] ); + } + + /** + * Test recently published posts query + */ + public function testRecentlyPublishedPostsQuery(): void + { + $userId = $this->createTestUser([ + 'username' => 'recency', + 'email' => 'recent@example.com' + ]); + + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, published_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + // Create posts published at different times + $now = new DateTimeImmutable(); + + for( $i = 5; $i >= 1; $i-- ) + { + $publishedAt = $now->modify( "-{$i} days" ); + + $stmt->execute([ + "Post from {$i} days ago", + "post-day-{$i}", + 'Content', + '{"blocks":[]}', + $userId, + 'published', + $publishedAt->format( 'Y-m-d H:i:s' ), + $publishedAt->format( 'Y-m-d H:i:s' ) + ]); + } + + // Query posts published in last 3 days + $threeDaysAgo = $now->modify( '-3 days' ); + + $stmt = $this->pdo->prepare( + "SELECT title FROM posts + WHERE author_id = ? AND status = ? AND published_at >= ? + ORDER BY published_at DESC" + ); + + $stmt->execute([ + $userId, + 'published', + $threeDaysAgo->format( 'Y-m-d H:i:s' ) + ]); + + $recentPosts = $stmt->fetchAll(); + + $this->assertCount( 3, $recentPosts ); + $this->assertEquals( 'Post from 1 days ago', $recentPosts[0]['title'] ); + $this->assertEquals( 'Post from 2 days ago', $recentPosts[1]['title'] ); + $this->assertEquals( 'Post from 3 days ago', $recentPosts[2]['title'] ); + } + + /** + * Test published date preservation on updates + */ + public function testPublishedDatePreservedOnUpdate(): void + { + $userId = $this->createTestUser([ + 'username' => 'datekeeper', + 'email' => 'datekeeper@example.com' + ]); + + // Create published post + $originalPublishDate = (new DateTimeImmutable())->modify( '-7 days' ); + $now = date( 'Y-m-d H:i:s' ); + + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, published_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute([ + 'Original Title', + 'date-preservation', + 'Original content', + '{"blocks":[]}', + $userId, + 'published', + $originalPublishDate->format( 'Y-m-d H:i:s' ), + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Update post content (but keep published status) + $stmt = $this->pdo->prepare( + "UPDATE posts SET title = ?, body = ?, updated_at = ? WHERE id = ?" + ); + + $stmt->execute([ + 'Updated Title', + 'Updated content', + date( 'Y-m-d H:i:s' ), + $postId + ]); + + // Verify published_at was NOT changed + $stmt = $this->pdo->prepare( "SELECT published_at FROM posts WHERE id = ?" ); + $stmt->execute( [$postId] ); + $publishedAt = $stmt->fetchColumn(); + + $this->assertEquals( + $originalPublishDate->format( 'Y-m-d H:i:s' ), + $publishedAt, + 'Published date should be preserved on update' + ); + } +} diff --git a/tests/Integration/TagManagementTest.php b/tests/Integration/TagManagementTest.php new file mode 100644 index 0000000..3501a75 --- /dev/null +++ b/tests/Integration/TagManagementTest.php @@ -0,0 +1,529 @@ +pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $tagNames = ['php', 'javascript', 'python', 'ruby', 'go']; + + foreach( $tagNames as $name ) + { + $stmt->execute( [$name, $name, $now, $now] ); + } + + // Verify all tags created + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM tags" ); + $stmt->execute(); + $count = (int)$stmt->fetchColumn(); + + $this->assertEquals( 5, $count ); + + // Verify names and slugs + $stmt = $this->pdo->prepare( "SELECT name, slug FROM tags ORDER BY name" ); + $stmt->execute(); + $tags = $stmt->fetchAll(); + + $this->assertEquals( 'go', $tags[0]['name'] ); + $this->assertEquals( 'go', $tags[0]['slug'] ); + } + + /** + * Test tag slug auto-generation from multi-word names + */ + public function testTagSlugGeneration(): void + { + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $tags = [ + ['Machine Learning', 'machine-learning'], + ['Web Development', 'web-development'], + ['API Design', 'api-design'], + ['Code Review', 'code-review'] + ]; + + foreach( $tags as [$name, $slug] ) + { + $stmt->execute( [$name, $slug, $now, $now] ); + } + + // Verify slugs are correct + $stmt = $this->pdo->prepare( "SELECT name, slug FROM tags WHERE name = ?" ); + $stmt->execute( ['Machine Learning'] ); + $tag = $stmt->fetch(); + + $this->assertEquals( 'machine-learning', $tag['slug'] ); + } + + /** + * Test tag resolution - find existing tag + */ + public function testFindExistingTag(): void + { + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['laravel', 'laravel', $now, $now] ); + $tagId = (int)$this->pdo->lastInsertId(); + + // Find tag by name + $stmt = $this->pdo->prepare( "SELECT * FROM tags WHERE name = ?" ); + $stmt->execute( ['laravel'] ); + $tag = $stmt->fetch(); + + $this->assertNotFalse( $tag ); + $this->assertEquals( $tagId, $tag['id'] ); + $this->assertEquals( 'laravel', $tag['name'] ); + } + + /** + * Test tag resolution - create if not exists + */ + public function testCreateTagIfNotExists(): void + { + // Try to find non-existent tag + $stmt = $this->pdo->prepare( "SELECT * FROM tags WHERE name = ?" ); + $stmt->execute( ['symfony'] ); + $tag = $stmt->fetch(); + + $this->assertFalse( $tag ); + + // Create the tag + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['symfony', 'symfony', $now, $now] ); + $tagId = (int)$this->pdo->lastInsertId(); + + // Verify it now exists + $stmt = $this->pdo->prepare( "SELECT * FROM tags WHERE name = ?" ); + $stmt->execute( ['symfony'] ); + $tag = $stmt->fetch(); + + $this->assertNotFalse( $tag ); + $this->assertEquals( $tagId, $tag['id'] ); + } + + /** + * Test parsing comma-separated tag string + */ + public function testParseCommaSeparatedTags(): void + { + $tagString = 'php, javascript, python, ruby'; + $tagNames = array_map( 'trim', explode( ',', $tagString ) ); + + $this->assertCount( 4, $tagNames ); + $this->assertEquals( 'php', $tagNames[0] ); + $this->assertEquals( 'javascript', $tagNames[1] ); + $this->assertEquals( 'python', $tagNames[2] ); + $this->assertEquals( 'ruby', $tagNames[3] ); + + // Create tags from parsed names + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + foreach( $tagNames as $name ) + { + $stmt->execute( [$name, $name, $now, $now] ); + } + + // Verify all created + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM tags" ); + $stmt->execute(); + $count = (int)$stmt->fetchColumn(); + + $this->assertEquals( 4, $count ); + } + + /** + * Test tag case normalization + */ + public function testTagCaseNormalization(): void + { + $now = date( 'Y-m-d H:i:s' ); + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + // Create tag with lowercase + $stmt->execute( ['docker', 'docker', $now, $now] ); + + // Search case-insensitively (application layer would handle this) + $stmt = $this->pdo->prepare( "SELECT * FROM tags WHERE LOWER(name) = LOWER(?)" ); + $stmt->execute( ['DOCKER'] ); + $tag = $stmt->fetch(); + + $this->assertNotFalse( $tag ); + $this->assertEquals( 'docker', $tag['name'] ); + } + + /** + * Test finding unused tags + */ + public function testFindUnusedTags(): void + { + $userId = $this->createTestUser([ + 'username' => 'tagger', + 'email' => 'tagger@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + + // Create tags + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['used-tag', 'used-tag', $now, $now] ); + $usedTagId = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['unused-tag-1', 'unused-tag-1', $now, $now] ); + $unusedTagId1 = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['unused-tag-2', 'unused-tag-2', $now, $now] ); + $unusedTagId2 = (int)$this->pdo->lastInsertId(); + + // Create post with one tag + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute( ['Tagged Post', 'tagged-post', 'Content', '{"blocks":[]}', $userId, 'draft', $now] ); + $postId = (int)$this->pdo->lastInsertId(); + + // Attach used tag + $stmt = $this->pdo->prepare( + "INSERT INTO post_tags (post_id, tag_id, created_at) + VALUES (?, ?, ?)" + ); + + $stmt->execute( [$postId, $usedTagId, $now] ); + + // Find unused tags + $stmt = $this->pdo->prepare( + "SELECT t.* FROM tags t + LEFT JOIN post_tags pt ON t.id = pt.tag_id + WHERE pt.tag_id IS NULL + ORDER BY t.name" + ); + + $stmt->execute(); + $unusedTags = $stmt->fetchAll(); + + $this->assertCount( 2, $unusedTags ); + $this->assertEquals( 'unused-tag-1', $unusedTags[0]['name'] ); + $this->assertEquals( 'unused-tag-2', $unusedTags[1]['name'] ); + } + + /** + * Test tag usage count + */ + public function testTagUsageCount(): void + { + $userId = $this->createTestUser([ + 'username' => 'usageuser', + 'email' => 'usage@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + + // Create tag + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['popular', 'popular', $now, $now] ); + $tagId = (int)$this->pdo->lastInsertId(); + + // Create multiple posts + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $postIds = []; + for( $i = 1; $i <= 5; $i++ ) + { + $stmt->execute([ + "Post {$i}", + "post-usage-{$i}", + 'Content', + '{"blocks":[]}', + $userId, + 'published', + $now + ]); + $postIds[] = (int)$this->pdo->lastInsertId(); + } + + // Attach tag to all posts + $stmt = $this->pdo->prepare( + "INSERT INTO post_tags (post_id, tag_id, created_at) + VALUES (?, ?, ?)" + ); + + foreach( $postIds as $postId ) + { + $stmt->execute( [$postId, $tagId, $now] ); + } + + // Count tag usage + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM post_tags WHERE tag_id = ?" + ); + + $stmt->execute( [$tagId] ); + $usageCount = (int)$stmt->fetchColumn(); + + $this->assertEquals( 5, $usageCount ); + } + + /** + * Test most popular tags query + */ + public function testMostPopularTagsQuery(): void + { + $userId = $this->createTestUser([ + 'username' => 'popularuser', + 'email' => 'popular@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + + // Create tags with different usage counts + $tagData = [ + ['tag-1', 10], + ['tag-2', 5], + ['tag-3', 15], + ['tag-4', 2] + ]; + + foreach( $tagData as [$tagName, $usageCount] ) + { + // Create tag + $tagStmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $tagStmt->execute( [$tagName, $tagName, $now, $now] ); + $tagId = (int)$this->pdo->lastInsertId(); + + // Create posts and attach tag + $postStmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $pivotStmt = $this->pdo->prepare( + "INSERT INTO post_tags (post_id, tag_id, created_at) + VALUES (?, ?, ?)" + ); + + for( $i = 0; $i < $usageCount; $i++ ) + { + $postStmt->execute([ + "{$tagName} post {$i}", + "{$tagName}-post-{$i}", + 'Content', + '{"blocks":[]}', + $userId, + 'published', + $now + ]); + + $postId = (int)$this->pdo->lastInsertId(); + + // Attach tag + $pivotStmt->execute( [$postId, $tagId, $now] ); + } + } + + // Query top 3 most popular tags + $stmt = $this->pdo->prepare( + "SELECT t.name, COUNT(pt.post_id) as post_count + FROM tags t + LEFT JOIN post_tags pt ON t.id = pt.tag_id + GROUP BY t.id, t.name + ORDER BY post_count DESC + LIMIT 3" + ); + + $stmt->execute(); + $popularTags = $stmt->fetchAll(); + + $this->assertCount( 3, $popularTags ); + $this->assertEquals( 'tag-3', $popularTags[0]['name'] ); + $this->assertEquals( 15, $popularTags[0]['post_count'] ); + $this->assertEquals( 'tag-1', $popularTags[1]['name'] ); + $this->assertEquals( 10, $popularTags[1]['post_count'] ); + $this->assertEquals( 'tag-2', $popularTags[2]['name'] ); + $this->assertEquals( 5, $popularTags[2]['post_count'] ); + } + + /** + * Test deleting unused tags + */ + public function testDeleteUnusedTags(): void + { + $now = date( 'Y-m-d H:i:s' ); + + // Create unused tags + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['orphan-1', 'orphan-1', $now, $now] ); + $orphan1Id = (int)$this->pdo->lastInsertId(); + + $stmt->execute( ['orphan-2', 'orphan-2', $now, $now] ); + $orphan2Id = (int)$this->pdo->lastInsertId(); + + // Delete unused tags + $stmt = $this->pdo->prepare( + "DELETE FROM tags + WHERE id NOT IN (SELECT DISTINCT tag_id FROM post_tags)" + ); + + $stmt->execute(); + $deletedCount = $stmt->rowCount(); + + $this->assertEquals( 2, $deletedCount ); + + // Verify they're deleted + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM tags WHERE id IN (?, ?)" ); + $stmt->execute( [$orphan1Id, $orphan2Id] ); + $count = (int)$stmt->fetchColumn(); + + $this->assertEquals( 0, $count ); + } + + /** + * Test tag deletion cascades to post_tags pivot + */ + public function testTagDeletionCascadesToPivot(): void + { + $userId = $this->createTestUser([ + 'username' => 'cascadetag', + 'email' => 'cascadetag@example.com' + ]); + + $now = date( 'Y-m-d H:i:s' ); + + // Create tag + $stmt = $this->pdo->prepare( + "INSERT INTO tags (name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute( ['temp-tag', 'temp-tag', $now, $now] ); + $tagId = (int)$this->pdo->lastInsertId(); + + // Create post + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, body, content_raw, author_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + $stmt->execute( ['Post', 'post-cascade', 'Content', '{"blocks":[]}', $userId, 'draft', $now] ); + $postId = (int)$this->pdo->lastInsertId(); + + // Attach tag + $stmt = $this->pdo->prepare( + "INSERT INTO post_tags (post_id, tag_id, created_at) + VALUES (?, ?, ?)" + ); + + $stmt->execute( [$postId, $tagId, $now] ); + + // Verify pivot exists + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM post_tags WHERE tag_id = ?" + ); + $stmt->execute( [$tagId] ); + $this->assertEquals( 1, (int)$stmt->fetchColumn() ); + + // Delete tag + $stmt = $this->pdo->prepare( "DELETE FROM tags WHERE id = ?" ); + $stmt->execute( [$tagId] ); + + // Verify pivot entry was also deleted + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM post_tags WHERE tag_id = ?" + ); + $stmt->execute( [$tagId] ); + $this->assertEquals( 0, (int)$stmt->fetchColumn(), 'Tag deletion should cascade to pivot table' ); + } + + /** + * Test handling whitespace in tag names + */ + public function testTagWhitespaceHandling(): void + { + $tagString = ' php , javascript , python, ruby '; + $tagNames = array_map( 'trim', explode( ',', $tagString ) ); + + // Remove empty tags + $tagNames = array_filter( $tagNames ); + + $this->assertCount( 4, $tagNames ); + $this->assertEquals( 'php', $tagNames[0] ); + $this->assertEquals( 'javascript', $tagNames[1] ); + } + + /** + * Test empty tag string handling + */ + public function testEmptyTagStringHandling(): void + { + $tagString = ''; + $tagNames = array_map( 'trim', explode( ',', $tagString ) ); + $tagNames = array_filter( $tagNames ); + + $this->assertEmpty( $tagNames ); + } +} diff --git a/tests/Integration/UserAuthenticationFlowTest.php b/tests/Integration/UserAuthenticationFlowTest.php new file mode 100644 index 0000000..8562792 --- /dev/null +++ b/tests/Integration/UserAuthenticationFlowTest.php @@ -0,0 +1,422 @@ +pdo->prepare( + "INSERT INTO users (username, email, password_hash, role, status, email_verified, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + + $now = date( 'Y-m-d H:i:s' ); + $stmt->execute([ + 'newuser', + 'newuser@example.com', + $passwordHash, + 'subscriber', + 'active', + 0, // Not verified yet + $now, + $now + ]); + + $userId = (int)$this->pdo->lastInsertId(); + $this->assertGreaterThan( 0, $userId ); + + // 2. Verify user was created with correct data + $stmt = $this->pdo->prepare( "SELECT * FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + $user = $stmt->fetch(); + + $this->assertEquals( 'newuser', $user['username'] ); + $this->assertEquals( 'newuser@example.com', $user['email'] ); + $this->assertEquals( 'subscriber', $user['role'] ); + $this->assertEquals( 'active', $user['status'] ); + $this->assertEquals( 0, $user['email_verified'] ); + + // 3. Verify password hash works + $this->assertTrue( password_verify( 'SecurePassword123!', $user['password_hash'] ) ); + $this->assertFalse( password_verify( 'WrongPassword', $user['password_hash'] ) ); + } + + /** + * Test email verification token flow + */ + public function testEmailVerificationTokenFlow(): void + { + // 1. Create unverified user + $userId = $this->createTestUser([ + 'username' => 'unverified', + 'email' => 'unverified@example.com', + 'email_verified' => 0 + ]); + + // 2. Generate verification token + $plainToken = bin2hex( random_bytes( 32 ) ); + $hashedToken = hash( 'sha256', $plainToken ); + $expiresAt = (new DateTimeImmutable())->modify( '+60 minutes' ); + + $stmt = $this->pdo->prepare( + "INSERT INTO email_verification_tokens (user_id, token, created_at, expires_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute([ + $userId, + $hashedToken, + date( 'Y-m-d H:i:s' ), + $expiresAt->format( 'Y-m-d H:i:s' ) + ]); + + $tokenId = (int)$this->pdo->lastInsertId(); + $this->assertGreaterThan( 0, $tokenId ); + + // 3. Verify token exists + $stmt = $this->pdo->prepare( + "SELECT * FROM email_verification_tokens WHERE user_id = ? AND token = ?" + ); + $stmt->execute( [$userId, $hashedToken] ); + $token = $stmt->fetch(); + + $this->assertNotFalse( $token ); + $this->assertEquals( $userId, $token['user_id'] ); + $this->assertEquals( $hashedToken, $token['token'] ); + + // 4. Verify the user's email + $stmt = $this->pdo->prepare( "UPDATE users SET email_verified = 1 WHERE id = ?" ); + $stmt->execute( [$userId] ); + + // 5. Delete used token + $stmt = $this->pdo->prepare( "DELETE FROM email_verification_tokens WHERE id = ?" ); + $stmt->execute( [$tokenId] ); + + // 6. Verify user is now verified + $stmt = $this->pdo->prepare( "SELECT email_verified FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + $verified = (int)$stmt->fetchColumn(); + + $this->assertEquals( 1, $verified ); + + // 7. Verify token was deleted + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM email_verification_tokens WHERE id = ?" ); + $stmt->execute( [$tokenId] ); + $count = (int)$stmt->fetchColumn(); + + $this->assertEquals( 0, $count ); + } + + /** + * Test password reset token flow + */ + public function testPasswordResetTokenFlow(): void + { + // 1. Create user + $userId = $this->createTestUser([ + 'username' => 'resetuser', + 'email' => 'reset@example.com' + ]); + + // 2. Request password reset - generate token + $plainToken = bin2hex( random_bytes( 32 ) ); + $hashedToken = hash( 'sha256', $plainToken ); + $expiresAt = (new DateTimeImmutable())->modify( '+60 minutes' ); + + $stmt = $this->pdo->prepare( + "INSERT INTO password_reset_tokens (email, token, created_at, expires_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute([ + 'reset@example.com', + $hashedToken, + date( 'Y-m-d H:i:s' ), + $expiresAt->format( 'Y-m-d H:i:s' ) + ]); + + $tokenId = (int)$this->pdo->lastInsertId(); + + // 3. Validate token exists + $stmt = $this->pdo->prepare( + "SELECT * FROM password_reset_tokens WHERE email = ? AND token = ?" + ); + $stmt->execute( ['reset@example.com', $hashedToken] ); + $token = $stmt->fetch(); + + $this->assertNotFalse( $token ); + $this->assertEquals( 'reset@example.com', $token['email'] ); + + // 4. Reset password + $newPasswordHash = password_hash( 'NewSecurePassword456!', PASSWORD_DEFAULT ); + + $stmt = $this->pdo->prepare( + "UPDATE users SET password_hash = ?, updated_at = ? WHERE email = ?" + ); + $stmt->execute([ + $newPasswordHash, + date( 'Y-m-d H:i:s' ), + 'reset@example.com' + ]); + + // 5. Delete used token + $stmt = $this->pdo->prepare( "DELETE FROM password_reset_tokens WHERE id = ?" ); + $stmt->execute( [$tokenId] ); + + // 6. Verify password was changed + $stmt = $this->pdo->prepare( "SELECT password_hash FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + $updatedHash = $stmt->fetchColumn(); + + $this->assertTrue( password_verify( 'NewSecurePassword456!', $updatedHash ) ); + $this->assertFalse( password_verify( 'password', $updatedHash ) ); // Old password doesn't work + + // 7. Verify token was deleted + $stmt = $this->pdo->prepare( "SELECT COUNT(*) FROM password_reset_tokens WHERE id = ?" ); + $stmt->execute( [$tokenId] ); + $count = (int)$stmt->fetchColumn(); + + $this->assertEquals( 0, $count ); + } + + /** + * Test expired token cleanup + */ + public function testExpiredTokenCleanup(): void + { + $userId = $this->createTestUser([ + 'username' => 'tokenuser', + 'email' => 'token@example.com' + ]); + + // Create expired email verification token + $expiredTime = (new DateTimeImmutable())->modify( '-1 hour' ); + + $stmt = $this->pdo->prepare( + "INSERT INTO email_verification_tokens (user_id, token, created_at, expires_at) + VALUES (?, ?, ?, ?)" + ); + + $stmt->execute([ + $userId, + hash( 'sha256', 'expired_token' ), + $expiredTime->modify( '-60 minutes' )->format( 'Y-m-d H:i:s' ), + $expiredTime->format( 'Y-m-d H:i:s' ) + ]); + + // Create valid token + $validTime = (new DateTimeImmutable())->modify( '+1 hour' ); + + $stmt->execute([ + $userId, + hash( 'sha256', 'valid_token' ), + date( 'Y-m-d H:i:s' ), + $validTime->format( 'Y-m-d H:i:s' ) + ]); + + // Clean up expired tokens + $stmt = $this->pdo->prepare( + "DELETE FROM email_verification_tokens WHERE expires_at < ?" + ); + $stmt->execute( [date( 'Y-m-d H:i:s' )] ); + + // Verify only valid token remains + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM email_verification_tokens WHERE user_id = ?" + ); + $stmt->execute( [$userId] ); + $count = (int)$stmt->fetchColumn(); + + $this->assertEquals( 1, $count, 'Only valid token should remain after cleanup' ); + } + + /** + * Test user login attempt tracking + */ + public function testLoginAttemptTracking(): void + { + $userId = $this->createTestUser([ + 'username' => 'loginuser', + 'email' => 'login@example.com', + 'failed_login_attempts' => 0 + ]); + + // Simulate 3 failed login attempts + for( $i = 1; $i <= 3; $i++ ) + { + $stmt = $this->pdo->prepare( + "UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE id = ?" + ); + $stmt->execute( [$userId] ); + + // Verify count incremented + $stmt = $this->pdo->prepare( "SELECT failed_login_attempts FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + $attempts = (int)$stmt->fetchColumn(); + + $this->assertEquals( $i, $attempts ); + } + + // Lock account after 3 failed attempts + $lockedUntil = (new DateTimeImmutable())->modify( '+30 minutes' ); + + $stmt = $this->pdo->prepare( + "UPDATE users SET locked_until = ? WHERE id = ?" + ); + $stmt->execute([ + $lockedUntil->format( 'Y-m-d H:i:s' ), + $userId + ]); + + // Verify account is locked + $stmt = $this->pdo->prepare( "SELECT locked_until FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + $locked = $stmt->fetchColumn(); + + $this->assertNotNull( $locked ); + $this->assertGreaterThan( date( 'Y-m-d H:i:s' ), $locked ); + + // Simulate successful login - reset attempts + $stmt = $this->pdo->prepare( + "UPDATE users SET + failed_login_attempts = 0, + locked_until = NULL, + last_login_at = ? + WHERE id = ?" + ); + $stmt->execute([ + date( 'Y-m-d H:i:s' ), + $userId + ]); + + // Verify reset + $stmt = $this->pdo->prepare( + "SELECT failed_login_attempts, locked_until FROM users WHERE id = ?" + ); + $stmt->execute( [$userId] ); + $user = $stmt->fetch(); + + $this->assertEquals( 0, $user['failed_login_attempts'] ); + $this->assertNull( $user['locked_until'] ); + } + + /** + * Test username and email uniqueness constraints + */ + public function testUsernameEmailUniqueness(): void + { + // Create first user + $this->createTestUser([ + 'username' => 'unique_user', + 'email' => 'unique@example.com' + ]); + + // Try to create user with duplicate username + $this->expectException( \PDOException::class ); + + $stmt = $this->pdo->prepare( + "INSERT INTO users (username, email, password_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)" + ); + + $now = date( 'Y-m-d H:i:s' ); + $stmt->execute([ + 'unique_user', // Duplicate username + 'different@example.com', + password_hash( 'password', PASSWORD_DEFAULT ), + $now, + $now + ]); + } + + /** + * Test email uniqueness constraint + */ + public function testEmailUniquenessConstraint(): void + { + // Create first user + $this->createTestUser([ + 'username' => 'user1', + 'email' => 'same@example.com' + ]); + + // Try to create user with duplicate email + $this->expectException( \PDOException::class ); + + $stmt = $this->pdo->prepare( + "INSERT INTO users (username, email, password_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)" + ); + + $now = date( 'Y-m-d H:i:s' ); + $stmt->execute([ + 'user2', // Different username + 'same@example.com', // Duplicate email + password_hash( 'password', PASSWORD_DEFAULT ), + $now, + $now + ]); + } + + /** + * Test user roles and status + */ + public function testUserRolesAndStatus(): void + { + $roles = ['subscriber', 'contributor', 'author', 'editor', 'administrator']; + $statuses = ['active', 'inactive', 'suspended']; + + foreach( $roles as $role ) + { + $userId = $this->createTestUser([ + 'username' => "user_role_{$role}", + 'email' => "{$role}@example.com", + 'role' => $role + ]); + + $stmt = $this->pdo->prepare( "SELECT role FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + $savedRole = $stmt->fetchColumn(); + + $this->assertEquals( $role, $savedRole ); + } + + foreach( $statuses as $status ) + { + $userId = $this->createTestUser([ + 'username' => "user_status_{$status}", + 'email' => "{$status}@example.com", + 'status' => $status + ]); + + $stmt = $this->pdo->prepare( "SELECT status FROM users WHERE id = ?" ); + $stmt->execute( [$userId] ); + $savedStatus = $stmt->fetchColumn(); + + $this->assertEquals( $status, $savedStatus ); + } + } +} diff --git a/tests/TESTING.md b/tests/TESTING.md new file mode 100644 index 0000000..552ee53 --- /dev/null +++ b/tests/TESTING.md @@ -0,0 +1,300 @@ +# Testing Guide + +This document explains the test structure and how to run different types of tests. + +## Test Directory Structure + +``` +tests/ +├── Unit/ # Unit tests (fast, isolated, use mocks) +│ ├── Cms/ # CMS component unit tests (546 tests) +│ └── BootstrapTest.php # Bootstrap unit test +├── Integration/ # Integration tests (slower, use real infrastructure) +│ ├── IntegrationTestCase.php # Base class for integration tests +│ ├── PostPublishingFlowTest.php # Post workflow (6 tests) +│ ├── PostPublishingSchedulingTest.php # Publishing/scheduling (9 tests) +│ ├── UserAuthenticationFlowTest.php # User auth (8 tests) +│ ├── CategoryTagRelationshipTest.php # Category/tag relations (10 tests) +│ ├── PageManagementFlowTest.php # Page management (9 tests) +│ └── TagManagementTest.php # Tag operations (13 tests) +├── resources/ # Test resources (views, fixtures, etc.) +├── bootstrap.php # PHPUnit bootstrap file +└── phpunit.xml # PHPUnit configuration +``` + +## Test Types + +### Unit Tests +- **Location**: `tests/Unit/` +- **Purpose**: Fast, isolated tests that use mocks and stubs +- **Database**: In-memory SQLite or mocks +- **Speed**: Very fast (< 1 second for most tests) +- **When to use**: Testing individual classes, methods, and logic + +### Integration Tests +- **Location**: `tests/Integration/` +- **Purpose**: Test real infrastructure and component interactions +- **Database**: Real database (SQLite file, MySQL, or PostgreSQL) +- **Migrations**: Runs actual Phinx migrations from `resources/database/migrate/` +- **Speed**: Slower (1-2 seconds per test) +- **When to use**: Testing database operations, workflows, constraints + +**Current Integration Test Coverage (55 tests, 195 assertions):** + +1. **Post Publishing Flow** (6 tests) + - Complete post creation → update → publish → delete workflow + - Posts with categories (many-to-many) + - Posts with tags (many-to-many) + - Foreign key cascade deletes (user → posts) + - Slug uniqueness constraints + - Transaction isolation between tests + +2. **Post Publishing & Scheduling** (9 tests) + - Publishing draft posts (draft → published) + - Unpublishing posts (published → draft) + - Scheduling posts for future publication + - Auto-publishing scheduled posts when date arrives + - View count tracking and increment + - Querying posts by status (draft, published, scheduled) + - Most viewed posts ranking + - Recently published posts queries + - Published date preservation on updates + +3. **User Authentication Flow** (8 tests) + - User registration with password hashing + - Email verification token generation and validation + - Password reset token workflow + - Expired token cleanup + - Failed login attempt tracking and account locking + - Username uniqueness constraint + - Email uniqueness constraint + - User roles and status management + +4. **Category & Tag Relationships** (10 tests) + - Category creation and retrieval + - Category slug uniqueness + - Attaching multiple categories to posts + - Querying posts by category + - Tag creation and retrieval + - Tag slug uniqueness + - Attaching multiple tags to posts + - Querying posts by tag + - Category deletion cascade to pivot table + - Posts with both categories and tags + +5. **Page Management Flow** (9 tests) + - Page creation with templates and metadata + - Page updates and content changes + - Page publishing workflow (draft → published → draft) + - Page slug uniqueness constraints + - Multiple page templates (default, full-width, sidebar, landing) + - View count tracking + - SEO metadata (meta_title, meta_description, meta_keywords) + - User deletion cascades to pages + - Querying published pages + +6. **Tag Management** (13 tests) + - Creating tags from tag names + - Auto-generating slugs from multi-word names + - Finding existing tags (find-or-create pattern) + - Parsing comma-separated tag strings + - Tag case normalization + - Finding unused/orphaned tags + - Tag usage count tracking + - Most popular tags ranking + - Deleting unused tags + - Tag deletion cascades to pivot table + - Whitespace handling in tag names + - Empty tag string handling + +## Running Tests + +### Run All Tests +```bash +vendor/bin/phpunit -c tests/phpunit.xml +``` + +### Run Only Unit Tests (Default for CI) +```bash +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=unit +``` + +### Run Only Integration Tests +```bash +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=integration +``` + +### Run with Coverage +```bash +# Unit tests with coverage +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=unit --coverage-text --coverage-filter=src + +# Integration tests with coverage +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=integration --coverage-text --coverage-filter=src +``` + +### Run Specific Test File +```bash +# Unit test +vendor/bin/phpunit -c tests/phpunit.xml tests/Unit/Cms/Services/Post/CreatorTest.php + +# Integration test +vendor/bin/phpunit -c tests/phpunit.xml tests/Integration/PostPublishingFlowTest.php +``` + +### Run with Testdox (Human-Readable Output) +```bash +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=unit --testdox +``` + +## Integration Test Configuration + +Integration tests can be configured to use different databases via environment variables: + +### SQLite (Default) +```bash +# Uses temporary SQLite file +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=integration +``` + +### MySQL +```bash +export TEST_DB_DRIVER=mysql +export TEST_DB_HOST=localhost +export TEST_DB_PORT=3306 +export TEST_DB_NAME=cms_test +export TEST_DB_USER=root +export TEST_DB_PASSWORD=secret +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=integration +``` + +### PostgreSQL +```bash +export TEST_DB_DRIVER=pgsql +export TEST_DB_HOST=localhost +export TEST_DB_PORT=5432 +export TEST_DB_NAME=cms_test +export TEST_DB_USER=postgres +export TEST_DB_PASSWORD=secret +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=integration +``` + +## Writing Tests + +### Unit Test Example +```php +createMock(IPostRepository::class); + $creator = new Creator($repository); + + // Test logic + $post = $creator->create('Title', '{}', 1, 'draft'); + + $this->assertEquals('Title', $post->getTitle()); + } +} +``` + +### Integration Test Example +```php +createTestUser(['username' => 'test']); + + $stmt = $this->pdo->prepare( + "INSERT INTO posts (title, slug, author_id, status) VALUES (?, ?, ?, ?)" + ); + $stmt->execute(['My Post', 'my-post', $userId, 'draft']); + + $postId = (int)$this->pdo->lastInsertId(); + $this->assertGreaterThan(0, $postId); + + // Transaction will be rolled back after test + } +} +``` + +## Continuous Integration (GitHub Actions) + +The CI workflow (`.github/workflows/ci.yml`) runs **both unit and integration tests** to ensure comprehensive validation. + +```yaml +- name: Run Unit Tests with Coverage + run: vendor/bin/phpunit -c tests/phpunit.xml --testsuite=unit --coverage-clover coverage.xml --coverage-filter src + +- name: Run Integration Tests + run: vendor/bin/phpunit -c tests/phpunit.xml --testsuite=integration +``` + +**Why both test suites in CI?** +- Unit tests provide fast feedback (< 30 seconds) +- Integration tests validate real database behavior and migrations (< 20 seconds) +- Total CI time remains under 1 minute +- Catches both logic errors and infrastructure issues automatically +- Uses SQLite with real Phinx migrations for reliable testing + +## Best Practices + +1. **Write unit tests first**: Fast feedback, test logic in isolation +2. **Use integration tests for infrastructure**: Database constraints, migrations, real workflows +3. **Keep integration tests focused**: Test specific workflows, not every edge case +4. **Use transactions for isolation**: IntegrationTestCase handles this automatically +5. **Don't skip tests in CI**: If a test is flaky, fix it or remove it +6. **Test behavior, not implementation**: Tests should verify outcomes, not internals + +## Coverage + +View coverage reports: + +```bash +# Generate HTML coverage report +vendor/bin/phpunit -c tests/phpunit.xml --testsuite=unit --coverage-html coverage + +# Open in browser +open coverage/index.html +``` + +Current coverage (as of last update): +- **Overall**: 39.57% +- **Classes**: 25.00% (26/104) +- **Methods**: 55.62% (406/730) +- **Lines**: 39.57% (1956/4943) + +## Troubleshooting + +### "No tests executed" +- Check that you're in the correct directory +- Verify the test file has `Test.php` suffix +- Ensure the test class extends `TestCase` or `IntegrationTestCase` + +### Integration tests fail with database errors +- Check that migrations exist in `resources/database/migrate/` +- Verify PDO extension is installed +- For MySQL/PostgreSQL, ensure database exists and credentials are correct + +### Tests are very slow +- Are you running integration tests? Try `--testsuite=unit` for faster tests +- Check for network calls or file I/O in unit tests (should use mocks) + +### "Serialization of 'Closure' is not allowed" +- This warning can appear with process isolation +- Usually safe to ignore if tests pass +- To suppress: Add `@runInSeparateProcess` annotation to specific tests if needed diff --git a/tests/BootstrapTest.php b/tests/Unit/BootstrapTest.php similarity index 100% rename from tests/BootstrapTest.php rename to tests/Unit/BootstrapTest.php diff --git a/tests/Cms/Auth/MemberAuthenticationFilterTest.php b/tests/Unit/Cms/Auth/MemberAuthenticationFilterTest.php similarity index 100% rename from tests/Cms/Auth/MemberAuthenticationFilterTest.php rename to tests/Unit/Cms/Auth/MemberAuthenticationFilterTest.php diff --git a/tests/Cms/Auth/PasswordHasherTest.php b/tests/Unit/Cms/Auth/PasswordHasherTest.php similarity index 100% rename from tests/Cms/Auth/PasswordHasherTest.php rename to tests/Unit/Cms/Auth/PasswordHasherTest.php diff --git a/tests/Cms/Auth/ResendVerificationThrottleTest.php b/tests/Unit/Cms/Auth/ResendVerificationThrottleTest.php similarity index 100% rename from tests/Cms/Auth/ResendVerificationThrottleTest.php rename to tests/Unit/Cms/Auth/ResendVerificationThrottleTest.php diff --git a/tests/Cms/Auth/SessionManagerTest.php b/tests/Unit/Cms/Auth/SessionManagerTest.php similarity index 100% rename from tests/Cms/Auth/SessionManagerTest.php rename to tests/Unit/Cms/Auth/SessionManagerTest.php diff --git a/tests/Cms/Auth/UserTest.php b/tests/Unit/Cms/Auth/UserTest.php similarity index 100% rename from tests/Cms/Auth/UserTest.php rename to tests/Unit/Cms/Auth/UserTest.php diff --git a/tests/Cms/BlogControllerTest.php b/tests/Unit/Cms/BlogControllerTest.php similarity index 90% rename from tests/Cms/BlogControllerTest.php rename to tests/Unit/Cms/BlogControllerTest.php index 1839211..d916850 100644 --- a/tests/Cms/BlogControllerTest.php +++ b/tests/Unit/Cms/BlogControllerTest.php @@ -10,9 +10,10 @@ use Neuron\Cms\Repositories\DatabasePostRepository; use Neuron\Cms\Repositories\DatabaseCategoryRepository; use Neuron\Cms\Repositories\DatabaseTagRepository; -use Neuron\Data\Setting\Source\Memory; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\Source\Memory; +use Neuron\Data\Settings\SettingManager; use Neuron\Mvc\Requests\Request; +use Neuron\Orm\Model; use Neuron\Patterns\Registry; use PDO; use PHPUnit\Framework\TestCase; @@ -50,6 +51,9 @@ protected function setUp(): void // Create tables $this->createTables(); + // Initialize ORM with the PDO connection + Model::setPdo( $this->_pdo ); + // Set up Settings with database config $settings = new Memory(); $settings->set( 'site', 'name', 'Test Blog' ); @@ -65,8 +69,8 @@ protected function setUp(): void Registry::getInstance()->set( 'Settings', $settingManager ); // Set paths for views - point to CMS component's resources - Registry::getInstance()->set( 'Base.Path', __DIR__ . '/../..' ); - Registry::getInstance()->set( 'Views.Path', __DIR__ . '/../../resources/views' ); + Registry::getInstance()->set( 'Base.Path', __DIR__ . '/../../..' ); + Registry::getInstance()->set( 'Views.Path', __DIR__ . '/../../../resources/views' ); // Initialize ViewDataProvider for tests $provider = \Neuron\Mvc\Views\ViewDataProvider::getInstance(); @@ -93,6 +97,28 @@ protected function tearDown(): void private function createTables(): void { + // Create users table + $this->_pdo->exec( " + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'subscriber', + status VARCHAR(50) DEFAULT 'active', + email_verified BOOLEAN DEFAULT 0, + two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, + remember_token VARCHAR(255) NULL, + failed_login_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP NULL, + last_login_at TIMESTAMP NULL, + timezone VARCHAR(50) DEFAULT 'UTC', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + " ); + // Create posts table $this->_pdo->exec( " CREATE TABLE posts ( @@ -100,6 +126,7 @@ private function createTables(): void title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, body TEXT NOT NULL, + content_raw TEXT DEFAULT '{\"blocks\":[]}', excerpt TEXT, featured_image VARCHAR(255), author_id INTEGER NOT NULL, diff --git a/tests/Cms/BlogTest.php b/tests/Unit/Cms/BlogTest.php similarity index 100% rename from tests/Cms/BlogTest.php rename to tests/Unit/Cms/BlogTest.php diff --git a/tests/Cms/Cli/Commands/Install/InstallCommandTest.php b/tests/Unit/Cms/Cli/Commands/Install/InstallCommandTest.php similarity index 97% rename from tests/Cms/Cli/Commands/Install/InstallCommandTest.php rename to tests/Unit/Cms/Cli/Commands/Install/InstallCommandTest.php index 3322e69..f1b5a0d 100644 --- a/tests/Cms/Cli/Commands/Install/InstallCommandTest.php +++ b/tests/Unit/Cms/Cli/Commands/Install/InstallCommandTest.php @@ -5,8 +5,8 @@ use PHPUnit\Framework\TestCase; use Neuron\Cms\Cli\Commands\Install\InstallCommand; use org\bovigo\vfs\vfsStream; -use Neuron\Data\Setting\SettingManager; -use Neuron\Data\Setting\Source\Yaml; +use Neuron\Data\Settings\SettingManager; +use Neuron\Data\Settings\Source\Yaml; class InstallCommandTest extends TestCase { diff --git a/tests/Cms/Cli/Commands/User/CreateCommandTest.php b/tests/Unit/Cms/Cli/Commands/User/CreateCommandTest.php similarity index 100% rename from tests/Cms/Cli/Commands/User/CreateCommandTest.php rename to tests/Unit/Cms/Cli/Commands/User/CreateCommandTest.php diff --git a/tests/Cms/ContentControllerTest.php b/tests/Unit/Cms/ContentControllerTest.php similarity index 89% rename from tests/Cms/ContentControllerTest.php rename to tests/Unit/Cms/ContentControllerTest.php index f5fe9a4..60c461e 100644 --- a/tests/Cms/ContentControllerTest.php +++ b/tests/Unit/Cms/ContentControllerTest.php @@ -3,39 +3,31 @@ namespace Tests\Cms; use Neuron\Cms\Controllers\Content; -use Neuron\Data\Setting\Source\Memory; +use Neuron\Data\Settings\Source\Memory; use Neuron\Mvc\Requests\Request; use Neuron\Mvc\Responses\HttpResponseStatus; use Neuron\Patterns\Registry; -use Neuron\Routing\Router; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\TestCase; class ContentControllerTest extends TestCase { - private $root; - private $router; - private $settings; - protected function setUp(): void { parent::setUp(); - - // Create virtual filesystem - $this->root = vfsStream::setup( 'test' ); - // Create mock router - $this->router = $this->createMock( Router::class ); + // Create virtual filesystem (local variable, not stored) + $root = vfsStream::setup( 'test' ); - // Create mock settings - $this->settings = new Memory(); - $this->settings->set( 'site', 'name', 'Test Site' ); - $this->settings->set( 'site', 'title', 'Test Title' ); - $this->settings->set( 'site', 'description', 'Test Description' ); - $this->settings->set( 'site', 'url', 'http://test.com' ); + // Create mock settings (local variable, not stored as test property) + $settings = new Memory(); + $settings->set( 'site', 'name', 'Test Site' ); + $settings->set( 'site', 'title', 'Test Title' ); + $settings->set( 'site', 'description', 'Test Description' ); + $settings->set( 'site', 'url', 'http://test.com' ); // Store settings in registry - Registry::getInstance()->set( 'Settings', $this->settings ); + Registry::getInstance()->set( 'Settings', $settings ); // Create version file $versionContent = json_encode([ @@ -45,7 +37,7 @@ protected function setUp(): void ]); vfsStream::newFile( '.version.json' ) - ->at( $this->root ) + ->at( $root ) ->setContent( $versionContent ); // Create a real version file in parent directory for the controller @@ -64,11 +56,12 @@ protected function tearDown(): void Registry::getInstance()->set( 'version', null ); Registry::getInstance()->set( 'name', null ); Registry::getInstance()->set( 'rss_url', null ); - + Registry::getInstance()->set( 'DtoFactoryService', null ); + // Clean up temp version file $parentDir = dirname( getcwd() ); @unlink( $parentDir . '/.version.json' ); - + parent::tearDown(); } @@ -175,6 +168,7 @@ public function testMarkdownMethod() /** * Test getSessionManager returns SessionManager instance * @runInSeparateProcess + * @preserveGlobalState disabled */ public function testGetSessionManager() { @@ -197,6 +191,7 @@ public function testGetSessionManager() /** * Test flash method sets flash message * @runInSeparateProcess + * @preserveGlobalState disabled */ public function testFlash() { diff --git a/tests/Cms/Events/EventsTest.php b/tests/Unit/Cms/Events/EventsTest.php similarity index 100% rename from tests/Cms/Events/EventsTest.php rename to tests/Unit/Cms/Events/EventsTest.php diff --git a/tests/Cms/Listeners/ListenersTest.php b/tests/Unit/Cms/Listeners/ListenersTest.php similarity index 100% rename from tests/Cms/Listeners/ListenersTest.php rename to tests/Unit/Cms/Listeners/ListenersTest.php diff --git a/tests/Cms/Maintenance/MaintenanceCommandsTest.php b/tests/Unit/Cms/Maintenance/MaintenanceCommandsTest.php similarity index 100% rename from tests/Cms/Maintenance/MaintenanceCommandsTest.php rename to tests/Unit/Cms/Maintenance/MaintenanceCommandsTest.php diff --git a/tests/Cms/Maintenance/MaintenanceConfigTest.php b/tests/Unit/Cms/Maintenance/MaintenanceConfigTest.php similarity index 99% rename from tests/Cms/Maintenance/MaintenanceConfigTest.php rename to tests/Unit/Cms/Maintenance/MaintenanceConfigTest.php index 657c38c..b766bd6 100644 --- a/tests/Cms/Maintenance/MaintenanceConfigTest.php +++ b/tests/Unit/Cms/Maintenance/MaintenanceConfigTest.php @@ -3,7 +3,7 @@ namespace Tests\Cms\Maintenance; use Neuron\Cms\Maintenance\MaintenanceConfig; -use Neuron\Data\Setting\Source\Yaml; +use Neuron\Data\Settings\Source\Yaml; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\TestCase; diff --git a/tests/Cms/Maintenance/MaintenanceFilterTest.php b/tests/Unit/Cms/Maintenance/MaintenanceFilterTest.php similarity index 100% rename from tests/Cms/Maintenance/MaintenanceFilterTest.php rename to tests/Unit/Cms/Maintenance/MaintenanceFilterTest.php diff --git a/tests/Cms/Maintenance/MaintenanceManagerTest.php b/tests/Unit/Cms/Maintenance/MaintenanceManagerTest.php similarity index 100% rename from tests/Cms/Maintenance/MaintenanceManagerTest.php rename to tests/Unit/Cms/Maintenance/MaintenanceManagerTest.php diff --git a/tests/Cms/Models/CategoryTest.php b/tests/Unit/Cms/Models/CategoryTest.php similarity index 100% rename from tests/Cms/Models/CategoryTest.php rename to tests/Unit/Cms/Models/CategoryTest.php diff --git a/tests/Cms/Models/EmailVerificationTokenTest.php b/tests/Unit/Cms/Models/EmailVerificationTokenTest.php similarity index 100% rename from tests/Cms/Models/EmailVerificationTokenTest.php rename to tests/Unit/Cms/Models/EmailVerificationTokenTest.php diff --git a/tests/Cms/Models/PostTest.php b/tests/Unit/Cms/Models/PostTest.php similarity index 65% rename from tests/Cms/Models/PostTest.php rename to tests/Unit/Cms/Models/PostTest.php index 6312d14..839eddd 100644 --- a/tests/Cms/Models/PostTest.php +++ b/tests/Unit/Cms/Models/PostTest.php @@ -319,4 +319,187 @@ public function testAddingDuplicateTagDoesNotCreateDuplicate(): void $this->assertCount( 1, $post->getTags() ); } + + public function testSetContentExtractsPlainTextFromSimpleList(): void + { + $post = new Post(); + + $content = json_encode( [ + 'blocks' => [ + [ + 'type' => 'list', + 'data' => [ + 'style' => 'unordered', + 'items' => [ 'Item 1', 'Item 2', 'Item 3' ] + ] + ] + ] + ] ); + + $post->setContent( $content ); + + $body = $post->getBody(); + $this->assertStringContainsString( 'Item 1', $body ); + $this->assertStringContainsString( 'Item 2', $body ); + $this->assertStringContainsString( 'Item 3', $body ); + } + + public function testSetContentExtractsPlainTextFromNestedList(): void + { + $post = new Post(); + + $content = json_encode( [ + 'blocks' => [ + [ + 'type' => 'list', + 'data' => [ + 'style' => 'unordered', + 'items' => [ + 'Simple item', + [ + 'content' => 'Item with nested list', + 'items' => [ + 'Nested item 1', + 'Nested item 2' + ] + ] + ] + ] + ] + ] + ] ); + + $post->setContent( $content ); + + $body = $post->getBody(); + $this->assertStringContainsString( 'Simple item', $body ); + $this->assertStringContainsString( 'Item with nested list', $body ); + $this->assertStringContainsString( 'Nested item 1', $body ); + $this->assertStringContainsString( 'Nested item 2', $body ); + } + + public function testSetContentExtractsPlainTextFromMultiLevelNestedList(): void + { + $post = new Post(); + + $content = json_encode( [ + 'blocks' => [ + [ + 'type' => 'list', + 'data' => [ + 'style' => 'unordered', + 'items' => [ + [ + 'content' => 'Level 1 item', + 'items' => [ + [ + 'content' => 'Level 2 item', + 'items' => [ + 'Level 3 item' + ] + ] + ] + ] + ] + ] + ] + ] + ] ); + + $post->setContent( $content ); + + $body = $post->getBody(); + $this->assertStringContainsString( 'Level 1 item', $body ); + $this->assertStringContainsString( 'Level 2 item', $body ); + $this->assertStringContainsString( 'Level 3 item', $body ); + + // Should not contain the string "Array" + $this->assertStringNotContainsString( 'Array', $body ); + } + + public function testSetContentExtractsPlainTextFromMixedContent(): void + { + $post = new Post(); + + $content = json_encode( [ + 'blocks' => [ + [ + 'type' => 'header', + 'data' => [ 'text' => 'Test Header', 'level' => 1 ] + ], + [ + 'type' => 'paragraph', + 'data' => [ 'text' => 'This is a paragraph.' ] + ], + [ + 'type' => 'list', + 'data' => [ + 'style' => 'ordered', + 'items' => [ + 'First', + [ + 'content' => 'Second with nested', + 'items' => [ 'Nested A', 'Nested B' ] + ], + 'Third' + ] + ] + ] + ] + ] ); + + $post->setContent( $content ); + + $body = $post->getBody(); + $this->assertStringContainsString( 'Test Header', $body ); + $this->assertStringContainsString( 'This is a paragraph.', $body ); + $this->assertStringContainsString( 'First', $body ); + $this->assertStringContainsString( 'Second with nested', $body ); + $this->assertStringContainsString( 'Nested A', $body ); + $this->assertStringContainsString( 'Nested B', $body ); + $this->assertStringContainsString( 'Third', $body ); + + // Should not contain the string "Array" + $this->assertStringNotContainsString( 'Array', $body ); + } + + public function testSetContentStripsHtmlTags(): void + { + $post = new Post(); + + $content = json_encode( [ + 'blocks' => [ + [ + 'type' => 'paragraph', + 'data' => [ 'text' => 'Text with HTML tags' ] + ], + [ + 'type' => 'list', + 'data' => [ + 'items' => [ + 'Italic item', + [ + 'content' => 'Bold nested item', + 'items' => [ 'Link text' ] + ] + ] + ] + ] + ] + ] ); + + $post->setContent( $content ); + + $body = $post->getBody(); + // Text should be extracted but HTML tags stripped + $this->assertStringContainsString( 'HTML', $body ); + $this->assertStringContainsString( 'Bold', $body ); + $this->assertStringContainsString( 'Link', $body ); + + // HTML tags should be stripped + $this->assertStringNotContainsString( '', $body ); + $this->assertStringNotContainsString( '', $body ); + $this->assertStringNotContainsString( '', $body ); + $this->assertStringNotContainsString( 'createTables(); + // Initialize ORM with the PDO connection + Model::setPdo( $this->_PDO ); + // Initialize repository with in-memory database // Create a test subclass that allows PDO injection $pdo = $this->_PDO; diff --git a/tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php b/tests/Unit/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php similarity index 99% rename from tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php rename to tests/Unit/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php index 257d2bf..ee000e1 100644 --- a/tests/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php +++ b/tests/Unit/Cms/Repositories/DatabaseEmailVerificationTokenRepositoryTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\TestCase; use Neuron\Cms\Repositories\DatabaseEmailVerificationTokenRepository; use Neuron\Cms\Models\EmailVerificationToken; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; use PDO; use DateTimeImmutable; diff --git a/tests/Unit/Cms/Repositories/DatabasePageRepositoryTest.php b/tests/Unit/Cms/Repositories/DatabasePageRepositoryTest.php new file mode 100644 index 0000000..c20df43 --- /dev/null +++ b/tests/Unit/Cms/Repositories/DatabasePageRepositoryTest.php @@ -0,0 +1,389 @@ +pdo = new PDO( + 'sqlite::memory:', + null, + null, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC + ] + ); + + // Create tables + $this->createTables(); + + // Initialize ORM with the PDO connection + Model::setPdo( $this->pdo ); + + // Create repository + $settings = $this->createMock( SettingManager::class ); + $this->repository = new DatabasePageRepository( $settings ); + } + + private function createTables(): void + { + // Create users table + $this->pdo->exec( " + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'subscriber', + status VARCHAR(50) DEFAULT 'active', + email_verified BOOLEAN DEFAULT 0, + two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, + remember_token VARCHAR(255) NULL, + failed_login_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP NULL, + last_login_at TIMESTAMP NULL, + timezone VARCHAR(50) DEFAULT 'UTC', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + " ); + + // Create pages table + $this->pdo->exec( " + CREATE TABLE pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) UNIQUE NOT NULL, + content TEXT NOT NULL, + template VARCHAR(100) DEFAULT 'default', + meta_title VARCHAR(255), + meta_description TEXT, + meta_keywords VARCHAR(255), + author_id INTEGER NOT NULL, + status VARCHAR(50) DEFAULT 'draft', + view_count INTEGER DEFAULT 0, + published_at TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (author_id) REFERENCES users(id) + ) + " ); + } + + private function createTestUser(): User + { + $unique = uniqid(); + return User::create([ + 'username' => 'testuser_' . $unique, + 'email' => "test_{$unique}@example.com", + 'password_hash' => 'hash123' + ]); + } + + private function createTestPage( array $overrides = [] ): Page + { + $user = $this->createTestUser(); + + $data = array_merge([ + 'title' => 'Test Page', + 'slug' => 'test-page-' . uniqid(), + 'content' => '{"blocks":[]}', + 'author_id' => $user->getId(), + 'status' => 'draft' + ], $overrides); + + return Page::create( $data ); + } + + public function testFindByIdReturnsPage(): void + { + $page = $this->createTestPage(); + + $found = $this->repository->findById( $page->getId() ); + + $this->assertNotNull( $found ); + $this->assertEquals( $page->getId(), $found->getId() ); + $this->assertEquals( $page->getTitle(), $found->getTitle() ); + } + + public function testFindByIdReturnsNullForNonexistent(): void + { + $found = $this->repository->findById( 9999 ); + + $this->assertNull( $found ); + } + + public function testFindBySlugReturnsPage(): void + { + $page = $this->createTestPage([ 'slug' => 'about-us' ]); + + $found = $this->repository->findBySlug( 'about-us' ); + + $this->assertNotNull( $found ); + $this->assertEquals( $page->getId(), $found->getId() ); + $this->assertEquals( 'about-us', $found->getSlug() ); + } + + public function testFindBySlugReturnsNullForNonexistent(): void + { + $found = $this->repository->findBySlug( 'nonexistent-slug' ); + + $this->assertNull( $found ); + } + + public function testCreateSavesPage(): void + { + $user = $this->createTestUser(); + + $page = new Page(); + $page->setTitle( 'New Page' ); + $page->setSlug( 'new-page' ); + $page->setContent( '{"blocks":[]}' ); + $page->setAuthorId( $user->getId() ); + $page->setStatus( Page::STATUS_DRAFT ); + + $result = $this->repository->create( $page ); + + $this->assertGreaterThan( 0, $result->getId() ); + $this->assertEquals( 'New Page', $result->getTitle() ); + + // Verify it was saved to database + $found = $this->repository->findById( $result->getId() ); + $this->assertNotNull( $found ); + } + + public function testCreateThrowsExceptionForDuplicateSlug(): void + { + $user = $this->createTestUser(); + + // Create first page + $this->createTestPage([ 'slug' => 'duplicate-slug' ]); + + // Try to create second page with same slug + $page = new Page(); + $page->setTitle( 'Duplicate' ); + $page->setSlug( 'duplicate-slug' ); + $page->setContent( '{"blocks":[]}' ); + $page->setAuthorId( $user->getId() ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Slug already exists' ); + + $this->repository->create( $page ); + } + + public function testUpdateModifiesPage(): void + { + $page = $this->createTestPage(); + $originalId = $page->getId(); + + $page->setTitle( 'Updated Title' ); + $result = $this->repository->update( $page ); + + $this->assertTrue( $result ); + + $found = $this->repository->findById( $originalId ); + $this->assertEquals( 'Updated Title', $found->getTitle() ); + } + + public function testUpdateReturnsFalseForPageWithoutId(): void + { + $page = new Page(); + $page->setTitle( 'No ID' ); + + $result = $this->repository->update( $page ); + + $this->assertFalse( $result ); + } + + public function testUpdateThrowsExceptionForDuplicateSlug(): void + { + // Create two pages + $page1 = $this->createTestPage([ 'slug' => 'page-one' ]); + $page2 = $this->createTestPage([ 'slug' => 'page-two' ]); + + // Try to update page2 with page1's slug + $page2->setSlug( 'page-one' ); + + $this->expectException( \Exception::class ); + $this->expectExceptionMessage( 'Slug already exists' ); + + $this->repository->update( $page2 ); + } + + public function testDeleteRemovesPage(): void + { + $page = $this->createTestPage(); + $pageId = $page->getId(); + + $result = $this->repository->delete( $pageId ); + + $this->assertTrue( $result ); + + $found = $this->repository->findById( $pageId ); + $this->assertNull( $found ); + } + + public function testDeleteReturnsFalseForNonexistent(): void + { + $result = $this->repository->delete( 9999 ); + + $this->assertFalse( $result ); + } + + public function testAllReturnsAllPages(): void + { + $this->createTestPage([ 'title' => 'Page 1' ]); + $this->createTestPage([ 'title' => 'Page 2' ]); + $this->createTestPage([ 'title' => 'Page 3' ]); + + $pages = $this->repository->all(); + + $this->assertCount( 3, $pages ); + } + + public function testAllFiltersByStatus(): void + { + $this->createTestPage([ 'status' => Page::STATUS_DRAFT ]); + $this->createTestPage([ 'status' => Page::STATUS_PUBLISHED ]); + $this->createTestPage([ 'status' => Page::STATUS_DRAFT ]); + + $drafts = $this->repository->all( Page::STATUS_DRAFT ); + $published = $this->repository->all( Page::STATUS_PUBLISHED ); + + $this->assertCount( 2, $drafts ); + $this->assertCount( 1, $published ); + } + + public function testAllRespectsLimitAndOffset(): void + { + for( $i = 1; $i <= 5; $i++ ) + { + $this->createTestPage([ 'title' => "Page {$i}" ]); + } + + $pages = $this->repository->all( null, 2, 1 ); + + $this->assertCount( 2, $pages ); + } + + public function testGetPublishedReturnsOnlyPublished(): void + { + $this->createTestPage([ 'status' => Page::STATUS_DRAFT ]); + $this->createTestPage([ 'status' => Page::STATUS_PUBLISHED ]); + $this->createTestPage([ 'status' => Page::STATUS_PUBLISHED ]); + + $published = $this->repository->getPublished(); + + $this->assertCount( 2, $published ); + foreach( $published as $page ) + { + $this->assertEquals( Page::STATUS_PUBLISHED, $page->getStatus() ); + } + } + + public function testGetDraftsReturnsOnlyDrafts(): void + { + $this->createTestPage([ 'status' => Page::STATUS_DRAFT ]); + $this->createTestPage([ 'status' => Page::STATUS_PUBLISHED ]); + $this->createTestPage([ 'status' => Page::STATUS_DRAFT ]); + + $drafts = $this->repository->getDrafts(); + + $this->assertCount( 2, $drafts ); + foreach( $drafts as $page ) + { + $this->assertEquals( Page::STATUS_DRAFT, $page->getStatus() ); + } + } + + public function testGetByAuthorReturnsAuthorPages(): void + { + $user1 = $this->createTestUser(); + $user2 = User::create([ + 'username' => 'user2', + 'email' => 'user2@example.com', + 'password_hash' => 'hash' + ]); + + $this->createTestPage([ 'author_id' => $user1->getId() ]); + $this->createTestPage([ 'author_id' => $user1->getId() ]); + $this->createTestPage([ 'author_id' => $user2->getId() ]); + + $user1Pages = $this->repository->getByAuthor( $user1->getId() ); + + $this->assertCount( 2, $user1Pages ); + } + + public function testGetByAuthorFiltersByStatus(): void + { + $user = $this->createTestUser(); + + $this->createTestPage([ 'author_id' => $user->getId(), 'status' => Page::STATUS_DRAFT ]); + $this->createTestPage([ 'author_id' => $user->getId(), 'status' => Page::STATUS_PUBLISHED ]); + + $drafts = $this->repository->getByAuthor( $user->getId(), Page::STATUS_DRAFT ); + + $this->assertCount( 1, $drafts ); + $this->assertEquals( Page::STATUS_DRAFT, $drafts[0]->getStatus() ); + } + + public function testCountReturnsTotal(): void + { + $this->createTestPage(); + $this->createTestPage(); + $this->createTestPage(); + + $count = $this->repository->count(); + + $this->assertEquals( 3, $count ); + } + + public function testCountFiltersByStatus(): void + { + $this->createTestPage([ 'status' => Page::STATUS_DRAFT ]); + $this->createTestPage([ 'status' => Page::STATUS_PUBLISHED ]); + $this->createTestPage([ 'status' => Page::STATUS_DRAFT ]); + + $draftCount = $this->repository->count( Page::STATUS_DRAFT ); + $publishedCount = $this->repository->count( Page::STATUS_PUBLISHED ); + + $this->assertEquals( 2, $draftCount ); + $this->assertEquals( 1, $publishedCount ); + } + + public function testIncrementViewCountIncreasesCount(): void + { + $page = $this->createTestPage(); + $pageId = $page->getId(); + + $this->repository->incrementViewCount( $pageId ); + $this->repository->incrementViewCount( $pageId ); + + $found = $this->repository->findById( $pageId ); + $this->assertEquals( 2, $found->getViewCount() ); + } + + public function testIncrementViewCountReturnsFalseForNonexistent(): void + { + $result = $this->repository->incrementViewCount( 9999 ); + + $this->assertFalse( $result ); + } +} diff --git a/tests/Unit/Cms/Repositories/DatabasePasswordResetTokenRepositoryTest.php b/tests/Unit/Cms/Repositories/DatabasePasswordResetTokenRepositoryTest.php new file mode 100644 index 0000000..eb678c1 --- /dev/null +++ b/tests/Unit/Cms/Repositories/DatabasePasswordResetTokenRepositoryTest.php @@ -0,0 +1,253 @@ +pdo = new PDO( + 'sqlite::memory:', + null, + null, + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC + ] + ); + + // Create password_reset_tokens table + $this->createTable(); + + // Create repository with injected PDO + $settings = $this->createMock( SettingManager::class ); + $pdo = $this->pdo; + + $this->repository = new class( $settings, $pdo ) extends DatabasePasswordResetTokenRepository + { + public function __construct( SettingManager $settings, PDO $pdo ) + { + // Skip parent constructor and inject PDO directly + $reflection = new \ReflectionClass( DatabasePasswordResetTokenRepository::class ); + $property = $reflection->getProperty( '_pdo' ); + $property->setAccessible( true ); + $property->setValue( $this, $pdo ); + } + }; + } + + private function createTable(): void + { + $this->pdo->exec( " + CREATE TABLE password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email VARCHAR(255) NOT NULL, + token VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL + ) + " ); + } + + private function createTestToken( array $overrides = [] ): PasswordResetToken + { + $data = array_merge([ + 'email' => 'test@example.com', + 'token' => hash( 'sha256', 'test-token-' . uniqid() ), + 'created_at' => (new DateTimeImmutable())->format( 'Y-m-d H:i:s' ), + 'expires_at' => (new DateTimeImmutable())->modify( '+1 hour' )->format( 'Y-m-d H:i:s' ) + ], $overrides); + + return PasswordResetToken::fromArray( $data ); + } + + public function testCreateSavesToken(): void + { + $token = $this->createTestToken(); + + $result = $this->repository->create( $token ); + + $this->assertGreaterThan( 0, $result->getId() ); + $this->assertEquals( $token->getEmail(), $result->getEmail() ); + $this->assertEquals( $token->getToken(), $result->getToken() ); + } + + public function testCreateSetsId(): void + { + $token = $this->createTestToken(); + $this->assertNull( $token->getId() ); + + $this->repository->create( $token ); + + $this->assertGreaterThan( 0, $token->getId() ); + } + + public function testFindByTokenReturnsToken(): void + { + $token = $this->createTestToken([ 'token' => hash( 'sha256', 'findable-token' ) ]); + $this->repository->create( $token ); + + $found = $this->repository->findByToken( $token->getToken() ); + + $this->assertNotNull( $found ); + $this->assertEquals( $token->getEmail(), $found->getEmail() ); + $this->assertEquals( $token->getToken(), $found->getToken() ); + } + + public function testFindByTokenReturnsNullForNonexistent(): void + { + $found = $this->repository->findByToken( hash( 'sha256', 'nonexistent' ) ); + + $this->assertNull( $found ); + } + + public function testDeleteByEmailRemovesAllTokensForEmail(): void + { + // Create multiple tokens for same email + $email = 'user@example.com'; + $this->repository->create( $this->createTestToken([ + 'email' => $email, + 'token' => hash( 'sha256', 'token1' ) + ])); + + $this->repository->create( $this->createTestToken([ + 'email' => $email, + 'token' => hash( 'sha256', 'token2' ) + ])); + + $this->repository->create( $this->createTestToken([ + 'email' => 'other@example.com', + 'token' => hash( 'sha256', 'token3' ) + ])); + + $deletedCount = $this->repository->deleteByEmail( $email ); + + $this->assertEquals( 2, $deletedCount ); + + // Verify the tokens are gone + $this->assertNull( $this->repository->findByToken( hash( 'sha256', 'token1' ) ) ); + $this->assertNull( $this->repository->findByToken( hash( 'sha256', 'token2' ) ) ); + + // Verify other email's token still exists + $this->assertNotNull( $this->repository->findByToken( hash( 'sha256', 'token3' ) ) ); + } + + public function testDeleteByEmailReturnsZeroWhenNoTokensFound(): void + { + $deletedCount = $this->repository->deleteByEmail( 'nobody@example.com' ); + + $this->assertEquals( 0, $deletedCount ); + } + + public function testDeleteByTokenRemovesSpecificToken(): void + { + $token = $this->createTestToken([ 'token' => hash( 'sha256', 'deletable' ) ]); + $this->repository->create( $token ); + + $result = $this->repository->deleteByToken( $token->getToken() ); + + $this->assertTrue( $result ); + + $found = $this->repository->findByToken( $token->getToken() ); + $this->assertNull( $found ); + } + + public function testDeleteByTokenReturnsFalseForNonexistent(): void + { + $result = $this->repository->deleteByToken( hash( 'sha256', 'nonexistent' ) ); + + $this->assertFalse( $result ); + } + + public function testDeleteExpiredRemovesExpiredTokens(): void + { + // Create expired token (1 hour ago) + $expiredToken = $this->createTestToken([ + 'token' => hash( 'sha256', 'expired' ), + 'expires_at' => (new DateTimeImmutable())->modify( '-1 hour' )->format( 'Y-m-d H:i:s' ) + ]); + $this->repository->create( $expiredToken ); + + // Create valid token (1 hour in future) + $validToken = $this->createTestToken([ + 'token' => hash( 'sha256', 'valid' ), + 'expires_at' => (new DateTimeImmutable())->modify( '+1 hour' )->format( 'Y-m-d H:i:s' ) + ]); + $this->repository->create( $validToken ); + + $deletedCount = $this->repository->deleteExpired(); + + $this->assertEquals( 1, $deletedCount ); + + // Verify expired token is gone + $this->assertNull( $this->repository->findByToken( $expiredToken->getToken() ) ); + + // Verify valid token still exists + $this->assertNotNull( $this->repository->findByToken( $validToken->getToken() ) ); + } + + public function testDeleteExpiredReturnsZeroWhenNoExpiredTokens(): void + { + // Create valid token + $token = $this->createTestToken([ + 'expires_at' => (new DateTimeImmutable())->modify( '+1 hour' )->format( 'Y-m-d H:i:s' ) + ]); + $this->repository->create( $token ); + + $deletedCount = $this->repository->deleteExpired(); + + $this->assertEquals( 0, $deletedCount ); + } + + public function testCreateMultipleTokensForSameEmail(): void + { + $email = 'user@example.com'; + + $token1 = $this->createTestToken([ + 'email' => $email, + 'token' => hash( 'sha256', 'token1' ) + ]); + $this->repository->create( $token1 ); + + $token2 = $this->createTestToken([ + 'email' => $email, + 'token' => hash( 'sha256', 'token2' ) + ]); + $this->repository->create( $token2 ); + + $found1 = $this->repository->findByToken( $token1->getToken() ); + $found2 = $this->repository->findByToken( $token2->getToken() ); + + $this->assertNotNull( $found1 ); + $this->assertNotNull( $found2 ); + $this->assertEquals( $email, $found1->getEmail() ); + $this->assertEquals( $email, $found2->getEmail() ); + } + + public function testTokenPreservesExpirationTime(): void + { + $expiresAt = (new DateTimeImmutable())->modify( '+2 hours' ); + $token = $this->createTestToken([ 'expires_at' => $expiresAt->format( 'Y-m-d H:i:s' ) ]); + + $this->repository->create( $token ); + + $found = $this->repository->findByToken( $token->getToken() ); + + $this->assertNotNull( $found ); + $this->assertEquals( + $expiresAt->format( 'Y-m-d H:i:s' ), + $found->getExpiresAt()->format( 'Y-m-d H:i:s' ) + ); + } +} diff --git a/tests/Cms/Repositories/DatabasePostRepositoryTest.php b/tests/Unit/Cms/Repositories/DatabasePostRepositoryTest.php similarity index 95% rename from tests/Cms/Repositories/DatabasePostRepositoryTest.php rename to tests/Unit/Cms/Repositories/DatabasePostRepositoryTest.php index 9737afc..3801a42 100644 --- a/tests/Cms/Repositories/DatabasePostRepositoryTest.php +++ b/tests/Unit/Cms/Repositories/DatabasePostRepositoryTest.php @@ -7,6 +7,7 @@ use Neuron\Cms\Models\Category; use Neuron\Cms\Models\Tag; use Neuron\Cms\Repositories\DatabasePostRepository; +use Neuron\Orm\Model; use PHPUnit\Framework\TestCase; use PDO; @@ -31,6 +32,9 @@ protected function setUp(): void // Create tables $this->createTables(); + // Initialize ORM with the PDO connection + Model::setPdo( $this->_PDO ); + // Initialize repository with in-memory database // Create a test subclass that allows PDO injection $pdo = $this->_PDO; @@ -49,6 +53,28 @@ public function __construct( PDO $PDO ) private function createTables(): void { + // Create users table + $this->_PDO->exec( " + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) DEFAULT 'subscriber', + status VARCHAR(50) DEFAULT 'active', + email_verified BOOLEAN DEFAULT 0, + two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, + remember_token VARCHAR(255) NULL, + failed_login_attempts INTEGER DEFAULT 0, + locked_until TIMESTAMP NULL, + last_login_at TIMESTAMP NULL, + timezone VARCHAR(50) DEFAULT 'UTC', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + " ); + // Create posts table $this->_PDO->exec( " CREATE TABLE posts ( @@ -56,6 +82,7 @@ private function createTables(): void title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL UNIQUE, body TEXT NOT NULL, + content_raw TEXT DEFAULT '{\"blocks\":[]}', excerpt TEXT, featured_image VARCHAR(255), author_id INTEGER NOT NULL, diff --git a/tests/Cms/Repositories/DatabaseTagRepositoryTest.php b/tests/Unit/Cms/Repositories/DatabaseTagRepositoryTest.php similarity index 99% rename from tests/Cms/Repositories/DatabaseTagRepositoryTest.php rename to tests/Unit/Cms/Repositories/DatabaseTagRepositoryTest.php index 3f13745..c311a33 100644 --- a/tests/Cms/Repositories/DatabaseTagRepositoryTest.php +++ b/tests/Unit/Cms/Repositories/DatabaseTagRepositoryTest.php @@ -5,6 +5,7 @@ use DateTimeImmutable; use Neuron\Cms\Models\Tag; use Neuron\Cms\Repositories\DatabaseTagRepository; +use Neuron\Orm\Model; use PHPUnit\Framework\TestCase; use PDO; @@ -29,6 +30,9 @@ protected function setUp(): void // Create tables $this->createTables(); + // Initialize ORM with the PDO connection + Model::setPdo( $this->_PDO ); + // Initialize repository with in-memory database // Create a test subclass that allows PDO injection $pdo = $this->_PDO; diff --git a/tests/Cms/Repositories/DatabaseUserRepositoryTest.php b/tests/Unit/Cms/Repositories/DatabaseUserRepositoryTest.php similarity index 98% rename from tests/Cms/Repositories/DatabaseUserRepositoryTest.php rename to tests/Unit/Cms/Repositories/DatabaseUserRepositoryTest.php index a02424f..f2dd947 100644 --- a/tests/Cms/Repositories/DatabaseUserRepositoryTest.php +++ b/tests/Unit/Cms/Repositories/DatabaseUserRepositoryTest.php @@ -5,7 +5,8 @@ use PHPUnit\Framework\TestCase; use Neuron\Cms\Repositories\DatabaseUserRepository; use Neuron\Cms\Models\User; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; +use Neuron\Orm\Model; use PDO; use DateTimeImmutable; @@ -43,6 +44,9 @@ protected function setUp(): void $property->setAccessible( true ); $this->pdo = $property->getValue( $this->repository ); + // Initialize ORM with the PDO connection + Model::setPdo( $this->pdo ); + // Create users table $this->createUsersTable(); } @@ -59,6 +63,7 @@ private function createUsersTable(): void status VARCHAR(50) DEFAULT 'active', email_verified BOOLEAN DEFAULT 0, two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, remember_token VARCHAR(255) NULL, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP NULL, diff --git a/tests/Cms/Services/AuthenticationTest.php b/tests/Unit/Cms/Services/AuthenticationTest.php similarity index 96% rename from tests/Cms/Services/AuthenticationTest.php rename to tests/Unit/Cms/Services/AuthenticationTest.php index 3830b7b..8f42c9c 100644 --- a/tests/Cms/Services/AuthenticationTest.php +++ b/tests/Unit/Cms/Services/AuthenticationTest.php @@ -8,7 +8,8 @@ use Neuron\Cms\Auth\PasswordHasher; use Neuron\Cms\Models\User; use Neuron\Cms\Repositories\DatabaseUserRepository; -use Neuron\Data\Setting\SettingManager; +use Neuron\Data\Settings\SettingManager; +use Neuron\Orm\Model; use DateTimeImmutable; use PDO; @@ -24,6 +25,17 @@ class AuthenticationTest extends TestCase private PasswordHasher $_passwordHasher; private PDO $pdo; + public function __sleep(): array + { + // Don't serialize PDO for process isolation + return ['_authentication', '_userRepository', '_sessionManager', '_passwordHasher']; + } + + public function __wakeup(): void + { + // PDO will be re-initialized in setUp() + } + protected function setUp(): void { // Create in-memory SQLite database for testing @@ -46,6 +58,9 @@ protected function setUp(): void $property->setAccessible(true); $this->pdo = $property->getValue($this->_userRepository); + // Initialize ORM with the PDO connection + Model::setPdo( $this->pdo ); + // Create users table $this->createUsersTable(); @@ -73,6 +88,7 @@ private function createUsersTable(): void status VARCHAR(50) DEFAULT 'active', email_verified BOOLEAN DEFAULT 0, two_factor_secret VARCHAR(255) NULL, + two_factor_recovery_codes TEXT NULL, remember_token VARCHAR(255) NULL, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP NULL, diff --git a/tests/Cms/Services/Category/CreatorTest.php b/tests/Unit/Cms/Services/Category/CreatorTest.php similarity index 100% rename from tests/Cms/Services/Category/CreatorTest.php rename to tests/Unit/Cms/Services/Category/CreatorTest.php diff --git a/tests/Cms/Services/Category/DeleterTest.php b/tests/Unit/Cms/Services/Category/DeleterTest.php similarity index 100% rename from tests/Cms/Services/Category/DeleterTest.php rename to tests/Unit/Cms/Services/Category/DeleterTest.php diff --git a/tests/Cms/Services/Category/UpdaterTest.php b/tests/Unit/Cms/Services/Category/UpdaterTest.php similarity index 100% rename from tests/Cms/Services/Category/UpdaterTest.php rename to tests/Unit/Cms/Services/Category/UpdaterTest.php diff --git a/tests/Unit/Cms/Services/Content/EditorJsRendererTest.php b/tests/Unit/Cms/Services/Content/EditorJsRendererTest.php new file mode 100644 index 0000000..70a6ce1 --- /dev/null +++ b/tests/Unit/Cms/Services/Content/EditorJsRendererTest.php @@ -0,0 +1,561 @@ +renderer = new EditorJsRenderer(); + } + + public function testRenderEmptyData(): void + { + $result = $this->renderer->render( [] ); + + $this->assertSame( '', $result ); + } + + public function testRenderEmptyBlocks(): void + { + $result = $this->renderer->render( [ 'blocks' => [] ] ); + + $this->assertSame( '', $result ); + } + + public function testRenderParagraphBlock(): void + { + $data = [ + 'blocks' => [ + [ 'type' => 'paragraph', 'data' => [ 'text' => 'Hello World' ] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringContainsString( 'Hello World', $result ); + $this->assertStringContainsString( '

    ', $result ); + } + + public function testRenderHeaderBlock(): void + { + $data = [ + 'blocks' => [ + [ 'type' => 'header', 'data' => [ 'text' => 'Title', 'level' => 1 ] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringContainsString( 'Title', $result ); + $this->assertStringContainsString( '', $result ); + } + + public function testRenderHeaderWithDifferentLevels(): void + { + for( $level = 1; $level <= 6; $level++ ) + { + $data = [ + 'blocks' => [ + [ 'type' => 'header', 'data' => [ 'text' => "Level {$level}", 'level' => $level ] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( "assertStringContainsString( "Level {$level}", $result ); + $this->assertStringContainsString( "", $result ); + } + } + + public function testRenderHeaderClampsInvalidLevel(): void + { + // Test level > 6 (should clamp to 6) + $data = [ + 'blocks' => [ + [ 'type' => 'header', 'data' => [ 'text' => 'Test', 'level' => 10 ] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( ' [ + [ 'type' => 'header', 'data' => [ 'text' => 'Test', 'level' => 0 ] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( ' [ + [ + 'type' => 'list', + 'data' => [ + 'style' => 'unordered', + 'items' => [ 'Item 1', 'Item 2', 'Item 3' ] + ] + ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringContainsString( '
  • Item 1
  • ', $result ); + $this->assertStringContainsString( '
  • Item 2
  • ', $result ); + $this->assertStringContainsString( '
  • Item 3
  • ', $result ); + $this->assertStringContainsString( '', $result ); + } + + public function testRenderOrderedList(): void + { + $data = [ + 'blocks' => [ + [ + 'type' => 'list', + 'data' => [ + 'style' => 'ordered', + 'items' => [ 'First', 'Second', 'Third' ] + ] + ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringContainsString( '
  • First
  • ', $result ); + $this->assertStringContainsString( '
  • Second
  • ', $result ); + $this->assertStringContainsString( '
  • Third
  • ', $result ); + $this->assertStringContainsString( '', $result ); + } + + public function testRenderImageBlock(): void + { + $data = [ + 'blocks' => [ + [ + 'type' => 'image', + 'data' => [ + 'file' => [ 'url' => 'https://example.com/image.jpg' ], + 'caption' => 'Test Image' + ] + ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringContainsString( 'assertStringContainsString( 'https://example.com/image.jpg', $result ); + $this->assertStringContainsString( 'Test Image', $result ); + $this->assertStringContainsString( 'assertStringContainsString( '', $result ); + } + + public function testRenderImageWithoutCaption(): void + { + $data = [ + 'blocks' => [ + [ + 'type' => 'image', + 'data' => [ + 'file' => [ 'url' => 'https://example.com/image.jpg' ] + ] + ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringNotContainsString( ' [ + [ + 'type' => 'quote', + 'data' => [ + 'text' => 'This is a quote', + 'caption' => 'Author Name' + ] + ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringContainsString( 'This is a quote', $result ); + $this->assertStringContainsString( 'assertStringContainsString( 'Author Name', $result ); + $this->assertStringContainsString( '', $result ); + } + + public function testRenderQuoteWithAlignment(): void + { + $alignments = [ + 'left' => '', + 'center' => 'text-center', + 'right' => 'text-end' + ]; + + foreach( $alignments as $alignment => $expectedClass ) + { + $data = [ + 'blocks' => [ + [ + 'type' => 'quote', + 'data' => [ + 'text' => 'Test quote', + 'alignment' => $alignment + ] + ] + ] + ]; + + $result = $this->renderer->render( $data ); + + if( $expectedClass ) + { + $this->assertStringContainsString( $expectedClass, $result ); + } + } + } + + public function testRenderCodeBlock(): void + { + $data = [ + 'blocks' => [ + [ + 'type' => 'code', + 'data' => [ + 'code' => 'function test() { return true; }' + ] + ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringContainsString( '', $result ); + $this->assertStringContainsString( 'function test()', $result ); + $this->assertStringContainsString( '', $result ); + $this->assertStringContainsString( '', $result ); + } + + public function testRenderDelimiterBlock(): void + { + $data = [ + 'blocks' => [ + [ 'type' => 'delimiter', 'data' => [] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( ' [ + [ + 'type' => 'raw', + 'data' => [ + 'html' => '
    Test Content
    ' + ] + ] + ] + ]; + + $result = $this->renderer->render( $data ); + + // Raw HTML should be sanitized (div tags stripped) + $this->assertStringContainsString( 'Test Content', $result ); + } + + public function testRenderUnknownBlockType(): void + { + $data = [ + 'blocks' => [ + [ 'type' => 'unknown-type', 'data' => [] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( '', $result ); + } + + public function testRenderMultipleBlocks(): void + { + $data = [ + 'blocks' => [ + [ 'type' => 'header', 'data' => [ 'text' => 'Title', 'level' => 1 ] ], + [ 'type' => 'paragraph', 'data' => [ 'text' => 'First paragraph' ] ], + [ 'type' => 'paragraph', 'data' => [ 'text' => 'Second paragraph' ] ], + [ 'type' => 'delimiter', 'data' => [] ], + [ 'type' => 'quote', 'data' => [ 'text' => 'A quote' ] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringContainsString( 'assertStringContainsString( 'First paragraph', $result ); + $this->assertStringContainsString( 'Second paragraph', $result ); + $this->assertStringContainsString( 'assertStringContainsString( ' [ + [ 'type' => 'paragraph', 'data' => [ 'text' => 'Safe text' ] ] + ] + ]; + + $result = $this->renderer->render( $data ); + + $this->assertStringNotContainsString( '' ); + $post->setSlug( 'safe-slug' ); + $post->setExcerpt( '' ); + $post->setPublishedAt( new \DateTimeImmutable( '2024-01-01' ) ); + + $repository = $this->createMock( IPostRepository::class ); + $repository + ->method( 'getPublished' ) + ->willReturn( [$post] ); + + $renderer = new WidgetRenderer( $repository ); + $result = $renderer->render( 'latest-posts', [] ); + + // HTML should be escaped + $this->assertStringNotContainsString( '