Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -89,23 +89,31 @@ public static function update_single_plugin( $plugin_slug ) {
$release_time = self::compute_release_time( $post, $release );
$existing_row = $wpdb->get_row(
$wpdb->prepare(
"SELECT version, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
"SELECT version, stable_tag, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
$post->post_name
)
);
$existing_version = (string) ( $existing_row->version ?? '' );
$existing_ref = (string) ( $existing_row->stable_tag ?? '' );
$stable_tag = (string) get_post_meta( $post->ID, 'stable_tag', true );

$release_delay = (int) ( $release['release_delay'] ?? 0 );

// `update_source.version` is varchar(128); mirror cron_trigger()'s `left( pm.meta_value, 128 )` truncation allowance.
$is_new_version = substr( (string) $version, 0, 128 ) !== $existing_version;
/*
* A new release is a change to the served version OR the served ref: a new tag
* carrying the same version still ships different content, so it must pass through
* the cooldown and block gates rather than skip them on the unchanged version alone.
* Both columns are varchar(128); mirror cron_trigger()'s `left( …, 128 )` allowance.
*/
$is_new_release = substr( (string) $version, 0, 128 ) !== $existing_version
|| substr( $stable_tag, 0, 128 ) !== $existing_ref;

/*
* Hold a blocked version out of the row: the previously served version keeps
* being served, and the deferred serve is cancelled rather than postponed.
* Status changes still reach the row right away.
*/
if ( self::is_release_blocked( $release ) && $is_new_version ) {
if ( self::is_release_blocked( $release ) && $is_new_release ) {
wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" );

if ( $existing_row ) {
Expand All @@ -125,13 +133,13 @@ public static function update_single_plugin( $plugin_slug ) {
* gate is false when called from cron_trigger_release() and no explicit bypass
* is needed.
*
* Only the version bump waits for the cooldown: a status change made
* Only the new release waits for the cooldown: a status change made
* mid-cooldown (a closure, a reopen) reaches the existing row right away,
* while it keeps serving the previous release's data. Until the cooldown
* expires, cron_trigger() keeps re-selecting the plugin and this write
* repeats as a no-op.
*/
if ( $release_delay && $is_new_version ) {
if ( $release_delay && $is_new_release ) {
$cooldown_until = $release_time + $release_delay;
if ( $cooldown_until > time() ) {
self::queue_release_to_update_api( $post->post_name, $cooldown_until );
Expand All @@ -148,7 +156,7 @@ public static function update_single_plugin( $plugin_slug ) {
// to now — that's the moment the version is actually available to sites. Keeps
// phased_rollout()'s `manual-updates-24hr` window measuring from public availability,
// even if the commit/confirmation was long ago because the cooldown deferred the write.
if ( $release_delay && $is_new_version ) {
if ( $release_delay && $is_new_release ) {
$release_time = time();
}

Expand Down Expand Up @@ -176,7 +184,7 @@ public static function update_single_plugin( $plugin_slug ) {
'plugin_slug' => $post->post_name,
'available' => (int) self::is_available( $post ),
'version' => $version,
'stable_tag' => get_post_meta( $post->ID, 'stable_tag', true ),
'stable_tag' => $stable_tag,
'plugin_name' => strip_tags( get_post_meta( $post->ID, 'header_name', true ) ),
'plugin_name_san' => sanitize_title_with_dashes( strip_tags( get_post_meta( $post->ID, 'header_name', true ) ) ),
'plugin_author' => strip_tags( get_post_meta( $post->ID, 'header_author', true ) ),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php
/**
* Tests that update_source newness is ref-aware, not version-only.
*
* @package WordPressdotorg\Plugin_Directory\Tests
*/

declare( strict_types = 1 );

use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
use WordPressdotorg\Plugin_Directory\Jobs\API_Update_Updater;
use WordPressdotorg\Plugin_Directory\Plugin_Directory;

/**
* Tests that a new release is recognised from a change to the served version OR
* the served ref, so a new ref carrying the same version still passes through
* the cooldown and block gates instead of shipping immediately.
*
* Extends the plain PHPUnit TestCase: WP_UnitTestCase is not compatible with
* the PHPUnit 11 runner used by this suite. Isolation comes from giving every
* test its own plugin post instead of per-test transactions.
*
* The group is declared as an attribute as well as `@group`: PHPUnit 11 ignores
* a class-level `@group` docblock, while older runners ignore the attribute.
*
* @group jobs
*/
#[Group( 'jobs' )]
class Release_Newness_Test extends TestCase {

/** The version served by the update_source row fixture and the new tag. */
private const VERSION = '2.0';

/** The ref the update_source row serves — content differs from the new tag's. */
private const SERVED_REF = 'trunk';

/** The new tag carrying the same version as the served ref. */
private const NEW_TAG = '2.0';

/**
* Counter to give every test plugin a unique slug.
*
* @var int
*/
private static int $plugin_count = 0;

/**
* The plugin post under test.
*
* @var \WP_Post
*/
private \WP_Post $plugin;

/**
* Create a published plugin whose stable tag has flipped to a new ref at the
* same version the update_source row already serves.
*/
protected function setUp(): void {
parent::setUp();

wp_cache_flush();

// Tools::audit_log() reads it unguarded.
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';

$plugin = Plugin_Directory::create_plugin_post(
array(
'post_name' => 'newness-test-' . ( ++self::$plugin_count ),
'post_title' => 'Release Newness Test Plugin',
'post_status' => 'publish',
)
);

$this->assertInstanceOf( \WP_Post::class, $plugin );
$this->plugin = $plugin;

/*
* The stub update_source table survives across runs — the WP test
* installer only drops core tables — so clear leftovers that would
* collide with this run's plugin ID or read as a served version.
*/
global $wpdb;
$wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_id' => $this->plugin->ID ) );
$wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => $this->plugin->post_name ) );

update_post_meta( $this->plugin->ID, 'version', self::VERSION );
update_post_meta( $this->plugin->ID, 'stable_tag', self::NEW_TAG );
}

/**
* Insert an update_source row serving a version from a ref.
*
* @param string $version The served version.
* @param string $stable_tag The served ref.
*/
private function insert_served_row( string $version, string $stable_tag ): void {
global $wpdb;

$wpdb->insert(
$wpdb->prefix . 'update_source',
array(
'plugin_id' => $this->plugin->ID,
'plugin_slug' => $this->plugin->post_name,
'available' => 1,
'version' => $version,
'stable_tag' => $stable_tag,
'plugin_name' => $this->plugin->post_title,
'requires_plugins' => '',
'last_updated' => $this->plugin->post_modified,
)
);
}

/**
* Register a release for the new tag.
*
* @param array $overrides Fields to override.
*/
private function add_new_tag_release( array $overrides = array() ): void {
update_post_meta(
$this->plugin->ID,
'releases',
array(
array_merge(
array(
'date' => time(),
'tag' => self::NEW_TAG,
'version' => self::VERSION,
'zips_built' => true,
'zips_built_from_revision' => 0,
'confirmations' => array(),
'confirmed' => true,
'confirmations_required' => 0,
'committer' => array(),
'revision' => array(),
'release_delay' => DAY_IN_SECONDS,
),
$overrides
),
)
);
}

/**
* Fetch the plugin's update_source row.
*
* @return object|null The row, or null when none exists.
*/
private function get_row(): ?object {
global $wpdb;

return $wpdb->get_row(
$wpdb->prepare(
"SELECT version, stable_tag FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
$this->plugin->post_name
)
);
}

/**
* A new ref at the already-served version is held by the cooldown, not
* written straight into the row.
*/
public function test_new_ref_same_version_defers_under_cooldown(): void {
$this->insert_served_row( self::VERSION, self::SERVED_REF );
$this->add_new_tag_release();

$this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );

$row = $this->get_row();
$this->assertSame( self::SERVED_REF, $row->stable_tag );
$this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
}

/**
* A new ref at the already-served version that is blocked is held out of the
* row, and its deferred serve is cancelled.
*/
public function test_new_ref_same_version_blocked_is_held(): void {
$this->insert_served_row( self::VERSION, self::SERVED_REF );
$this->add_new_tag_release( array( 'release_block' => array( 'blocked_at' => time() ) ) );

$this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );

$row = $this->get_row();
$this->assertSame( self::SERVED_REF, $row->stable_tag );
$this->assertFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
}

/**
* An unchanged release — same version and same ref — is not treated as new,
* so the cooldown does not spuriously defer it.
*/
public function test_unchanged_release_is_not_deferred(): void {
$this->insert_served_row( self::VERSION, self::NEW_TAG );
$this->add_new_tag_release();

$this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );

$row = $this->get_row();
$this->assertSame( self::NEW_TAG, $row->stable_tag );
$this->assertFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ public function test_served_truncated_version_is_not_deferred(): void {
$release['version'] = $long_version;

update_post_meta( $this->plugin->ID, 'version', $long_version );
update_post_meta( $this->plugin->ID, 'stable_tag', $long_version );
update_post_meta( $this->plugin->ID, 'releases', array( $release ) );
$this->insert_served_row( substr( $long_version, 0, 128 ) );

Expand Down