diff --git a/includes/Admin.php b/includes/Admin.php index 82a3c628e..0e1421460 100644 --- a/includes/Admin.php +++ b/includes/Admin.php @@ -290,23 +290,19 @@ function commonsbooking_sanitizeArrayorString( $data, $sanitizeFunction = 'sanit * @return void */ function commonsbooking_write_log( $log, $backtrace = true ) { + $logmessage = is_array( $log ) || is_object( $log ) ? print_r( $log, true ) : (string) $log; - if ( ! WP_DEBUG_LOG ) { - return; + if ( $backtrace ) { + $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 ); + $logmessage = ( $bt[0]['file'] ?? '' ) . ':' . ( $bt[0]['line'] ?? 0 ) . ' ' . $logmessage; } - if ( is_array( $log ) || is_object( $log ) ) { - $logmessage = ( print_r( $log, true ) ); - } else { - $logmessage = $log; - } + // Always persist to the error monitor — works even without WP_DEBUG_LOG + \CommonsBooking\Service\ErrorMonitor::record( $logmessage, \CommonsBooking\Service\ErrorMonitor::SEVERITY_WARNING, [], 1 ); - if ( $backtrace ) { - $bt = debug_backtrace(); - $file = $bt[0]['file']; - $line = $bt[0]['line']; - $logmessage = $file . ':' . $line . ' ' . $logmessage; + if ( ! WP_DEBUG_LOG ) { + return; } - error_log( $logmessage ); + error_log( $logmessage ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } diff --git a/src/API/BaseRoute.php b/src/API/BaseRoute.php index 5e13be79d..cf92fa0cc 100644 --- a/src/API/BaseRoute.php +++ b/src/API/BaseRoute.php @@ -131,13 +131,11 @@ public function validateData( $data ) { } } } catch ( Exception $e ) { - // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_error_log + commonsbooking_write_log( 'Problem while trying to access wp rest endpoint url for schema ' . $this->schemaUrl, false ); + commonsbooking_write_log( (string) $e, false ); if ( WP_DEBUG ) { - error_log( 'Problem while trying to access wp rest endpoint url for schema ' . $this->schemaUrl ); - error_log( $e ); die; } - // phpcs:enable } } diff --git a/src/API/GBFS/BaseRoute.php b/src/API/GBFS/BaseRoute.php index f44c3ecfc..9ac45f10c 100644 --- a/src/API/GBFS/BaseRoute.php +++ b/src/API/GBFS/BaseRoute.php @@ -56,9 +56,7 @@ public function getItemData( $request ): array { $itemdata = $this->prepare_item_for_response( $location, $request ); $data[] = $itemdata->data; } catch ( Exception $exception ) { - if ( WP_DEBUG ) { - error_log( $exception->getMessage() ); - } + commonsbooking_write_log( $exception->getMessage(), false ); } } diff --git a/src/API/LocationsRoute.php b/src/API/LocationsRoute.php index c429278f0..80abcccdf 100644 --- a/src/API/LocationsRoute.php +++ b/src/API/LocationsRoute.php @@ -79,9 +79,7 @@ public function getItemData( $request ) { $itemdata = $this->prepare_item_for_response( $location, $request ); $features[] = $itemdata->get_data(); } catch ( Exception $exception ) { - if ( WP_DEBUG ) { - error_log( $exception->getMessage() ); - } + commonsbooking_write_log( $exception->getMessage(), false ); } } diff --git a/src/Helper/NominatimGeoCodeService.php b/src/Helper/NominatimGeoCodeService.php index 5034d3a79..fb4f3d13f 100644 --- a/src/Helper/NominatimGeoCodeService.php +++ b/src/Helper/NominatimGeoCodeService.php @@ -55,7 +55,7 @@ public function getAddressData( $addressString ): ?Location { return $addresses->first(); } } catch ( Exception $exception ) { - error_log( $exception->getMessage() ); + commonsbooking_write_log( $exception->getMessage(), false ); } return null; diff --git a/src/Plugin.php b/src/Plugin.php index b7e143506..ad660eea1 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -17,8 +17,11 @@ use CommonsBooking\Service\Upgrade; use CommonsBooking\Settings\Settings; use CommonsBooking\Repository\BookingCodes; +use CommonsBooking\Service\ErrorMonitor; +use CommonsBooking\Service\TroubleshootingReport; use CommonsBooking\View\Dashboard; use CommonsBooking\View\MassOperations; +use CommonsBooking\View\SystemHealth; use CommonsBooking\Wordpress\CustomPostType\CustomPostType; use CommonsBooking\Wordpress\CustomPostType\Item; use CommonsBooking\Wordpress\CustomPostType\Location; @@ -323,6 +326,13 @@ protected static function addRoleCaps( $postType, $roleName ) { } public static function admin_init() { + // Handle "clear error log" form submission from System Health page + if ( isset( $_POST['cb_clear_error_log'] ) && check_admin_referer( 'cb_clear_error_log' ) ) { + ErrorMonitor::clear(); + wp_safe_redirect( admin_url( 'admin.php?page=cb-system-health&cleared=1' ) ); + exit; + } + // check if we have a new version and run tasks Upgrade::runTasksAfterUpdate(); @@ -408,6 +418,16 @@ public static function addMenuPages() { array( MassOperations::class, 'index' ) ); } + + // System Health — available to all CB users (not just admins) + add_submenu_page( + 'cb-dashboard', + esc_html__( 'System Health', 'commonsbooking' ), + esc_html__( 'System Health', 'commonsbooking' ), + 'manage_' . COMMONSBOOKING_PLUGIN_SLUG, + 'cb-system-health', + array( SystemHealth::class, 'index' ) + ); } /** @@ -425,6 +445,11 @@ public static function handleBookingForms(): void { $e->getMessage(), 30 // Expires very quickly, so that outdated messsages will not be shown to the user ); + ErrorMonitor::record( + $e->getMessage(), + ErrorMonitor::SEVERITY_WARNING, + [ 'type' => 'BookingDeniedException' ] + ); $targetUrl = $e->getRedirectUrl(); if ( $targetUrl ) { header( 'Location: ' . $targetUrl ); @@ -507,6 +532,11 @@ public static function renderError() { foreach ( $errorTypes as $errorType ) { if ( $error = get_transient( $errorType ) ) { + ErrorMonitor::record( + $error, + ErrorMonitor::SEVERITY_ERROR, + [ 'type' => $errorType, 'source' => 'admin_notice' ] + ); $class = 'notice notice-error'; printf( '

%2$s

', @@ -772,6 +802,9 @@ public function init() { // Add menu pages add_action( 'admin_menu', array( self::class, 'addMenuPages' ) ); + // Troubleshooting report download (admin_post_ = logged-in users only) + add_action( 'admin_post_' . TroubleshootingReport::AJAX_ACTION, array( TroubleshootingReport::class, 'handleDownload' ) ); + // Filter body classes of admin pages add_filter( 'admin_body_class', array( self::class, 'filterAdminBodyClass' ), 10, 1 ); diff --git a/src/Repository/Timeframe.php b/src/Repository/Timeframe.php index 3130c26c5..9c9d2d39b 100644 --- a/src/Repository/Timeframe.php +++ b/src/Repository/Timeframe.php @@ -695,7 +695,7 @@ function ( $post ) { return $timeframe->isBookable(); } catch ( Exception $e ) { - error_log( $e->getMessage() ); + commonsbooking_write_log( $e->getMessage(), false ); return false; } @@ -721,7 +721,7 @@ function ( $post ) { try { return commonsbooking_isCurrentUserAllowedToBook( $post->ID ); } catch ( Exception $e ) { - error_log( $e->getMessage() ); + commonsbooking_write_log( $e->getMessage(), false ); return false; } diff --git a/src/Service/BookingRuleApplied.php b/src/Service/BookingRuleApplied.php index a9db1e2b6..8deae790c 100644 --- a/src/Service/BookingRuleApplied.php +++ b/src/Service/BookingRuleApplied.php @@ -203,13 +203,11 @@ function ( $rule ) { ); if ( $json ) { return $json; - } elseif ( WP_DEBUG ) { - error_log( 'Could not encode rules object.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } else { + commonsbooking_write_log( 'Could not encode rules object.', false ); } } catch ( Exception $e ) { - if ( WP_DEBUG ) { - error_log( $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log - } + commonsbooking_write_log( $e->getMessage(), false ); } return ''; // Return an empty string if initialization or encoding fails } diff --git a/src/Service/Cache.php b/src/Service/Cache.php index 61d0c2d83..a8b63f52a 100644 --- a/src/Service/Cache.php +++ b/src/Service/Cache.php @@ -501,8 +501,7 @@ private static function runShortcodeCalls( array $shortCodeCalls ): void { try { $class::$function( $attributes, $shortcodeBody ); } catch ( Exception $e ) { - // Writes error to log anyway - error_log( (string) $e ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + commonsbooking_write_log( (string) $e, false ); } } } diff --git a/src/Service/ErrorMonitor.php b/src/Service/ErrorMonitor.php new file mode 100644 index 000000000..34ef343a2 --- /dev/null +++ b/src/Service/ErrorMonitor.php @@ -0,0 +1,92 @@ + time(), + 'severity' => $severity, + 'message' => $message, + 'context' => array_merge( + [ + 'file' => $frame['file'] ?? '', + 'line' => $frame['line'] ?? 0, + ], + $context + ), + ]; + + $log = get_option( self::OPTION_KEY, [] ); + array_unshift( $log, $entry ); + if ( count( $log ) > self::MAX_ENTRIES ) { + $log = array_slice( $log, 0, self::MAX_ENTRIES ); + } + // autoload=false: this option can grow large; only load on-demand + update_option( self::OPTION_KEY, $log, false ); + } + + /** + * Returns the most recent entries, newest first. + * + * @param int $limit 0 returns up to MAX_ENTRIES. + * @return array + */ + public static function getEntries( int $limit = 50 ): array { + $log = get_option( self::OPTION_KEY, [] ); + return $limit > 0 ? array_slice( $log, 0, $limit ) : $log; + } + + /** Returns the total number of stored entries. */ + public static function count(): int { + return count( get_option( self::OPTION_KEY, [] ) ); + } + + /** Returns the count of entries matching a specific severity level. */ + public static function countBySeverity( string $severity ): int { + return count( + array_filter( + get_option( self::OPTION_KEY, [] ), + fn( $e ) => ( $e['severity'] ?? '' ) === $severity + ) + ); + } + + /** Deletes all stored entries. */ + public static function clear(): void { + delete_option( self::OPTION_KEY ); + } +} diff --git a/src/Service/TroubleshootingReport.php b/src/Service/TroubleshootingReport.php new file mode 100644 index 000000000..ed9a0d101 --- /dev/null +++ b/src/Service/TroubleshootingReport.php @@ -0,0 +1,123 @@ + gmdate( 'c' ), + 'system' => self::systemInfo(), + 'health' => SystemHealth::getChecks(), + 'error_log' => ErrorMonitor::getEntries( 0 ), + 'stats' => self::pluginStats(), + 'cron' => self::cronInfo(), + ]; + + return apply_filters( 'commonsbooking_troubleshooting_report', $report ); + } + + // ------------------------------------------------------------------------- + // Report sections + // ------------------------------------------------------------------------- + + private static function systemInfo(): array { + global $wp_version; + + return [ + 'plugin_version' => COMMONSBOOKING_VERSION, + 'wordpress_version' => $wp_version, + 'php_version' => PHP_VERSION, + 'php_extensions' => get_loaded_extensions(), + 'wp_multisite' => is_multisite(), + 'wp_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + 'wp_debug_log' => defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG, + 'active_plugins' => (array) get_option( 'active_plugins', [] ), + 'active_theme' => wp_get_theme()->get( 'Name' ), + 'site_url' => home_url(), + ]; + } + + private static function pluginStats(): array { + $counts = wp_count_posts( 'cb_booking' ); + $statuses = [ 'confirmed', 'unconfirmed', 'canceled' ]; + $bookings = []; + foreach ( $statuses as $status ) { + $bookings[ $status ] = isset( $counts->$status ) ? (int) $counts->$status : 0; + } + + return [ + 'items' => (int) ( wp_count_posts( 'cb_item' )->publish ?? 0 ), + 'locations' => (int) ( wp_count_posts( 'cb_location' )->publish ?? 0 ), + 'timeframes' => (int) ( wp_count_posts( 'cb_timeframe' )->publish ?? 0 ), + 'restrictions' => (int) ( wp_count_posts( 'cb_restriction' )->publish ?? 0 ), + 'bookings' => $bookings, + 'orphaned_bookings' => count( \CommonsBooking\Repository\Booking::getOrphaned() ), + ]; + } + + private static function cronInfo(): array { + $hooks = [ + COMMONSBOOKING_PLUGIN_SLUG . '_cleanup', + COMMONSBOOKING_PLUGIN_SLUG . '_reminder', + COMMONSBOOKING_PLUGIN_SLUG . '_feedback', + COMMONSBOOKING_PLUGIN_SLUG . '_email_bookingcodes', + COMMONSBOOKING_PLUGIN_SLUG . '_export', + COMMONSBOOKING_PLUGIN_SLUG . '_cache_warmup', + ]; + + $result = []; + foreach ( $hooks as $hook ) { + $next = wp_next_scheduled( $hook ); + $result[ $hook ] = [ + 'next_run' => $next ? gmdate( 'c', $next ) : null, + 'next_run_diff' => $next ? human_time_diff( $next ) : 'not scheduled', + ]; + } + + return $result; + } +} diff --git a/src/View/Booking.php b/src/View/Booking.php index 0c3ded1dc..bd8762b34 100755 --- a/src/View/Booking.php +++ b/src/View/Booking.php @@ -253,12 +253,10 @@ public static function getBookingListData( int $postsPerPage = 6, ?\WP_User $use // Only includes non-null array row data objects if ( isset( $filteredRowData ) && is_array( $filteredRowData ) ) { - if ( WP_DEBUG ) { - // Logs absent keys, relative to the original row data keys, could cause problems - $absentKeys = array_diff_key( $filteredRowData, $rowData ); - if ( count( $absentKeys ) > 0 ) { - error_log( 'After commonsbooking_booking_filter: Filtered rows have missing keys: ' . join( ',', array_keys( $absentKeys ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log - } + // Logs absent keys, relative to the original row data keys, could cause problems + $absentKeys = array_diff_key( $filteredRowData, $rowData ); + if ( count( $absentKeys ) > 0 ) { + commonsbooking_write_log( 'After commonsbooking_booking_filter: Filtered rows have missing keys: ' . join( ',', array_keys( $absentKeys ) ), false ); } } else { continue; diff --git a/src/View/SystemHealth.php b/src/View/SystemHealth.php new file mode 100644 index 000000000..25873e8f7 --- /dev/null +++ b/src/View/SystemHealth.php @@ -0,0 +1,166 @@ + + */ + public static function getChecks(): array { + $checks = [ + self::checkPhpVersion(), + self::checkWordPressVersion(), + self::checkPhpExtensions(), + self::checkDatabase(), + self::checkCronJobs(), + self::checkRestApi(), + self::checkRecentErrors(), + ]; + + return apply_filters( 'commonsbooking_health_checks', $checks ); + } + + // ------------------------------------------------------------------------- + // Individual health checks + // ------------------------------------------------------------------------- + + private static function checkPhpVersion(): array { + $required = '8.1'; + $ok = version_compare( PHP_VERSION, $required, '>=' ); + return [ + 'label' => __( 'PHP Version', 'commonsbooking' ), + 'status' => $ok ? 'ok' : 'fail', + 'detail' => sprintf( __( 'Current: %1$s (required: >= %2$s)', 'commonsbooking' ), PHP_VERSION, $required ), + ]; + } + + private static function checkWordPressVersion(): array { + global $wp_version; + $required = '5.6'; + $ok = version_compare( $wp_version, $required, '>=' ); + return [ + 'label' => __( 'WordPress Version', 'commonsbooking' ), + 'status' => $ok ? 'ok' : 'fail', + 'detail' => sprintf( __( 'Current: %1$s (required: >= %2$s)', 'commonsbooking' ), $wp_version, $required ), + ]; + } + + private static function checkPhpExtensions(): array { + $required = [ 'json', 'mbstring', 'curl', 'dom', 'libxml' ]; + $missing = array_values( array_filter( $required, fn( $ext ) => ! extension_loaded( $ext ) ) ); + return [ + 'label' => __( 'PHP Extensions', 'commonsbooking' ), + 'status' => empty( $missing ) ? 'ok' : 'fail', + 'detail' => empty( $missing ) + ? implode( ', ', $required ) . ' — ' . __( 'all loaded', 'commonsbooking' ) + : __( 'Missing:', 'commonsbooking' ) . ' ' . implode( ', ', $missing ), + ]; + } + + private static function checkDatabase(): array { + global $wpdb; + $result = $wpdb->get_var( 'SELECT 1' ); + $ok = $result !== null && $wpdb->last_error === ''; + return [ + 'label' => __( 'Database', 'commonsbooking' ), + 'status' => $ok ? 'ok' : 'fail', + 'detail' => $ok + ? __( 'Connection OK', 'commonsbooking' ) + : ( $wpdb->last_error ?: __( 'Query returned null', 'commonsbooking' ) ), + ]; + } + + private static function checkCronJobs(): array { + $hooks = [ + COMMONSBOOKING_PLUGIN_SLUG . '_cleanup', + COMMONSBOOKING_PLUGIN_SLUG . '_reminder', + COMMONSBOOKING_PLUGIN_SLUG . '_feedback', + COMMONSBOOKING_PLUGIN_SLUG . '_email_bookingcodes', + ]; + $missing = []; + $scheduled = []; + foreach ( $hooks as $hook ) { + $next = wp_next_scheduled( $hook ); + if ( $next ) { + $scheduled[] = basename( $hook ) . ' (' . human_time_diff( $next ) . ')'; + } else { + $missing[] = basename( $hook ); + } + } + $status = empty( $missing ) ? 'ok' : 'warn'; + $detail = empty( $missing ) + ? implode( ', ', $scheduled ) + : __( 'Not scheduled:', 'commonsbooking' ) . ' ' . implode( ', ', $missing ); + return [ + 'label' => __( 'Scheduled Jobs', 'commonsbooking' ), + 'status' => $status, + 'detail' => $detail, + ]; + } + + private static function checkRestApi(): array { + $url = get_rest_url(); + $ok = ! empty( $url ); + return [ + 'label' => __( 'REST API', 'commonsbooking' ), + 'status' => $ok ? 'ok' : 'warn', + 'detail' => $ok + ? $url + : __( 'REST API URL could not be determined — map and API features may not work', 'commonsbooking' ), + ]; + } + + private static function checkRecentErrors(): array { + $count = ErrorMonitor::count(); + if ( $count === 0 ) { + $status = 'ok'; + } elseif ( $count < 10 ) { + $status = 'warn'; + } else { + $status = 'fail'; + } + return [ + 'label' => __( 'Recent Errors', 'commonsbooking' ), + 'status' => $status, + /* translators: %d: number of errors recorded */ + 'detail' => sprintf( _n( '%d error recorded', '%d errors recorded', $count, 'commonsbooking' ), $count ), + ]; + } +} diff --git a/src/Wordpress/Options/OptionsTab.php b/src/Wordpress/Options/OptionsTab.php index 7b556a443..4fe51d59b 100644 --- a/src/Wordpress/Options/OptionsTab.php +++ b/src/Wordpress/Options/OptionsTab.php @@ -165,9 +165,7 @@ public static function savePostOptions() { 45 ); } catch ( Exception $e ) { - if ( WP_DEBUG ) { - error_log( $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log - } + commonsbooking_write_log( $e->getMessage(), false ); set_transient( self::ERROR_TYPE, commonsbooking_sanitizeHTML( __( 'Error while clearing the cache.', 'commonsbooking' ) ), diff --git a/templates/system-health.php b/templates/system-health.php new file mode 100644 index 000000000..14b8f5ff1 --- /dev/null +++ b/templates/system-health.php @@ -0,0 +1,153 @@ + '✔ OK', + 'warn' => '⚠ WARN', + 'fail' => '✘ FAIL', +]; + +$severityStyle = [ + ErrorMonitor::SEVERITY_ERROR => 'color:#dc3232;font-weight:bold;', + ErrorMonitor::SEVERITY_WARNING => 'color:#ffb900;font-weight:bold;', + ErrorMonitor::SEVERITY_INFO => 'color:#0073aa;font-weight:bold;', +]; +?> +
+

+ + +
+

+
+ + +
+ + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + +
+
+ + + +
+ + diff --git a/tests/cypress/e2e/08-system-health.cy.js b/tests/cypress/e2e/08-system-health.cy.js new file mode 100644 index 000000000..3144ba143 --- /dev/null +++ b/tests/cypress/e2e/08-system-health.cy.js @@ -0,0 +1,39 @@ +describe('System Health Page', () => { + const url = '/wp-admin/admin.php?page=cb-system-health'; + + beforeEach(() => { + cy.loginAs('admin'); + }); + + it('loads the system health page without errors', () => { + cy.visit(url); + cy.get('h1').should('contain', 'System Health'); + cy.screenshot('system-health_loaded'); + }); + + it('shows the System Status tab with health check rows', () => { + cy.visit(url); + cy.get('#cb-system-status').should('be.visible'); + cy.get('#cb-system-status table tbody tr').should('have.length.greaterThan', 0); + cy.screenshot('system-health_status-tab'); + }); + + it('switches to the Error Log tab when clicked', () => { + cy.visit(url); + cy.get('a[data-tab="cb-error-log"]').click(); + cy.get('#cb-error-log').should('be.visible'); + cy.get('#cb-system-status').should('not.be.visible'); + cy.screenshot('system-health_error-log-tab'); + }); + + it('shows a Clear Error Log button in the Error Log tab', () => { + cy.visit(url); + cy.get('a[data-tab="cb-error-log"]').click(); + cy.get('#cb-error-log button[type="submit"]').should('contain', 'Clear Error Log'); + }); + + it('shows System Health in the CommonsBooking admin menu', () => { + cy.visit('/wp-admin/admin.php?page=cb-dashboard'); + cy.get('#adminmenu').should('contain', 'System Health'); + }); +}); diff --git a/tests/php/Service/ErrorMonitorTest.php b/tests/php/Service/ErrorMonitorTest.php new file mode 100644 index 000000000..f3a8094b0 --- /dev/null +++ b/tests/php/Service/ErrorMonitorTest.php @@ -0,0 +1,108 @@ +assertSame( 1, ErrorMonitor::count() ); + $entries = ErrorMonitor::getEntries(); + $this->assertSame( 'test error', $entries[0]['message'] ); + $this->assertSame( ErrorMonitor::SEVERITY_ERROR, $entries[0]['severity'] ); + } + + public function testEntriesAreNewestFirst(): void { + ErrorMonitor::record( 'first' ); + ErrorMonitor::record( 'second' ); + $entries = ErrorMonitor::getEntries(); + $this->assertSame( 'second', $entries[0]['message'] ); + $this->assertSame( 'first', $entries[1]['message'] ); + } + + public function testRingBufferEnforcesMaxEntries(): void { + for ( $i = 0; $i < ErrorMonitor::MAX_ENTRIES + 5; $i++ ) { + ErrorMonitor::record( "entry $i" ); + } + $this->assertSame( ErrorMonitor::MAX_ENTRIES, ErrorMonitor::count() ); + } + + public function testClearRemovesAllEntries(): void { + ErrorMonitor::record( 'keep' ); + ErrorMonitor::clear(); + $this->assertSame( 0, ErrorMonitor::count() ); + $this->assertEmpty( ErrorMonitor::getEntries() ); + } + + public function testCountBySeverity(): void { + ErrorMonitor::record( 'e1', ErrorMonitor::SEVERITY_ERROR ); + ErrorMonitor::record( 'w1', ErrorMonitor::SEVERITY_WARNING ); + ErrorMonitor::record( 'w2', ErrorMonitor::SEVERITY_WARNING ); + $this->assertSame( 1, ErrorMonitor::countBySeverity( ErrorMonitor::SEVERITY_ERROR ) ); + $this->assertSame( 2, ErrorMonitor::countBySeverity( ErrorMonitor::SEVERITY_WARNING ) ); + $this->assertSame( 0, ErrorMonitor::countBySeverity( ErrorMonitor::SEVERITY_INFO ) ); + } + + public function testGetEntriesRespectsLimit(): void { + for ( $i = 0; $i < 20; $i++ ) { + ErrorMonitor::record( "entry $i" ); + } + $this->assertCount( 5, ErrorMonitor::getEntries( 5 ) ); + $this->assertCount( 20, ErrorMonitor::getEntries( 0 ) ); + } + + public function testRecordCapturesCallerContext(): void { + ErrorMonitor::record( 'context test' ); + $entry = ErrorMonitor::getEntries( 1 )[0]; + $this->assertArrayHasKey( 'file', $entry['context'] ); + $this->assertArrayHasKey( 'line', $entry['context'] ); + $this->assertStringContainsString( 'ErrorMonitorTest', $entry['context']['file'] ); + } + + public function testRecordIgnoresEmptyMessage(): void { + ErrorMonitor::record( '' ); + $this->assertSame( 0, ErrorMonitor::count() ); + } + + public function testOptionIsNotAutoloaded(): void { + ErrorMonitor::record( 'check autoload' ); + global $wpdb; + $autoload = $wpdb->get_var( + $wpdb->prepare( + "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", + ErrorMonitor::OPTION_KEY + ) + ); + // WordPress 6.6+ uses 'yes'/'no', older versions use 'yes'/'no' or '1'/'0' + $this->assertNotEquals( 'yes', $autoload ); + $this->assertNotEquals( '1', $autoload ); + } + + public function testEntryIncludesTimestamp(): void { + $before = time(); + ErrorMonitor::record( 'ts test' ); + $after = time(); + $entry = ErrorMonitor::getEntries( 1 )[0]; + $this->assertGreaterThanOrEqual( $before, $entry['timestamp'] ); + $this->assertLessThanOrEqual( $after, $entry['timestamp'] ); + } + + public function testExtraContextIsMerged(): void { + ErrorMonitor::record( 'ctx', ErrorMonitor::SEVERITY_INFO, [ 'type' => 'BookingDeniedException' ] ); + $entry = ErrorMonitor::getEntries( 1 )[0]; + $this->assertSame( 'BookingDeniedException', $entry['context']['type'] ); + } +} diff --git a/tests/php/Service/TroubleshootingReportTest.php b/tests/php/Service/TroubleshootingReportTest.php new file mode 100644 index 000000000..6bce79190 --- /dev/null +++ b/tests/php/Service/TroubleshootingReportTest.php @@ -0,0 +1,111 @@ +assertArrayHasKey( $key, $report, "Missing key: $key" ); + } + } + + public function testGeneratedAtIsValidIso8601(): void { + $ts = TroubleshootingReport::generate()['generated_at']; + $dt = \DateTime::createFromFormat( 'Y-m-d\TH:i:sP', $ts ); + $this->assertNotFalse( $dt, "generated_at is not valid ISO 8601: $ts" ); + } + + public function testSystemInfoContainsVersions(): void { + $sys = TroubleshootingReport::generate()['system']; + $this->assertSame( COMMONSBOOKING_VERSION, $sys['plugin_version'] ); + $this->assertSame( PHP_VERSION, $sys['php_version'] ); + $this->assertIsBool( $sys['wp_debug'] ); + $this->assertIsBool( $sys['wp_debug_log'] ); + $this->assertIsBool( $sys['wp_multisite'] ); + $this->assertIsArray( $sys['active_plugins'] ); + $this->assertIsArray( $sys['php_extensions'] ); + } + + public function testStatsContainsExpectedKeys(): void { + $stats = TroubleshootingReport::generate()['stats']; + foreach ( [ 'items', 'locations', 'timeframes', 'restrictions', 'bookings', 'orphaned_bookings' ] as $key ) { + $this->assertArrayHasKey( $key, $stats, "stats missing key: $key" ); + } + } + + public function testBookingStatsHaveStatusBreakdown(): void { + $bookings = TroubleshootingReport::generate()['stats']['bookings']; + $this->assertArrayHasKey( 'confirmed', $bookings ); + $this->assertArrayHasKey( 'unconfirmed', $bookings ); + $this->assertArrayHasKey( 'canceled', $bookings ); + foreach ( $bookings as $count ) { + $this->assertIsInt( $count ); + $this->assertGreaterThanOrEqual( 0, $count ); + } + } + + public function testCronSectionContainsKnownHooks(): void { + $cron = TroubleshootingReport::generate()['cron']; + $this->assertArrayHasKey( COMMONSBOOKING_PLUGIN_SLUG . '_cleanup', $cron ); + $first = reset( $cron ); + $this->assertArrayHasKey( 'next_run', $first ); + $this->assertArrayHasKey( 'next_run_diff', $first ); + } + + public function testErrorLogReflectsCurrentMonitorState(): void { + ErrorMonitor::record( 'report export test' ); + $log = TroubleshootingReport::generate()['error_log']; + $this->assertCount( 1, $log ); + $this->assertSame( 'report export test', $log[0]['message'] ); + } + + public function testHealthSectionIsNonEmptyWithValidStructure(): void { + $health = TroubleshootingReport::generate()['health']; + $this->assertNotEmpty( $health ); + foreach ( $health as $check ) { + $this->assertArrayHasKey( 'label', $check ); + $this->assertArrayHasKey( 'status', $check ); + $this->assertContains( $check['status'], [ 'ok', 'warn', 'fail' ] ); + } + } + + public function testCustomSectionCanBeAddedViaFilter(): void { + add_filter( + 'commonsbooking_troubleshooting_report', + function ( array $report ) { + $report['custom'] = [ 'added_by_filter' => true ]; + return $report; + } + ); + + $report = TroubleshootingReport::generate(); + $this->assertArrayHasKey( 'custom', $report ); + $this->assertTrue( $report['custom']['added_by_filter'] ); + } + + public function testStatsCountsAreIntegers(): void { + $stats = TroubleshootingReport::generate()['stats']; + $this->assertIsInt( $stats['items'] ); + $this->assertIsInt( $stats['locations'] ); + $this->assertIsInt( $stats['timeframes'] ); + $this->assertIsInt( $stats['restrictions'] ); + $this->assertIsInt( $stats['orphaned_bookings'] ); + } +} diff --git a/tests/php/View/SystemHealthTest.php b/tests/php/View/SystemHealthTest.php new file mode 100644 index 000000000..7da205d38 --- /dev/null +++ b/tests/php/View/SystemHealthTest.php @@ -0,0 +1,93 @@ +assertIsArray( $checks ); + $this->assertNotEmpty( $checks ); + } + + public function testEachCheckHasRequiredKeys(): void { + foreach ( SystemHealth::getChecks() as $check ) { + $this->assertArrayHasKey( 'label', $check, 'Check is missing "label" key' ); + $this->assertArrayHasKey( 'status', $check, 'Check is missing "status" key' ); + $this->assertArrayHasKey( 'detail', $check, 'Check is missing "detail" key' ); + $this->assertContains( + $check['status'], + [ 'ok', 'warn', 'fail' ], + 'Status must be ok, warn, or fail' + ); + } + } + + public function testPhpVersionCheckPassesForCurrentPhp(): void { + $checks = SystemHealth::getChecks(); + $phpCheck = array_values( + array_filter( $checks, fn( $c ) => $c['label'] === __( 'PHP Version', 'commonsbooking' ) ) + ); + $this->assertNotEmpty( $phpCheck, 'PHP version check not found' ); + // Tests run on a supported PHP version, so this must pass + $this->assertSame( 'ok', $phpCheck[0]['status'] ); + } + + public function testDatabaseCheckPassesInTestEnvironment(): void { + $checks = SystemHealth::getChecks(); + $dbCheck = array_values( + array_filter( $checks, fn( $c ) => $c['label'] === __( 'Database', 'commonsbooking' ) ) + ); + $this->assertNotEmpty( $dbCheck, 'Database check not found' ); + $this->assertSame( 'ok', $dbCheck[0]['status'] ); + } + + public function testRecentErrorsCheckReflectsErrorMonitorState(): void { + // With 0 errors the check should be ok + $checks = SystemHealth::getChecks(); + $errChecks = array_values( + array_filter( $checks, fn( $c ) => $c['label'] === __( 'Recent Errors', 'commonsbooking' ) ) + ); + $this->assertNotEmpty( $errChecks ); + $this->assertSame( 'ok', $errChecks[0]['status'] ); + + // Record 15 errors → status should become 'fail' + for ( $i = 0; $i < 15; $i++ ) { + ErrorMonitor::record( "error $i" ); + } + $checks = SystemHealth::getChecks(); + $errChecks = array_values( + array_filter( $checks, fn( $c ) => $c['label'] === __( 'Recent Errors', 'commonsbooking' ) ) + ); + $this->assertSame( 'fail', $errChecks[0]['status'] ); + } + + public function testCustomHealthCheckIsAddedViaFilter(): void { + add_filter( + 'commonsbooking_health_checks', + function ( array $checks ) { + $checks[] = [ 'label' => 'Custom Check', 'status' => 'ok', 'detail' => 'test detail' ]; + return $checks; + } + ); + + $checks = SystemHealth::getChecks(); + $labels = array_column( $checks, 'label' ); + $this->assertContains( 'Custom Check', $labels ); + } +}