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/.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
diff --git a/composer.json b/composer.json
index c0a7c55..fc802de 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": {
@@ -21,8 +25,18 @@
"WeDevs\\WPKit\\Tests\\": "tests/phpunit/tests/"
}
},
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
+ },
"minimum-stability": "stable",
"scripts": {
- "test": "phpunit"
+ "test": "phpunit",
+ "phpcs": "phpcs",
+ "phpcbf": "phpcbf",
+ "lint": [
+ "@phpcs"
+ ]
}
}
diff --git a/docs/developer-guide.md b/docs/developer-guide.md
index 7417bd0..b99e11e 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)
@@ -79,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
@@ -99,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();
} );
```
@@ -982,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
@@ -993,11 +999,79 @@ $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
+
+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
+
+`MigrationRESTController` provides REST endpoints for polling migration status and triggering upgrades 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
@@ -1040,6 +1114,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 +1214,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!';
}
@@ -1150,7 +1231,7 @@ if ( ! empty( $queue ) && is_array( $queue ) ) {
## 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
@@ -1245,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' ),
],
] );
```
@@ -1258,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' );
@@ -1287,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) |
-**Permission:** Requires `manage_options` capability.
+Dismissed notices are stored in the `{prefix}_dismissed_notices` option as an array of keys.
+
+**Permission:** All endpoints require `manage_options` capability.
---
@@ -1625,16 +1692,28 @@ 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 |
+#### Migration REST Endpoints
+
+| 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
@@ -1642,5 +1721,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}_dismissed_notices` | DismissalHandler | Array of dismissed notice keys |
+| `{prefix}_bg_{action}_total` | BackgroundProcess | Total items pushed (for progress percentage) |
+| `{prefix}_dismissed_notices` | NoticeRESTController | Array of dismissed notice keys |
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..d4bc004
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,56 @@
+
+
+ PHP CodeSniffer rules for wedevs/wp-kit.
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ src/DataLayer/DataStore/BaseDataStore\.php
+ src/Migration/Schema\.php
+
+
+ src/DataLayer/DataStore/BaseDataStore\.php
+
+
+ src/DataLayer/DataStore/BaseDataStore\.php
+
+
+
+
+ 0
+
+
+
+
+ 0
+
+
+
+
+
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 @@
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/Notice.php b/src/AdminNotification/Notice.php
index 8f614c2..49e3c10 100644
--- a/src/AdminNotification/Notice.php
+++ b/src/AdminNotification/Notice.php
@@ -1,4 +1,9 @@
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',
+ ],
+ ],
+ ],
+ ]
+ );
}
/**
@@ -74,12 +100,33 @@ public function register_routes(): void {
* @return WP_REST_Response
*/
public function get_admin_notices( WP_REST_Request $request ): WP_REST_Response {
- $scope = $request->get_param( 'scope' ) ?: '';
+ $scope = $request->get_param( 'scope' ) ? $request->get_param( 'scope' ) : '';
$notices = $this->manager->get_notices( $scope );
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..f44f5aa 100644
--- a/src/AdminNotification/NotificationHelper.php
+++ b/src/AdminNotification/NotificationHelper.php
@@ -1,4 +1,9 @@
'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/Cache/CacheNameSpaceTrait.php b/src/Cache/CacheNameSpaceTrait.php
index c1af8ce..b035538 100644
--- a/src/Cache/CacheNameSpaceTrait.php
+++ b/src/Cache/CacheNameSpaceTrait.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 8af5325..a44184d 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,11 +246,16 @@ 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;
- $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 = [];
@@ -369,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.
@@ -450,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 ) {
@@ -567,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 ) {
@@ -709,7 +734,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;
}
diff --git a/src/DataLayer/DataStore/SqlQuery.php b/src/DataLayer/DataStore/SqlQuery.php
index dc57197..41167c5 100644
--- a/src/DataLayer/DataStore/SqlQuery.php
+++ b/src/DataLayer/DataStore/SqlQuery.php
@@ -1,4 +1,9 @@
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/DateTime.php b/src/DataLayer/DateTime.php
index baf3130..09a1ea9 100644
--- a/src/DataLayer/DateTime.php
+++ b/src/DataLayer/DateTime.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 );
@@ -360,14 +371,14 @@ 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 ) {
return null;
}
- $model = DataLayerFactory::make_model( static::class, $id );
+ $model = DataLayerFactory::make_model( static::class, $id );
$model->data_store = $store;
try {
@@ -418,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 );
diff --git a/src/DataLayer/QueryResult.php b/src/DataLayer/QueryResult.php
index 671bbf4..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 3583f50..ab115de 100644
--- a/src/Migration/BackgroundProcess.php
+++ b/src/Migration/BackgroundProcess.php
@@ -1,4 +1,9 @@
action ) {
$this->action = $this->prefix . '_' . md5( static::class );
@@ -76,6 +84,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 +137,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 +163,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 +195,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/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 @@
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' ] );
}
@@ -78,29 +82,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/MigrationManager.php b/src/Migration/MigrationManager.php
index 810c67d..25f4ca7 100644
--- a/src/Migration/MigrationManager.php
+++ b/src/Migration/MigrationManager.php
@@ -1,4 +1,9 @@
get_upgrades();
@@ -100,8 +109,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 +144,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/MigrationRESTController.php b/src/Migration/MigrationRESTController.php
new file mode 100644
index 0000000..1131dc2
--- /dev/null
+++ b/src/Migration/MigrationRESTController.php
@@ -0,0 +1,121 @@
+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/src/Migration/MigrationRegistry.php b/src/Migration/MigrationRegistry.php
index 9e11fd6..a101700 100644
--- a/src/Migration/MigrationRegistry.php
+++ b/src/Migration/MigrationRegistry.php
@@ -1,4 +1,9 @@
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;
+
+ /**
+ * Constructor.
+ *
+ * @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 ( 'running' === $entry['status'] ) {
+ 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/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 @@
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/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/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() );
+ }
+}
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();
+ }
+}