diff --git a/includes/content-distribution/class-incoming-post.php b/includes/content-distribution/class-incoming-post.php index ef9fdb2..465c2c9 100644 --- a/includes/content-distribution/class-incoming-post.php +++ b/includes/content-distribution/class-incoming-post.php @@ -682,19 +682,42 @@ public function insert( $payload = [] ) { /** * Post status handling. * - * If post is being published, use the incoming or stored - * `status_on_publish` if available. Otherwise, use the post status from - * the payload. + * If post is being published, apply the stored `status_on_publish` + * override if one exists. For new posts, use the incoming payload + * value instead. If no override is configured, the post status is + * left unchanged for existing posts and defaults to the incoming + * status for new ones. If post is being scheduled (future) and + * `status_on_publish` is a non-publish status, keep the node post + * in that status. This prevents WP cron from scheduling + * `publish_future_post` and auto-publishing the node post. + * Otherwise, use the post status from the payload. */ if ( $post_data['post_status'] === 'publish' ) { if ( $is_new_post ) { - $postarr['post_status'] = $this->payload['status_on_publish']; + $postarr['post_status'] = isset( $this->payload['status_on_publish'] ) ? $this->payload['status_on_publish'] : $post_data['post_status']; } else { $status_on_publish = get_post_meta( $this->ID, self::STATUS_ON_PUBLISH_META, true ); if ( $status_on_publish ) { $postarr['post_status'] = $status_on_publish; } } + } elseif ( $post_data['post_status'] === 'future' ) { + if ( $is_new_post ) { + if ( isset( $this->payload['status_on_publish'] ) ) { + $status_on_publish = $this->payload['status_on_publish']; + } else { + $status_on_publish = ''; + } + } else { + $status_on_publish = get_post_meta( $this->ID, self::STATUS_ON_PUBLISH_META, true ); + } + + if ( $status_on_publish && 'publish' !== $status_on_publish ) { + $postarr['post_status'] = $status_on_publish; + } else { + // If status_on_publish is 'publish' or unset, mirror the hub's schedule. + $postarr['post_status'] = 'future'; + } } else { $postarr['post_status'] = $post_data['post_status']; } @@ -743,11 +766,16 @@ public function insert( $payload = [] ) { // Handle `status_on_publish` meta. if ( $post_data['post_status'] !== 'publish' && $is_new_post ) { // Store the publish status for new posts. - update_post_meta( - $post_id, - self::STATUS_ON_PUBLISH_META, - $this->payload['status_on_publish'] - ); + if ( isset( $this->payload['status_on_publish'] ) ) { + // Only store the meta if the key is present. An absent status_on_publish + // means no override was configured — the node will fall back to safe + // defaults when the hub later sends 'publish' or 'future'. + update_post_meta( + $post_id, + self::STATUS_ON_PUBLISH_META, + $this->payload['status_on_publish'] + ); + } } elseif ( $post_data['post_status'] === 'publish' && ! $is_new_post ) { // Clean up the meta for published posts so it's not re-published after // being unpublished. diff --git a/tests/unit-tests/content-distribution/test-incoming-post.php b/tests/unit-tests/content-distribution/test-incoming-post.php index b5d0250..4426bdc 100644 --- a/tests/unit-tests/content-distribution/test-incoming-post.php +++ b/tests/unit-tests/content-distribution/test-incoming-post.php @@ -524,6 +524,92 @@ public function test_pending_distribution() { $this->assertSame( 'publish', get_post_status( $post_id ) ); } + /** + * Test that a scheduled (future) hub post does not auto-publish on the node + * when status_on_publish is set to a non-publish status. + * + * Regression test for: hub distributes a draft, then schedules it shortly + * after. The scheduling sync sends post_status='future' to the node. If the + * node applies that status directly, WordPress cron will find a future-status + * post and auto-publish it via wp_publish_post(), bypassing the + * status_on_publish setting entirely. + * + * The node should keep the post in the status_on_publish state, such as draft, + * so that WP cron never has a chance to publish it. + */ + public function test_future_status_with_non_publish_status_on_publish() { + $payload = $this->get_sample_payload(); + + // Simulate Event 1: hub distributes the post as a draft. + // status_on_publish='draft' means the node should never auto-publish. + $payload['post_data']['post_status'] = 'draft'; + $payload['status_on_publish'] = 'draft'; + + $post_id = $this->incoming_post->insert( $payload ); + $this->assertSame( 'draft', get_post_status( $post_id ) ); + + // Simulate Event 2: hub schedules the post, syncing post_status='future'. + // The node must not apply 'future', as a future-status post with a past date + // is auto-published by WP cron, bypassing status_on_publish entirely. + $payload['post_data']['post_status'] = 'future'; + $this->incoming_post->insert( $payload ); + + $this->assertSame( 'draft', get_post_status( $post_id ) ); + } + + /** + * Test that a scheduled (future) hub post mirrors the future status on the + * node when status_on_publish is 'publish'. + * + * When a publisher wants the node to publish in sync with the hub, the node + * should mirror the 'future' status so that WP cron fires on both sites at + * the same scheduled time. + */ + public function test_future_status_with_publish_status_on_publish() { + $payload = $this->get_sample_payload(); + + $payload['post_data']['post_status'] = 'draft'; + $payload['status_on_publish'] = 'publish'; + + $post_id = $this->incoming_post->insert( $payload ); + + // Hub schedules the post. date_gmt must be in the future or WordPress + // will immediately publish the post rather than storing it as 'future'. + $payload['post_data']['post_status'] = 'future'; + $payload['post_data']['date_gmt'] = gmdate( 'Y-m-d H:i:s', strtotime( '+1 week' ) ); + $this->incoming_post->insert( $payload ); + + // Node should mirror the hub's scheduled status so both publish together. + $this->assertSame( 'future', get_post_status( $post_id ) ); + } + + /** + * Test that a scheduled (future) hub post mirrors the future status on the + * node when status_on_publish is absent from the payload. + * + * The sample payload includes status_on_publish by default, so it is + * explicitly unset here to simulate a payload that omits the key. + * The node should fall back to mirroring the hub's future status. + */ + public function test_future_status_with_unset_status_on_publish() { + $payload = $this->get_sample_payload(); + + // Remove status_on_publish to simulate a payload that omits the key. + unset( $payload['status_on_publish'] ); + + $payload['post_data']['post_status'] = 'draft'; + $post_id = $this->incoming_post->insert( $payload ); + + // Hub schedules the post. date_gmt must be in the future or WordPress + // will immediately publish the post rather than storing it as 'future'. + $payload['post_data']['post_status'] = 'future'; + $payload['post_data']['date_gmt'] = gmdate( 'Y-m-d H:i:s', strtotime( '+1 week' ) ); + $this->incoming_post->insert( $payload ); + + // With no status_on_publish set, the node should mirror the hub's schedule. + $this->assertSame( 'future', get_post_status( $post_id ) ); + } + /** * Test that "status on publish" only applies once. */