diff --git a/includes/admin/class-rop-admin.php b/includes/admin/class-rop-admin.php index 321dd0663..2081594e0 100644 --- a/includes/admin/class-rop-admin.php +++ b/includes/admin/class-rop-admin.php @@ -888,7 +888,9 @@ public function rop_publish_now_metabox_html() { public function publish_now_attributes( $default ) { global $post; - $default['action'] = 'no' !== get_post_meta( $post->ID, 'rop_publish_now', true ); + $meta_value = get_post_meta( $post->ID, 'rop_publish_now', true ); + // Already-published posts only pre-check the box for a still-pending share; the instant-share default is for new posts. + $default['action'] = 'publish' === get_post_status( $post ) ? 'yes' === $meta_value : 'no' !== $meta_value; $default['page_active_accounts'] = get_post_meta( $post->ID, 'rop_publish_now_accounts', true ); return $default; @@ -910,6 +912,39 @@ public function transition_post_status( $new_status, $old_status, $post ) { $this->maybe_publish_now( $post->ID, true ); } + /** + * Publish now on post save, hooked to `wp_after_insert_post`. + * + * Routine edits of an already-published post must not re-queue shares from + * leftover meta; on those saves only an explicit metabox submission counts + * as sharing intent. Re-sharing from the Block Editor goes through the + * REST `share` endpoint instead, which forces the share. + * + * @param int $post_id The post ID. + * @param WP_Post $post The post object. + * @param bool $update Whether this is an update. + * @param WP_Post|null $post_before The post object before the update, null for new posts. + * + * @return void + */ + public function maybe_publish_now_after_insert( $post_id, $post, $update, $post_before ) { + if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status ) { + if ( empty( $_POST['publish_now'] ) ) { + return; + } + + // The Classic metabox keeps the box checked while a share is still pending, so every + // ordinary save of such a post submits `publish_now`. Re-queueing here would refresh + // the history timestamp and let a long-stalled entry escape the expiration cutoff, + // so leave a request that is already queued exactly as it is. + if ( 'queued' === get_post_meta( $post_id, 'rop_publish_now_status', true ) ) { + return; + } + } + + $this->maybe_publish_now( $post_id ); + } + /** * Publish now, if enabled. * diff --git a/includes/admin/models/class-rop-posts-selector-model.php b/includes/admin/models/class-rop-posts-selector-model.php index edc463e47..c6448293d 100644 --- a/includes/admin/models/class-rop-posts-selector-model.php +++ b/includes/admin/models/class-rop-posts-selector-model.php @@ -804,6 +804,23 @@ public function update_buffer( $account_id, $post_id, $refresh = false ) { return $this->set( 'posts_buffer', $this->buffer, $refresh ); } + /** + * How many publish now entries a single drain may consume. + */ + const PUBLISH_NOW_BATCH_SIZE = 300; + + /** + * Get the number of publish now entries a single drain may consume. + * + * @access public + * @return int + */ + public static function get_publish_now_batch_size() { + $batch_size = (int) apply_filters( 'rop_publish_now_batch_size', self::PUBLISH_NOW_BATCH_SIZE ); + + return $batch_size > 0 ? $batch_size : self::PUBLISH_NOW_BATCH_SIZE; + } + /** * Get posts to be published now. * @@ -828,7 +845,7 @@ public function get_publish_now_posts() { 'value' => 'queued', ), ), - 'numberposts' => 300, + 'posts_per_page' => self::get_publish_now_batch_size(), // NOTE: WP_Query ignores `numberposts`; without this the batch silently fell back to the site's `posts_per_page` option. 'orderby' => 'modified', 'order' => 'ASC', 'fields' => 'ids', diff --git a/includes/admin/models/class-rop-queue-model.php b/includes/admin/models/class-rop-queue-model.php index ab7f32c28..5c8ed0353 100644 --- a/includes/admin/models/class-rop-queue-model.php +++ b/includes/admin/models/class-rop-queue-model.php @@ -366,10 +366,21 @@ public function build_queue_publish_now() { return $normalized_queue; } + $expiration = apply_filters( 'rop_publish_now_expiration', DAY_IN_SECONDS ); + $index = 0; foreach ( $posts as $post_id ) { $accounts = get_post_meta( $post_id, 'rop_publish_now_accounts', true ); - if ( ! $accounts ) { + if ( ! $accounts || ! is_array( $accounts ) ) { + // NOTE: retire it through the same path as an expired entry. Clearing only the + // status would leave `queued` history rows behind, and the editor treats those as + // an active share, so it would spin on "Posting to social media…" forever. + $this->expire_publish_now( $post_id, 'has no accounts left to share to' ); + continue; + } + + if ( $this->is_publish_now_expired( $post_id, $expiration ) ) { + $this->expire_publish_now( $post_id ); continue; } @@ -385,9 +396,74 @@ public function build_queue_publish_now() { $index ++; } + // A full batch means more entries are still waiting. The drain runs on a single event, so + // without another pass a freshly published post sitting behind a large backlog would wait + // for an unrelated share event. Each pass clears its own batch, so this always terminates. + if ( count( $posts ) >= Rop_Posts_Selector_Model::get_publish_now_batch_size() ) { + ( new Rop_Cron_Helper() )->manage_cron( array( 'action' => 'publish-now' ) ); + } + return $normalized_queue; } + /** + * Check whether a publish now request is too old to still be shared. + * + * A stalled cron must not blast months-old queue entries to the accounts + * once it recovers. The queue time is the newest "queued" entry in the + * sharing history; entries without one predate the history meta and are + * stale by definition. + * + * @param int $post_id The post ID. + * @param int $expiration Maximum age, in seconds, of a queue entry. + * + * @return bool + */ + private function is_publish_now_expired( $post_id, $expiration ) { + $queued_at = 0; + $history = get_post_meta( $post_id, 'rop_publish_now_history', true ); + + if ( is_array( $history ) ) { + foreach ( $history as $item ) { + if ( + is_array( $item ) && + isset( $item['status'], $item['timestamp'] ) && + 'queued' === $item['status'] && + is_numeric( $item['timestamp'] ) + ) { + $queued_at = max( $queued_at, (int) $item['timestamp'] ); + } + } + } + + return ( time() - $queued_at ) > $expiration; + } + + /** + * Drop a publish now request without sharing it. + * + * @param int $post_id The post ID. + * @param string $reason Why the request was dropped, for the log line. + * + * @return void + */ + private function expire_publish_now( $post_id, $reason = 'expired before it could be shared' ) { + delete_post_meta( $post_id, 'rop_publish_now_accounts' ); + delete_post_meta( $post_id, 'rop_publish_now_status' ); + + $history = get_post_meta( $post_id, 'rop_publish_now_history', true ); + if ( is_array( $history ) ) { + foreach ( $history as $i => $item ) { + if ( is_array( $item ) && isset( $item['status'] ) && 'queued' === $item['status'] ) { + $history[ $i ]['status'] = 'expired'; + } + } + update_post_meta( $post_id, 'rop_publish_now_history', $history ); + } + + $this->logger->info( sprintf( 'Publish now request for post %d %s, skipping.', $post_id, $reason ) ); + } + /** * Method to build the queue according to the timeline. * diff --git a/includes/class-rop.php b/includes/class-rop.php index 40c422f7b..3db7eb35d 100644 --- a/includes/class-rop.php +++ b/includes/class-rop.php @@ -164,7 +164,7 @@ function ( $message ) { $this->loader->add_action( 'rop_cron_job_publish_now', $plugin_admin, 'rop_cron_job_publish_now' ); $this->loader->add_action( 'add_meta_boxes', $plugin_admin, 'rop_publish_now_metabox' ); - $this->loader->add_action( 'wp_after_insert_post', $plugin_admin, 'maybe_publish_now' ); + $this->loader->add_action( 'wp_after_insert_post', $plugin_admin, 'maybe_publish_now_after_insert', 10, 4 ); $this->loader->add_action( 'transition_post_status', $plugin_admin, 'transition_post_status', 10, 3 ); $this->loader->add_action( 'rop_publish_now_instant_share', $plugin_admin, 'maybe_publish_now', 10, 2 ); $this->loader->add_filter( 'rop_publish_now_attributes', $plugin_admin, 'publish_now_attributes' ); diff --git a/phpunit.xml b/phpunit.xml index c519dbdc7..04d1d8dbc 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -28,6 +28,9 @@ ./tests/test-queue.php + + ./tests/test-publish-now.php + ./tests/test-selector.php diff --git a/tests/e2e/fixtures/index.js b/tests/e2e/fixtures/index.js index afc3a274a..81316c655 100644 --- a/tests/e2e/fixtures/index.js +++ b/tests/e2e/fixtures/index.js @@ -18,6 +18,12 @@ export const test = base.extend({ runPublishNow: (postId) => call('publish-now', { postId }), getRequests: () => call('requests').then((result) => result.requests), + seedQueuedPost: (title, ageSeconds = 0) => + call('queued-post', { title, ageSeconds }).then( + (result) => result.postId + ), + getPublishNowState: (postId) => + call('publish-now-state', { postId }), }); }, }); diff --git a/tests/e2e/mu-plugins/rop-e2e-bootstrap.php b/tests/e2e/mu-plugins/rop-e2e-bootstrap.php index bb991532b..6a719a18c 100644 --- a/tests/e2e/mu-plugins/rop-e2e-bootstrap.php +++ b/tests/e2e/mu-plugins/rop-e2e-bootstrap.php @@ -61,6 +61,7 @@ function rop_e2e_reset() { delete_metadata( 'post', 0, 'rop_publish_now', '', true ); delete_metadata( 'post', 0, 'rop_publish_now_status', '', true ); delete_metadata( 'post', 0, 'rop_publish_now_accounts', '', true ); + delete_metadata( 'post', 0, 'rop_publish_now_history', '', true ); return rest_ensure_response( array( 'ok' => true ) ); } @@ -128,6 +129,82 @@ function rop_e2e_run_publish_now( WP_REST_Request $request ) { return rest_ensure_response( array( 'ok' => true ) ); } +/** + * Seed a post already sitting in the instant-share queue, optionally aged. + * + * Reproduces the #1102 backlog: entries queued while cron was dead. Aging it + * through the API is the only way to get a months-old entry inside a test. + */ +function rop_e2e_seed_queued_post( WP_REST_Request $request ) { + $loaded = rop_e2e_activate_plugin(); + if ( is_wp_error( $loaded ) ) { + return $loaded; + } + if ( ! $loaded ) { + return new WP_Error( 'rop_e2e_plugin_missing', 'Revive Old Posts is not loaded.', array( 'status' => 500 ) ); + } + + $accounts = ( new Rop_Services_Model() )->get_active_accounts(); + $account_id = array_key_first( $accounts ); + if ( ! $account_id ) { + return new WP_Error( 'rop_e2e_no_account', 'Seed an account first.', array( 'status' => 400 ) ); + } + + $age = absint( $request->get_param( 'ageSeconds' ) ); + $title = sanitize_text_field( (string) $request->get_param( 'title' ) ); + $post_id = wp_insert_post( + array( + 'post_title' => $title ? $title : 'Backlog Post', + 'post_status' => 'publish', + ), + true + ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + update_post_meta( $post_id, 'rop_publish_now', 'yes' ); + update_post_meta( $post_id, 'rop_publish_now_status', 'queued' ); + update_post_meta( $post_id, 'rop_publish_now_accounts', array( $account_id => '' ) ); + update_post_meta( + $post_id, + 'rop_publish_now_history', + array( + array( + 'account' => $account_id, + 'service' => $accounts[ $account_id ]['service'], + 'timestamp' => time() - $age, + 'status' => 'queued', + ), + ) + ); + + return rest_ensure_response( + array( + 'ok' => true, + 'postId' => $post_id, + ) + ); +} + +/** + * Read back the publish-now meta of a post so tests can assert on queue state. + */ +function rop_e2e_get_publish_now_state( WP_REST_Request $request ) { + $post_id = absint( $request->get_param( 'postId' ) ); + if ( ! $post_id || ! get_post( $post_id ) ) { + return new WP_Error( 'rop_e2e_post_missing', 'A valid postId is required.', array( 'status' => 400 ) ); + } + + return rest_ensure_response( + array( + 'status' => get_post_meta( $post_id, 'rop_publish_now_status', true ), + 'history' => get_post_meta( $post_id, 'rop_publish_now_history', true ), + ) + ); +} + function rop_e2e_get_requests() { $state = get_option( ROP_E2E_STATE_OPTION, array() ); @@ -175,5 +252,7 @@ function () { register_rest_route( ROP_E2E_NAMESPACE, '/account', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_seed_account' ) ); register_rest_route( ROP_E2E_NAMESPACE, '/publish-now', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_run_publish_now' ) ); register_rest_route( ROP_E2E_NAMESPACE, '/requests', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_get_requests' ) ); + register_rest_route( ROP_E2E_NAMESPACE, '/queued-post', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_seed_queued_post' ) ); + register_rest_route( ROP_E2E_NAMESPACE, '/publish-now-state', $admin + array( 'methods' => 'POST', 'callback' => 'rop_e2e_get_publish_now_state' ) ); } ); diff --git a/tests/e2e/specs/dashboard/publish-now-backlog.spec.js b/tests/e2e/specs/dashboard/publish-now-backlog.spec.js new file mode 100644 index 000000000..3f49206eb --- /dev/null +++ b/tests/e2e/specs/dashboard/publish-now-backlog.spec.js @@ -0,0 +1,127 @@ +import { test, expect } from '../../fixtures'; +import { tryCloseTourModal } from '../../utils'; + +const DAY = 24 * 60 * 60; + +/** + * Regression coverage for #1102. + * + * A site whose cron stalled for months accumulates `queued` instant-share + * entries. When cron recovered the drain ran oldest-modified first with no + * cutoff, flooding the accounts with archive content while the post the author + * just published waited behind the backlog. + * + * The queue can be drained either by the explicit `/publish-now` trigger or by + * a wp-cron run, so these assertions look at what reached the mocked API + * rather than at who drained it. + */ +test.describe('Publish Now backlog', () => { + test.beforeEach(async ({ ropUtils }) => { + await ropUtils.reset(); + await ropUtils.seedAccount(); + }); + + /** + * Wait for a post to leave the mocked API as a share request. + * + * @param {Object} ropUtils The ROP fixture. + * @param {string} title Post title expected in the payload. + */ + const waitForShare = async (ropUtils, title) => { + await expect + .poll( + async () => { + const requests = await ropUtils.getRequests(); + + return requests.some( + (request) => + request.url.endsWith('/post-on-x') && + JSON.stringify(request.body).includes(title) + ); + }, + { timeout: 30000 } + ) + .toBe(true); + }; + + test('drops the stale backlog and shares only the fresh post', async ({ + page, + admin, + ropUtils, + }, testInfo) => { + const backlogIds = []; + for (const [ index, age ] of [ 60 * DAY, 45 * DAY, 30 * DAY ].entries()) { + backlogIds.push( + await ropUtils.seedQueuedPost(`Backlog Post ${index + 1}`, age) + ); + } + + await admin.createNewPost({ title: 'Breaking News' }); + await tryCloseTourModal(page); + + await page.getByRole('button', { name: 'Revive Social' }).click(); + await expect( + page.getByRole('checkbox', { name: 'Share Immediately' }) + ).toBeChecked(); + + await page + .getByRole('button', { name: 'Publish', exact: true }) + .click(); + const publishPanel = page.getByLabel('Editor publish'); + await publishPanel + .getByRole('button', { name: 'Publish', exact: true }) + .click(); + + const freshId = await page.evaluate(() => + wp.data.select('core/editor').getCurrentPostId() + ); + + // The share runs server-side; wait for the plugin to have picked the + // post up before draining, so the trigger cannot outrun the save. + await expect + .poll( + async () => + (await ropUtils.getPublishNowState(freshId)).status, + { timeout: 20000 } + ) + .not.toBe('pending'); + + await ropUtils.runPublishNow(freshId); + await waitForShare(ropUtils, 'Breaking News'); + + const requests = await ropUtils.getRequests(); + await testInfo.attach('rop-social-requests', { + body: JSON.stringify(requests, null, 2), + contentType: 'application/json', + }); + + const shareRequests = requests.filter((request) => + request.url.endsWith('/post-on-x') + ); + expect(shareRequests).toHaveLength(1); + + for (const request of requests) { + expect(JSON.stringify(request.body)).not.toContain('Backlog Post'); + } + + // The backlog is retired rather than left queued for the next drain. + for (const postId of backlogIds) { + const state = await ropUtils.getPublishNowState(postId); + expect(state.status).not.toBe('queued'); + expect(state.history[0].status).toBe('expired'); + } + }); + + test('still shares an entry queued moments ago', async ({ ropUtils }) => { + const postId = await ropUtils.seedQueuedPost('Just Queued', 30); + + await ropUtils.runPublishNow(postId); + await waitForShare(ropUtils, 'Just Queued'); + + const requests = await ropUtils.getRequests(); + const shareRequests = requests.filter((request) => + request.url.endsWith('/post-on-x') + ); + expect(shareRequests).toHaveLength(1); + }); +}); diff --git a/tests/test-publish-now.php b/tests/test-publish-now.php new file mode 100644 index 000000000..a234163c0 --- /dev/null +++ b/tests/test-publish-now.php @@ -0,0 +1,425 @@ +post->create( array( 'post_status' => 'publish' ) ); + + update_post_meta( $post_id, 'rop_publish_now', 'yes' ); + update_post_meta( $post_id, 'rop_publish_now_status', 'queued' ); + update_post_meta( $post_id, 'rop_publish_now_accounts', array( $account_id => '' ) ); + + if ( $with_history ) { + update_post_meta( + $post_id, + 'rop_publish_now_history', + array( + array( + 'account' => $account_id, + 'service' => 'twitter', + 'timestamp' => time() - $age_seconds, + 'status' => 'queued', + ), + ) + ); + } + + return $post_id; + } + + /** + * Create an already published post and clear the throttling transient, so + * the next save runs `maybe_publish_now` for real. + * + * Publishing sets `rop_maybe_publish_now_` for a minute; a later edit + * of an archive post — the scenario in #1102 — never sees it. + * + * @return int The post ID. + */ + private function published_post_ready_for_edit() { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + delete_transient( 'rop_maybe_publish_now_' . $post_id ); + + return $post_id; + } + + /** + * A freshly queued post ends up in the publish now queue. + */ + public function test_fresh_entry_is_queued() { + $account_id = Rop_InitAccounts::get_account_id(); + $post_id = $this->queue_post(); + + $queue = ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertArrayHasKey( $account_id, $queue ); + $event = reset( $queue[ $account_id ] ); + $this->assertEquals( array( $post_id ), $event['post'] ); + } + + /** + * Entries older than the expiration threshold are dropped, marked expired + * and no longer reported as queued. + */ + public function test_stale_entry_expires_instead_of_sharing() { + $post_id = $this->queue_post( 2 * DAY_IN_SECONDS ); + + $queue = ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertEmpty( $queue, 'Stale entries must not be shared.' ); + $this->assertNotEquals( 'queued', get_post_meta( $post_id, 'rop_publish_now_status', true ) ); + + $history = get_post_meta( $post_id, 'rop_publish_now_history', true ); + $this->assertEquals( 'expired', $history[0]['status'] ); + } + + /** + * The core symptom of #1102: a backlog of stale entries must not hold up + * the post that was just published. + */ + public function test_backlog_does_not_delay_fresh_post() { + $account_id = Rop_InitAccounts::get_account_id(); + + for ( $i = 0; $i < 3; $i ++ ) { + $this->queue_post( 60 * DAY_IN_SECONDS ); + } + $fresh_id = $this->queue_post(); + + $queue = ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertCount( 1, $queue[ $account_id ], 'Only the fresh post may be shared.' ); + $event = reset( $queue[ $account_id ] ); + $this->assertEquals( array( $fresh_id ), $event['post'] ); + } + + /** + * The expiration threshold is filterable. + */ + public function test_expiration_is_filterable() { + $this->queue_post( HOUR_IN_SECONDS ); + + add_filter( + 'rop_publish_now_expiration', + function () { + return 10 * MINUTE_IN_SECONDS; + } + ); + + $queue = ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertEmpty( $queue ); + } + + /** + * Legacy entries without a sharing history are stale by definition. + */ + public function test_entry_without_history_expires() { + $post_id = $this->queue_post( 0, false ); + + $queue = ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertEmpty( $queue ); + $this->assertNotEquals( 'queued', get_post_meta( $post_id, 'rop_publish_now_status', true ) ); + } + + /** + * Entries without accounts are skipped AND their status is cleared, so + * they do not linger as queued forever. + */ + public function test_orphan_entry_clears_status() { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + update_post_meta( $post_id, 'rop_publish_now', 'yes' ); + update_post_meta( $post_id, 'rop_publish_now_status', 'queued' ); + + $queue = ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertEmpty( $queue ); + $this->assertNotEquals( 'queued', get_post_meta( $post_id, 'rop_publish_now_status', true ) ); + } + + /** + * An orphaned entry must also retire its history rows. The editor treats a + * `queued` history row as an active share regardless of the top level + * status, so leaving one behind spins the sidebar forever. + */ + public function test_orphan_entry_retires_history() { + $post_id = $this->queue_post(); + delete_post_meta( $post_id, 'rop_publish_now_accounts' ); + + ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $history = get_post_meta( $post_id, 'rop_publish_now_history', true ); + $statuses = wp_list_pluck( is_array( $history ) ? $history : array(), 'status' ); + $this->assertNotContains( 'queued', $statuses ); + } + + /** + * A full batch schedules another drain, so a fresh post sitting behind a + * backlog bigger than one batch is not stranded. + */ + public function test_full_batch_schedules_another_pass() { + add_filter( 'rop_publish_now_batch_size', function () { + return 2; + } ); + + for ( $i = 0; $i < 3; $i ++ ) { + $this->queue_post(); + } + + wp_clear_scheduled_hook( 'rop_cron_job_publish_now' ); + ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertNotFalse( + wp_next_scheduled( 'rop_cron_job_publish_now' ), + 'A full batch must schedule a follow-up drain.' + ); + } + + /** + * A partial batch means the queue is drained, so nothing is rescheduled. + */ + public function test_partial_batch_does_not_reschedule() { + add_filter( 'rop_publish_now_batch_size', function () { + return 10; + } ); + + $this->queue_post(); + + wp_clear_scheduled_hook( 'rop_cron_job_publish_now' ); + ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertFalse( wp_next_scheduled( 'rop_cron_job_publish_now' ) ); + } + + /** + * The drain batch is not capped by the site posts_per_page option + * (WP_Query ignores the numberposts argument the query used to pass). + */ + public function test_batch_size_exceeds_posts_per_page_option() { + $account_id = Rop_InitAccounts::get_account_id(); + $posts_per_page = (int) get_option( 'posts_per_page' ); + $count = $posts_per_page + 5; + + for ( $i = 0; $i < $count; $i ++ ) { + $this->queue_post(); + } + + $queue = ( new Rop_Queue_Model() )->build_queue_publish_now(); + + $this->assertCount( $count, $queue[ $account_id ] ); + } + + /** + * Editing an already-published post must not queue a share from leftover + * meta. + */ + public function test_editing_published_post_does_not_requeue() { + $account_id = Rop_InitAccounts::get_account_id(); + $post_id = $this->published_post_ready_for_edit(); + + // Leftover meta from a share that never drained. + update_post_meta( $post_id, 'rop_publish_now', 'yes' ); + update_post_meta( $post_id, 'rop_publish_now_accounts', array( $account_id => '' ) ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Routine edit', + ) + ); + + $this->assertNotEquals( 'queued', get_post_meta( $post_id, 'rop_publish_now_status', true ) ); + $this->assertEmpty( get_post_meta( $post_id, 'rop_publish_now_history', true ) ); + } + + /** + * Publishing a draft with instant sharing enabled still queues the share. + */ + public function test_publishing_draft_queues_share() { + $account_id = Rop_InitAccounts::get_account_id(); + $post_id = self::factory()->post->create( array( 'post_status' => 'draft' ) ); + + update_post_meta( $post_id, 'rop_publish_now', 'yes' ); + update_post_meta( $post_id, 'rop_publish_now_accounts', array( $account_id => '' ) ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_status' => 'publish', + ) + ); + + $this->assertEquals( 'queued', get_post_meta( $post_id, 'rop_publish_now_status', true ) ); + + $history = get_post_meta( $post_id, 'rop_publish_now_history', true ); + $this->assertEquals( 'queued', $history[0]['status'] ); + $this->assertEquals( $account_id, $history[0]['account'] ); + } + + /** + * A scheduled post going live still queues the share. + */ + public function test_scheduled_post_going_live_queues_share() { + $account_id = Rop_InitAccounts::get_account_id(); + $post_id = self::factory()->post->create( + array( + 'post_status' => 'future', + 'post_date' => gmdate( 'Y-m-d H:i:s', time() + HOUR_IN_SECONDS ), + ) + ); + + update_post_meta( $post_id, 'rop_publish_now', 'yes' ); + update_post_meta( $post_id, 'rop_publish_now_accounts', array( $account_id => '' ) ); + + wp_publish_post( $post_id ); + + $this->assertEquals( 'queued', get_post_meta( $post_id, 'rop_publish_now_status', true ) ); + } + + /** + * The Classic Editor metabox is an explicit opt-in, so submitting it on an + * already-published post must still share. + */ + public function test_explicit_metabox_submit_shares_published_post() { + $account_id = Rop_InitAccounts::get_account_id(); + $post_id = $this->published_post_ready_for_edit(); + + $_POST['publish_now'] = '1'; + $_POST['publish_now_accounts'] = array( $account_id ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Deliberate re-share', + ) + ); + + $this->assertEquals( 'queued', get_post_meta( $post_id, 'rop_publish_now_status', true ) ); + } + + /** + * The Classic metabox stays checked while a share is pending, so ordinary + * saves of such a post keep submitting `publish_now`. That must not refresh + * the queue timestamp, or a long-stalled entry would never expire. + */ + public function test_saving_a_pending_share_does_not_refresh_its_timestamp() { + $post_id = $this->queue_post( 5 * DAY_IN_SECONDS ); + delete_transient( 'rop_maybe_publish_now_' . $post_id ); + + $history = get_post_meta( $post_id, 'rop_publish_now_history', true ); + $queued_at = $history[0]['timestamp']; + + $_POST['publish_now'] = '1'; + $_POST['publish_now_accounts'] = array( Rop_InitAccounts::get_account_id() ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => 'Routine edit while queued', + ) + ); + + $history = get_post_meta( $post_id, 'rop_publish_now_history', true ); + $this->assertEquals( $queued_at, $history[0]['timestamp'], 'The queue timestamp must not be refreshed.' ); + + // And it must therefore still expire rather than be shared. + $this->assertEmpty( ( new Rop_Queue_Model() )->build_queue_publish_now() ); + } + + /** + * The Block Editor re-share button posts to the REST share endpoint, which + * fires `rop_publish_now_instant_share`. That path must keep sharing + * already-published posts. + */ + public function test_reshare_action_shares_published_post() { + $account_id = Rop_InitAccounts::get_account_id(); + $post_id = $this->published_post_ready_for_edit(); + + update_post_meta( $post_id, 'rop_publish_now', 'yes' ); + update_post_meta( $post_id, 'rop_publish_now_accounts', array( $account_id => '' ) ); + + do_action( 'rop_publish_now_instant_share', $post_id, true ); + + $this->assertEquals( 'queued', get_post_meta( $post_id, 'rop_publish_now_status', true ) ); + } + + /** + * The metabox checkbox is not pre-checked when editing a published post + * that has no share pending — this is what re-queued the archive content. + */ + public function test_metabox_not_prechecked_on_published_post() { + $GLOBALS['post'] = get_post( self::factory()->post->create( array( 'post_status' => 'publish' ) ) ); + + $attributes = ( new Rop_Admin() )->publish_now_attributes( array() ); + + $this->assertFalse( $attributes['action'] ); + } + + /** + * A published post whose share is still pending keeps the box checked. + */ + public function test_metabox_prechecked_while_share_pending() { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + update_post_meta( $post_id, 'rop_publish_now', 'yes' ); + + $GLOBALS['post'] = get_post( $post_id ); + + $attributes = ( new Rop_Admin() )->publish_now_attributes( array() ); + + $this->assertTrue( $attributes['action'] ); + } + + /** + * Drafts keep the "instant share by default" behaviour. + */ + public function test_metabox_prechecked_on_draft() { + $GLOBALS['post'] = get_post( self::factory()->post->create( array( 'post_status' => 'draft' ) ) ); + + $attributes = ( new Rop_Admin() )->publish_now_attributes( array() ); + + $this->assertTrue( $attributes['action'] ); + } +}