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' );
+ }
+}