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