diff --git a/src/bootstrap.php b/src/bootstrap.php index 8d268f2..8470110 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -19,6 +19,8 @@ use DateTimeZone; use WP_Error; use Exception; +use InvalidArgumentException; +use RuntimeException; use IntlTimeZone; use ReflectionException; @@ -160,6 +162,79 @@ function pauser() { } } +/** + * 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. * @@ -939,18 +1014,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, @@ -962,8 +1032,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( @@ -972,46 +1040,7 @@ function action_handle_posts() { ) ); - fputcsv( $csv, $headers ); - - if ( isset( $events[ $type ] ) ) { - foreach ( $events[ $type ] as $event ) { - $next_run_local = $event->get_next_run_local(); - $next_run_utc = $event->get_next_run_utc(); - $hook_callbacks = $event->get_callbacks(); - - $args = $event->get_args_display(); - - if ( ( PHPCronEvent::HOOK_NAME === $event->hook ) || ( URLCronEvent::HOOK_NAME === $event->hook ) ) { - $action = 'WP Crontrol'; - } else { - $callbacks = array(); - - foreach ( $hook_callbacks as $callback ) { - $callbacks[] = $callback['callback']['name']; - } - - $action = implode( ',', $callbacks ); - } - - try { - $schedule_name = $event->get_schedule_name(); - } catch ( UnknownScheduleException $e ) { - $schedule_name = $e->getMessage(); - } - - $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 0000000..9c5fb1d --- /dev/null +++ b/tests/integration/CSVExportTest.php @@ -0,0 +1,221 @@ +> Array of CSV rows + */ + private function exportEventsToArray( string $type = 'all' ): array { + $stream = fopen( 'php://memory', 'w+' ); + + if ( ! is_resource( $stream ) ) { + self::fail( 'Failed to open memory stream for CSV export' ); + } + + Crontrol\export_events_csv( $type, $stream ); + rewind( $stream ); + + $rows = array(); + while ( ( $row = fgetcsv( $stream ) ) !== false ) { + if ( $row !== null ) { + $rows[] = $row; + } + } + + fclose( $stream ); + return $rows; + } + + /** + * Export events to CSV and return only the header row + * + * @param string $type Event type to export + * @return list Header row + */ + private function exportEventsHeaders( string $type = 'all' ): array { + $rows = $this->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 list> 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 list> $rows CSV rows to search + * @param string $hook Hook name to find + * @return list|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 list 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] ); + } +}