Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 80 additions & 51 deletions src/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use DateTimeZone;
use WP_Error;
use Exception;
use InvalidArgumentException;
use RuntimeException;
use IntlTimeZone;
use ReflectionException;

Expand Down Expand Up @@ -160,6 +162,79 @@
}
}

/**
* 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 );

Check failure on line 216 in src/bootstrap.php

View workflow job for this annotation

GitHub Actions / PHP / PHPStan on PHP 8.3

Function Crontrol\Event\get_schedule_name not found.

Check failure on line 216 in src/bootstrap.php

View workflow job for this annotation

GitHub Actions / PHP / PHPStan on PHP 7.4

Function Crontrol\Event\get_schedule_name not found.
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.
*
Expand Down Expand Up @@ -939,18 +1014,13 @@
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,
Expand All @@ -962,8 +1032,6 @@
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(
Expand All @@ -972,46 +1040,7 @@
)
);

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 );

Expand Down
221 changes: 221 additions & 0 deletions tests/integration/CSVExportTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<?php declare(strict_types = 1);

namespace Crontrol\Tests;

use Crontrol;

class CSVExportTest extends Test {
/**
* Export events to CSV and return all rows
*
* @param string $type Event type to export
* @return list<list<string|null>> 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 ) {

Check failure on line 26 in tests/integration/CSVExportTest.php

View workflow job for this annotation

GitHub Actions / PHP / PHPStan on PHP 8.3

Strict comparison using !== between list<string|null> and null will always evaluate to true.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ( $row !== null ) {
if ( $row !== [null] ) {

A blank line in a CSV file will be returned as an array comprising a single null field, and will not be treated as an error.

$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<string|null> 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<list<string|null>> 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<list<string|null>> $rows CSV rows to search
* @param string $hook Hook name to find
* @return list<string|null>|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<string|null> 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] );
}
}
Loading