diff --git a/CHANGELOG.md b/CHANGELOG.md index a2622b52..0d1c97d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard. +## [0.0.7] 2025-09-08 + +* Fix - Ensure the regulator is registered only when the tables are created/updated successfully. +* Fix - When scheduling an action, return 0 if the action ID is not an integer. +* Fix - Fix fetch_all Custom_Table_Query_Methods batch generator by properly incrementing the offset. +* Tweak - Update the schema version of the Tasks table to 0.0.3 to fix a typo in the version string. +* Tweak - Update the get_pending_actions_by_ids method to also exclude null actions. +* Tweak - Use the hook `action_scheduler_init` to determine if Action Scheduler is initialized instead of the `init` hook. + +[0.0.7]: https://github.com/stellarwp/shepherd/releases/tag/0.0.7 + ## [0.0.6] 2025-08-26 * Fix - Update Email task to properly handle multiple email recipients separated by commas. diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 4f1ccc0b..37539333 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -97,6 +97,67 @@ When dispatching a duplicate task: Prevents accidental duplication and is enabled by default. +## Task Dispatching Requirements (Since 0.0.7) + +When dispatching tasks, Shepherd performs several checks: + +1. **Table Registration**: Verifies that Shepherd's database tables are registered +2. **Action Scheduler**: Ensures Action Scheduler is initialized + +### Synchronous Fallback When Tables Not Registered + +If Shepherd's database tables are not yet registered when you dispatch a task, by default the task will be **processed immediately in a synchronous manner** instead of being queued for background processing. This ensures tasks can still execute even during early initialization phases. + +```php +// If tables are not registered, this task will run immediately +shepherd()->dispatch( new My_Task() ); +``` + +You can monitor when this synchronous processing occurs: + +```php +$prefix = Config::get_hook_prefix(); + +add_action( "shepherd_{$prefix}_dispatched_sync", function( $task ) { + error_log( 'Task processed synchronously: ' . get_class( $task ) ); +}); +``` + +#### Disabling Synchronous Fallback + +If you prefer tasks to be skipped rather than processed synchronously when tables are unavailable or handle their scheduling yourself: + +```php +add_filter( "shepherd_{$prefix}_should_dispatch_sync_on_tables_unavailable", function( $should_dispatch, Task $task ) { + // Return false to skip task processing when tables are not ready + return false; +}, 10, 2 ); +``` + +### Action Scheduler Initialization + +If Action Scheduler is not yet initialized when you dispatch a task, Shepherd will automatically queue it and dispatch once Action Scheduler is ready via the `action_scheduler_init` hook. + +### Handling Table Registration Errors (Since 0.0.7) + +Your application should handle cases where Shepherd's tables fail to register by listening to the `shepherd_{prefix}_tables_error` action: + +```php +$prefix = Config::get_hook_prefix(); + +add_action( "shepherd_{$prefix}_tables_error", function( $error ) { + // Log the error + error_log( 'Shepherd tables failed to register: ' . $error->getMessage() ); + + // Notify administrators + add_action( 'admin_notices', function() use ( $error ) { + echo '

' . esc_html__( 'Background processing is unavailable. Please contact support.', 'stellarwp-shepherd' ) . '

'; + } ); +}); +``` + +If this action is not handled, Shepherd will trigger a `_doing_it_wrong` notice to alert developers during development. + ## Logging Comprehensive logging tracks the complete task lifecycle. diff --git a/docs/api-reference.md b/docs/api-reference.md index 46105f0d..333b8bf7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -18,15 +18,21 @@ The main orchestrator for task scheduling and processing. #### Methods -##### `dispatch( Task $task, int $delay = 0 ): void` +##### `dispatch( Task $task, int $delay = 0 ): self` Schedules a task for execution. - **Parameters:** - `$task` - The task instance to schedule - `$delay` - Delay in seconds before execution (default: 0) +- **Returns:** The Regulator instance for method chaining - **Throws:** and **Catches:** `ShepherdTaskAlreadyExistsException` if duplicate task exists - **Throws:** and **Catches:** `RuntimeException` if task fails to be scheduled or inserted into the database. +- **Since 0.0.7 - Synchronous Fallback:** When Shepherd tables are not registered: + - Tasks are processed immediately in a synchronous manner by default + - Fires `shepherd_{prefix}_dispatched_sync` action when processing synchronously + - Can be disabled via `shepherd_{prefix}_should_dispatch_sync_on_tables_unavailable` filter +- **Hook Integration:** As of version 0.0.7, uses `action_scheduler_init` hook instead of `init` to ensure Action Scheduler is ready. - You can listen for those errors above, by listening to the following actions: - `shepherd_{prefix}_task_scheduling_failed` - `shepherd_{prefix}_task_already_exists` @@ -106,6 +112,8 @@ Service provider for dependency injection and initialization. Initializes Shepherd and registers all components. +- **Since version 0.0.7:** The Regulator is only registered after tables are successfully created/updated via the `shepherd_{prefix}_tables_registered` action. + ##### `set_container( ContainerInterface $container ): void` Sets the dependency injection container. @@ -118,6 +126,19 @@ Returns the container instance. Checks if Shepherd has been registered. +##### `register_regulator(): void` + +Registers the Regulator component to start processing tasks. + +- **Since:** 0.0.7 +- **Visibility:** Public +- **Purpose:** Separated from the main registration flow to allow for conditional registration +- **Behavior:** + - Retrieves the Regulator instance from the DI container + - Calls the Regulator's `register()` method to initialize task processing +- **Hook:** Automatically called on `shepherd_{prefix}_tables_registered` action +- **Usage:** Can be manually removed from the action hook if custom registration timing is needed + ##### `delete_tasks_on_action_deletion( int $action_id ): void` Automatically removes task data when Action Scheduler deletes an action. @@ -133,6 +154,64 @@ Automatically removes task data when Action Scheduler deletes an action. --- +### `Action_Scheduler_Methods` + +Wrapper class for Action Scheduler integration (since 0.0.1). + +#### Methods + +##### `has_scheduled_action( string $hook, array $args = [], string $group = '' ): bool` + +Checks if an action is scheduled. + +- **Parameters:** + - `$hook` - The hook of the action + - `$args` - The arguments of the action + - `$group` - The group of the action +- **Returns:** Whether the action is scheduled + +##### `schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int` + +Schedules a single action. + +- **Parameters:** + - `$timestamp` - The timestamp when the action should run + - `$hook` - The hook of the action + - `$args` - The arguments of the action + - `$group` - The group of the action + - `$unique` - Whether the action should be unique + - `$priority` - The priority of the action (0-255) +- **Returns:** The action ID, or 0 if scheduling failed (since 0.0.7) + +##### `get_action_by_id( int $action_id ): ActionScheduler_Action` + +Gets an action by its ID. + +- **Parameters:** + - `$action_id` - The action ID +- **Returns:** The action object +- **Throws:** `RuntimeException` if the action is not found + +##### `get_actions_by_ids( array $action_ids ): array` + +Gets multiple actions by their IDs. + +- **Parameters:** + - `$action_ids` - Array of action IDs +- **Returns:** Array of ActionScheduler_Action objects keyed by ID +- **Throws:** `RuntimeException` if any action is not found + +##### `get_pending_actions_by_ids( array $action_ids ): array` + +Gets pending actions by their IDs, excluding finished and null actions. + +- **Parameters:** + - `$action_ids` - Array of action IDs +- **Returns:** Array of pending ActionScheduler_Action objects +- **Since 0.0.7:** Also excludes `ActionScheduler_NullAction` instances + +--- + ### `Email` Task Built-in task for sending emails asynchronously. @@ -416,6 +495,13 @@ Table name: `shepherd_{prefix}_task_logs` ### Actions +- `shepherd_{prefix}_tables_registered` - Fired when Shepherd tables are successfully registered (since 0.0.7) + - No parameters + - Used internally to ensure safe initialization of the Regulator + +- `shepherd_{prefix}_tables_error` - Fired when database table creation/update fails (since 0.0.7) + - Parameters: `$exception` (DatabaseQueryException) + - `shepherd_{prefix}_task_scheduling_failed` - Fired when a task fails to be scheduled - Parameters: `$task`, `$exception` @@ -442,4 +528,6 @@ Table name: `shepherd_{prefix}_task_logs` ### Filters -Currently, Shepherd does not provide any filters. +- `shepherd_{prefix}_should_log` - Filter to control whether logging should occur (since 0.0.5) + - Parameters: `$should_log` (bool, default: true) + - Return false to disable logging diff --git a/docs/configuration.md b/docs/configuration.md index 32203519..f3fe8f26 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -115,10 +115,22 @@ The tasks table is created automatically when you call `Provider::register()`. The logs table is only created if you're using the `DB_Logger`. When using the default `ActionScheduler_DB_Logger`, logs are stored in Action Scheduler's existing `actionscheduler_logs` table. +**Since version 0.0.7:** + +- Tables are created/updated before the Regulator is initialized +- The `shepherd_{prefix}_tables_registered` action is fired upon successful table registration +- The `shepherd_{prefix}_tables_error` action is fired if table creation fails +- The Regulator will only be registered after tables are successfully created + ## Action Scheduler Configuration Shepherd uses Action Scheduler for task scheduling. You can configure Action Scheduler settings separately: +**Since version 0.0.7:** + +- Shepherd now uses the `action_scheduler_init` hook to ensure Action Scheduler is ready before dispatching tasks +- Tasks dispatched before Action Scheduler initialization are automatically queued and dispatched once it's ready + ### Custom Action Scheduler Tables Action Scheduler uses its own tables. If you need custom table names, configure Action Scheduler before loading Shepherd. @@ -161,6 +173,7 @@ $container->get( Provider::class )->register(); 2. **Use Consistent Prefixes**: Keep your hook prefix consistent across your application 3. **Container Singleton**: Always register Provider as a singleton 4. **Check Registration**: If you are not sure whether Shepherd is registered, you can check it using `Provider::is_registered()` before accessing Shepherd +5. **Table Initialization** (since 0.0.7): Listen to `shepherd_{prefix}_tables_registered` if you need to perform actions after tables are ready ```php if ( ! Provider::is_registered() ) { diff --git a/docs/getting-started.md b/docs/getting-started.md index 96c316ea..4ca065b0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -138,11 +138,12 @@ shepherd()->dispatch( $my_task, 5 * MINUTE_IN_SECONDS ); // Execute after 5 minu ### What Happens Next? -1. Shepherd schedules your task with Action Scheduler -2. WordPress cron picks up the task -3. Your task's `process()` method executes -4. The lifecycle is logged in the database -5. Failed tasks may be retried based on configuration +1. Shepherd validates that its tables are registered (since 0.0.7) +2. Shepherd schedules your task with Action Scheduler +3. WordPress cron picks up the task +4. Your task's `process()` method executes +5. The lifecycle is logged in the database +6. Failed tasks may be retried based on configuration Check `debug.log` for the message "Shepherd Task: Hello, World! with code 200". @@ -176,3 +177,5 @@ If your tasks aren't running: 2. **Verify WP-Cron**: Ensure WordPress cron is running or set up a real cron job 3. **Check Logs**: Look for errors in your WordPress debug log 4. **Database Tables**: Ensure Shepherd's tables were created during registration +5. **Table Registration** (since 0.0.7): Check for `shepherd_{prefix}_tables_error` action if tables fail to create +6. **Initialization Order** (since 0.0.7): Ensure Action Scheduler is loaded before dispatching tasks diff --git a/shepherd.php b/shepherd.php index 90a82f98..535aac98 100644 --- a/shepherd.php +++ b/shepherd.php @@ -9,7 +9,7 @@ * @wordpress-plugin * Plugin Name: Shepherd * Description: A library for offloading tasks to background processes. - * Version: 0.0.6 + * Version: 0.0.7 * Author: StellarWP * Author URI: https://stellarwp.com * License: GPL-2.0-or-later diff --git a/src/Action_Scheduler_Methods.php b/src/Action_Scheduler_Methods.php index 8edc3bfb..eef684a7 100644 --- a/src/Action_Scheduler_Methods.php +++ b/src/Action_Scheduler_Methods.php @@ -14,6 +14,7 @@ use ActionScheduler; use ActionScheduler_Action; use ActionScheduler_FinishedAction; +use ActionScheduler_NullAction; use RuntimeException; /** @@ -43,6 +44,7 @@ public static function has_scheduled_action( string $hook, array $args = [], str * Schedules a single action. * * @since 0.0.1 + * @since 0.0.7 Updated to return 0 if the action ID is not an integer. * * @param int $timestamp The timestamp of the action. * @param string $hook The hook of the action. @@ -54,7 +56,9 @@ public static function has_scheduled_action( string $hook, array $args = [], str * @return int The action ID. */ public static function schedule_single_action( int $timestamp, string $hook, array $args = [], string $group = '', bool $unique = false, int $priority = 10 ): int { - return as_schedule_single_action( $timestamp, $hook, $args, $group, $unique, $priority ); + $action_id = as_schedule_single_action( $timestamp, $hook, $args, $group, $unique, $priority ); + + return is_int( $action_id ) ? $action_id : 0; } /** @@ -112,6 +116,7 @@ public static function get_actions_by_ids( array $action_ids ): array { * Gets pending actions by their IDs. * * @since 0.0.1 + * @since 0.0.7 Updated to filter out null actions. * * @param array $action_ids The action IDs. * @@ -120,6 +125,6 @@ public static function get_actions_by_ids( array $action_ids ): array { public static function get_pending_actions_by_ids( array $action_ids ): array { $actions = self::get_actions_by_ids( $action_ids ); - return array_filter( $actions, fn( ActionScheduler_Action $action ) => ! $action instanceof ActionScheduler_FinishedAction ); + return array_filter( $actions, static fn( ActionScheduler_Action $action ) => ! $action instanceof ActionScheduler_FinishedAction && ! $action instanceof ActionScheduler_NullAction ); } } diff --git a/src/Provider.php b/src/Provider.php index bceba39d..06de65dd 100644 --- a/src/Provider.php +++ b/src/Provider.php @@ -59,6 +59,7 @@ class Provider extends Provider_Abstract { * Registers Shepherd's specific providers and starts core functionality * * @since 0.0.1 + * @since 0.0.7 Updated to register the regulator after the tables are registered successfully. * * @return void The method does not return any value. */ @@ -78,19 +79,38 @@ public function register(): void { $this->container->singleton( Logger::class, Config::get_logger() ); $this->container->singleton( Tables_Provider::class ); $this->container->singleton( Regulator::class ); + + $prefix = Config::get_hook_prefix(); + + add_action( "shepherd_{$prefix}_tables_registered", [ $this, 'register_regulator' ] ); + + if ( ! has_action( "shepherd_{$prefix}_tables_error" ) ) { + _doing_it_wrong( __METHOD__, esc_html__( 'Your software should be handling the case where Shepherd tables are not registered successfully and notify your end users about it.', 'stellarwp-shepherd' ), '0.0.7' ); + } + $this->container->get( Tables_Provider::class )->register(); - $this->container->get( Regulator::class )->register(); add_action( 'action_scheduler_deleted_action', [ $this, 'delete_tasks_on_action_deletion' ] ); self::$has_registered = true; } + /** + * Registers the regulator. + * + * @since 0.0.7 + * + * @return void + */ + public function register_regulator(): void { + $this->container->get( Regulator::class )->register(); + } + /** * Requires Action Scheduler. * * @since 0.0.1 - * @since 0.0.2 + * @since 0.0.2 Look into multiple places for the action scheduler main file. * * @return void * diff --git a/src/Regulator.php b/src/Regulator.php index d945cf28..1644ac27 100644 --- a/src/Regulator.php +++ b/src/Regulator.php @@ -89,6 +89,7 @@ public function __construct( Container $container ) { * Registers the regulator. * * @since 0.0.1 + * @since 0.0.7 Updated to use the `wp_loaded` hook instead of the `init` hook to schedule the cleanup task. */ public function register(): void { add_action( $this->process_task_hook, [ $this, 'process_task' ] ); @@ -97,7 +98,7 @@ public function register(): void { add_action( 'action_scheduler_execution_ignored', [ $this, 'untrack_action' ], 1, 0 ); add_action( 'action_scheduler_failed_execution', [ $this, 'untrack_action' ], 1, 0 ); add_action( 'action_scheduler_after_process_queue', [ $this, 'handle_reschedule_of_failed_task' ], 1, 0 ); - add_action( 'init', [ $this, 'schedule_cleanup_task' ], 20, 0 ); + add_action( 'wp_loaded', [ $this, 'schedule_cleanup_task' ], 20, 0 ); } /** @@ -139,6 +140,8 @@ public function untrack_action(): void { * Dispatches a task to be processed later. * * @since 0.0.1 + * @since 0.0.7 Updated to check if the Shepherd tables have been registered already. + * @since 0.0.7 Updated to use the `action_scheduler_init` hook instead of the `init` hook to check if Action Scheduler is initialized. * * @param Task $task The task to dispatch. * @param int $delay The delay in seconds before the task is processed. @@ -146,13 +149,43 @@ public function untrack_action(): void { * @return self The regulator instance. */ public function dispatch( Task $task, int $delay = 0 ): self { - if ( did_action( 'init' ) || doing_action( 'init' ) ) { + $prefix = Config::get_hook_prefix(); + + if ( ! did_action( "shepherd_{$prefix}_tables_registered" ) ) { + /** + * Filters whether to dispatch a task synchronously. + * + * @since 0.0.7 + * + * @param bool $should_dispatch_sync Whether to dispatch a task synchronously. + * @param Task $task The task that should be dispatched synchronously. + */ + if ( ! apply_filters( "shepherd_{$prefix}_should_dispatch_sync_on_tables_unavailable", true, $task ) ) { + return $this; + } + + // Process the task immediately if the tables are not registered. + $task->process(); + + /** + * Fires an action when a task is dispatched synchronously. + * + * @since 0.0.7 + * + * @param Task $task The task that was dispatched synchronously. + */ + do_action( "shepherd_{$prefix}_dispatched_sync", $task ); + + return $this; + } + + if ( did_action( 'action_scheduler_init' ) || doing_action( 'action_scheduler_init' ) ) { $this->dispatch_callback( $task, $delay ); return $this; } add_action( - 'init', + 'action_scheduler_init', function () use ( $task, $delay ): void { $this->dispatch_callback( $task, $delay ); }, diff --git a/src/Tables/Provider.php b/src/Tables/Provider.php index 3b653d02..d381708d 100644 --- a/src/Tables/Provider.php +++ b/src/Tables/Provider.php @@ -15,6 +15,8 @@ use StellarWP\Schema\Register; use StellarWP\Shepherd\Contracts\Logger; use StellarWP\Shepherd\Loggers\DB_Logger; +use StellarWP\DB\Database\Exceptions\DatabaseQueryException; +use StellarWP\Shepherd\Config; /** * Shepherd Tables Service Provider @@ -38,6 +40,7 @@ class Provider extends Provider_Abstract { * Registers the service provider bindings. * * @since 0.0.1 + * @since 0.0.7 Updated to catch DatabaseQueryException. * * @return void The method does not return any value. */ @@ -46,10 +49,30 @@ public function register(): void { $this->container->singleton( Utility\Safe_Dynamic_Prefix::class ); $this->container->get( Utility\Safe_Dynamic_Prefix::class )->calculate_longest_table_name( $this->tables ); - Register::table( Tasks::class ); + $prefix = Config::get_hook_prefix(); - if ( $this->container->get( Logger::class ) instanceof DB_Logger ) { - Register::table( Task_Logs::class ); + try { + Register::table( Tasks::class ); + + if ( $this->container->get( Logger::class ) instanceof DB_Logger ) { + Register::table( Task_Logs::class ); + } + + /** + * Fires an action when the Shepherd tables are registered. + * + * @since 0.0.7 + */ + do_action( "shepherd_{$prefix}_tables_registered" ); + } catch ( DatabaseQueryException $e ) { + /** + * Fires an action when an error or exception happens in the context of Shepherd tables implementation AND the server runs PHP 7.0+. + * + * @since 0.0.7 + * + * @param DatabaseQueryException $e The thrown error. + */ + do_action( "shepherd_{$prefix}_tables_error", $e ); } } } diff --git a/src/Tables/Tasks.php b/src/Tables/Tasks.php index e40e2aca..a6498ae6 100644 --- a/src/Tables/Tasks.php +++ b/src/Tables/Tasks.php @@ -48,10 +48,11 @@ class Tasks extends Table { * * @since 0.0.1 * @since 0.0.3 Updated to 0.0.2. + * @since 0.0.7 Updated to 0.0.3 to fix typo in the version string. * * @var string */ - const SCHEMA_VERSION = '0.0.2s'; + const SCHEMA_VERSION = '0.0.3'; /** * The base table name, without the table prefix. diff --git a/src/Traits/Custom_Table_Query_Methods.php b/src/Traits/Custom_Table_Query_Methods.php index f2af309e..f7329cce 100644 --- a/src/Traits/Custom_Table_Query_Methods.php +++ b/src/Traits/Custom_Table_Query_Methods.php @@ -28,6 +28,7 @@ trait Custom_Table_Query_Methods { * Fetches all the rows from the table using a batched query. * * @since 0.0.1 + * @since 0.0.7 Increment the $offset variable. * * @param int $batch_size The number of rows to fetch per batch. * @param string $output The output type of the query, one of OBJECT, ARRAY_A, or ARRAY_N. @@ -49,13 +50,15 @@ public static function fetch_all( int $batch_size = 50, string $output = OBJECT, $order_by = $order_by ?: $uid_column . ' ASC'; + $query = DB::prepare( + "SELECT {$sql_calc_found_rows} * FROM %i {$where_clause} ORDER BY {$order_by} LIMIT %d, %d", + static::table_name( true ), + $offset, + $batch_size + ); + $batch = DB::get_results( - DB::prepare( - "SELECT {$sql_calc_found_rows} * FROM %i {$where_clause} ORDER BY {$order_by} LIMIT %d, %d", - static::table_name( true ), - $offset, - $batch_size - ), + $query, $output ); @@ -63,6 +66,8 @@ public static function fetch_all( int $batch_size = 50, string $output = OBJECT, $total ??= DB::get_var( 'SELECT FOUND_ROWS()' ); $fetched += count( $batch ); + $offset += $batch_size; + yield from $batch; } while ( $fetched < $total ); } diff --git a/tests/_support/Helper/test-functions.php b/tests/_support/Helper/test-functions.php index 79955579..44bb9abc 100644 --- a/tests/_support/Helper/test-functions.php +++ b/tests/_support/Helper/test-functions.php @@ -145,6 +145,8 @@ function tests_shepherd_common_bootstrap(): void { $container = Config::get_container(); + add_action( 'shepherd_' . Config::get_hook_prefix() . '_tables_error', '__return_true' ); + // Bootstrap Shepherd. $container->singleton( Provider::class ); $container->get( Provider::class )->register(); diff --git a/tests/wpunit/Action_Scheduler_Methods_Test.php b/tests/wpunit/Action_Scheduler_Methods_Test.php index 122ed8a7..c208d4e7 100644 --- a/tests/wpunit/Action_Scheduler_Methods_Test.php +++ b/tests/wpunit/Action_Scheduler_Methods_Test.php @@ -5,8 +5,10 @@ namespace StellarWP\Shepherd; use lucatume\WPBrowser\TestCase\WPTestCase; +use StellarWP\Shepherd\Tests\Traits\With_Uopz; class Action_Scheduler_Methods_Test extends WPTestCase { + use With_Uopz; /** * @before * @after @@ -45,4 +47,41 @@ public function it_should_check_if_action_is_scheduled() { $this->assertTrue( Action_Scheduler_Methods::has_scheduled_action( $hook, $args, $group ) ); } + + /** + * @test + */ + public function it_should_return_zero_when_schedule_single_action_returns_non_integer() { + $this->set_fn_return( 'as_schedule_single_action', 'not-an-integer' ); + + $time = time() + 100; + $hook = 'shepherd_test_hook_non_int'; + $args = [ 'test' => 'non_int' ]; + $group = 'shepherd_test_group'; + + $action_id = Action_Scheduler_Methods::schedule_single_action( $time, $hook, $args, $group ); + + $this->assertSame( 0, $action_id ); + } + + /** + * @test + */ + public function it_should_filter_out_null_actions_from_pending_actions() { + $finished_action = $this->createMock( \ActionScheduler_FinishedAction::class ); + $null_action = $this->createMock( \ActionScheduler_NullAction::class ); + $normal_action = $this->createMock( \ActionScheduler_Action::class ); + + $this->set_class_fn_return( + Action_Scheduler_Methods::class, + 'get_actions_by_ids', + [ 1 => $finished_action, 2 => $null_action, 3 => $normal_action ] + ); + + $pending = Action_Scheduler_Methods::get_pending_actions_by_ids( [ 1, 2, 3 ] ); + + // Should only contain the normal action (not finished, not null) + $this->assertCount( 1, $pending ); + $this->assertSame( $normal_action, reset( $pending ) ); + } } diff --git a/tests/wpunit/Provider_Test.php b/tests/wpunit/Provider_Test.php index de892685..4394538e 100644 --- a/tests/wpunit/Provider_Test.php +++ b/tests/wpunit/Provider_Test.php @@ -6,10 +6,13 @@ use lucatume\WPBrowser\TestCase\WPTestCase; use StellarWP\Shepherd\Tables\Task_Logs; +use StellarWP\Shepherd\Tables\Provider as Tables_Provider; use StellarWP\Shepherd\Tables\Tasks; use StellarWP\Shepherd\Tests\Traits\With_Uopz; use StellarWP\Shepherd\Tests\Tasks\Do_Action_Task; use StellarWP\DB\DB; +use StellarWP\Shepherd\Tests\Container; +use StellarWP\ContainerContract\ContainerInterface; class Provider_Test extends WPTestCase { use With_Uopz; @@ -32,8 +35,8 @@ public function it_should_evaluate_hook_prefix(): void { * @test */ public function it_should_register_action_deletion_hook(): void { - $provider = Config::get_container()->get( Provider::class ); - + $provider = tests_shepherd_get_container()->get( Provider::class ); + $this->assertNotFalse( has_action( 'action_scheduler_deleted_action', [ $provider, 'delete_tasks_on_action_deletion' ] ) ); } @@ -43,13 +46,13 @@ public function it_should_register_action_deletion_hook(): void { public function it_should_delete_tasks_on_action_deletion_when_tasks_exist(): void { $provider = Config::get_container()->get( Provider::class ); $shepherd = shepherd(); - + // Create one task to get a valid action ID. $test_task = new Do_Action_Task(); $shepherd->dispatch( $test_task ); $task_id = $shepherd->get_last_scheduled_task_id(); $action_id = $this->get_task_action_id( $task_id ); - + // Create additional tasks with the same action_id directly in the database. $additional_task_ids = []; for ( $i = 0; $i < 2; $i++ ) { @@ -66,9 +69,9 @@ public function it_should_delete_tasks_on_action_deletion_when_tasks_exist(): vo ); $additional_task_ids[] = $GLOBALS['wpdb']->insert_id; } - + $all_task_ids = array_merge( [ $task_id ], $additional_task_ids ); - + $tasks_before = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i WHERE action_id = %d', @@ -77,9 +80,9 @@ public function it_should_delete_tasks_on_action_deletion_when_tasks_exist(): vo ) ); $this->assertEquals( 3, $tasks_before ); - + $provider->delete_tasks_on_action_deletion( $action_id ); - + $tasks_after = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i WHERE action_id = %d', @@ -88,7 +91,7 @@ public function it_should_delete_tasks_on_action_deletion_when_tasks_exist(): vo ) ); $this->assertEquals( 0, $tasks_after ); - + $logs_after = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i WHERE task_id IN (%s)', @@ -105,7 +108,7 @@ public function it_should_delete_tasks_on_action_deletion_when_tasks_exist(): vo public function it_should_not_delete_when_no_tasks_exist_for_action(): void { $provider = Config::get_container()->get( Provider::class ); $action_id = 456; - + $tasks_before = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i WHERE action_id = %d', @@ -114,9 +117,9 @@ public function it_should_not_delete_when_no_tasks_exist_for_action(): void { ) ); $this->assertEquals( 0, $tasks_before ); - + $provider->delete_tasks_on_action_deletion( $action_id ); - + $tasks_after = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i WHERE action_id = %d', @@ -133,13 +136,13 @@ public function it_should_not_delete_when_no_tasks_exist_for_action(): void { public function it_should_sanitize_task_ids_before_deletion(): void { $provider = Config::get_container()->get( Provider::class ); $shepherd = shepherd(); - + // Create one task to get a valid action ID. $test_task = new Do_Action_Task(); $shepherd->dispatch( $test_task ); $task_id = $shepherd->get_last_scheduled_task_id(); $action_id = $this->get_task_action_id( $task_id ); - + // Create additional tasks with the same action_id directly in the database. $additional_task_ids = []; for ( $i = 0; $i < 2; $i++ ) { @@ -156,11 +159,11 @@ public function it_should_sanitize_task_ids_before_deletion(): void { ); $additional_task_ids[] = $GLOBALS['wpdb']->insert_id; } - + $all_task_ids = array_merge( [ $task_id ], $additional_task_ids ); - + $provider->delete_tasks_on_action_deletion( $action_id ); - + $tasks_after = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i WHERE action_id = %d', @@ -169,7 +172,7 @@ public function it_should_sanitize_task_ids_before_deletion(): void { ) ); $this->assertEquals( 0, $tasks_after ); - + $logs_after = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i WHERE task_id IN (%s)', @@ -193,4 +196,79 @@ private function get_task_action_id( int $task_id ): int { ) ); } + + /** + * @test + */ + public function it_should_register_regulator_after_tables_are_registered(): void { + $prefix = Config::get_hook_prefix(); + + $this->assertEquals( 1, did_action( "shepherd_{$prefix}_tables_registered" ), 'Tables registered action should have fired' ); + + $this->assertNotFalse( + has_action( "shepherd_{$prefix}_tables_registered" ), + 'Regulator registration should be hooked to tables_registered action' + ); + } + + /** + * @test + */ + public function it_should_hook_register_regulator_method_to_tables_registered_action(): void { + $provider = Config::get_container()->get( Provider::class ); + $prefix = Config::get_hook_prefix(); + + $this->assertNotFalse( + has_action( "shepherd_{$prefix}_tables_registered", [ $provider, 'register_regulator' ] ), + 'register_regulator method should be hooked to tables_registered action' + ); + } + + /** + * @test + */ + public function it_should_trigger_doing_it_wrong_if_tables_error_hook_not_handled(): void { + $prefix = Config::get_hook_prefix(); + $action_name = "shepherd_{$prefix}_tables_error"; + + // Remove any existing error handler to simulate unhandled state. + remove_all_actions( $action_name ); + + // Mock _doing_it_wrong to verify it gets called. + $doing_it_wrong_called = false; + $this->set_fn_return( '_doing_it_wrong', function() use ( &$doing_it_wrong_called ) { + $doing_it_wrong_called = true; + }, true ); + + $this->set_class_fn_return( Provider::class, 'is_registered', false, false ); + $this->set_class_fn_return( Tables_Provider::class, 'register', true, false); + + $container = new Container(); + $container->singleton( ContainerInterface::class, $container ); + $provider = new Provider( $container ); + $provider->register(); + + $this->assertTrue( $doing_it_wrong_called, '_doing_it_wrong should be called when tables_error hook is not handled' ); + } + + /** + * @test + * @skip + */ + public function it_should_not_trigger_doing_it_wrong_if_tables_error_hook_is_handled(): void { + $doing_it_wrong_called = false; + $this->set_fn_return( '_doing_it_wrong', function() use ( &$doing_it_wrong_called ) { + $doing_it_wrong_called = true; + }, true ); + + $this->set_class_fn_return( Provider::class, 'is_registered', false, false ); + $this->set_class_fn_return( Tables_Provider::class, 'register', true, false); + + $container = new Container(); + $container->singleton( ContainerInterface::class, $container ); + $provider = new Provider( $container ); + $provider->register(); + + $this->assertFalse( $doing_it_wrong_called, '_doing_it_wrong should not be called when tables_error hook is handled' ); + } } diff --git a/tests/wpunit/Regulator_Test.php b/tests/wpunit/Regulator_Test.php index ad19a8e3..bf5fbe50 100644 --- a/tests/wpunit/Regulator_Test.php +++ b/tests/wpunit/Regulator_Test.php @@ -7,9 +7,12 @@ use lucatume\WPBrowser\TestCase\WPTestCase; use StellarWP\Shepherd\Tasks\Herding; use StellarWP\Shepherd\Tests\Traits\With_Uopz; +use StellarWP\Shepherd\Tests\Tasks\Do_Action_Task; +use StellarWP\Shepherd\Tests\Traits\With_AS_Assertions; class Regulator_Test extends WPTestCase { use With_Uopz; + use With_AS_Assertions; /** * @test @@ -24,19 +27,19 @@ public function it_should_have_as_hook_registered(): void { */ public function it_should_schedule_cleanup_task_on_init(): void { $regulator = Config::get_container()->get( Regulator::class ); - + $herding_dispatched = false; - + add_action( 'shepherd_' . Config::get_hook_prefix() . '_task_created', function( $task ) use ( &$herding_dispatched ) { if ( $task instanceof Herding ) { $herding_dispatched = true; } } ); - + $regulator->schedule_cleanup_task(); - + $this->assertTrue( $herding_dispatched ); - + // Also verify the task was scheduled with the correct delay by checking Action Scheduler $last_task_id = shepherd()->get_last_scheduled_task_id(); $this->assertNotNull( $last_task_id ); @@ -45,9 +48,106 @@ public function it_should_schedule_cleanup_task_on_init(): void { /** * @test */ - public function it_should_register_init_hook_for_cleanup_scheduling(): void { + public function it_should_register_wp_loaded_hook_for_cleanup_scheduling(): void { + $regulator = Config::get_container()->get( Regulator::class ); + + $this->assertSame( 20, has_action( 'wp_loaded', [ $regulator, 'schedule_cleanup_task' ] ) ); + } + + /** + * @test + */ + public function it_should_dispatch_immediately_when_action_scheduler_initialized(): void { + $regulator = Config::get_container()->get( Regulator::class ); + + $task = new Do_Action_Task(); + + $this->assertSame( 0, did_action( $task->get_task_name() ) ); + + $regulator->dispatch( $task ); + + $this->assertSame( 0, did_action( $task->get_task_name() ) ); + + $last_scheduled_task_id = $regulator->get_last_scheduled_task_id(); + $this->assertNotNull( $last_scheduled_task_id ); + + $this->assertTaskHasActionPending( $last_scheduled_task_id ); + + $this->assertTaskIsScheduledForExecutionAt( $last_scheduled_task_id, time() ); + + $this->assertTaskExecutesWithoutErrors( $last_scheduled_task_id ); + + $this->assertSame( 1, did_action( $task->get_task_name() ) ); + } + + /** + * @test + */ + public function it_should_process_task_synchronously_when_tables_not_registered(): void { + $prefix = Config::get_hook_prefix(); + + $this->set_fn_return( 'did_action', function( $action ) use ( $prefix ) { + if ( $action === "shepherd_{$prefix}_tables_registered" ) { + return 0; + } + + return did_action( $action ); + }, true ); + $regulator = Config::get_container()->get( Regulator::class ); - - $this->assertSame( 20, has_action( 'init', [ $regulator, 'schedule_cleanup_task' ] ) ); + + $test_task = new Do_Action_Task(); + + $this->assertSame( 0, did_action( $test_task->get_task_name() ) ); + $this->assertSame( 0, did_action( "shepherd_{$prefix}_dispatched_sync" ) ); + + $sync_dispatch_fired = false; + $dispatched_task = null; + add_action( "shepherd_{$prefix}_dispatched_sync", function( $task ) use ( &$sync_dispatch_fired, &$dispatched_task ) { + $sync_dispatch_fired = true; + $dispatched_task = $task; + } ); + + $regulator->dispatch( $test_task ); + + $this->assertSame( 1, did_action( $test_task->get_task_name() ) ); + $this->assertSame( 1, did_action( "shepherd_{$prefix}_dispatched_sync" ) ); + $this->assertTrue( $sync_dispatch_fired, 'Synchronous dispatch action should be fired' ); + $this->assertSame( $test_task, $dispatched_task, 'The dispatched task should be the same instance' ); + } + + /** + * @test + */ + public function it_should_respect_filter_to_disable_sync_dispatch_when_tables_not_registered(): void { + $prefix = Config::get_hook_prefix(); + + $this->set_fn_return( 'did_action', function( $action ) use ( $prefix ) { + if ( $action === "shepherd_{$prefix}_tables_registered" ) { + return 0; + } + + return did_action( $action ); + }, true ); + + $regulator = Config::get_container()->get( Regulator::class ); + + add_filter( "shepherd_{$prefix}_should_dispatch_sync_on_tables_unavailable", '__return_false' ); + + $test_task = new Do_Action_Task(); + + $this->assertSame( 0, did_action( $test_task->get_task_name() ) ); + $this->assertSame( 0, did_action( "shepherd_{$prefix}_dispatched_sync" ) ); + + $sync_dispatch_fired = false; + add_action( "shepherd_{$prefix}_dispatched_sync", function() use ( &$sync_dispatch_fired ) { + $sync_dispatch_fired = true; + } ); + + $regulator->dispatch( $test_task ); + + $this->assertSame( 0, did_action( $test_task->get_task_name() ) ); + $this->assertSame( 0, did_action( "shepherd_{$prefix}_dispatched_sync" ) ); + $this->assertFalse( $sync_dispatch_fired, 'Synchronous dispatch should not occur when disabled by filter' ); } } diff --git a/tests/wpunit/Tables/Tables_Provider_Test.php b/tests/wpunit/Tables/Tables_Provider_Test.php new file mode 100644 index 00000000..0e4cc2db --- /dev/null +++ b/tests/wpunit/Tables/Tables_Provider_Test.php @@ -0,0 +1,66 @@ +register(); + + $this->assertTrue( $hook_fired, 'The tables_registered action should be fired' ); + } + + /** + * @test + */ + public function it_should_fire_error_action_on_database_exception(): void { + $error_hook_fired = false; + $registered_hook_fired = false; + $caught_exception = null; + $prefix = Config::get_hook_prefix(); + + // Set up error action listener + add_action( "shepherd_{$prefix}_tables_error", function( $exception ) use ( &$error_hook_fired, &$caught_exception ) { + $error_hook_fired = true; + $caught_exception = $exception; + } ); + + add_action( "shepherd_{$prefix}_tables_registered", function() use ( &$registered_hook_fired ) { + $registered_hook_fired = true; + } ); + + // Mock Register::table to throw DatabaseQueryException + $this->set_class_fn_return( Register::class, 'table', function() { + throw new DatabaseQueryException( 'SELECT * FROM test', ['Test database error'], 'Test database error' ); + }, true ); + + // Re-register to trigger the exception + $provider = new Provider( Config::get_container() ); + $provider->register(); + + $this->assertTrue( $error_hook_fired, 'The tables_error action should be fired on database exception' ); + $this->assertInstanceOf( DatabaseQueryException::class, $caught_exception, 'Exception should be DatabaseQueryException' ); + $this->assertEquals( 'Test database error', $caught_exception->getMessage() ); + $this->assertFalse( $registered_hook_fired, 'The tables_registered action should NOT be fired on database exception' ); + } +}