From 0dd7dbdd5b67ed6c497d2e0c8a180d60dc163140 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sat, 20 Sep 2025 22:18:10 +0100 Subject: [PATCH 1/4] Extract the CSV export functionality so it can be tested. --- src/bootstrap.php | 140 ++++++++++-------- tests/integration/CSVExportTest.php | 219 ++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 60 deletions(-) create mode 100644 tests/integration/CSVExportTest.php diff --git a/src/bootstrap.php b/src/bootstrap.php index 3b792b16..fd51eb64 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -10,6 +10,8 @@ use stdClass; use WP_Error; use Exception; +use InvalidArgumentException; +use RuntimeException; use IntlTimeZone; use function Crontrol\Event\check_integrity; @@ -144,6 +146,79 @@ function pauser() { remove_all_actions( current_filter() ); } +/** + * Export cron events to CSV format + * + * @param string $type The type of events to export ('all', 'scheduled', 'paused', etc.) + * @param resource $output Output stream to write CSV to. + */ +function export_events_csv( string $type, $output ): void { + $headers = array( + 'hook', + 'arguments', + 'next_run', + 'next_run_gmt', + 'action', + 'schedule', + 'interval', + ); + + $events = Table::get_filtered_events( Event\get() ); + + fputcsv( $output, $headers ); + + if ( ! isset( $events[ $type ] ) ) { + return; + } + + foreach ( $events[ $type ] as $event ) { + $next_run_local = get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $event->timestamp ), 'c' ); + $next_run_utc = gmdate( 'c', $event->timestamp ); + $hook_callbacks = \Crontrol\get_hook_callbacks( $event->hook ); + + if ( 'crontrol_cron_job' === $event->hook ) { + $args = __( 'PHP Code', 'wp-crontrol' ); + } elseif ( empty( $event->args ) ) { + $args = ''; + } else { + $args = \Crontrol\json_output( $event->args, false ); + } + + if ( 'crontrol_cron_job' === $event->hook ) { + $action = 'WP Crontrol'; + } else { + $callbacks = array(); + + foreach ( $hook_callbacks as $callback ) { + $callbacks[] = $callback['callback']['name']; + } + + $action = implode( ',', $callbacks ); + } + + if ( $event->schedule ) { + $schedule_name = Event\get_schedule_name( $event ); + if ( is_wp_error( $schedule_name ) ) { + $schedule_name = $schedule_name->get_error_message(); + } + } else { + $schedule_name = __( 'Non-repeating', 'wp-crontrol' ); + } + + $row = array( + $event->hook, + $args, + $next_run_local, + $next_run_utc, + $action, + $schedule_name, + (int) $event->interval, + ); + + fputcsv( $output, $row ); + } +} + /** * Handles any POSTs and GETs made by the plugin. Run using the 'init' action. * @@ -912,18 +987,13 @@ function action_handle_posts() { wp_safe_redirect( add_query_arg( $redirect, admin_url( 'tools.php' ) ) ); exit; } elseif ( isset( $_POST['crontrol_action'] ) && 'export-event-csv' === $_POST['crontrol_action'] ) { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You are not allowed to export cron events.', 'wp-crontrol' ), 403 ); + } + check_admin_referer( 'crontrol-export-event-csv', 'crontrol_nonce' ); $type = isset( $_POST['crontrol_hooks_type'] ) ? wp_unslash( $_POST['crontrol_hooks_type'] ) : 'all'; - $headers = array( - 'hook', - 'arguments', - 'next_run', - 'next_run_gmt', - 'action', - 'schedule', - 'interval', - ); $filename = sanitize_file_name( sprintf( 'cron-events-%s-%s.csv', $type, @@ -935,8 +1005,6 @@ function action_handle_posts() { wp_die( esc_html__( 'Could not save CSV file.', 'wp-crontrol' ) ); } - $events = Table::get_filtered_events( Event\get() ); - header( 'Content-Type: text/csv; charset=utf-8' ); header( sprintf( @@ -945,55 +1013,7 @@ function action_handle_posts() { ) ); - fputcsv( $csv, $headers ); - - if ( isset( $events[ $type ] ) ) { - foreach ( $events[ $type ] as $event ) { - $next_run_local = get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $event->timestamp ), 'c' ); - $next_run_utc = gmdate( 'c', $event->timestamp ); - $hook_callbacks = \Crontrol\get_hook_callbacks( $event->hook ); - - if ( 'crontrol_cron_job' === $event->hook ) { - $args = __( 'PHP Code', 'wp-crontrol' ); - } elseif ( empty( $event->args ) ) { - $args = ''; - } else { - $args = \Crontrol\json_output( $event->args, false ); - } - - if ( 'crontrol_cron_job' === $event->hook ) { - $action = 'WP Crontrol'; - } else { - $callbacks = array(); - - foreach ( $hook_callbacks as $callback ) { - $callbacks[] = $callback['callback']['name']; - } - - $action = implode( ',', $callbacks ); - } - - if ( $event->schedule ) { - $schedule_name = Event\get_schedule_name( $event ); - if ( is_wp_error( $schedule_name ) ) { - $schedule_name = $schedule_name->get_error_message(); - } - } else { - $schedule_name = __( 'Non-repeating', 'wp-crontrol' ); - } - - $row = array( - $event->hook, - $args, - $next_run_local, - $next_run_utc, - $action, - $schedule_name, - (int) $event->interval, - ); - fputcsv( $csv, $row ); - } - } + export_events_csv( $type, $csv ); fclose( $csv ); diff --git a/tests/integration/CSVExportTest.php b/tests/integration/CSVExportTest.php new file mode 100644 index 00000000..464c6f4c --- /dev/null +++ b/tests/integration/CSVExportTest.php @@ -0,0 +1,219 @@ +exportEventsToArray( $type ); + return $rows[0]; + } + + /** + * Export events to CSV and return only the data rows (excluding headers) + * + * @param string $type Event type to export + * @return array Data rows without headers + */ + private function exportEventsDataRows( string $type = 'all' ): array { + $rows = $this->exportEventsToArray( $type ); + return array_slice( $rows, 1 ); + } + + /** + * Find a specific event row by hook name + * + * @param array $rows CSV rows to search + * @param string $hook Hook name to find + * @return array|null The matching row or null if not found + */ + private function findEventRow( array $rows, string $hook ): ?array { + foreach ( $rows as $row ) { + if ( $row[0] === $hook ) { + return $row; + } + } + + return null; + } + + /** + * Get and assert an event row exists + * + * @param string $hook Hook name to find + * @param string $type Event type to export + * @return array The event row + */ + private function getEventRow( string $hook, string $type = 'all' ): array { + $data_rows = $this->exportEventsDataRows( $type ); + $event_row = $this->findEventRow( $data_rows, $hook ); + + if ( $event_row === null ) { + self::fail( "Event with hook '$hook' not found in CSV export" ); + } + + return $event_row; + } + + /** + * Test that CSV export produces correct headers + */ + public function testCSVExportHeaders(): void { + $headers = $this->exportEventsHeaders( 'all' ); + + $expected_headers = array( + 'hook', + 'arguments', + 'next_run', + 'next_run_gmt', + 'action', + 'schedule', + 'interval', + ); + + self::assertSame( $expected_headers, $headers ); + } + + /** + * Test that CSV export includes scheduled events + */ + public function testCSVExportIncludesScheduledEvents(): void { + // Schedule a test event + $timestamp = time() + 123; + $hook = 'test_csv_export_hook'; + $args = array( 'test_arg' => 'test_value' ); + + wp_schedule_single_event( $timestamp, $hook, array( $args ) ); + + $test_event_row = $this->getEventRow( $hook, 'all' ); + + // Arguments + self::assertSame( '[{"test_arg":"test_value"}]', $test_event_row[1] ); + // Schedule + self::assertSame( 'Non-repeating', $test_event_row[5] ); + // Interval + self::assertSame( '0', $test_event_row[6] ); + } + + /** + * Test that CSV export includes recurring events + */ + public function testCSVExportIncludesRecurringEvents(): void { + // Schedule a recurring test event + $timestamp = time() + 123; + $hook = 'test_csv_export_recurring_hook'; + $args = array(); + $recurrence = 'hourly'; + + wp_schedule_event( $timestamp, $recurrence, $hook, $args ); + + $test_event_row = $this->getEventRow( $hook, 'all' ); + + // Arguments + self::assertSame( '[]', $test_event_row[1] ); + // Schedule + self::assertSame( 'Once Hourly', $test_event_row[5] ); + // Interval + self::assertSame( '3600', $test_event_row[6] ); + } + + /** + * Test that CSV export handles PHP cron jobs correctly + */ + public function testCSVExportHandlesPHPCronJobs(): void { + // Schedule a PHP cron job + $timestamp = time() + 123; + $hook = 'crontrol_cron_job'; + $php = 'echo "test";'; + $args = array( + array( + 'code' => $php, + 'name' => 'Test PHP Job', + 'hash' => wp_hash( $php ), + ), + ); + + wp_schedule_single_event( $timestamp, $hook, $args ); + + $php_job_row = $this->getEventRow( $hook, 'all' ); + + // Arguments + self::assertSame( 'PHP Code', $php_job_row[1] ); + // Action + self::assertSame( 'WP Crontrol', $php_job_row[4] ); + } + + /** + * Test that CSV export handles events with no arguments + */ + public function testCSVExportHandlesEventsWithNoArguments(): void { + // Schedule an event with no arguments + $timestamp = time() + 123; + $hook = 'test_csv_no_args_hook'; + + wp_schedule_single_event( $timestamp, $hook ); + + $test_event_row = $this->getEventRow( $hook, 'all' ); + + // Arguments + self::assertSame( '', $test_event_row[1] ); + } + + /** + * Test that CSV export handles invalid schedule names + */ + public function testCSVExportHandlesInvalidScheduleNames(): void { + // Create an event with a custom/invalid schedule + $timestamp = time() + 123; + $hook = 'test_csv_invalid_schedule'; + $key = md5( serialize( array() ) ); + + // Manually add an event with an invalid schedule using the proper structure + $crons = _get_cron_array(); + $crons[ $timestamp ][ $hook ][ $key ] = array( + 'schedule' => 'non_existent_schedule', + 'args' => array(), + 'interval' => 9999, + ); + _set_cron_array( $crons ); + + $test_event_row = $this->getEventRow( $hook, 'all' ); + + // Schedule name should show the error message for invalid schedule + self::assertSame( 'Unknown (non_existent_schedule)', $test_event_row[5] ); + // Interval should still be correct + self::assertSame( '9999', $test_event_row[6] ); + } +} From 65592e6a3dcd7efe2dfb8bb29265da3b0bc8b4dc Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sat, 20 Sep 2025 22:26:08 +0100 Subject: [PATCH 2/4] Empty args are shown as an empty string. --- tests/integration/CSVExportTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/CSVExportTest.php b/tests/integration/CSVExportTest.php index 464c6f4c..b6401a04 100644 --- a/tests/integration/CSVExportTest.php +++ b/tests/integration/CSVExportTest.php @@ -142,7 +142,7 @@ public function testCSVExportIncludesRecurringEvents(): void { $test_event_row = $this->getEventRow( $hook, 'all' ); // Arguments - self::assertSame( '[]', $test_event_row[1] ); + self::assertSame( '', $test_event_row[1] ); // Schedule self::assertSame( 'Once Hourly', $test_event_row[5] ); // Interval From 6e27ad8c3356867d7a049f3d1039369bfbcf5cde Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sat, 20 Sep 2025 22:31:02 +0100 Subject: [PATCH 3/4] Coding standards. --- tests/integration/CSVExportTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/CSVExportTest.php b/tests/integration/CSVExportTest.php index b6401a04..261289c4 100644 --- a/tests/integration/CSVExportTest.php +++ b/tests/integration/CSVExportTest.php @@ -9,7 +9,7 @@ class CSVExportTest extends Test { * Export events to CSV and return all rows * * @param string $type Event type to export - * @return array Array of CSV rows + * @return list> Array of CSV rows */ private function exportEventsToArray( string $type = 'all' ): array { $stream = fopen( 'php://memory', 'w+' ); @@ -34,7 +34,7 @@ private function exportEventsToArray( string $type = 'all' ): array { * Export events to CSV and return only the header row * * @param string $type Event type to export - * @return array Header row + * @return list Header row */ private function exportEventsHeaders( string $type = 'all' ): array { $rows = $this->exportEventsToArray( $type ); @@ -45,7 +45,7 @@ private function exportEventsHeaders( string $type = 'all' ): array { * Export events to CSV and return only the data rows (excluding headers) * * @param string $type Event type to export - * @return array Data rows without headers + * @return list> Data rows without headers */ private function exportEventsDataRows( string $type = 'all' ): array { $rows = $this->exportEventsToArray( $type ); @@ -55,9 +55,9 @@ private function exportEventsDataRows( string $type = 'all' ): array { /** * Find a specific event row by hook name * - * @param array $rows CSV rows to search + * @param list> $rows CSV rows to search * @param string $hook Hook name to find - * @return array|null The matching row or null if not found + * @return list|null The matching row or null if not found */ private function findEventRow( array $rows, string $hook ): ?array { foreach ( $rows as $row ) { @@ -74,7 +74,7 @@ private function findEventRow( array $rows, string $hook ): ?array { * * @param string $hook Hook name to find * @param string $type Event type to export - * @return array The event row + * @return list The event row */ private function getEventRow( string $hook, string $type = 'all' ): array { $data_rows = $this->exportEventsDataRows( $type ); From 431f3b0037bcf38e0cae37ecf2d35d48efa3beb0 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sat, 20 Sep 2025 22:50:10 +0100 Subject: [PATCH 4/4] This can be null too. --- tests/integration/CSVExportTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/CSVExportTest.php b/tests/integration/CSVExportTest.php index 261289c4..9c5fb1d8 100644 --- a/tests/integration/CSVExportTest.php +++ b/tests/integration/CSVExportTest.php @@ -23,7 +23,9 @@ private function exportEventsToArray( string $type = 'all' ): array { $rows = array(); while ( ( $row = fgetcsv( $stream ) ) !== false ) { - $rows[] = $row; + if ( $row !== null ) { + $rows[] = $row; + } } fclose( $stream );