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'] );
+ }
+}