From 54e4449a6ab91f5e6bfa95ea67db2952aea124fd Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 26 Feb 2026 12:29:31 +0600 Subject: [PATCH 1/9] Add PHPCS setup with WordPress Coding Standards Adds phpcs.xml.dist configuration, GitHub Actions workflow for PHP linting, and PHPCS dev dependencies in composer.json. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/php-lint.yml | 51 ++++++++++++++++++++++++++++++++++ composer.json | 13 +++++++-- phpcs.xml.dist | 31 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/php-lint.yml create mode 100644 phpcs.xml.dist diff --git a/.github/workflows/php-lint.yml b/.github/workflows/php-lint.yml new file mode 100644 index 0000000..89e3306 --- /dev/null +++ b/.github/workflows/php-lint.yml @@ -0,0 +1,51 @@ +name: PHP Linting + +on: + push: + branches: + - main + - 'feature/**' + - 'release/**' + paths: + - '.github/workflows/php-lint.yml' + - '**.php' + - 'phpcs.xml.dist' + - 'composer.json' + - 'composer.lock' + pull_request: + paths: + - '.github/workflows/php-lint.yml' + - '**.php' + - 'phpcs.xml.dist' + - 'composer.json' + - 'composer.lock' + types: + - opened + - reopened + - synchronize + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + phpcs: + name: PHPCS + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: cs2pr + + - name: Install PHP dependencies + uses: ramsey/composer-install@v3 + with: + composer-options: '--prefer-dist' + + - name: Run PHPCS + run: composer phpcs -- -q --report=checkstyle | cs2pr diff --git a/composer.json b/composer.json index c0a7c55..6643a04 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,11 @@ "require-dev": { "phpunit/phpunit": "^9.0 || ^10.0", "brain/monkey": "^2.6", - "mockery/mockery": "^1.6" + "mockery/mockery": "^1.6", + "squizlabs/php_codesniffer": "^3.7", + "wp-coding-standards/wpcs": "^3.0", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" }, "autoload": { "psr-4": { @@ -23,6 +27,11 @@ }, "minimum-stability": "stable", "scripts": { - "test": "phpunit" + "test": "phpunit", + "phpcs": "phpcs", + "phpcbf": "phpcbf", + "lint": [ + "@phpcs" + ] } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..54a2a86 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,31 @@ + + + PHP CodeSniffer rules for wedevs/wp-kit. + + + src + + + + + + + + + + + + + + + + + + + + + + + + + From a9ae54159c5c54977479885b8615445b6e8a11d2 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 26 Feb 2026 12:29:42 +0600 Subject: [PATCH 2/9] Add migration status tracking and background process progress - Add MigrationStatus class for querying per-migration execution history - Log migration start/complete/failed in MigrationManager::do_upgrade() - Add get_progress() to BackgroundProcess for completion ratio tracking - Add REST endpoint GET {prefix}/v1/migration/status - Update developer guide with status API and progress documentation - Add comprehensive tests for all new functionality Co-Authored-By: Claude Opus 4.6 --- docs/developer-guide.md | 96 ++++++++- src/Migration/BackgroundProcess.php | 35 ++++ src/Migration/MigrationHooks.php | 40 ++++ src/Migration/MigrationManager.php | 85 +++++++- src/Migration/MigrationStatus.php | 119 +++++++++++ .../tests/Migration/BackgroundProcessTest.php | 78 +++++++- .../tests/Migration/MigrationManagerTest.php | 113 ++++++++++- .../tests/Migration/MigrationStatusTest.php | 185 ++++++++++++++++++ 8 files changed, 739 insertions(+), 12 deletions(-) create mode 100644 src/Migration/MigrationStatus.php create mode 100644 tests/phpunit/tests/Migration/MigrationStatusTest.php diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 7417bd0..a5e6f69 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -35,6 +35,7 @@ This guide serves two audiences: - [Schema Helpers](#schema-helpers) - [Writing Migrations](#writing-migrations) - [Migration Registry & Manager](#migration-registry--manager) + - [Migration Status](#migration-status) - [Background Processing](#background-processing) - [Admin Notifications](#admin-notifications) - [Extending a WPKit-Based Plugin (Third-Party Guide)](#extending-a-wpkit-based-plugin-third-party-guide) @@ -999,6 +1000,70 @@ $hooks->register(); The AJAX handler verifies the nonce `{prefix}_admin` and requires the `update_plugins` capability. +#### Migration Status + +The migration system automatically logs each migration's execution. Use `MigrationStatus` to query the log: + +```php +$status = $manager->get_status(); + +// Full execution log (version-keyed array) +$log = $status->get_log(); +// [ +// '1.0.0' => [ +// 'version' => '1.0.0', +// 'class' => 'App\\Migrations\\V_1_0_0', +// 'status' => 'completed', // pending | running | completed | failed +// 'started_at' => 1708900000, +// 'completed_at' => 1708900002, +// 'error' => null, +// ], +// ] + +// Single migration entry +$entry = $status->get_status( '1.0.0' ); // array or null + +// Is any migration currently running? +$status->is_running(); // bool + +// Summary counts +$summary = $status->get_summary(); +// [ 'total' => 5, 'completed' => 3, 'failed' => 0, 'running' => 1, 'pending' => 1 ] + +// Clear the log (for debugging or re-runs) +$status->clear_log(); +``` + +Failed migrations are logged with their error message. The exception is re-thrown so existing error handling is preserved: + +```php +$entry = $status->get_status( '2.0.0' ); +if ( $entry && $entry['status'] === 'failed' ) { + error_log( 'Migration 2.0.0 failed: ' . $entry['error'] ); +} +``` + +#### REST API for Migration Status + +`MigrationHooks` registers a REST endpoint for polling migration status from the admin UI: + +``` +GET /wp-json/{prefix}/v1/migration/status +``` + +Requires the `update_plugins` capability. Returns: + +```json +{ + "summary": { "total": 5, "completed": 3, "failed": 0, "running": 1, "pending": 1 }, + "log": { "1.0.0": { "version": "1.0.0", "status": "completed", ... } }, + "is_running": true, + "is_upgrade_required": false, + "db_version": "1.2.0", + "plugin_version": "1.2.0" +} +``` + ### Background Processing For long-running tasks (data migration, batch updates), extend `BackgroundProcess`. It uses WP Cron for async execution with configurable time limits per batch — no WooCommerce dependency. @@ -1040,6 +1105,16 @@ $process->dispatch(); // Schedule first batch via WP Cron // Status & control $process->is_processing(); // bool — items in queue? $process->cancel(); // Clear queue and unschedule cron + +// Progress tracking (total is tracked automatically from push_to_queue calls) +$progress = $process->get_progress(); +// [ +// 'is_processing' => true, +// 'total' => 50, // Total items pushed across all push_to_queue() calls +// 'completed' => 30, // total - remaining +// 'remaining' => 20, // Items still in queue +// 'percentage' => 60, // (completed / total) * 100 +// ] ``` **How it works internally:** @@ -1130,17 +1205,14 @@ class V_1_2_0 extends MyPluginMigration { ( new TaskSeederProcess() )->init_hooks(); ``` -**Monitoring progress:** Check the queue option to determine how many batches remain: +**Monitoring progress:** Use the built-in `get_progress()` method: ```php -$queue = get_option( 'myplugin_bg_seed_tasks' ); +$seeder = new TaskSeederProcess(); +$progress = $seeder->get_progress(); -if ( ! empty( $queue ) && is_array( $queue ) ) { - $remaining = 0; - foreach ( $queue as $batch_group ) { - $remaining += is_array( $batch_group ) ? count( $batch_group ) : 0; - } - echo "Batches remaining: {$remaining}"; +if ( $progress['is_processing'] ) { + echo "Processing: {$progress['completed']}/{$progress['total']} batches ({$progress['percentage']}%)"; } elseif ( get_option( 'myplugin_seeder_completed' ) ) { echo 'Seeding complete!'; } @@ -1629,6 +1701,12 @@ The `{prefix}` is the plugin-level prefix set via `set_filter_prefix()` (e.g., ` | `{prefix}_upgrade_finished` | action | — | All migrations completed | | `{prefix}_upgrade_is_not_required` | action | — | No upgrade needed | +#### Migration REST Endpoints + +| Route | Method | Permission | Description | +|-------|--------|------------|-------------| +| `{prefix}/v1/migration/status` | GET | `update_plugins` | Returns migration log, summary, and status | + ### Notification Hooks | Hook | Type | Parameters | Description | @@ -1642,5 +1720,7 @@ The `{prefix}` is the plugin-level prefix set via `set_filter_prefix()` (e.g., ` |------------|-----------|-------------| | `{db_version_key}` | Migration | Stores installed DB version (e.g., `'1.2.0'`) | | `{prefix}_is_upgrading_db` | MigrationManager | Tracks ongoing upgrade (stores pending list) | +| `{prefix}_migration_log` | MigrationManager | Per-migration execution log (version, status, timestamps, errors) | | `{prefix}_bg_{action}` | BackgroundProcess | Queue storage (array of items) | +| `{prefix}_bg_{action}_total` | BackgroundProcess | Total items pushed (for progress percentage) | | `{prefix}_dismissed_notices` | DismissalHandler | Array of dismissed notice keys | diff --git a/src/Migration/BackgroundProcess.php b/src/Migration/BackgroundProcess.php index 3583f50..f4f1c41 100644 --- a/src/Migration/BackgroundProcess.php +++ b/src/Migration/BackgroundProcess.php @@ -76,6 +76,11 @@ public function push_to_queue( array $items ): self { update_option( $batch_key, $existing, false ); + // Track total items for progress reporting. + $total_key = $this->get_total_key(); + $current_total = (int) get_option( $total_key, 0 ); + update_option( $total_key, $current_total + count( $items ), false ); + return $this; } @@ -124,6 +129,7 @@ public function handle_cron(): void { wp_schedule_single_event( time() + 10, $this->get_cron_hook() ); } else { delete_option( $batch_key ); + delete_option( $this->get_total_key() ); $this->complete(); } } @@ -149,9 +155,29 @@ public function is_processing(): bool { */ public function cancel(): void { delete_option( $this->get_batch_key() ); + delete_option( $this->get_total_key() ); wp_clear_scheduled_hook( $this->get_cron_hook() ); } + /** + * Get the progress of the background process. + * + * @return array{is_processing: bool, total: int, completed: int, remaining: int, percentage: int} + */ + public function get_progress(): array { + $total = (int) get_option( $this->get_total_key(), 0 ); + $remaining = count( get_option( $this->get_batch_key(), [] ) ); + $completed = max( 0, $total - $remaining ); + + return [ + 'is_processing' => $this->is_processing(), + 'total' => $total, + 'completed' => $completed, + 'remaining' => $remaining, + 'percentage' => $total > 0 ? (int) round( ( $completed / $total ) * 100 ) : 0, + ]; + } + /** * Get the option key for the batch queue. * @@ -161,6 +187,15 @@ protected function get_batch_key(): string { return $this->prefix . '_bg_' . $this->action; } + /** + * Get the option key for the total items count. + * + * @return string + */ + protected function get_total_key(): string { + return $this->prefix . '_bg_' . $this->action . '_total'; + } + /** * Get the WP Cron hook name. * diff --git a/src/Migration/MigrationHooks.php b/src/Migration/MigrationHooks.php index 9d89b25..bdc4352 100644 --- a/src/Migration/MigrationHooks.php +++ b/src/Migration/MigrationHooks.php @@ -44,6 +44,46 @@ public function register(): void { add_action( "{$this->prefix}_upgrade_finished", [ $this, 'on_upgrade_finished' ] ); add_action( "{$this->prefix}_upgrade_is_not_required", [ $this, 'on_upgrade_not_required' ] ); + + add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); + } + + /** + * Register REST API routes for migration status. + */ + public function register_rest_routes(): void { + register_rest_route( + "{$this->prefix}/v1", + '/migration/status', + [ + 'methods' => 'GET', + 'callback' => [ $this, 'rest_get_status' ], + 'permission_callback' => function () { + return current_user_can( 'update_plugins' ); + }, + ] + ); + } + + /** + * REST callback: get migration status. + * + * @return \WP_REST_Response + */ + public function rest_get_status(): \WP_REST_Response { + $status = $this->manager->get_status(); + + return new \WP_REST_Response( + [ + 'summary' => $status->get_summary(), + 'log' => $status->get_log(), + 'is_running' => $status->is_running(), + 'is_upgrade_required' => $this->manager->is_upgrade_required(), + 'db_version' => $this->manager->get_registry()->get_db_installed_version(), + 'plugin_version' => $this->manager->get_registry()->get_plugin_version(), + ], + 200 + ); } /** diff --git a/src/Migration/MigrationManager.php b/src/Migration/MigrationManager.php index 810c67d..5bb5744 100644 --- a/src/Migration/MigrationManager.php +++ b/src/Migration/MigrationManager.php @@ -100,8 +100,16 @@ public function do_upgrade(): void { $upgrader = $upgrader['upgrader']; } - call_user_func( [ $upgrader, 'run' ], $required_version ); - call_user_func( [ $upgrader, 'update_db_version' ] ); + $this->log_migration_start( $version, $upgrader ); + + try { + call_user_func( [ $upgrader, 'run' ], $required_version ); + call_user_func( [ $upgrader, 'update_db_version' ] ); + $this->log_migration_complete( $version ); + } catch ( \Throwable $e ) { + $this->log_migration_failed( $version, $e->getMessage() ); + throw $e; + } } } @@ -127,4 +135,77 @@ public function get_registry(): MigrationRegistry { public function get_prefix(): string { return $this->prefix; } + + /** + * Get a migration status reader. + * + * @return MigrationStatus + */ + public function get_status(): MigrationStatus { + return new MigrationStatus( $this ); + } + + /** + * Get the option key for the migration log. + * + * @return string + */ + public function get_log_option_key(): string { + return $this->prefix . '_migration_log'; + } + + /** + * Log migration start. + * + * @param string $version Migration version. + * @param string $class Migration class name. + */ + protected function log_migration_start( string $version, string $class ): void { + $log = get_option( $this->get_log_option_key(), [] ); + + $log[ $version ] = [ + 'version' => $version, + 'class' => $class, + 'status' => 'running', + 'started_at' => time(), + 'completed_at' => null, + 'error' => null, + ]; + + update_option( $this->get_log_option_key(), $log, false ); + } + + /** + * Log migration completion. + * + * @param string $version Migration version. + */ + protected function log_migration_complete( string $version ): void { + $log = get_option( $this->get_log_option_key(), [] ); + + if ( isset( $log[ $version ] ) ) { + $log[ $version ]['status'] = 'completed'; + $log[ $version ]['completed_at'] = time(); + + update_option( $this->get_log_option_key(), $log, false ); + } + } + + /** + * Log migration failure. + * + * @param string $version Migration version. + * @param string $error Error message. + */ + protected function log_migration_failed( string $version, string $error ): void { + $log = get_option( $this->get_log_option_key(), [] ); + + if ( isset( $log[ $version ] ) ) { + $log[ $version ]['status'] = 'failed'; + $log[ $version ]['completed_at'] = time(); + $log[ $version ]['error'] = $error; + + update_option( $this->get_log_option_key(), $log, false ); + } + } } diff --git a/src/Migration/MigrationStatus.php b/src/Migration/MigrationStatus.php new file mode 100644 index 0000000..11252eb --- /dev/null +++ b/src/Migration/MigrationStatus.php @@ -0,0 +1,119 @@ +get_status(); + * $status->get_log(); // Full migration log + * $status->get_status( '1.2.0' ); // Single migration entry + * $status->is_running(); // Any migration currently running? + * $status->get_summary(); // { total, completed, pending, failed, running } + */ +class MigrationStatus { + + /** + * Migration manager. + * + * @var MigrationManager + */ + protected MigrationManager $manager; + + /** + * @param MigrationManager $manager Migration manager instance. + */ + public function __construct( MigrationManager $manager ) { + $this->manager = $manager; + } + + /** + * Get the full migration execution log. + * + * @return array Version-keyed array of log entries. + */ + public function get_log(): array { + return get_option( $this->get_option_key(), [] ); + } + + /** + * Get the status of a specific migration version. + * + * @param string $version Migration version (e.g., '1.2.0'). + * + * @return array|null Log entry or null if not found. + */ + public function get_status( string $version ): ?array { + $log = $this->get_log(); + + return $log[ $version ] ?? null; + } + + /** + * Check if any migration is currently running. + * + * @return bool + */ + public function is_running(): bool { + $log = $this->get_log(); + + foreach ( $log as $entry ) { + if ( $entry['status'] === 'running' ) { + return true; + } + } + + return false; + } + + /** + * Get a summary of migration status counts. + * + * @return array{total: int, completed: int, failed: int, running: int, pending: int} + */ + public function get_summary(): array { + $log = $this->get_log(); + $pending = $this->manager->get_registry()->get_pending_migrations(); + + $summary = [ + 'total' => 0, + 'completed' => 0, + 'failed' => 0, + 'running' => 0, + 'pending' => count( $pending ), + ]; + + foreach ( $log as $entry ) { + ++$summary['total']; + + if ( isset( $summary[ $entry['status'] ] ) ) { + ++$summary[ $entry['status'] ]; + } + } + + // Include pending migrations in total. + $summary['total'] += $summary['pending']; + + return $summary; + } + + /** + * Clear the migration log. + */ + public function clear_log(): void { + delete_option( $this->get_option_key() ); + } + + /** + * Get the option key for the migration log. + * + * @return string + */ + public function get_option_key(): string { + return $this->manager->get_log_option_key(); + } +} diff --git a/tests/phpunit/tests/Migration/BackgroundProcessTest.php b/tests/phpunit/tests/Migration/BackgroundProcessTest.php index a327cab..f13e911 100644 --- a/tests/phpunit/tests/Migration/BackgroundProcessTest.php +++ b/tests/phpunit/tests/Migration/BackgroundProcessTest.php @@ -46,10 +46,19 @@ public function test_push_to_queue_stores_items_in_option(): void { ->once() ->andReturn( [] ); + Functions\expect( 'get_option' ) + ->with( 'testplugin_bg_test_process_total', 0 ) + ->once() + ->andReturn( 0 ); + Functions\expect( 'update_option' ) ->with( 'testplugin_bg_test_process', [ 'item1', 'item2' ], false ) ->once(); + Functions\expect( 'update_option' ) + ->with( 'testplugin_bg_test_process_total', 2, false ) + ->once(); + $result = $this->process->push_to_queue( [ 'item1', 'item2' ] ); $this->assertSame( $this->process, $result ); @@ -61,10 +70,19 @@ public function test_push_to_queue_appends_to_existing_items(): void { ->once() ->andReturn( [ 'existing' ] ); + Functions\expect( 'get_option' ) + ->with( 'testplugin_bg_test_process_total', 0 ) + ->once() + ->andReturn( 1 ); + Functions\expect( 'update_option' ) ->with( 'testplugin_bg_test_process', [ 'existing', 'new_item' ], false ) ->once(); + Functions\expect( 'update_option' ) + ->with( 'testplugin_bg_test_process_total', 2, false ) + ->once(); + $this->process->push_to_queue( [ 'new_item' ] ); } @@ -114,6 +132,10 @@ public function test_handle_cron_processes_items(): void { ->with( 'testplugin_bg_test_process' ) ->once(); + Functions\expect( 'delete_option' ) + ->with( 'testplugin_bg_test_process_total' ) + ->once(); + $this->process->handle_cron(); $this->assertSame( [ 'item_a', 'item_b' ], $this->process->processed_items ); @@ -149,15 +171,69 @@ public function test_is_processing_returns_false_when_empty(): void { $this->assertFalse( $this->process->is_processing() ); } - public function test_cancel_clears_queue_and_unschedules(): void { + public function test_cancel_clears_queue_total_and_unschedules(): void { Functions\expect( 'delete_option' ) ->with( 'testplugin_bg_test_process' ) ->once(); + Functions\expect( 'delete_option' ) + ->with( 'testplugin_bg_test_process_total' ) + ->once(); + Functions\expect( 'wp_clear_scheduled_hook' ) ->with( 'testplugin_process_test_process' ) ->once(); $this->process->cancel(); } + + public function test_get_progress_returns_stats(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_bg_test_process_total', 0 ) + ->once() + ->andReturn( 50 ); + + Functions\expect( 'get_option' ) + ->with( 'testplugin_bg_test_process', [] ) + ->once() + ->andReturn( array_fill( 0, 20, 'item' ) ); + + Functions\expect( 'get_option' ) + ->with( 'testplugin_bg_test_process', false ) + ->once() + ->andReturn( array_fill( 0, 20, 'item' ) ); + + $progress = $this->process->get_progress(); + + $this->assertTrue( $progress['is_processing'] ); + $this->assertSame( 50, $progress['total'] ); + $this->assertSame( 30, $progress['completed'] ); + $this->assertSame( 20, $progress['remaining'] ); + $this->assertSame( 60, $progress['percentage'] ); + } + + public function test_get_progress_returns_zero_when_no_items(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_bg_test_process_total', 0 ) + ->once() + ->andReturn( 0 ); + + Functions\expect( 'get_option' ) + ->with( 'testplugin_bg_test_process', [] ) + ->once() + ->andReturn( [] ); + + Functions\expect( 'get_option' ) + ->with( 'testplugin_bg_test_process', false ) + ->once() + ->andReturn( false ); + + $progress = $this->process->get_progress(); + + $this->assertFalse( $progress['is_processing'] ); + $this->assertSame( 0, $progress['total'] ); + $this->assertSame( 0, $progress['completed'] ); + $this->assertSame( 0, $progress['remaining'] ); + $this->assertSame( 0, $progress['percentage'] ); + } } diff --git a/tests/phpunit/tests/Migration/MigrationManagerTest.php b/tests/phpunit/tests/Migration/MigrationManagerTest.php index eb09000..467cc15 100644 --- a/tests/phpunit/tests/Migration/MigrationManagerTest.php +++ b/tests/phpunit/tests/Migration/MigrationManagerTest.php @@ -4,6 +4,7 @@ use WeDevs\WPKit\Migration\MigrationManager; use WeDevs\WPKit\Migration\MigrationRegistry; +use WeDevs\WPKit\Migration\MigrationStatus; use WeDevs\WPKit\Tests\TestCase; use Brain\Monkey\Functions; use Mockery; @@ -113,7 +114,7 @@ public function test_do_upgrade_runs_all_pending_and_fires_action(): void { ->once() ->andReturn( [] ); - Functions\expect( 'update_option' )->once(); + Functions\expect( 'update_option' )->zeroOrMoreTimes(); Functions\expect( 'delete_option' ) ->with( 'testplugin_is_upgrading_db' ) ->once(); @@ -124,4 +125,114 @@ public function test_do_upgrade_runs_all_pending_and_fires_action(): void { $this->manager->do_upgrade(); } + + public function test_get_log_option_key(): void { + $this->assertSame( 'testplugin_migration_log', $this->manager->get_log_option_key() ); + } + + public function test_get_status_returns_migration_status_instance(): void { + $status = $this->manager->get_status(); + + $this->assertInstanceOf( MigrationStatus::class, $status ); + } + + public function test_do_upgrade_logs_migration_start_and_complete(): void { + $migration_class = Mockery::mock( 'alias:TestMigration_V_1_0_0' ); + $migration_class->shouldReceive( 'run' )->with( null )->once(); + $migration_class->shouldReceive( 'update_db_version' )->once(); + + // Stateful mock: track migration log across get/update_option calls. + $stored_log = []; + $logged_entries = []; + + Functions\expect( 'get_option' ) + ->andReturnUsing( function ( $key, $default = false ) use ( &$stored_log ) { + if ( $key === 'testplugin_is_upgrading_db' && $default === null ) { + return [ '1.0.0' => [ 'TestMigration_V_1_0_0' ] ]; + } + if ( $key === 'testplugin_migration_log' ) { + return $stored_log; + } + return $default; + } ); + + Functions\expect( 'update_option' ) + ->andReturnUsing( function ( $key, $value ) use ( &$stored_log, &$logged_entries ) { + if ( $key === 'testplugin_migration_log' ) { + $stored_log = $value; + $logged_entries[] = $value; + } + return true; + } ); + + Functions\expect( 'delete_option' ) + ->with( 'testplugin_is_upgrading_db' ) + ->once(); + + Functions\expect( 'do_action' ) + ->with( 'testplugin_upgrade_finished' ) + ->once(); + + $this->manager->do_upgrade(); + + $this->assertCount( 2, $logged_entries ); + + // First call: log_migration_start. + $this->assertArrayHasKey( '1.0.0', $logged_entries[0] ); + $this->assertSame( 'running', $logged_entries[0]['1.0.0']['status'] ); + $this->assertSame( 'TestMigration_V_1_0_0', $logged_entries[0]['1.0.0']['class'] ); + $this->assertNotNull( $logged_entries[0]['1.0.0']['started_at'] ); + $this->assertNull( $logged_entries[0]['1.0.0']['completed_at'] ); + + // Second call: log_migration_complete. + $this->assertSame( 'completed', $logged_entries[1]['1.0.0']['status'] ); + $this->assertNotNull( $logged_entries[1]['1.0.0']['completed_at'] ); + $this->assertNull( $logged_entries[1]['1.0.0']['error'] ); + } + + public function test_do_upgrade_logs_migration_failure(): void { + $exception = new \RuntimeException( 'Table creation failed' ); + $migration_class = Mockery::mock( 'alias:FailingMigration_V_2_0_0' ); + $migration_class->shouldReceive( 'run' )->with( null )->once()->andThrow( $exception ); + + // Stateful mock for migration log. + $stored_log = []; + $logged_entries = []; + + Functions\expect( 'get_option' ) + ->andReturnUsing( function ( $key, $default = false ) use ( &$stored_log ) { + if ( $key === 'testplugin_is_upgrading_db' && $default === null ) { + return [ '2.0.0' => [ 'FailingMigration_V_2_0_0' ] ]; + } + if ( $key === 'testplugin_migration_log' ) { + return $stored_log; + } + return $default; + } ); + + Functions\expect( 'update_option' ) + ->andReturnUsing( function ( $key, $value ) use ( &$stored_log, &$logged_entries ) { + if ( $key === 'testplugin_migration_log' ) { + $stored_log = $value; + $logged_entries[] = $value; + } + return true; + } ); + + try { + $this->manager->do_upgrade(); + $this->fail( 'Expected RuntimeException was not thrown.' ); + } catch ( \RuntimeException $e ) { + $this->assertSame( 'Table creation failed', $e->getMessage() ); + } + + $this->assertCount( 2, $logged_entries ); + + // First call: log_migration_start. + $this->assertSame( 'running', $logged_entries[0]['2.0.0']['status'] ); + + // Second call: log_migration_failed. + $this->assertSame( 'failed', $logged_entries[1]['2.0.0']['status'] ); + $this->assertSame( 'Table creation failed', $logged_entries[1]['2.0.0']['error'] ); + } } diff --git a/tests/phpunit/tests/Migration/MigrationStatusTest.php b/tests/phpunit/tests/Migration/MigrationStatusTest.php new file mode 100644 index 0000000..889c4b6 --- /dev/null +++ b/tests/phpunit/tests/Migration/MigrationStatusTest.php @@ -0,0 +1,185 @@ +registry = Mockery::mock( MigrationRegistry::class ); + $this->manager = new MigrationManager( $this->registry, 'testplugin' ); + $this->status = new MigrationStatus( $this->manager ); + } + + public function test_get_option_key_returns_prefixed_key(): void { + $this->assertSame( 'testplugin_migration_log', $this->status->get_option_key() ); + } + + public function test_get_log_returns_empty_array_when_no_log(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( [] ); + + $this->assertSame( [], $this->status->get_log() ); + } + + public function test_get_log_returns_stored_log(): void { + $log = [ + '1.0.0' => [ + 'version' => '1.0.0', + 'class' => 'V_1_0_0', + 'status' => 'completed', + 'started_at' => 1708900000, + 'completed_at' => 1708900002, + 'error' => null, + ], + ]; + + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( $log ); + + $this->assertSame( $log, $this->status->get_log() ); + } + + public function test_get_status_returns_entry_for_version(): void { + $entry = [ + 'version' => '1.0.0', + 'class' => 'V_1_0_0', + 'status' => 'completed', + 'started_at' => 1708900000, + 'completed_at' => 1708900002, + 'error' => null, + ]; + + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( [ '1.0.0' => $entry ] ); + + $this->assertSame( $entry, $this->status->get_status( '1.0.0' ) ); + } + + public function test_get_status_returns_null_for_unknown_version(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( [] ); + + $this->assertNull( $this->status->get_status( '9.9.9' ) ); + } + + public function test_is_running_returns_true_when_migration_running(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( [ + '1.0.0' => [ 'status' => 'completed' ], + '1.1.0' => [ 'status' => 'running' ], + ] ); + + $this->assertTrue( $this->status->is_running() ); + } + + public function test_is_running_returns_false_when_none_running(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( [ + '1.0.0' => [ 'status' => 'completed' ], + '1.1.0' => [ 'status' => 'completed' ], + ] ); + + $this->assertFalse( $this->status->is_running() ); + } + + public function test_is_running_returns_false_when_log_empty(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( [] ); + + $this->assertFalse( $this->status->is_running() ); + } + + public function test_get_summary_counts_statuses(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( [ + '1.0.0' => [ 'status' => 'completed' ], + '1.1.0' => [ 'status' => 'completed' ], + '1.2.0' => [ 'status' => 'failed' ], + '1.3.0' => [ 'status' => 'running' ], + ] ); + + $this->registry + ->shouldReceive( 'get_pending_migrations' ) + ->once() + ->andReturn( [ + '1.4.0' => 'V_1_4_0', + '1.5.0' => 'V_1_5_0', + ] ); + + $summary = $this->status->get_summary(); + + $this->assertSame( 6, $summary['total'] ); + $this->assertSame( 2, $summary['completed'] ); + $this->assertSame( 1, $summary['failed'] ); + $this->assertSame( 1, $summary['running'] ); + $this->assertSame( 2, $summary['pending'] ); + } + + public function test_get_summary_with_empty_log_and_no_pending(): void { + Functions\expect( 'get_option' ) + ->with( 'testplugin_migration_log', [] ) + ->once() + ->andReturn( [] ); + + $this->registry + ->shouldReceive( 'get_pending_migrations' ) + ->once() + ->andReturn( [] ); + + $summary = $this->status->get_summary(); + + $this->assertSame( 0, $summary['total'] ); + $this->assertSame( 0, $summary['completed'] ); + $this->assertSame( 0, $summary['failed'] ); + $this->assertSame( 0, $summary['running'] ); + $this->assertSame( 0, $summary['pending'] ); + } + + public function test_clear_log_deletes_option(): void { + Functions\expect( 'delete_option' ) + ->with( 'testplugin_migration_log' ) + ->once(); + + $this->status->clear_log(); + } +} From 2060efe83e9c35c09be3193340e0525fe155b5af Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 26 Feb 2026 12:30:09 +0600 Subject: [PATCH 3/9] Add GitHub Actions workflow for PHPUnit tests Co-Authored-By: Claude Opus 4.6 --- .github/workflows/php-test.yml | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/php-test.yml diff --git a/.github/workflows/php-test.yml b/.github/workflows/php-test.yml new file mode 100644 index 0000000..9f900b5 --- /dev/null +++ b/.github/workflows/php-test.yml @@ -0,0 +1,63 @@ +name: PHP Unit Testing + +on: + push: + branches: + - main + - 'feature/**' + - 'release/**' + paths: + - '.github/workflows/php-test.yml' + - '**.php' + - 'phpunit.xml.dist' + - 'composer.json' + - 'composer.lock' + pull_request: + paths: + - '.github/workflows/php-test.yml' + - '**.php' + - 'phpunit.xml.dist' + - 'composer.json' + - 'composer.lock' + types: + - opened + - reopened + - synchronize + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + test: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: true + matrix: + php: + - '7.4' + - '8.0' + - '8.1' + - '8.2' + - '8.3' + - '8.4' + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Validate Composer configuration + run: composer validate --strict + + - name: Install PHP dependencies + uses: ramsey/composer-install@v3 + with: + composer-options: '--prefer-dist' + + - name: Run tests + run: composer test From ac2b266883aa1af14a7cffd489404bbbfe953fec Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 26 Feb 2026 14:38:47 +0600 Subject: [PATCH 4/9] Replace all AJAX with REST API and refactor controllers - Remove DismissalHandler (AJAX dismissal) and merge dismiss endpoint into NoticeRESTController as POST /notices/dismiss - Extract migration REST endpoints from MigrationHooks into dedicated MigrationRESTController with GET /migration/status and POST /migration/upgrade - Rename NotificationHelper::ajax_action() to rest_action() with REST-oriented signature (endpoint, method, nonce) - Add WP REST class stubs to test bootstrap for Brain\Monkey compat - Update tests and developer guide to reflect all changes Co-Authored-By: Claude Opus 4.6 --- docs/developer-guide.md | 71 ++++----- src/AdminNotification/DismissalHandler.php | 58 ------- .../NoticeRESTController.php | 40 +++++ src/AdminNotification/NotificationHelper.php | 19 +-- src/Migration/MigrationHooks.php | 68 +------- src/Migration/MigrationRESTController.php | 114 ++++++++++++++ tests/bootstrap.php | 28 ++++ .../DismissalHandlerTest.php | 113 ------------- .../NoticeRESTControllerTest.php | 148 ++++++++++++++++++ .../NotificationHelperTest.php | 23 ++- .../Migration/MigrationRESTControllerTest.php | 119 ++++++++++++++ 11 files changed, 512 insertions(+), 289 deletions(-) delete mode 100644 src/AdminNotification/DismissalHandler.php create mode 100644 src/Migration/MigrationRESTController.php delete mode 100644 tests/phpunit/tests/AdminNotification/DismissalHandlerTest.php create mode 100644 tests/phpunit/tests/AdminNotification/NoticeRESTControllerTest.php create mode 100644 tests/phpunit/tests/Migration/MigrationRESTControllerTest.php diff --git a/docs/developer-guide.md b/docs/developer-guide.md index a5e6f69..b99e11e 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -80,8 +80,8 @@ use WeDevs\WPKit\DataLayer\DataLayerFactory; use WeDevs\WPKit\Migration\MigrationRegistry; use WeDevs\WPKit\Migration\MigrationManager; use WeDevs\WPKit\Migration\MigrationHooks; +use WeDevs\WPKit\Migration\MigrationRESTController; use WeDevs\WPKit\AdminNotification\NoticeManager; -use WeDevs\WPKit\AdminNotification\DismissalHandler; use WeDevs\WPKit\AdminNotification\NoticeRESTController; // 1. DataLayer @@ -100,9 +100,10 @@ $manager = new MigrationManager( $registry, 'myplugin' ); // 3. Admin Notifications $notices = new NoticeManager( 'myplugin' ); -( new DismissalHandler( 'myplugin' ) )->register(); -add_action( 'rest_api_init', function () use ( $notices ) { +// 4. REST API (migration + notifications) +add_action( 'rest_api_init', function () use ( $manager, $notices ) { + ( new MigrationRESTController( $manager, 'myplugin/v1' ) )->register_routes(); ( new NoticeRESTController( $notices, 'myplugin/v1' ) )->register_routes(); } ); ``` @@ -983,9 +984,13 @@ $registry->get_pending_migrations(); // versions > installed, sorted by versio $manager = new MigrationManager( $registry, 'myplugin' ); $manager->do_upgrade(); // Runs all pending, fires {prefix}_upgrade_finished -// MigrationHooks: adds WordPress AJAX handler and filter hooks +// MigrationHooks: adds WordPress filter hooks $hooks = new MigrationHooks( $manager, 'myplugin' ); $hooks->register(); + +// MigrationRESTController: REST API for status and upgrade +$rest = new MigrationRESTController( $manager, 'myplugin/v1' ); +add_action( 'rest_api_init', [ $rest, 'register_routes' ] ); ``` #### WordPress Hooks Registered by MigrationHooks @@ -994,11 +999,15 @@ $hooks->register(); |------|------|-------------| | `{prefix}_upgrade_is_upgrade_required` | filter | Returns whether upgrade is needed | | `{prefix}_upgrade_upgrades` | filter | Returns pending upgrade list | -| `wp_ajax_{prefix}_do_upgrade` | action | AJAX endpoint for admin-triggered upgrades | | `{prefix}_upgrade_finished` | action | Fires after all migrations complete | | `{prefix}_upgrade_is_not_required` | action | Fires when no upgrade is needed | -The AJAX handler verifies the nonce `{prefix}_admin` and requires the `update_plugins` capability. +#### Migration REST Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `{prefix}/v1/migration/status` | GET | Returns migration status, log, and summary | +| `{prefix}/v1/migration/upgrade` | POST | Triggers admin upgrade (requires `update_plugins` capability) | #### Migration Status @@ -1045,7 +1054,7 @@ if ( $entry && $entry['status'] === 'failed' ) { #### REST API for Migration Status -`MigrationHooks` registers a REST endpoint for polling migration status from the admin UI: +`MigrationRESTController` provides REST endpoints for polling migration status and triggering upgrades from the admin UI: ``` GET /wp-json/{prefix}/v1/migration/status @@ -1222,7 +1231,7 @@ if ( $progress['is_processing'] ) { ## Admin Notifications -A system for collecting, filtering, and serving admin notices via REST API with AJAX dismissal support. +A system for collecting, filtering, and serving admin notices via REST API. ### Notice Providers @@ -1317,7 +1326,7 @@ $notice = NotificationHelper::warning( 'Update Available', 'Version 2.0 is out.' 'is_dismissible' => true, 'actions' => [ NotificationHelper::link_action( 'Update Now', admin_url( 'update-core.php' ) ), - NotificationHelper::ajax_action( 'Remind Later', 'myplugin_snooze', 'myplugin_admin' ), + NotificationHelper::rest_action( 'Remind Later', '/wp-json/myplugin/v1/snooze' ), ], ] ); ``` @@ -1330,26 +1339,9 @@ $notice = NotificationHelper::warning( 'Update Available', 'Version 2.0 is out.' | info | 10 | local | | success | 10 | local | -### DismissalHandler - -Handles AJAX notice dismissal and persists dismissed notice keys: - -```php -$handler = new DismissalHandler( 'myplugin' ); -$handler->register(); // Registers wp_ajax_{prefix}_dismiss_notice -``` - -Dismissed notices are stored in the `{prefix}_dismissed_notices` option as an array of keys. - -**AJAX endpoint details:** -- Action: `wp_ajax_myplugin_dismiss_notice` -- Nonce action: `myplugin_admin` -- Required capability: `manage_options` -- POST parameter: `key` (the notice key to dismiss) - ### REST Controller -Exposes notices via the WordPress REST API: +Exposes notices via the WordPress REST API and handles notice dismissal: ```php $controller = new NoticeRESTController( $manager, 'myplugin/v1' ); @@ -1359,13 +1351,16 @@ add_action( 'rest_api_init', function () use ( $controller ) { } ); ``` -**Endpoint:** `GET /wp-json/myplugin/v1/notices/admin` +**Endpoints:** -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `scope` | string | No | `'local'` or `'global'`. Omit for all. | +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/wp-json/myplugin/v1/notices/admin` | GET | Get all notices (optional `scope` param: `local` or `global`) | +| `/wp-json/myplugin/v1/notices/dismiss` | POST | Dismiss a notice (required `key` param) | + +Dismissed notices are stored in the `{prefix}_dismissed_notices` option as an array of keys. -**Permission:** Requires `manage_options` capability. +**Permission:** All endpoints require `manage_options` capability. --- @@ -1697,7 +1692,6 @@ The `{prefix}` is the plugin-level prefix set via `set_filter_prefix()` (e.g., ` |------|------|------------|-------------| | `{prefix}_upgrade_is_upgrade_required` | filter | `$required` | Check if upgrade is needed | | `{prefix}_upgrade_upgrades` | filter | `$upgrades` | Get pending upgrades list | -| `wp_ajax_{prefix}_do_upgrade` | action | — | AJAX endpoint for admin-triggered upgrades | | `{prefix}_upgrade_finished` | action | — | All migrations completed | | `{prefix}_upgrade_is_not_required` | action | — | No upgrade needed | @@ -1706,13 +1700,20 @@ The `{prefix}` is the plugin-level prefix set via `set_filter_prefix()` (e.g., ` | Route | Method | Permission | Description | |-------|--------|------------|-------------| | `{prefix}/v1/migration/status` | GET | `update_plugins` | Returns migration log, summary, and status | +| `{prefix}/v1/migration/upgrade` | POST | `update_plugins` | Triggers admin upgrade | ### Notification Hooks | Hook | Type | Parameters | Description | |------|------|------------|-------------| | `{prefix}_admin_notices` | filter | `$notices` | Collect/modify admin notices | -| `wp_ajax_{prefix}_dismiss_notice` | action | — | AJAX dismissal endpoint | + +### Notification REST Endpoints + +| Route | Method | Permission | Description | +|-------|--------|------------|-------------| +| `{namespace}/notices/admin` | GET | `manage_options` | Get admin notices (optional `scope` param) | +| `{namespace}/notices/dismiss` | POST | `manage_options` | Dismiss a notice (required `key` param) | ### WordPress Options Used @@ -1723,4 +1724,4 @@ The `{prefix}` is the plugin-level prefix set via `set_filter_prefix()` (e.g., ` | `{prefix}_migration_log` | MigrationManager | Per-migration execution log (version, status, timestamps, errors) | | `{prefix}_bg_{action}` | BackgroundProcess | Queue storage (array of items) | | `{prefix}_bg_{action}_total` | BackgroundProcess | Total items pushed (for progress percentage) | -| `{prefix}_dismissed_notices` | DismissalHandler | Array of dismissed notice keys | +| `{prefix}_dismissed_notices` | NoticeRESTController | Array of dismissed notice keys | diff --git a/src/AdminNotification/DismissalHandler.php b/src/AdminNotification/DismissalHandler.php deleted file mode 100644 index b667513..0000000 --- a/src/AdminNotification/DismissalHandler.php +++ /dev/null @@ -1,58 +0,0 @@ -prefix = $prefix; - } - - /** - * Register AJAX hooks for dismissal. - */ - public function register(): void { - add_action( "wp_ajax_{$this->prefix}_dismiss_notice", [ $this, 'handle_dismiss' ] ); - } - - /** - * Handle the AJAX dismissal request. - */ - public function handle_dismiss(): void { - check_ajax_referer( $this->prefix . '_admin' ); - - if ( ! current_user_can( 'manage_options' ) ) { - wp_send_json_error( [ 'message' => 'Unauthorized.' ], 403 ); - return; - } - - $key = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : ''; - - if ( ! $key ) { - wp_send_json_error( [ 'message' => 'Missing notice key.' ], 400 ); - return; - } - - $dismissed = get_option( $this->prefix . '_dismissed_notices', [] ); - - if ( ! in_array( $key, $dismissed, true ) ) { - $dismissed[] = $key; - update_option( $this->prefix . '_dismissed_notices', $dismissed ); - } - - wp_send_json_success(); - } -} diff --git a/src/AdminNotification/NoticeRESTController.php b/src/AdminNotification/NoticeRESTController.php index 25fa2fa..4ab44be 100644 --- a/src/AdminNotification/NoticeRESTController.php +++ b/src/AdminNotification/NoticeRESTController.php @@ -64,6 +64,25 @@ public function register_routes(): void { ], ] ); + + register_rest_route( + $this->namespace, + '/' . $this->base . '/dismiss', + [ + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'dismiss_notice' ], + 'permission_callback' => [ $this, 'check_permission' ], + 'args' => [ + 'key' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + ], + ], + ] + ); } /** @@ -80,6 +99,27 @@ public function get_admin_notices( WP_REST_Request $request ): WP_REST_Response return rest_ensure_response( $notices ); } + /** + * Dismiss a notice by key. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response + */ + public function dismiss_notice( WP_REST_Request $request ): WP_REST_Response { + $key = $request->get_param( 'key' ); + $prefix = $this->manager->get_prefix(); + + $dismissed = get_option( $prefix . '_dismissed_notices', [] ); + + if ( ! in_array( $key, $dismissed, true ) ) { + $dismissed[] = $key; + update_option( $prefix . '_dismissed_notices', $dismissed ); + } + + return new WP_REST_Response( [ 'success' => true ], 200 ); + } + /** * Check if the current user has permission. * diff --git a/src/AdminNotification/NotificationHelper.php b/src/AdminNotification/NotificationHelper.php index 0c78325..6cc2484 100644 --- a/src/AdminNotification/NotificationHelper.php +++ b/src/AdminNotification/NotificationHelper.php @@ -96,23 +96,24 @@ public static function success( string $title, string $description, array $extra } /** - * Build an AJAX action button config. + * Build a REST API action button config. * - * @param string $text Button text. - * @param string $ajax_action The AJAX action name. - * @param string $nonce_action The nonce action. - * @param array $extra Additional action properties. + * @param string $text Button text. + * @param string $endpoint REST API endpoint URL. + * @param string $method HTTP method (default: POST). + * @param array $extra Additional action properties. * * @return array */ - public static function ajax_action( string $text, string $ajax_action, string $nonce_action, array $extra = [] ): array { + public static function rest_action( string $text, string $endpoint, string $method = 'POST', array $extra = [] ): array { return array_merge( [ 'type' => 'primary', 'text' => $text, - 'ajax_data' => [ - 'action' => $ajax_action, - '_wpnonce' => wp_create_nonce( $nonce_action ), + 'rest_data' => [ + 'endpoint' => $endpoint, + 'method' => $method, + 'nonce' => wp_create_nonce( 'wp_rest' ), ], ], $extra diff --git a/src/Migration/MigrationHooks.php b/src/Migration/MigrationHooks.php index bdc4352..7d09436 100644 --- a/src/Migration/MigrationHooks.php +++ b/src/Migration/MigrationHooks.php @@ -5,8 +5,7 @@ /** * Wires the migration system into WordPress hooks. * - * Registers filter hooks for upgrade detection, AJAX handler for - * admin-triggered upgrades, and post-upgrade cleanup. + * Registers filter hooks for upgrade detection and post-upgrade cleanup. */ class MigrationHooks { @@ -40,50 +39,8 @@ public function register(): void { add_filter( "{$this->prefix}_upgrade_is_upgrade_required", [ $this, 'is_upgrade_required' ], 1 ); add_filter( "{$this->prefix}_upgrade_upgrades", [ $this, 'get_upgrades' ], 1 ); - add_action( "wp_ajax_{$this->prefix}_do_upgrade", [ $this, 'ajax_do_upgrade' ] ); - add_action( "{$this->prefix}_upgrade_finished", [ $this, 'on_upgrade_finished' ] ); add_action( "{$this->prefix}_upgrade_is_not_required", [ $this, 'on_upgrade_not_required' ] ); - - add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); - } - - /** - * Register REST API routes for migration status. - */ - public function register_rest_routes(): void { - register_rest_route( - "{$this->prefix}/v1", - '/migration/status', - [ - 'methods' => 'GET', - 'callback' => [ $this, 'rest_get_status' ], - 'permission_callback' => function () { - return current_user_can( 'update_plugins' ); - }, - ] - ); - } - - /** - * REST callback: get migration status. - * - * @return \WP_REST_Response - */ - public function rest_get_status(): \WP_REST_Response { - $status = $this->manager->get_status(); - - return new \WP_REST_Response( - [ - 'summary' => $status->get_summary(), - 'log' => $status->get_log(), - 'is_running' => $status->is_running(), - 'is_upgrade_required' => $this->manager->is_upgrade_required(), - 'db_version' => $this->manager->get_registry()->get_db_installed_version(), - 'plugin_version' => $this->manager->get_registry()->get_plugin_version(), - ], - 200 - ); } /** @@ -118,29 +75,6 @@ public function get_upgrades( array $upgrades = [] ): array { return $upgrades; } - /** - * AJAX handler for admin-triggered upgrades. - */ - public function ajax_do_upgrade(): void { - check_ajax_referer( $this->prefix . '_admin' ); - - if ( ! current_user_can( 'update_plugins' ) ) { - wp_send_json_error( [ 'message' => 'Unauthorized.' ], 403 ); - } - - if ( $this->manager->has_ongoing_process() ) { - wp_send_json_error( [ 'message' => 'Upgrade already in progress.' ], 400 ); - } - - if ( ! $this->manager->is_upgrade_required() ) { - wp_send_json_error( [ 'message' => 'No upgrade required.' ], 400 ); - } - - $this->manager->do_upgrade(); - - wp_send_json_success( [ 'success' => true ], 201 ); - } - /** * Hook callback: after upgrade completes. */ diff --git a/src/Migration/MigrationRESTController.php b/src/Migration/MigrationRESTController.php new file mode 100644 index 0000000..fec9109 --- /dev/null +++ b/src/Migration/MigrationRESTController.php @@ -0,0 +1,114 @@ +manager = $manager; + $this->namespace = $namespace; + } + + /** + * Register REST routes. + */ + public function register_routes(): void { + register_rest_route( + $this->namespace, + '/migration/status', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_status' ], + 'permission_callback' => [ $this, 'check_permission' ], + ] + ); + + register_rest_route( + $this->namespace, + '/migration/upgrade', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'do_upgrade' ], + 'permission_callback' => [ $this, 'check_permission' ], + ] + ); + } + + /** + * Get migration status. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response + */ + public function get_status( WP_REST_Request $request ): WP_REST_Response { + $status = $this->manager->get_status(); + + return new WP_REST_Response( + [ + 'summary' => $status->get_summary(), + 'log' => $status->get_log(), + 'is_running' => $status->is_running(), + 'is_upgrade_required' => $this->manager->is_upgrade_required(), + 'db_version' => $this->manager->get_registry()->get_db_installed_version(), + 'plugin_version' => $this->manager->get_registry()->get_plugin_version(), + ], + 200 + ); + } + + /** + * Trigger admin upgrade. + * + * @param WP_REST_Request $request REST request. + * + * @return WP_REST_Response + */ + public function do_upgrade( WP_REST_Request $request ): WP_REST_Response { + if ( $this->manager->has_ongoing_process() ) { + return new WP_REST_Response( [ 'message' => 'Upgrade already in progress.' ], 400 ); + } + + if ( ! $this->manager->is_upgrade_required() ) { + return new WP_REST_Response( [ 'message' => 'No upgrade required.' ], 400 ); + } + + $this->manager->do_upgrade(); + + return new WP_REST_Response( [ 'success' => true ], 201 ); + } + + /** + * Check if the current user has permission. + * + * @return bool + */ + public function check_permission(): bool { + return current_user_can( 'update_plugins' ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 15fde5e..b8eb0bb 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,6 +7,34 @@ require_once dirname( __DIR__ ) . '/vendor/autoload.php'; +// Stub WordPress REST API classes used in source code. +if ( ! class_exists( 'WP_REST_Server' ) ) { + class WP_REST_Server { + const READABLE = 'GET'; + const CREATABLE = 'POST'; + } +} + +if ( ! class_exists( 'WP_REST_Request' ) ) { + class WP_REST_Request { + public function get_param( string $key ) { + return null; + } + } +} + +if ( ! class_exists( 'WP_REST_Response' ) ) { + class WP_REST_Response { + public $data; + public $status; + + public function __construct( $data = null, int $status = 200 ) { + $this->data = $data; + $this->status = $status; + } + } +} + // Define WordPress constants used in source code. if ( ! defined( 'ABSPATH' ) ) { define( 'ABSPATH', '/tmp/wordpress/' ); diff --git a/tests/phpunit/tests/AdminNotification/DismissalHandlerTest.php b/tests/phpunit/tests/AdminNotification/DismissalHandlerTest.php deleted file mode 100644 index 926020e..0000000 --- a/tests/phpunit/tests/AdminNotification/DismissalHandlerTest.php +++ /dev/null @@ -1,113 +0,0 @@ -handler = new DismissalHandler( 'testplugin' ); - } - - public function test_register_hooks_ajax_action(): void { - Functions\expect( 'add_action' ) - ->with( 'wp_ajax_testplugin_dismiss_notice', [ $this->handler, 'handle_dismiss' ] ) - ->once(); - - $this->handler->register(); - } - - public function test_handle_dismiss_adds_key_to_dismissed_list(): void { - $_POST['key'] = 'my_notice_key'; - - Functions\expect( 'check_ajax_referer' ) - ->with( 'testplugin_admin' ) - ->once(); - - Functions\expect( 'current_user_can' ) - ->with( 'manage_options' ) - ->once() - ->andReturn( true ); - - Functions\expect( 'sanitize_text_field' ) - ->once() - ->andReturnFirstArg(); - - Functions\expect( 'wp_unslash' ) - ->once() - ->andReturnFirstArg(); - - Functions\expect( 'get_option' ) - ->with( 'testplugin_dismissed_notices', [] ) - ->once() - ->andReturn( [] ); - - Functions\expect( 'update_option' ) - ->with( 'testplugin_dismissed_notices', [ 'my_notice_key' ] ) - ->once(); - - Functions\expect( 'wp_send_json_success' )->once(); - - $this->handler->handle_dismiss(); - - unset( $_POST['key'] ); - } - - public function test_handle_dismiss_rejects_unauthorized_user(): void { - Functions\expect( 'check_ajax_referer' )->once(); - - Functions\expect( 'current_user_can' ) - ->with( 'manage_options' ) - ->once() - ->andReturn( false ); - - Functions\expect( 'wp_send_json_error' ) - ->with( [ 'message' => 'Unauthorized.' ], 403 ) - ->once(); - - $this->handler->handle_dismiss(); - } - - public function test_handle_dismiss_rejects_missing_key(): void { - // No $_POST['key'] set. - Functions\expect( 'check_ajax_referer' )->once(); - Functions\expect( 'current_user_can' )->andReturn( true ); - - Functions\expect( 'wp_send_json_error' ) - ->with( [ 'message' => 'Missing notice key.' ], 400 ) - ->once(); - - $this->handler->handle_dismiss(); - } - - public function test_handle_dismiss_skips_duplicate_keys(): void { - $_POST['key'] = 'already_dismissed'; - - Functions\expect( 'check_ajax_referer' )->once(); - Functions\expect( 'current_user_can' )->andReturn( true ); - Functions\expect( 'sanitize_text_field' )->andReturnFirstArg(); - Functions\expect( 'wp_unslash' )->andReturnFirstArg(); - - Functions\expect( 'get_option' ) - ->with( 'testplugin_dismissed_notices', [] ) - ->once() - ->andReturn( [ 'already_dismissed' ] ); - - // update_option should NOT be called since key is already dismissed. - Functions\expect( 'update_option' )->never(); - Functions\expect( 'wp_send_json_success' )->once(); - - $this->handler->handle_dismiss(); - - unset( $_POST['key'] ); - } -} diff --git a/tests/phpunit/tests/AdminNotification/NoticeRESTControllerTest.php b/tests/phpunit/tests/AdminNotification/NoticeRESTControllerTest.php new file mode 100644 index 0000000..14e7d12 --- /dev/null +++ b/tests/phpunit/tests/AdminNotification/NoticeRESTControllerTest.php @@ -0,0 +1,148 @@ +manager = Mockery::mock( NoticeManager::class ); + $this->controller = new NoticeRESTController( $this->manager, 'testplugin/v1' ); + } + + public function test_register_routes_calls_register_rest_route(): void { + Functions\expect( 'register_rest_route' ) + ->twice(); + + $this->controller->register_routes(); + } + + public function test_get_admin_notices_returns_notices(): void { + $notices = [ + [ 'type' => 'info', 'title' => 'Test', 'description' => 'A notice.' ], + ]; + + $this->manager + ->shouldReceive( 'get_notices' ) + ->with( '' ) + ->once() + ->andReturn( $notices ); + + $rest_response = new \WP_REST_Response( $notices ); + + Functions\expect( 'rest_ensure_response' ) + ->once() + ->andReturn( $rest_response ); + + $request = Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' ) + ->with( 'scope' ) + ->andReturn( '' ); + + $result = $this->controller->get_admin_notices( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $result ); + $this->assertSame( $notices, $result->data ); + } + + public function test_get_admin_notices_filters_by_scope(): void { + $this->manager + ->shouldReceive( 'get_notices' ) + ->with( 'global' ) + ->once() + ->andReturn( [] ); + + Functions\expect( 'rest_ensure_response' ) + ->once() + ->andReturn( new \WP_REST_Response( [] ) ); + + $request = Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' ) + ->with( 'scope' ) + ->andReturn( 'global' ); + + $this->controller->get_admin_notices( $request ); + } + + public function test_dismiss_notice_adds_key_to_dismissed_list(): void { + $this->manager + ->shouldReceive( 'get_prefix' ) + ->andReturn( 'testplugin' ); + + Functions\expect( 'get_option' ) + ->with( 'testplugin_dismissed_notices', [] ) + ->once() + ->andReturn( [] ); + + Functions\expect( 'update_option' ) + ->with( 'testplugin_dismissed_notices', [ 'my_notice_key' ] ) + ->once(); + + $request = Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' ) + ->with( 'key' ) + ->andReturn( 'my_notice_key' ); + + $response = $this->controller->dismiss_notice( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( [ 'success' => true ], $response->data ); + } + + public function test_dismiss_notice_skips_duplicate_keys(): void { + $this->manager + ->shouldReceive( 'get_prefix' ) + ->andReturn( 'testplugin' ); + + Functions\expect( 'get_option' ) + ->with( 'testplugin_dismissed_notices', [] ) + ->once() + ->andReturn( [ 'already_dismissed' ] ); + + Functions\expect( 'update_option' )->never(); + + $request = Mockery::mock( \WP_REST_Request::class ); + $request->shouldReceive( 'get_param' ) + ->with( 'key' ) + ->andReturn( 'already_dismissed' ); + + $response = $this->controller->dismiss_notice( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + } + + public function test_check_permission_requires_manage_options(): void { + Functions\expect( 'current_user_can' ) + ->with( 'manage_options' ) + ->once() + ->andReturn( true ); + + $this->assertTrue( $this->controller->check_permission() ); + } + + public function test_check_permission_denies_unauthorized_user(): void { + Functions\expect( 'current_user_can' ) + ->with( 'manage_options' ) + ->once() + ->andReturn( false ); + + $this->assertFalse( $this->controller->check_permission() ); + } +} diff --git a/tests/phpunit/tests/AdminNotification/NotificationHelperTest.php b/tests/phpunit/tests/AdminNotification/NotificationHelperTest.php index 5f4df9d..c79dc3f 100644 --- a/tests/phpunit/tests/AdminNotification/NotificationHelperTest.php +++ b/tests/phpunit/tests/AdminNotification/NotificationHelperTest.php @@ -55,24 +55,33 @@ public function test_extra_params_override_defaults(): void { $this->assertSame( 'custom_key', $result['key'] ); } - public function test_ajax_action_creates_button_config(): void { + public function test_rest_action_creates_button_config(): void { Functions\expect( 'wp_create_nonce' ) - ->with( 'my_nonce_action' ) + ->with( 'wp_rest' ) ->once() ->andReturn( 'nonce_value_123' ); - $result = NotificationHelper::ajax_action( 'Click Me', 'my_ajax_action', 'my_nonce_action' ); + $result = NotificationHelper::rest_action( 'Click Me', '/wp-json/myplugin/v1/upgrade' ); $this->assertSame( 'primary', $result['type'] ); $this->assertSame( 'Click Me', $result['text'] ); - $this->assertSame( 'my_ajax_action', $result['ajax_data']['action'] ); - $this->assertSame( 'nonce_value_123', $result['ajax_data']['_wpnonce'] ); + $this->assertSame( '/wp-json/myplugin/v1/upgrade', $result['rest_data']['endpoint'] ); + $this->assertSame( 'POST', $result['rest_data']['method'] ); + $this->assertSame( 'nonce_value_123', $result['rest_data']['nonce'] ); } - public function test_ajax_action_accepts_extra_params(): void { + public function test_rest_action_accepts_custom_method(): void { Functions\expect( 'wp_create_nonce' )->andReturn( 'nonce' ); - $result = NotificationHelper::ajax_action( 'Btn', 'action', 'nonce_action', [ + $result = NotificationHelper::rest_action( 'Delete', '/wp-json/myplugin/v1/item', 'DELETE' ); + + $this->assertSame( 'DELETE', $result['rest_data']['method'] ); + } + + public function test_rest_action_accepts_extra_params(): void { + Functions\expect( 'wp_create_nonce' )->andReturn( 'nonce' ); + + $result = NotificationHelper::rest_action( 'Btn', '/endpoint', 'POST', [ 'type' => 'secondary', ] ); diff --git a/tests/phpunit/tests/Migration/MigrationRESTControllerTest.php b/tests/phpunit/tests/Migration/MigrationRESTControllerTest.php new file mode 100644 index 0000000..f0c76b5 --- /dev/null +++ b/tests/phpunit/tests/Migration/MigrationRESTControllerTest.php @@ -0,0 +1,119 @@ +manager = Mockery::mock( MigrationManager::class ); + $this->controller = new MigrationRESTController( $this->manager, 'testplugin/v1' ); + } + + public function test_register_routes_registers_status_and_upgrade(): void { + Functions\expect( 'register_rest_route' ) + ->twice(); + + $this->controller->register_routes(); + } + + public function test_get_status_returns_migration_info(): void { + $status = Mockery::mock( MigrationStatus::class ); + $status->shouldReceive( 'get_summary' )->once()->andReturn( [ + 'total' => 2, + 'completed' => 2, + 'failed' => 0, + 'running' => 0, + 'pending' => 0, + ] ); + $status->shouldReceive( 'get_log' )->once()->andReturn( [] ); + $status->shouldReceive( 'is_running' )->once()->andReturn( false ); + + $this->manager->shouldReceive( 'get_status' )->once()->andReturn( $status ); + $this->manager->shouldReceive( 'is_upgrade_required' )->once()->andReturn( false ); + + $registry = Mockery::mock( MigrationRegistry::class ); + $registry->shouldReceive( 'get_db_installed_version' )->once()->andReturn( '1.2.0' ); + $registry->shouldReceive( 'get_plugin_version' )->once()->andReturn( '1.2.0' ); + $this->manager->shouldReceive( 'get_registry' )->andReturn( $registry ); + + $request = Mockery::mock( \WP_REST_Request::class ); + $response = $this->controller->get_status( $request ); + + $this->assertInstanceOf( \WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->status ); + $this->assertFalse( $response->data['is_running'] ); + $this->assertFalse( $response->data['is_upgrade_required'] ); + $this->assertSame( '1.2.0', $response->data['db_version'] ); + } + + public function test_do_upgrade_returns_error_when_ongoing(): void { + $this->manager->shouldReceive( 'has_ongoing_process' )->once()->andReturn( true ); + + $request = Mockery::mock( \WP_REST_Request::class ); + $response = $this->controller->do_upgrade( $request ); + + $this->assertSame( 400, $response->status ); + $this->assertSame( 'Upgrade already in progress.', $response->data['message'] ); + } + + public function test_do_upgrade_returns_error_when_not_required(): void { + $this->manager->shouldReceive( 'has_ongoing_process' )->once()->andReturn( false ); + $this->manager->shouldReceive( 'is_upgrade_required' )->once()->andReturn( false ); + + $request = Mockery::mock( \WP_REST_Request::class ); + $response = $this->controller->do_upgrade( $request ); + + $this->assertSame( 400, $response->status ); + $this->assertSame( 'No upgrade required.', $response->data['message'] ); + } + + public function test_do_upgrade_runs_upgrade_successfully(): void { + $this->manager->shouldReceive( 'has_ongoing_process' )->once()->andReturn( false ); + $this->manager->shouldReceive( 'is_upgrade_required' )->once()->andReturn( true ); + $this->manager->shouldReceive( 'do_upgrade' )->once(); + + $request = Mockery::mock( \WP_REST_Request::class ); + $response = $this->controller->do_upgrade( $request ); + + $this->assertSame( 201, $response->status ); + $this->assertTrue( $response->data['success'] ); + } + + public function test_check_permission_requires_update_plugins(): void { + Functions\expect( 'current_user_can' ) + ->with( 'update_plugins' ) + ->once() + ->andReturn( true ); + + $this->assertTrue( $this->controller->check_permission() ); + } + + public function test_check_permission_denies_unauthorized_user(): void { + Functions\expect( 'current_user_can' ) + ->with( 'update_plugins' ) + ->once() + ->andReturn( false ); + + $this->assertFalse( $this->controller->check_permission() ); + } +} From 069ff8c62e678849626e5d9307530d86639b5d14 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 26 Feb 2026 14:46:56 +0600 Subject: [PATCH 5/9] Allow dealerdirect/phpcodesniffer-composer-installer plugin in Composer config Co-Authored-By: Claude Opus 4.6 --- composer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/composer.json b/composer.json index 6643a04..fc802de 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,11 @@ "WeDevs\\WPKit\\Tests\\": "tests/phpunit/tests/" } }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, "minimum-stability": "stable", "scripts": { "test": "phpunit", From b5c8c91c21b2d10a3eec00f380df0d0d8257f85c Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 26 Feb 2026 14:49:28 +0600 Subject: [PATCH 6/9] Fix PHP 7.4 compatibility: replace static return type with self The `static` return type requires PHP 8.0+ but the package supports PHP 7.4+. Co-Authored-By: Claude Opus 4.6 --- src/DataLayer/Model/BaseModel.php | 2 +- src/DataLayer/QueryResult.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DataLayer/Model/BaseModel.php b/src/DataLayer/Model/BaseModel.php index 56105dd..a01ecbc 100644 --- a/src/DataLayer/Model/BaseModel.php +++ b/src/DataLayer/Model/BaseModel.php @@ -360,7 +360,7 @@ public static function delete_by( array $data ): bool { * * @return static|null The populated model, or null if not found. */ - public static function find( int $id ): ?static { + public static function find( int $id ): ?self { $store = DataLayerFactory::make_store( static::class ); if ( ! $store ) { diff --git a/src/DataLayer/QueryResult.php b/src/DataLayer/QueryResult.php index 671bbf4..736a00b 100644 --- a/src/DataLayer/QueryResult.php +++ b/src/DataLayer/QueryResult.php @@ -71,7 +71,7 @@ public function __construct( * * @return static */ - public static function from_array( array $result ): static { + public static function from_array( array $result ): self { return new static( is_array( $result['items'] ) ? $result['items'] : [], (int) $result['total'], From 8d12a20e3bdc6b9c93c3cda49b5b42786759acc6 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 26 Feb 2026 14:54:34 +0600 Subject: [PATCH 7/9] Fix PHP 7.4 compatibility: replace str_starts_with() with strpos() str_starts_with() requires PHP 8.0+ but the package supports PHP 7.4+. Co-Authored-By: Claude Opus 4.6 --- src/DataLayer/DataStore/BaseDataStore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DataLayer/DataStore/BaseDataStore.php b/src/DataLayer/DataStore/BaseDataStore.php index 8af5325..4684417 100644 --- a/src/DataLayer/DataStore/BaseDataStore.php +++ b/src/DataLayer/DataStore/BaseDataStore.php @@ -709,7 +709,7 @@ protected function get_table_name_with_prefix(): string { $table_name = $this->get_table_name(); - if ( ! str_starts_with( $table_name, $wpdb->prefix ) ) { + if ( strpos( $table_name, $wpdb->prefix ) !== 0 ) { $table_name = $wpdb->prefix . $table_name; } From cad61923d1b23f4b3935839451dd1b4d95057f33 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Thu, 26 Feb 2026 15:05:55 +0600 Subject: [PATCH 8/9] Fix all PHPCS errors: add file doc comments, fix code style issues - Add file-level doc comments with @package tags to all source files - Add missing short descriptions to constructor doc blocks - Add missing @param and @throws tags to method doc blocks - Fix Yoda conditions and replace short ternaries - Fix multi-line function call formatting in QueryResult - Suppress DirectDatabaseQuery/PreparedSQL rules for query builder files in phpcs.xml.dist (false positives for internal $wpdb->prepare usage) Co-Authored-By: Claude Opus 4.6 --- phpcs.xml.dist | 15 +++++++ .../Contracts/NoticeProviderInterface.php | 5 +++ src/AdminNotification/Notice.php | 7 ++++ src/AdminNotification/NoticeManager.php | 9 +++- .../NoticeRESTController.php | 9 +++- src/AdminNotification/NotificationHelper.php | 5 +++ src/Cache/CacheNameSpaceTrait.php | 5 +++ src/Cache/Contracts/CacheEngineInterface.php | 5 +++ src/Cache/ObjectCache.php | 7 ++++ src/Cache/WPCacheEngine.php | 30 ++++++++++++++ src/DataLayer/Bridge/WCDataStoreBridge.php | 27 ++++++++++++ src/DataLayer/Bridge/WCModelAdapter.php | 7 ++++ .../Contracts/DataStoreInterface.php | 5 +++ src/DataLayer/Contracts/ModelInterface.php | 5 +++ src/DataLayer/DataLayerFactory.php | 7 +++- src/DataLayer/DataStore/BaseDataStore.php | 25 +++++++++++ src/DataLayer/DataStore/SqlQuery.php | 7 ++++ src/DataLayer/DateTime.php | 5 +++ src/DataLayer/Model/BaseModel.php | 11 +++++ src/DataLayer/QueryResult.php | 41 +++++++++++++++++-- src/Migration/BackgroundProcess.php | 8 ++++ src/Migration/BaseMigration.php | 9 +++- .../Contracts/MigrationInterface.php | 5 +++ src/Migration/MigrationHooks.php | 7 ++++ src/Migration/MigrationManager.php | 9 ++++ src/Migration/MigrationRESTController.php | 7 ++++ src/Migration/MigrationRegistry.php | 7 ++++ src/Migration/MigrationStatus.php | 9 +++- src/Migration/Schema.php | 5 +++ 29 files changed, 295 insertions(+), 8 deletions(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 54a2a86..924249c 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -26,6 +26,21 @@ + + + src/DataLayer/DataStore/BaseDataStore\.php + src/Migration/Schema\.php + + + src/DataLayer/DataStore/BaseDataStore\.php + + + src/DataLayer/DataStore/BaseDataStore\.php + + diff --git a/src/AdminNotification/Contracts/NoticeProviderInterface.php b/src/AdminNotification/Contracts/NoticeProviderInterface.php index 1401db7..68d5cfc 100644 --- a/src/AdminNotification/Contracts/NoticeProviderInterface.php +++ b/src/AdminNotification/Contracts/NoticeProviderInterface.php @@ -1,4 +1,9 @@ get_param( 'scope' ) ?: ''; + $scope = $request->get_param( 'scope' ) ? $request->get_param( 'scope' ) : ''; $notices = $this->manager->get_notices( $scope ); return rest_ensure_response( $notices ); diff --git a/src/AdminNotification/NotificationHelper.php b/src/AdminNotification/NotificationHelper.php index 6cc2484..f44f5aa 100644 --- a/src/AdminNotification/NotificationHelper.php +++ b/src/AdminNotification/NotificationHelper.php @@ -1,4 +1,9 @@ get_prefixed_key( $key, $group ); @@ -54,6 +64,9 @@ public function get( string $key, string $group = '' ) { /** * {@inheritdoc} + * + * @param string[] $keys The cache keys. + * @param string $group The cache group. */ public function get_many( array $keys, string $group = '' ): array { $prefix = $this->get_cache_prefix( $group ); @@ -83,6 +96,11 @@ function ( $key ) use ( $prefix ) { /** * {@inheritdoc} + * + * @param string $key The cache key. + * @param mixed $value The value to cache. + * @param string $group The cache group. + * @param int $expiration Expiration in seconds. */ public function set( string $key, $value, string $group = '', int $expiration = 0 ): bool { $prefixed_key = $this->get_prefixed_key( $key, $group ); @@ -92,6 +110,10 @@ public function set( string $key, $value, string $group = '', int $expiration = /** * {@inheritdoc} + * + * @param array $items Associative array of key => value. + * @param string $group The cache group. + * @param int $expiration Expiration in seconds. */ public function set_many( array $items, string $group = '', int $expiration = 0 ): array { $prefix = $this->get_cache_prefix( $group ); @@ -106,6 +128,9 @@ public function set_many( array $items, string $group = '', int $expiration = 0 /** * {@inheritdoc} + * + * @param string $key The cache key. + * @param string $group The cache group. */ public function delete( string $key, string $group = '' ): bool { $prefixed_key = $this->get_prefixed_key( $key, $group ); @@ -115,6 +140,9 @@ public function delete( string $key, string $group = '' ): bool { /** * {@inheritdoc} + * + * @param string $key The cache key. + * @param string $group The cache group. */ public function exists( string $key, string $group = '' ): bool { $prefixed_key = $this->get_prefixed_key( $key, $group ); @@ -124,6 +152,8 @@ public function exists( string $key, string $group = '' ): bool { /** * {@inheritdoc} + * + * @param string $group The cache group to flush. */ public function flush_group( string $group = '' ): bool { return $this->invalidate_cache_group( $group ); diff --git a/src/DataLayer/Bridge/WCDataStoreBridge.php b/src/DataLayer/Bridge/WCDataStoreBridge.php index 79b90a0..4395ad1 100644 --- a/src/DataLayer/Bridge/WCDataStoreBridge.php +++ b/src/DataLayer/Bridge/WCDataStoreBridge.php @@ -1,4 +1,9 @@ newInstanceWithoutConstructor(); - return $property->getValue( $instance ) ?: ''; + return $property->getValue( $instance ) ? $property->getValue( $instance ) : ''; } catch ( \ReflectionException $e ) { return ''; } diff --git a/src/DataLayer/DataStore/BaseDataStore.php b/src/DataLayer/DataStore/BaseDataStore.php index 4684417..83fd13d 100644 --- a/src/DataLayer/DataStore/BaseDataStore.php +++ b/src/DataLayer/DataStore/BaseDataStore.php @@ -1,4 +1,9 @@ map_model_to_db_data( $model ); @@ -92,6 +99,10 @@ public function create( ModelInterface &$model ) { /** * {@inheritdoc} + * + * @param ModelInterface $model The model to populate. + * + * @throws Exception If the entity has no ID or is not found. */ public function read( ModelInterface &$model ) { global $wpdb; @@ -150,6 +161,8 @@ public function read( ModelInterface &$model ) { /** * {@inheritdoc} + * + * @param ModelInterface $model The model to update. */ public function update( ModelInterface &$model ) { global $wpdb; @@ -177,6 +190,9 @@ public function update( ModelInterface &$model ) { /** * {@inheritdoc} + * + * @param ModelInterface $model The model to delete. + * @param array $args Additional arguments. */ public function delete( ModelInterface &$model, array $args = [] ) { $model_id = $model->get_id(); @@ -202,6 +218,10 @@ public function delete_by_id( int $id ): int { /** * {@inheritdoc} + * + * @param array $data Associative array of column => value conditions. + * + * @throws Exception If the delete query fails. */ public function delete_by( array $data ): int { global $wpdb; @@ -226,6 +246,11 @@ public function delete_by( array $data ): int { /** * {@inheritdoc} + * + * @param array $where Conditions for matching records. + * @param array $data_to_update Data to update. + * + * @throws Exception If the update query fails. */ public function update_by( array $where, array $data_to_update ): int { global $wpdb; diff --git a/src/DataLayer/DataStore/SqlQuery.php b/src/DataLayer/DataStore/SqlQuery.php index dc57197..66205f1 100644 --- a/src/DataLayer/DataStore/SqlQuery.php +++ b/src/DataLayer/DataStore/SqlQuery.php @@ -1,4 +1,9 @@ id = absint( $id ); @@ -123,6 +130,8 @@ public function get_object_read(): bool { /** * {@inheritdoc} + * + * @param bool $read Read state. */ public function set_object_read( bool $read = true ): void { $this->object_read = $read; @@ -314,6 +323,8 @@ public function save(): int { /** * {@inheritdoc} + * + * @param bool $force_delete Whether to force delete. */ public function delete( bool $force_delete = false ): bool { $check = apply_filters( $this->hook_prefix . 'pre_delete_' . $this->object_type, null, $this, $force_delete ); diff --git a/src/DataLayer/QueryResult.php b/src/DataLayer/QueryResult.php index 736a00b..19021cd 100644 --- a/src/DataLayer/QueryResult.php +++ b/src/DataLayer/QueryResult.php @@ -1,4 +1,9 @@ {$method}(); - }, $this->items ); + return array_map( + function ( $item ) use ( $method ) { + return $item->{$method}(); + }, + $this->items + ); } /** diff --git a/src/Migration/BackgroundProcess.php b/src/Migration/BackgroundProcess.php index f4f1c41..ab115de 100644 --- a/src/Migration/BackgroundProcess.php +++ b/src/Migration/BackgroundProcess.php @@ -1,4 +1,9 @@ action ) { $this->action = $this->prefix . '_' . md5( static::class ); diff --git a/src/Migration/BaseMigration.php b/src/Migration/BaseMigration.php index 451134e..084f1b4 100644 --- a/src/Migration/BaseMigration.php +++ b/src/Migration/BaseMigration.php @@ -1,4 +1,9 @@ get_upgrades(); diff --git a/src/Migration/MigrationRESTController.php b/src/Migration/MigrationRESTController.php index fec9109..1131dc2 100644 --- a/src/Migration/MigrationRESTController.php +++ b/src/Migration/MigrationRESTController.php @@ -1,4 +1,9 @@ get_log(); foreach ( $log as $entry ) { - if ( $entry['status'] === 'running' ) { + if ( 'running' === $entry['status'] ) { return true; } } diff --git a/src/Migration/Schema.php b/src/Migration/Schema.php index a7fa297..aec6063 100644 --- a/src/Migration/Schema.php +++ b/src/Migration/Schema.php @@ -1,4 +1,9 @@ Date: Thu, 26 Feb 2026 15:10:27 +0600 Subject: [PATCH 9/9] Fix remaining PHPCS warnings: alignment and rule suppressions - Fix equals sign alignment in SqlQuery, BaseDataStore, and BaseModel - Suppress reserved keyword parameter name warnings (namespace, object, class are valid names in this context) - Suppress unused function parameter warnings (required by WP filter callbacks and REST handler signatures) Co-Authored-By: Claude Opus 4.6 --- phpcs.xml.dist | 10 ++++++++++ src/DataLayer/DataStore/BaseDataStore.php | 18 +++++++++--------- src/DataLayer/DataStore/SqlQuery.php | 16 ++++++++-------- src/DataLayer/Model/BaseModel.php | 4 ++-- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 924249c..d4bc004 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -41,6 +41,16 @@ src/DataLayer/DataStore/BaseDataStore\.php + + + 0 + + + + + 0 + + diff --git a/src/DataLayer/DataStore/BaseDataStore.php b/src/DataLayer/DataStore/BaseDataStore.php index 83fd13d..a44184d 100644 --- a/src/DataLayer/DataStore/BaseDataStore.php +++ b/src/DataLayer/DataStore/BaseDataStore.php @@ -255,7 +255,7 @@ public function delete_by( array $data ): int { public function update_by( array $where, array $data_to_update ): int { global $wpdb; - $fields_format = $this->get_fields_with_format(); + $fields_format = $this->get_fields_with_format(); $fields_format[ $this->get_id_field_name() ] = $this->get_id_field_format(); $data_format = []; @@ -394,9 +394,9 @@ public function query( array $args = [] ): array { } // Order. - $allowed_fields = array_merge( $this->get_fields(), [ $this->get_id_field_name() ] ); - $orderby = in_array( $args['orderby'], $allowed_fields, true ) ? $args['orderby'] : $this->get_id_field_name(); - $order = strtoupper( $args['order'] ) === 'ASC' ? 'ASC' : 'DESC'; + $allowed_fields = array_merge( $this->get_fields(), [ $this->get_id_field_name() ] ); + $orderby = in_array( $args['orderby'], $allowed_fields, true ) ? $args['orderby'] : $this->get_id_field_name(); + $order = strtoupper( $args['order'] ) === 'ASC' ? 'ASC' : 'DESC'; $this->add_sql_clause( 'order_by', "{$orderby} {$order}" ); // Pagination. @@ -475,10 +475,10 @@ protected function get_searchable_fields(): array { protected function build_query_where( array $args ): void { global $wpdb; - $fields = $this->get_fields(); - $field_format = $this->get_fields_with_format(); + $fields = $this->get_fields(); + $field_format = $this->get_fields_with_format(); $field_format[ $this->get_id_field_name() ] = $this->get_id_field_format(); - $reserved = array_keys( $this->get_default_query_args() ); + $reserved = array_keys( $this->get_default_query_args() ); foreach ( $args as $key => $value ) { if ( in_array( $key, $reserved, true ) || null === $value ) { @@ -592,8 +592,8 @@ protected function get_query_cache_key( array $args ): string { protected function prepare_where_clause( array $data ): string { global $wpdb; - $where = [ '1=1' ]; - $field_format = $this->get_fields_with_format(); + $where = [ '1=1' ]; + $field_format = $this->get_fields_with_format(); $field_format[ $this->get_id_field_name() ] = $this->get_id_field_format(); foreach ( $data as $key => $value ) { diff --git a/src/DataLayer/DataStore/SqlQuery.php b/src/DataLayer/DataStore/SqlQuery.php index 66205f1..41167c5 100644 --- a/src/DataLayer/DataStore/SqlQuery.php +++ b/src/DataLayer/DataStore/SqlQuery.php @@ -125,15 +125,15 @@ public function clear_all_clauses(): void { * @return string */ public function get_query_statement(): string { - $select = $this->get_sql_clause( 'select' ); - $from = $this->get_sql_clause( 'from' ); - $join = $this->get_sql_clause( 'join' ); + $select = $this->get_sql_clause( 'select' ); + $from = $this->get_sql_clause( 'from' ); + $join = $this->get_sql_clause( 'join' ); $left_join = $this->get_sql_clause( 'left_join' ); - $where = $this->get_sql_clause( 'where' ); - $group_by = $this->get_sql_clause( 'group_by' ); - $having = $this->get_sql_clause( 'having' ); - $order_by = $this->get_sql_clause( 'order_by' ); - $limit = $this->get_sql_clause( 'limit' ); + $where = $this->get_sql_clause( 'where' ); + $group_by = $this->get_sql_clause( 'group_by' ); + $having = $this->get_sql_clause( 'having' ); + $order_by = $this->get_sql_clause( 'order_by' ); + $limit = $this->get_sql_clause( 'limit' ); $query = "SELECT {$select} FROM {$from}"; diff --git a/src/DataLayer/Model/BaseModel.php b/src/DataLayer/Model/BaseModel.php index 147ee29..1c717ac 100644 --- a/src/DataLayer/Model/BaseModel.php +++ b/src/DataLayer/Model/BaseModel.php @@ -378,7 +378,7 @@ public static function find( int $id ): ?self { return null; } - $model = DataLayerFactory::make_model( static::class, $id ); + $model = DataLayerFactory::make_model( static::class, $id ); $model->data_store = $store; try { @@ -429,7 +429,7 @@ public static function query( array $args = [] ): QueryResult { continue; } - $model = new static(); + $model = new static(); $model->data_store = $store; $model->set_id( (int) ( $row->{$id_field} ?? 0 ) ); $model->set_props( (array) $row );