diff --git a/tests/integration/TestAPIIntegration.php b/tests/integration/TestAPIIntegration.php index c0dc0c9..61777cf 100644 --- a/tests/integration/TestAPIIntegration.php +++ b/tests/integration/TestAPIIntegration.php @@ -47,8 +47,9 @@ protected function setUpContentFiltering() { public function setUp(): void { parent::setUp(); - // Reset HTTP mocks + // Reset HTTP mocks and captured args self::$http_requests_mock = array(); + self::$captured_request_args = array(); global $wp_post_meta, $test_get_option_overrides; $wp_post_meta = array(); @@ -736,6 +737,103 @@ public function test_store_notice_error_status_on_wp_error() { $this->assertSame('Connection timeout', $this->lastTransient['detail']); } + /** + * Test that a pending notification is cancelled when a scheduled post reverts to draft. + */ + public function test_cancel_notification_when_scheduled_post_reverts_to_draft() { + $post_id = 2001; + $notification_id = 'scheduled-notif-to-cancel'; + + global $wp_post_meta; + $wp_post_meta[$post_id]['os_notification_id'] = $notification_id; + $wp_post_meta[$post_id]['os_previous_publish_date'] = '2030-06-01 10:00:00'; + + WP_Mock::userFunction('delete_post_meta') + ->andReturnUsing(function($pid, $meta_key) { + global $wp_post_meta; + unset($wp_post_meta[$pid][$meta_key]); + return true; + }); + + $cancel_url = 'https://onesignal.com/api/v1/notifications/' . $notification_id . '?app_id=test-app-id'; + $this->mock_http_request($cancel_url, [ + 'response' => ['code' => 200], + 'body' => json_encode(['success' => true]), + ]); + + $post = (object) [ + 'ID' => $post_id, + 'post_title' => 'Formerly Scheduled Post', + 'post_type' => 'post', + 'post_date' => '2030-06-01 10:00:00', + 'post_date_gmt' => '2030-06-01 10:00:00', + ]; + + onesignal_schedule_notification('draft', 'future', $post); + + $this->assertArrayHasKey('wp_remote_request', self::$captured_request_args); + $this->assertArrayHasKey($cancel_url, self::$captured_request_args['wp_remote_request']); + $this->assertSame('', onesignal_get_notification_id($post_id)); + $this->assertSame('', get_post_meta($post_id, 'os_previous_publish_date', true)); + } + + /** + * Test that no cancellation is attempted when a scheduled post reverts to draft but has no stored notification ID. + */ + public function test_no_cancellation_when_no_notification_stored_on_draft_revert() { + $post_id = 2002; + + WP_Mock::userFunction('delete_post_meta') + ->andReturnUsing(function($pid, $meta_key) { + global $wp_post_meta; + unset($wp_post_meta[$pid][$meta_key]); + return true; + }); + + $post = (object) [ + 'ID' => $post_id, + 'post_title' => 'No Notification Post', + 'post_type' => 'post', + 'post_date' => '2030-06-01 10:00:00', + 'post_date_gmt' => '2030-06-01 10:00:00', + ]; + + onesignal_schedule_notification('draft', 'future', $post); + + // No HTTP request should have been made + $this->assertArrayNotHasKey('wp_remote_request', self::$captured_request_args); + } + + /** + * Test that no cancellation is attempted for disallowed post types on draft revert. + */ + public function test_no_cancellation_for_disallowed_post_type_on_draft_revert() { + $post_id = 2003; + $notification_id = 'should-not-be-cancelled'; + + global $wp_post_meta; + $wp_post_meta[$post_id]['os_notification_id'] = $notification_id; + + WP_Mock::userFunction('delete_post_meta') + ->andReturnUsing(function($pid, $meta_key) { + global $wp_post_meta; + unset($wp_post_meta[$pid][$meta_key]); + return true; + }); + + $post = (object) [ + 'ID' => $post_id, + 'post_title' => 'Disallowed Type Post', + 'post_type' => 'portfolio', + ]; + + onesignal_schedule_notification('draft', 'future', $post); + + // No HTTP request should have been made; notification ID should remain + $this->assertArrayNotHasKey('wp_remote_request', self::$captured_request_args); + $this->assertSame($notification_id, onesignal_get_notification_id($post_id)); + } + /** * Test store_send_notice stores status='error' and the API error string as detail * when the API returns a non-200 response with a string errors[0]. diff --git a/tests/integration/TestPostHooks.php b/tests/integration/TestPostHooks.php index 52aac58..d9b8efb 100644 --- a/tests/integration/TestPostHooks.php +++ b/tests/integration/TestPostHooks.php @@ -192,7 +192,7 @@ public function setUp(): void { 'accepted_args' => 3 ); $wp_filters['wp_trash_post'][10][] = array( - 'function' => 'onesignal_cancel_notification_on_post_delete', + 'function' => 'onesignal_cancel_and_clear_notification', 'accepted_args' => 1 ); } @@ -215,7 +215,7 @@ public function test_save_post_hook_registered() { * Test that wp_trash_post hook is registered */ public function test_wp_trash_post_hook_registered() { - $this->assertNotFalse(has_action('wp_trash_post', 'onesignal_cancel_notification_on_post_delete')); + $this->assertNotFalse(has_action('wp_trash_post', 'onesignal_cancel_and_clear_notification')); } /** diff --git a/v3/onesignal-notification.php b/v3/onesignal-notification.php index 282fb14..1eef7aa 100644 --- a/v3/onesignal-notification.php +++ b/v3/onesignal-notification.php @@ -10,7 +10,7 @@ add_action('save_post', 'onesignal_handle_quick_edit_date_change', 10, 3); // Register handler to cancel scheduled notifications when posts are deleted -add_action('wp_trash_post', 'onesignal_cancel_notification_on_post_delete'); +add_action('wp_trash_post', 'onesignal_cancel_and_clear_notification'); // Display admin notices after a notification send attempt (classic editor) add_action('admin_notices', 'onesignal_display_send_notice'); @@ -21,7 +21,9 @@ // Enqueue block editor notice script add_action('enqueue_block_editor_assets', 'onesignal_enqueue_block_editor_notice'); -// Core function to create and send/schedule a notification +/** + * Creates and sends (or schedules) a OneSignal notification for a post. + */ function onesignal_create_notification($post, $notification_options = array()) { $onesignal_wp_settings = get_option("OneSignalWPSetting"); @@ -306,7 +308,9 @@ function onesignal_enqueue_block_editor_notice() )); } -// Function to schedule notification (called on post status transitions) +/** + * Fires on every post status transition; sends or cancels notifications as appropriate. + */ function onesignal_schedule_notification($new_status, $old_status, $post) { if (($new_status === 'publish') || ($new_status === 'future')) { @@ -328,10 +332,20 @@ function onesignal_schedule_notification($new_status, $old_status, $post) // Call the core notification function onesignal_create_notification($post, $notification_options); + } elseif ($old_status === 'future') { + // A scheduled post was reverted to a non-scheduled status (e.g. draft, pending). + // Cancel the pending OneSignal notification so it isn't delivered at the old scheduled time. + if (!onesignal_is_post_type_allowed($post->post_type)) { + return; + } + + onesignal_cancel_and_clear_notification($post->ID); } } -// Function to handle quick-edit publish date changes +/** + * Cancels and re-schedules a notification when a scheduled post's publish date is changed via quick-edit. + */ function onesignal_handle_quick_edit_date_change($post_id, $post, $update) { // Check user capability to edit this post @@ -405,21 +419,20 @@ function onesignal_handle_quick_edit_date_change($post_id, $post, $update) } } -// Function to cancel scheduled notifications when a post is deleted -function onesignal_cancel_notification_on_post_delete($post_id) +/** + * Cancels the stored OneSignal notification, and clears its meta, on post deletion or status change (e.g. scheduled -> draft). + */ +function onesignal_cancel_and_clear_notification($post_id) { - $post = get_post($post_id); - - if (!$post) { - return; + $existing_notification_id = onesignal_get_notification_id($post_id); + if (empty($existing_notification_id)) { + return false; } - $existing_notification_id = onesignal_get_notification_id($post_id); - if (!empty($existing_notification_id)) { - $cancelled = onesignal_cancel_notification($existing_notification_id); - if ($cancelled) { - delete_post_meta($post_id, 'os_notification_id'); - delete_post_meta($post_id, 'os_previous_publish_date'); - } + $cancelled = onesignal_cancel_notification($existing_notification_id); + if ($cancelled) { + delete_post_meta($post_id, 'os_notification_id'); + delete_post_meta($post_id, 'os_previous_publish_date'); } + return $cancelled; }