Commit 5478426b9fa for woocommerce
commit 5478426b9fa59da46268ee85682a034293392e71
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Mon Oct 19 14:25:55 2026 +0300
Hide WooCommerce.com store alerts after they are unpublished (#69747)
* fix(admin): hide store alerts that WooCommerce.com unpublished
Store Alerts on the Home screen come from the WooCommerce.com remote
inbox feed. The engine only evaluates specs that are still in the feed,
so once a security or update alert is unpublished its note is never
revisited and stays unactioned, keeping the banner on Home for good.
Record which spec slugs were published, together with the fetch time,
whenever a read returns a nonempty, fully valid, duplicate-free list
from every data source, including the WooCommerce.com feed. Any other
read that returns specs clears that record, because those specs are
newer evidence. After running specs, move unactioned WooCommerce.com
update and error notes to pending when their slug is in neither the
record nor the processed specs, and only if the note last changed
status more than an hour before that read. An older read, such as one
cached for another locale, can then never hide an alert that newer
specs made visible.
Empty feeds stay non-authoritative: WooCommerce.com serves the same
empty list when nothing is published and when its own query fails.
Pending notes keep their history and become visible again if the spec
is republished; actioned, snoozed and deleted notes are left alone.
Refs #53587
* chore(changelog): document withdrawn store alert fix
Record the merchant-facing fix that removes WooCommerce.com Store
Alerts from Home once WooCommerce.com stops publishing them.
Refs #53587
* refactor(admin): simplify withdrawn store alert tracking
The poller kept its own set of merged slugs alongside the merged specs,
and wrote its initial read state in two places. Record the merged keys
directly after an array_replace(), and start every read from one
READ_START constant. Snapshot validation drops a redundant isset().
The engine now returns before building the published slug set when no
unactioned WooCommerce.com alert exists, which is the common case.
In the tests, arrange_feed() keys specs with array_column(), names the
snapshot transient once, and registers a named HTTP blocker so repeated
calls no longer stack filters. The poller test reuses its healthy
responses instead of rebuilding them. Behavior is unchanged.
Refs #53587
* fix(admin): Refine withdrawn Store Alert reconciliation
Publication evidence previously depended on every source returning valid
runnable specs. An unrelated feed outage or a spec unsupported by the
client could therefore prevent cleanup of withdrawn WooCommerce.com
alerts.
Use canonical feed identities independently of executable validation.
Keep identifiable invalid specs protected and allow reconciliation when
no executable specs remain. Clear inconclusive publication evidence and
log each persisted withdrawal for diagnosis.
Cover source failures, invalid identities and specs, retained caches,
withdrawal logging, and malformed filter results with regression tests.
Refs #53587
* fix(admin): Preserve inconclusive feed read timestamps
Deleting an inconclusive read's snapshot also discarded its timestamp.
An older complete request finishing afterward could restore stale
publication evidence and authorize withdrawal from that older response.
Keep the timestamp with null slugs and route both read outcomes through
one save guard. Check the stored timestamp independently of whether the
snapshot can authorize reconciliation. Existing complete snapshots and
the public accessor's return shape remain compatible.
Cover late older reads, preservation of newer evidence, and recovery
when a later complete response arrives.
Refs #53587
* fix: Clarify remote alert withdrawal diagnostics
The pending status documentation omitted withdrawn Store Alerts, and the
snapshot accessor described inconclusive reads as absent from storage.
Document the retained timestamp and null slugs accurately.
Log a warning with the feed source and duplicate slug when a canonical
response cannot authorize withdrawal cleanup. This makes skipped cleanup
diagnosable without changing snapshot or caching behavior.
* docs(admin): Explain why an empty Store Alert feed is inconclusive
A review flagged the empty-list guard in merge_specs() as a bug: if
WooCommerce.com withdraws its last spec, nothing gets cleaned up. The
guard is intentional. The WooCommerce.com feed does not check its
notifications query for errors, so a failed query also returns an
empty list, and the server caches that list for a day. Treating it as
authoritative would hide every Store Alert after one failed query.
Add an inline comment at the guard so the next reader does not need
to rediscover this.
Refs #53587
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/53587-stale-remote-inbox-notes b/plugins/woocommerce/changelog/53587-stale-remote-inbox-notes
new file mode 100644
index 00000000000..6668746368c
--- /dev/null
+++ b/plugins/woocommerce/changelog/53587-stale-remote-inbox-notes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Remove WooCommerce.com store alerts from the Home screen once WooCommerce.com stops publishing them.
diff --git a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/README.md b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/README.md
index 216c15d2d1d..c063239ddf3 100644
--- a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/README.md
+++ b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/README.md
@@ -65,7 +65,7 @@ The status will usually be `unactioned`, as this will get the note to appear.
There are other note statuses but we just use them **internally**:
-- `pending`: the note is pending - hidden but not actioned. When the spec/rules are invalid, the note status will be set to pending.
+- `pending`: the note is hidden but not actioned. Notes can become pending when their spec or rules are invalid, or when an unactioned WooCommerce.com Store Alert is no longer listed in a complete canonical feed response.
- `sent`: the note has been sent by email to the user.
### Locale
diff --git a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePoller.php b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePoller.php
index 4580948672a..879579d1164 100644
--- a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePoller.php
+++ b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePoller.php
@@ -19,6 +19,18 @@ use WC_Helper;
class RemoteInboxNotificationsDataSourcePoller extends DataSourcePoller {
const ID = 'remote_inbox_notifications';
+ /**
+ * Transient holding the latest read's fetch time and published slugs, or null slugs for an inconclusive read.
+ */
+ private const PUBLISHED_SNAPSHOT_TRANSIENT = 'woocommerce_admin_remote_inbox_notifications_published_snapshot';
+
+ /**
+ * Published identities from this read's canonical response, or null when it is not authoritative.
+ *
+ * @var string[]|null
+ */
+ private $published_slugs = null;
+
/**
* Default data sources array.
*
@@ -50,6 +62,113 @@ class RemoteInboxNotificationsDataSourcePoller extends DataSourcePoller {
return self::$instance;
}
+ /**
+ * Read the data sources and record the identities published by the canonical WooCommerce.com feed.
+ *
+ * Invalid specs with usable slugs still count as published. An inconclusive response retains only its timestamp to reject older reads.
+ *
+ * @since 11.3.0
+ *
+ * @return bool Whether any specs were read.
+ */
+ public function read_specs_from_data_sources() {
+ $fetched_at = time();
+ $this->published_slugs = null;
+
+ $result = parent::read_specs_from_data_sources();
+
+ $this->save_published_snapshot( $fetched_at, $this->published_slugs );
+
+ return $result;
+ }
+
+ /**
+ * Get the published identities from the latest complete canonical response.
+ *
+ * Inconclusive reads store their fetch time with null slugs to reject older reads, but this method returns null for them.
+ *
+ * @internal
+ * @since 11.3.0
+ *
+ * @return array{fetched_at: int, slugs: string[]}|null The snapshot, or null when the latest read is inconclusive or absent.
+ */
+ public function get_published_snapshot(): ?array {
+ $snapshot = get_transient( self::PUBLISHED_SNAPSHOT_TRANSIENT );
+
+ if ( ! is_array( $snapshot ) || ! is_int( $snapshot['fetched_at'] ?? null ) || ! is_array( $snapshot['slugs'] ?? null ) ) {
+ return null;
+ }
+
+ return array(
+ 'fetched_at' => $snapshot['fetched_at'],
+ 'slugs' => array_map( 'strval', array_values( array_filter( $snapshot['slugs'], 'is_scalar' ) ) ),
+ );
+ }
+
+ /**
+ * Merge runnable specs and track canonical publication identities independently of validation.
+ *
+ * @since 11.3.0
+ *
+ * @param array $specs_to_merge_in The specs to merge in to $specs.
+ * @param array $specs The list of specs being merged into.
+ * @param string $url The url of the feed being merged in.
+ * @return void
+ */
+ protected function merge_specs( $specs_to_merge_in, &$specs, $url ) {
+ parent::merge_specs( $specs_to_merge_in, $specs, $url );
+
+ if ( ! in_array( $url, self::get_data_sources(), true ) ) {
+ return;
+ }
+
+ $this->published_slugs = null;
+ $slugs = array();
+ foreach ( $specs_to_merge_in as $spec ) {
+ if ( ! isset( $spec->slug ) || ! is_scalar( $spec->slug ) || '' === (string) $spec->slug ) {
+ return;
+ }
+ $slug = (string) $spec->slug;
+ if ( isset( $slugs[ $slug ] ) ) {
+ wc_get_logger()->warning(
+ 'Skipping Store Alert withdrawal cleanup because the canonical feed contains a duplicate slug.',
+ array(
+ 'source' => $url,
+ 'slug' => $slug,
+ )
+ );
+ return;
+ }
+ $slugs[ $slug ] = $slug;
+ }
+ // An empty list is inconclusive: WooCommerce.com also serves one when its own notifications query fails.
+ if ( ! empty( $slugs ) ) {
+ $this->published_slugs = array_values( $slugs );
+ }
+ }
+
+ /**
+ * Store a read's publication evidence unless a newer read is already stored.
+ *
+ * @param int $fetched_at When the read started.
+ * @param string[]|null $slugs Published slugs, or null for an inconclusive read.
+ */
+ private function save_published_snapshot( int $fetched_at, ?array $slugs ): void {
+ $stored = get_transient( self::PUBLISHED_SNAPSHOT_TRANSIENT );
+ if ( is_array( $stored ) && is_int( $stored['fetched_at'] ?? null ) && $stored['fetched_at'] > $fetched_at ) {
+ return;
+ }
+
+ set_transient(
+ self::PUBLISHED_SNAPSHOT_TRANSIENT,
+ array(
+ 'fetched_at' => $fetched_at,
+ 'slugs' => $slugs,
+ ),
+ $this->args['transient_expiry']
+ );
+ }
+
/**
* Validate the spec.
*
@@ -62,9 +181,9 @@ class RemoteInboxNotificationsDataSourcePoller extends DataSourcePoller {
$logger = self::get_logger();
$logger_context = array( 'source' => $url );
- if ( ! isset( $spec->slug ) ) {
+ if ( ! isset( $spec->slug ) || ! is_scalar( $spec->slug ) || '' === (string) $spec->slug ) {
$logger->error(
- 'Spec is invalid because the slug is missing in feed',
+ 'Spec is invalid because the slug is missing or invalid in feed',
$logger_context
);
// phpcs:ignore
diff --git a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php
index 6ecca0a1b4b..2c08206be37 100644
--- a/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php
+++ b/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngine.php
@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;
defined( 'ABSPATH' ) || exit;
+use Automattic\WooCommerce\Admin\Notes\DataStore;
use Automattic\WooCommerce\Admin\Notes\Notes;
use Automattic\WooCommerce\Admin\PluginsProvider\PluginsProvider;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
@@ -23,6 +24,12 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine {
const STORED_STATE_OPTION_NAME = 'wc_remote_inbox_notifications_stored_state';
const WCA_UPDATED_OPTION_NAME = 'wc_remote_inbox_notifications_wca_updated';
+ /**
+ * How long before a complete feed read an alert must have last changed status for that read to hide it.
+ * Allows for feed responses served from a cache.
+ */
+ private const WITHDRAWN_ALERT_GRACE_PERIOD = HOUR_IN_SECONDS;
+
/**
* Initialize the engine.
* phpcs:disable WooCommerce.Functions.InternalInjectionMethod.MissingFinal
@@ -141,27 +148,94 @@ class RemoteInboxNotificationsEngine extends RemoteSpecsEngine {
* Go through the specs and run them.
*/
public static function run() {
- $specs = RemoteInboxNotificationsDataSourcePoller::get_instance()->get_specs_from_data_sources();
+ $data_source_poller = RemoteInboxNotificationsDataSourcePoller::get_instance();
+ $specs = $data_source_poller->get_specs_from_data_sources();
- if ( false === $specs || ! is_countable( $specs ) || count( $specs ) === 0 ) {
+ if ( false === $specs || ! is_countable( $specs ) ) {
return;
}
- $stored_state = self::get_stored_state();
- $errors = array();
+ $stored_state = self::get_stored_state();
+ $errors = array();
+ $processed_slugs = array();
foreach ( $specs as $spec ) {
$error = SpecRunner::run_spec( $spec, $stored_state );
if ( isset( $error ) ) {
$errors[] = $error;
}
+ if ( isset( $spec->slug ) && is_scalar( $spec->slug ) ) {
+ $processed_slugs[] = (string) $spec->slug;
+ }
}
+ self::hide_withdrawn_alerts( $processed_slugs, $data_source_poller->get_published_snapshot() );
+
if ( count( $errors ) > 0 ) {
self::log_errors( $errors );
}
}
+ /**
+ * Move unactioned WooCommerce.com-owned alerts to pending when the canonical feed no longer lists them.
+ *
+ * Only alerts that last changed status well before that read are hidden, so an older read (for example, one
+ * cached for another locale) cannot hide an alert that newer specs made visible.
+ *
+ * @param string[] $processed_slugs Slugs of the specs this run processed, after filtering.
+ * @param array{fetched_at: int, slugs: string[]}|null $snapshot The latest complete read, if any.
+ */
+ private static function hide_withdrawn_alerts( array $processed_slugs, ?array $snapshot ): void {
+ if ( null === $snapshot ) {
+ return;
+ }
+
+ /**
+ * Note data store.
+ *
+ * @var DataStore $data_store
+ */
+ $data_store = Notes::load_data_store();
+ $alerts = $data_store->lookup_notes(
+ array(
+ 'source' => array( 'woocommerce.com' ),
+ 'type' => array( Note::E_WC_ADMIN_NOTE_ERROR, Note::E_WC_ADMIN_NOTE_UPDATE ),
+ 'status' => array( Note::E_WC_ADMIN_NOTE_UNACTIONED ),
+ )
+ );
+ if ( empty( $alerts ) ) {
+ return;
+ }
+
+ $published_slugs = array_fill_keys( array_merge( $snapshot['slugs'], $processed_slugs ), true );
+ $changed_before = $snapshot['fetched_at'] - self::WITHDRAWN_ALERT_GRACE_PERIOD;
+
+ foreach ( $alerts as $alert ) {
+ // Stored dates are GMT; the creation date moves forward whenever a spec changes the note's status.
+ $changed_at = wc_string_to_timestamp( $alert->date_created );
+ if ( isset( $published_slugs[ $alert->name ] ) || (int) $changed_at <= 0 || $changed_at >= $changed_before ) {
+ continue;
+ }
+
+ $note = Notes::get_note( $alert->note_id );
+ if ( ! $note instanceof Note ) {
+ continue;
+ }
+
+ $note->set_status( Note::E_WC_ADMIN_NOTE_PENDING );
+ $note->save();
+ wc_get_logger()->info(
+ 'Changed an unactioned Store Alert to pending because it is no longer published by WooCommerce.com.',
+ array(
+ 'source' => 'remote-inbox-notifications',
+ 'note_id' => $note->get_id(),
+ 'slug' => $note->get_name(),
+ 'fetched_at' => $snapshot['fetched_at'],
+ )
+ );
+ }
+ }
+
/**
* Set an option indicating that WooCommerce Admin has just been updated,
* run the specs, then clear that option. This lets the
diff --git a/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePollerTest.php b/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePollerTest.php
new file mode 100644
index 00000000000..f9d371b78a4
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePollerTest.php
@@ -0,0 +1,444 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\RemoteInboxNotifications;
+
+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsDataSourcePoller;
+use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller;
+use WC_Unit_Test_Case;
+use WP_Error;
+
+/**
+ * Tests for the published snapshot recorded by the remote inbox notifications poller.
+ *
+ * @covers \Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsDataSourcePoller
+ */
+class RemoteInboxNotificationsDataSourcePollerTest extends WC_Unit_Test_Case {
+
+ /**
+ * Transient holding the latest complete read.
+ */
+ private const SNAPSHOT_TRANSIENT = 'woocommerce_admin_remote_inbox_notifications_published_snapshot';
+
+ /**
+ * Auxiliary data source added through the data sources filter.
+ */
+ private const AUXILIARY_URL = 'https://example.com/remote-inbox-notifications.json';
+
+ /**
+ * The System Under Test.
+ *
+ * @var RemoteInboxNotificationsDataSourcePoller
+ */
+ private $sut;
+
+ /**
+ * Response body per data source, or null for a transport failure.
+ *
+ * @var array<string, string|null>
+ */
+ private array $responses = array();
+
+ /**
+ * Data sources returned by the data sources filter.
+ *
+ * @var string[]
+ */
+ private array $data_sources = array();
+
+ /**
+ * Route the poller to controlled data sources and responses.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = RemoteInboxNotificationsDataSourcePoller::get_instance();
+ $canonical_url = RemoteInboxNotificationsDataSourcePoller::get_data_sources()[0];
+ $this->data_sources = array( $canonical_url, self::AUXILIARY_URL );
+ $this->responses = array(
+ $canonical_url => $this->encode_specs( 'remote-alert-a', 'remote-alert-b' ),
+ self::AUXILIARY_URL => $this->encode_specs( 'remote-alert-c' ),
+ );
+
+ delete_transient( 'woocommerce_admin_' . RemoteInboxNotificationsDataSourcePoller::ID . '_specs' );
+ delete_transient( self::SNAPSHOT_TRANSIENT );
+
+ add_filter(
+ DataSourcePoller::FILTER_NAME,
+ fn( $data_sources, $poller_id ) => RemoteInboxNotificationsDataSourcePoller::ID === $poller_id ? $this->data_sources : $data_sources,
+ 10,
+ 2
+ );
+ add_filter( 'pre_http_request', array( $this, 'get_controlled_response' ), 10, 3 );
+ }
+
+ /**
+ * Serve the controlled response for a data source request.
+ *
+ * @param mixed $preempt Preempted response.
+ * @param mixed $args Request arguments.
+ * @param string $url Request URL.
+ * @return mixed Controlled response.
+ */
+ public function get_controlled_response( $preempt, $args, $url ) {
+ unset( $args );
+
+ foreach ( $this->responses as $source_url => $body ) {
+ if ( 0 !== strpos( $url, $source_url ) ) {
+ continue;
+ }
+
+ if ( null === $body ) {
+ return new WP_Error( 'http_request_failed', 'Controlled transport failure.' );
+ }
+
+ return array(
+ 'headers' => array(),
+ 'body' => $body,
+ 'response' => array(
+ 'code' => 200,
+ 'message' => 'OK',
+ ),
+ 'cookies' => array(),
+ 'filename' => null,
+ );
+ }
+
+ return $preempt;
+ }
+
+ /**
+ * @testdox A complete canonical response records its slugs and fetch time, independently of other sources.
+ */
+ public function test_complete_read_records_published_snapshot(): void {
+ $before = time();
+
+ $this->assertTrue( $this->sut->read_specs_from_data_sources() );
+
+ $snapshot = $this->sut->get_published_snapshot();
+ $this->assertNotNull( $snapshot, 'A complete read must record a snapshot.' );
+ $this->assertEqualsCanonicalizing( array( 'remote-alert-a', 'remote-alert-b' ), $snapshot['slugs'] );
+ $this->assertGreaterThanOrEqual( $before, $snapshot['fetched_at'], 'The snapshot time must not predate the read.' );
+ $this->assertLessThanOrEqual( time(), $snapshot['fetched_at'], 'The snapshot time must not be in the future.' );
+ }
+
+ /**
+ * @testdox A read that returns specs without being complete clears the snapshot and keeps caching valid specs.
+ * @dataProvider incomplete_read_provider
+ *
+ * @param string|null $canonical_body Canonical response body, or null for a transport failure.
+ * @param string|null $auxiliary_body Auxiliary response body, or null for a transport failure.
+ * @param bool $includes_canonical Whether the canonical source is read.
+ * @param string[] $expected_cached Slugs the read must still cache.
+ */
+ public function test_incomplete_read_clears_snapshot( ?string $canonical_body, ?string $auxiliary_body, bool $includes_canonical, array $expected_cached ): void {
+ $canonical_url = $this->data_sources[0];
+
+ $this->responses[ $canonical_url ] = $canonical_body;
+ $this->responses[ self::AUXILIARY_URL ] = $auxiliary_body;
+ if ( ! $includes_canonical ) {
+ $this->data_sources = array( self::AUXILIARY_URL );
+ }
+ set_transient(
+ self::SNAPSHOT_TRANSIENT,
+ array(
+ 'fetched_at' => time() - HOUR_IN_SECONDS,
+ 'slugs' => array( 'remote-alert-a' ),
+ ),
+ DAY_IN_SECONDS
+ );
+
+ $this->assertTrue( $this->sut->read_specs_from_data_sources() );
+
+ $this->assertNull( $this->sut->get_published_snapshot(), 'An incomplete read must not authorize hiding alerts, and its specs outrank an older snapshot.' );
+ $this->assertEqualsCanonicalizing( $expected_cached, array_keys( $this->sut->get_cached_specs() ), 'Snapshot tracking must not change what the read caches.' );
+ }
+
+ /**
+ * Reads that do not prove the full published set.
+ *
+ * @return array<string, array{string|null, string|null, bool, string[]}>
+ */
+ public function incomplete_read_provider(): array {
+ $valid_canonical = $this->encode_specs( 'remote-alert-a' );
+ $valid_auxiliary = $this->encode_specs( 'remote-alert-c' );
+ $invalid_spec = $this->create_spec( 'remote-alert-invalid' );
+ unset( $invalid_spec->slug );
+
+ return array(
+ 'canonical transport failure' => array( null, $valid_auxiliary, true, array( 'remote-alert-c' ) ),
+ 'canonical empty list' => array( '[]', $valid_auxiliary, true, array( 'remote-alert-c' ) ),
+ 'canonical error object' => array( '{"code":"rest_no_route"}', $valid_auxiliary, true, array( 'remote-alert-c' ) ),
+ 'canonical truncated JSON' => array( substr( $this->encode_specs( 'remote-alert-a', 'remote-alert-b' ), 0, -20 ), $valid_auxiliary, true, array( 'remote-alert-c' ) ),
+ 'canonical HTML error page' => array( '<html><body>502 Bad Gateway</body></html>', $valid_auxiliary, true, array( 'remote-alert-c' ) ),
+ 'canonical missing slug' => array( (string) wp_json_encode( array( $this->create_spec( 'remote-alert-a' ), $invalid_spec ) ), $valid_auxiliary, true, array( 'remote-alert-a', 'remote-alert-c' ) ),
+ 'canonical duplicate slug' => array( $this->encode_specs( 'remote-alert-a', 'remote-alert-a' ), $valid_auxiliary, true, array( 'remote-alert-a', 'remote-alert-c' ) ),
+ 'canonical source filtered out' => array( $valid_canonical, $valid_auxiliary, false, array( 'remote-alert-c' ) ),
+ );
+ }
+
+
+ /**
+ * @testdox An auxiliary outage does not veto the canonical published slugs.
+ */
+ public function test_auxiliary_failure_does_not_veto_snapshot(): void {
+ $this->responses[ self::AUXILIARY_URL ] = null;
+
+ $this->assertTrue( $this->sut->read_specs_from_data_sources() );
+
+ $snapshot = $this->sut->get_published_snapshot();
+ $this->assertNotNull( $snapshot );
+ $this->assertEqualsCanonicalizing( array( 'remote-alert-a', 'remote-alert-b' ), $snapshot['slugs'] );
+ }
+
+ /**
+ * @testdox Identifiable invalid specs remain published even when none can be cached.
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $include_valid Whether the canonical response also has a runnable spec.
+ */
+ public function test_snapshot_preserves_invalid_spec_slugs( bool $include_valid ): void {
+ $invalid = $this->create_spec( 'remote-alert-invalid' );
+ unset( $invalid->type );
+ $specs = array( $invalid );
+ if ( $include_valid ) {
+ $specs[] = $this->create_spec( 'remote-alert-valid' );
+ }
+ $this->data_sources = array( $this->data_sources[0] );
+ $this->responses[ $this->data_sources[0] ] = (string) wp_json_encode( $specs );
+
+ $this->assertSame( $include_valid, $this->sut->read_specs_from_data_sources(), 'The existing return value describes runnable specs.' );
+
+ $snapshot = $this->sut->get_published_snapshot();
+ $this->assertNotNull( $snapshot );
+ $this->assertContains( 'remote-alert-invalid', $snapshot['slugs'] );
+ $this->assertCount( $include_valid ? 2 : 1, $snapshot['slugs'] );
+ $cached = $this->sut->get_cached_specs();
+ $this->assertArrayNotHasKey( 'remote-alert-invalid', is_array( $cached ) ? $cached : array() );
+ }
+
+ /**
+ * @testdox Malformed identities cannot authorize withdrawal or crash spec merging.
+ * @testWith [null]
+ * [""]
+ * [["invalid"]]
+ * [false]
+ *
+ * @param mixed $slug Malformed identity.
+ */
+ public function test_malformed_identity_clears_snapshot( $slug ): void {
+ $invalid = $this->create_spec( 'remote-alert-invalid' );
+ $invalid->slug = $slug;
+ $this->responses[ $this->data_sources[0] ] = (string) wp_json_encode( array( $invalid ) );
+ set_transient(
+ self::SNAPSHOT_TRANSIENT,
+ array(
+ 'fetched_at' => time(),
+ 'slugs' => array( 'old' ),
+ ),
+ DAY_IN_SECONDS
+ );
+
+ $this->assertTrue( $this->sut->read_specs_from_data_sources() );
+
+ $this->assertNull( $this->sut->get_published_snapshot() );
+ $this->assertSame( array( 'remote-alert-c' ), array_keys( $this->sut->get_cached_specs() ) );
+ }
+
+ /**
+ * @testdox A failed read clears the previous snapshot, and an older complete read cannot replace a newer one.
+ */
+ public function test_snapshot_only_moves_forward(): void {
+ $this->sut->read_specs_from_data_sources();
+ $complete_snapshot = $this->sut->get_published_snapshot();
+ $this->assertNotNull( $complete_snapshot );
+
+ $healthy_responses = $this->responses;
+ $this->responses = array_fill_keys( array_keys( $healthy_responses ), null );
+ $this->sut->delete_specs_transient();
+
+ $this->assertFalse( $this->sut->read_specs_from_data_sources() );
+ $this->assertNull( $this->sut->get_published_snapshot(), 'A failed canonical read must not leave stale absence evidence available.' );
+
+ $newer_snapshot = array(
+ 'fetched_at' => time() + HOUR_IN_SECONDS,
+ 'slugs' => array( 'remote-alert-newer' ),
+ );
+ set_transient( self::SNAPSHOT_TRANSIENT, $newer_snapshot, DAY_IN_SECONDS );
+ $this->responses = $healthy_responses;
+ $this->sut->delete_specs_transient();
+
+ $this->assertTrue( $this->sut->read_specs_from_data_sources() );
+ $this->assertSame( $newer_snapshot, $this->sut->get_published_snapshot(), 'An older complete read must not replace newer evidence.' );
+ }
+
+ /**
+ * @testdox An inconclusive read retains its timestamp without authorizing withdrawal, and a later complete read can recover.
+ */
+ public function test_inconclusive_read_retains_timestamp(): void {
+ $healthy_responses = $this->responses;
+ $this->responses = array_fill_keys( array_keys( $healthy_responses ), null );
+ $before = time();
+
+ $this->assertFalse( $this->sut->read_specs_from_data_sources() );
+
+ $stored = get_transient( self::SNAPSHOT_TRANSIENT );
+ $this->assertIsArray( $stored, 'An inconclusive read must retain its timestamp to reject older reads.' );
+ $this->assertGreaterThanOrEqual( $before, $stored['fetched_at'] );
+ $this->assertLessThanOrEqual( time(), $stored['fetched_at'] );
+ $this->assertNull( $this->sut->get_published_snapshot(), 'The retained timestamp must not authorize withdrawal.' );
+
+ $stored['fetched_at'] -= HOUR_IN_SECONDS;
+ set_transient( self::SNAPSHOT_TRANSIENT, $stored, DAY_IN_SECONDS );
+ $this->responses = $healthy_responses;
+
+ $this->assertTrue( $this->sut->read_specs_from_data_sources() );
+ $this->assertSame( array( 'remote-alert-a', 'remote-alert-b' ), $this->sut->get_published_snapshot()['slugs'] );
+ }
+
+ /**
+ * @testdox An older read cannot replace the timestamp of a newer inconclusive read.
+ * @testWith [true]
+ * [false]
+ *
+ * @param bool $complete Whether the older read returns complete publication evidence.
+ */
+ public function test_older_read_preserves_inconclusive_timestamp( bool $complete ): void {
+ $newer_read = array(
+ 'fetched_at' => time() + HOUR_IN_SECONDS,
+ 'slugs' => null,
+ );
+ set_transient( self::SNAPSHOT_TRANSIENT, $newer_read, DAY_IN_SECONDS );
+ if ( ! $complete ) {
+ $this->responses = array_fill_keys( array_keys( $this->responses ), null );
+ }
+
+ $this->assertSame( $complete, $this->sut->read_specs_from_data_sources() );
+
+ $this->assertSame( $newer_read, get_transient( self::SNAPSHOT_TRANSIENT ), 'Older reads must preserve the newer timestamp even without published slugs.' );
+ $this->assertNull( $this->sut->get_published_snapshot(), 'An older complete read must not restore stale publication evidence.' );
+ }
+
+ /**
+ * @testdox An older inconclusive read cannot erase a newer complete snapshot.
+ */
+ public function test_older_inconclusive_read_preserves_complete_snapshot(): void {
+ $newer_snapshot = array(
+ 'fetched_at' => time() + HOUR_IN_SECONDS,
+ 'slugs' => array( 'remote-alert-newer' ),
+ );
+ set_transient( self::SNAPSHOT_TRANSIENT, $newer_snapshot, DAY_IN_SECONDS );
+ $this->responses[ $this->data_sources[0] ] = null;
+
+ $this->assertTrue( $this->sut->read_specs_from_data_sources() );
+
+ $this->assertSame( $newer_snapshot, $this->sut->get_published_snapshot(), 'An older inconclusive read must not discard newer evidence.' );
+ }
+
+ /**
+ * @testdox A malformed stored timestamp cannot block a fresh complete response.
+ */
+ public function test_malformed_timestamp_does_not_block_complete_read(): void {
+ set_transient(
+ self::SNAPSHOT_TRANSIENT,
+ array(
+ 'fetched_at' => (string) ( time() + HOUR_IN_SECONDS ),
+ 'slugs' => null,
+ ),
+ DAY_IN_SECONDS
+ );
+
+ $this->assertTrue( $this->sut->read_specs_from_data_sources() );
+
+ $snapshot = $this->sut->get_published_snapshot();
+ $this->assertNotNull( $snapshot, 'Only valid timestamps may prevent a complete read from saving its evidence.' );
+ $this->assertSame( array( 'remote-alert-a', 'remote-alert-b' ), $snapshot['slugs'] );
+ }
+
+ /**
+ * @testdox Stored snapshot slugs are returned as a list of strings.
+ */
+ public function test_snapshot_slugs_are_normalized(): void {
+ set_transient(
+ self::SNAPSHOT_TRANSIENT,
+ array(
+ 'fetched_at' => 1,
+ 'slugs' => array( 'remote-alert-a', array( 'remote-alert-b' ), 123 ),
+ ),
+ DAY_IN_SECONDS
+ );
+
+ $this->assertSame( array( 'remote-alert-a', '123' ), $this->sut->get_published_snapshot()['slugs'] );
+ }
+
+ /**
+ * @testdox A malformed stored snapshot is ignored.
+ * @dataProvider malformed_snapshot_provider
+ *
+ * @param mixed $stored Stored transient value.
+ */
+ public function test_malformed_snapshot_is_ignored( $stored ): void {
+ set_transient( self::SNAPSHOT_TRANSIENT, $stored, DAY_IN_SECONDS );
+
+ $this->assertNull( $this->sut->get_published_snapshot() );
+ }
+
+ /**
+ * Stored values that are not a snapshot.
+ *
+ * @return array<string, array{mixed}>
+ */
+ public function malformed_snapshot_provider(): array {
+ return array(
+ 'string' => array( 'remote-alert-a' ),
+ 'missing slugs' => array( array( 'fetched_at' => 1 ) ),
+ 'missing time' => array( array( 'slugs' => array( 'remote-alert-a' ) ) ),
+ 'non-integer time' => array(
+ array(
+ 'fetched_at' => '1',
+ 'slugs' => array( 'remote-alert-a' ),
+ ),
+ ),
+ 'non-list slugs' => array(
+ array(
+ 'fetched_at' => 1,
+ 'slugs' => 'remote-alert-a',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Encode valid specs as a feed response body.
+ *
+ * @param string ...$slugs Spec slugs.
+ * @return string Response body.
+ */
+ private function encode_specs( string ...$slugs ): string {
+ return (string) wp_json_encode( array_map( array( $this, 'create_spec' ), $slugs ) );
+ }
+
+ /**
+ * Create a minimal valid remote alert spec.
+ *
+ * @param string $slug Spec slug.
+ * @return object Remote spec.
+ */
+ private function create_spec( string $slug ): object {
+ return (object) array(
+ 'slug' => $slug,
+ 'status' => Note::E_WC_ADMIN_NOTE_UNACTIONED,
+ 'type' => Note::E_WC_ADMIN_NOTE_UPDATE,
+ 'source' => 'woocommerce.com',
+ 'locales' => array(
+ (object) array(
+ 'locale' => 'en_US',
+ 'title' => 'Remote alert',
+ 'content' => 'Remote alert content.',
+ ),
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngineTest.php b/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngineTest.php
index 6ba05e34d4d..510baebeccb 100644
--- a/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngineTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsEngineTest.php
@@ -5,14 +5,18 @@ namespace Automattic\WooCommerce\Tests\Admin\RemoteInboxNotifications;
use ActionScheduler;
use ActionScheduler_Store;
+use Automattic\WooCommerce\Admin\Notes\Note;
+use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsDataSourcePoller;
use Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine;
+use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller;
use WC_Action_Queue;
use WC_Queue;
use WC_Queue_Interface;
use WC_Unit_Test_Case;
+use WP_Error;
/**
- * Tests for upgrade-triggered remote inbox scheduling.
+ * Tests for upgrade-triggered remote inbox scheduling and withdrawn alert reconciliation.
*
* @covers \Automattic\WooCommerce\Admin\RemoteInboxNotifications\RemoteInboxNotificationsEngine
*/
@@ -28,6 +32,11 @@ class RemoteInboxNotificationsEngineTest extends WC_Unit_Test_Case {
*/
private const GROUP = 'woocommerce-remote-inbox-engine';
+ /**
+ * Transient holding the latest complete feed read.
+ */
+ private const SNAPSHOT_TRANSIENT = 'woocommerce_admin_remote_inbox_notifications_published_snapshot';
+
/**
* Initialize only the engine's update listener. The parent restores hooks and database fixtures.
*/
@@ -269,4 +278,362 @@ class RemoteInboxNotificationsEngineTest extends WC_Unit_Test_Case {
);
return array_map( 'intval', $action_ids );
}
+
+ /**
+ * @testdox A complete feed read hides only unactioned WooCommerce.com alerts it no longer contains.
+ */
+ public function test_run_hides_only_withdrawn_remote_alerts(): void {
+ $published = $this->create_note( 'remote-alert-published' );
+ $withdrawn = $this->create_note( 'remote-alert-withdrawn' );
+ $error = $this->create_note( 'remote-error-withdrawn', 'woocommerce.com', Note::E_WC_ADMIN_NOTE_ERROR );
+ $local = $this->create_note( 'local-alert', 'woocommerce' );
+ $information = $this->create_note( 'remote-information', 'woocommerce.com', Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
+ $actioned = $this->create_note( 'remote-alert-actioned', 'woocommerce.com', Note::E_WC_ADMIN_NOTE_UPDATE, Note::E_WC_ADMIN_NOTE_ACTIONED );
+ $snoozed = $this->create_note( 'remote-alert-snoozed', 'woocommerce.com', Note::E_WC_ADMIN_NOTE_UPDATE, Note::E_WC_ADMIN_NOTE_SNOOZED );
+ $deleted = $this->create_note( 'remote-alert-deleted' );
+ $deleted->set_is_deleted( true );
+ $deleted->save();
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-published' ) ), time() );
+
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $withdrawn ), 'A withdrawn update alert must be hidden.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $error ), 'A withdrawn error alert must be hidden.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $published ), 'A published alert must stay visible.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $local ), 'Notes from other sources must not change.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $information ), 'Inbox notes are outside the Store Alerts scope.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_ACTIONED, $this->get_status( $actioned ), 'A merchant action must be preserved.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_SNOOZED, $this->get_status( $snoozed ), 'A snoozed alert must keep its reminder.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $deleted ), 'A deleted note must not be rewritten.' );
+ }
+
+ /**
+ * @testdox Without a complete feed read, no alert is hidden.
+ */
+ public function test_run_without_published_snapshot_hides_nothing(): void {
+ $withdrawn = $this->create_note( 'remote-alert-withdrawn' );
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-published' ) ), null );
+
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $withdrawn ) );
+ }
+
+ /**
+ * @testdox An alert that became visible after, or shortly before, the complete read is not hidden by it.
+ */
+ public function test_run_does_not_hide_alerts_newer_than_the_published_snapshot(): void {
+ $fetched_at = time() - 2 * HOUR_IN_SECONDS;
+ $after_read = $this->create_note( 'remote-alert-after-read', 'woocommerce.com', Note::E_WC_ADMIN_NOTE_UPDATE, Note::E_WC_ADMIN_NOTE_UNACTIONED, $fetched_at + 60 );
+ $shortly_before = $this->create_note( 'remote-alert-shortly-before-read', 'woocommerce.com', Note::E_WC_ADMIN_NOTE_UPDATE, Note::E_WC_ADMIN_NOTE_UNACTIONED, $fetched_at - 30 * MINUTE_IN_SECONDS );
+ $well_before_read = $this->create_note( 'remote-alert-well-before-read', 'woocommerce.com', Note::E_WC_ADMIN_NOTE_UPDATE, Note::E_WC_ADMIN_NOTE_UNACTIONED, $fetched_at - 2 * HOUR_IN_SECONDS );
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-published' ) ), $fetched_at );
+
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $after_read ), 'An older read, for example from another locale, must not hide a newer alert.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $shortly_before ), 'The read may have been served from a cache.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $well_before_read ) );
+ }
+
+ /**
+ * @testdox An alert whose last status change is unknown is not hidden.
+ */
+ public function test_run_does_not_hide_alerts_with_an_unknown_date(): void {
+ global $wpdb;
+ $undated = $this->create_note( 'remote-alert-undated' );
+ $wpdb->update( $wpdb->prefix . 'wc_admin_notes', array( 'date_created' => '0000-00-00 00:00:00' ), array( 'note_id' => $undated->get_id() ) );
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-published' ) ), time() );
+
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $undated ) );
+ }
+
+ /**
+ * @testdox A filtered spec with a non-scalar slug does not stop reconciliation.
+ */
+ public function test_run_ignores_non_scalar_slugs_from_filtered_specs(): void {
+ $withdrawn = $this->create_note( 'remote-alert-withdrawn' );
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-published' ) ), time() );
+ $malformed = $this->create_spec( 'remote-alert-malformed' );
+ $malformed->slug = (object) array( 'name' => 'remote-alert-malformed' );
+ add_filter(
+ DataSourcePoller::FILTER_NAME_SPECS,
+ fn( $specs, $poller_id ) => RemoteInboxNotificationsDataSourcePoller::ID === $poller_id ? array_merge( array_values( $specs ), array( $malformed ) ) : $specs,
+ 10,
+ 2
+ );
+ $this->setExpectedIncorrectUsage( 'wpdb::prepare' );
+
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $withdrawn ) );
+ }
+
+ /**
+ * @testdox Specs added or removed by the specs filter keep their alerts visible.
+ */
+ public function test_run_preserves_alerts_for_filtered_specs(): void {
+ $added = $this->create_note( 'remote-alert-added-by-filter' );
+ $removed = $this->create_note( 'remote-alert-removed-by-filter' );
+ $this->arrange_feed(
+ array( $this->create_spec( 'remote-alert-published' ), $this->create_spec( 'remote-alert-removed-by-filter' ) ),
+ time()
+ );
+ add_filter(
+ DataSourcePoller::FILTER_NAME_SPECS,
+ fn( $specs, $poller_id ) => RemoteInboxNotificationsDataSourcePoller::ID === $poller_id
+ ? array( $this->create_spec( 'remote-alert-published' ), $this->create_spec( 'remote-alert-added-by-filter', null ) )
+ : $specs,
+ 10,
+ 2
+ );
+
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $added ), 'A spec added by a filter is published for this site.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $removed ), 'A spec removed by a filter is still in the feed.' );
+ }
+
+ /**
+ * @testdox A republished alert becomes visible again, while one the merchant dismissed stays dismissed.
+ */
+ public function test_republished_alert_returns_without_undoing_merchant_actions(): void {
+ $withdrawn = $this->create_note( 'remote-alert-withdrawn' );
+ $dismissed = $this->create_note( 'remote-alert-dismissed', 'woocommerce.com', Note::E_WC_ADMIN_NOTE_UPDATE, Note::E_WC_ADMIN_NOTE_ACTIONED );
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-published' ) ), time() );
+ RemoteInboxNotificationsEngine::run();
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $withdrawn ) );
+
+ $republished = array( $this->create_spec( 'remote-alert-withdrawn' ), $this->create_spec( 'remote-alert-dismissed' ) );
+ foreach ( $republished as $spec ) {
+ $spec->rules = array( (object) array( 'type' => 'pass' ) );
+ }
+ $this->arrange_feed( $republished, time() );
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $withdrawn ), 'A republished alert must be shown again.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_ACTIONED, $this->get_status( $dismissed ), 'Republishing must not undo a dismissal.' );
+ }
+
+
+ /**
+ * @testdox Canonical identities reconcile alerts despite invalid specs or an auxiliary outage.
+ * @testWith ["valid", false]
+ * ["missing_type", false]
+ * ["missing_locale", false]
+ * ["invalid_rule", false]
+ * ["missing_type", true]
+ *
+ * @param string $shape Shape of the published spec.
+ * @param bool $cached_specs Whether an older executable cache exists.
+ */
+ public function test_run_reconciles_canonical_identities( string $shape, bool $cached_specs ): void {
+ $published = $this->create_note( 'remote-alert-published' );
+ $withdrawn = $this->create_note( 'remote-alert-withdrawn' );
+ $cached = $this->create_note( 'remote-alert-cached' );
+ $spec = $this->create_spec( 'remote-alert-published' );
+ if ( 'missing_type' === $shape ) {
+ unset( $spec->type );
+ } elseif ( 'missing_locale' === $shape ) {
+ $spec->locales = array();
+ } elseif ( 'invalid_rule' === $shape ) {
+ $spec->rules = array( (object) array( 'type' => 'publish_before_time' ) );
+ }
+
+ $poller = RemoteInboxNotificationsDataSourcePoller::get_instance();
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-cached' ) ), null );
+ if ( ! $cached_specs ) {
+ $poller->delete_specs_transient();
+ }
+ $canonical_url = RemoteInboxNotificationsDataSourcePoller::get_data_sources()[0];
+ add_filter(
+ DataSourcePoller::FILTER_NAME,
+ fn( $sources, $id ) => RemoteInboxNotificationsDataSourcePoller::ID === $id ? array( $canonical_url, 'https://example.com/unavailable-feed' ) : $sources,
+ 10,
+ 2
+ );
+ add_filter(
+ 'pre_http_request',
+ static function ( $preempt, $args, $url ) use ( $canonical_url, $spec ) {
+ unset( $args );
+ if ( 0 !== strpos( $url, $canonical_url ) ) {
+ return $preempt;
+ }
+ return array(
+ 'body' => wp_json_encode( array( $spec ) ),
+ 'headers' => array(),
+ 'response' => array( 'code' => 200 ),
+ );
+ },
+ 20,
+ 3
+ );
+
+ $poller->read_specs_from_data_sources();
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $published ), 'Published identities are protected even when their specs cannot run.' );
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $withdrawn ), 'Canonical evidence must not depend on an auxiliary feed or runnable specs.' );
+ $this->assertSame( $cached_specs ? Note::E_WC_ADMIN_NOTE_UNACTIONED : Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $cached ), 'Previously cached specs remain protected when processed.' );
+ }
+
+ /**
+ * @testdox A malformed specs filter result does not authorize withdrawal.
+ * @testWith [null]
+ * [123]
+ * ["invalid"]
+ *
+ * @param mixed $filtered_specs Malformed filtered specs.
+ */
+ public function test_run_ignores_invalid_filtered_specs( $filtered_specs ): void {
+ $withdrawn = $this->create_note( 'remote-alert-withdrawn' );
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-published' ) ), time() );
+ add_filter(
+ DataSourcePoller::FILTER_NAME_SPECS,
+ fn( $specs, $id ) => RemoteInboxNotificationsDataSourcePoller::ID === $id ? $filtered_specs : $specs,
+ 10,
+ 2
+ );
+
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_UNACTIONED, $this->get_status( $withdrawn ) );
+ }
+
+ /**
+ * @testdox A withdrawal logs its persisted transition once, without logging preserved alerts.
+ */
+ public function test_run_logs_withdrawal_once(): void {
+ $withdrawn = $this->create_note( 'remote-alert-withdrawn' );
+ $this->create_note( 'remote-alert-published' );
+ $fetched_at = time();
+ $this->arrange_feed( array( $this->create_spec( 'remote-alert-published' ) ), $fetched_at );
+ $logger = $this->createMock( \WC_Logger_Interface::class );
+ $logger->expects( $this->once() )->method( 'info' )->with(
+ $this->isType( 'string' ),
+ $this->callback(
+ function ( $context ) use ( $withdrawn, $fetched_at ) {
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $withdrawn ), 'Only a persisted transition should be logged.' );
+ $this->assertSame( 'remote-inbox-notifications', $context['source'] );
+ $this->assertSame( $withdrawn->get_id(), $context['note_id'] );
+ $this->assertSame( 'remote-alert-withdrawn', $context['slug'] );
+ $this->assertSame( $fetched_at, $context['fetched_at'] );
+ return true;
+ }
+ )
+ );
+ add_filter( 'woocommerce_logging_class', static fn() => $logger );
+
+ RemoteInboxNotificationsEngine::run();
+ RemoteInboxNotificationsEngine::run();
+
+ $this->assertSame( Note::E_WC_ADMIN_NOTE_PENDING, $this->get_status( $withdrawn ) );
+ }
+
+ /**
+ * Cache the given specs for the current locale and record a matching published snapshot.
+ *
+ * @param object[] $specs Specs the feed returned.
+ * @param int|null $fetched_at Time of the complete read, or null when there is none.
+ */
+ private function arrange_feed( array $specs, ?int $fetched_at ): void {
+ $keyed_specs = array_column( $specs, null, 'slug' );
+ set_transient( 'woocommerce_admin_' . RemoteInboxNotificationsDataSourcePoller::ID . '_specs', array( get_user_locale() => $keyed_specs ), DAY_IN_SECONDS );
+
+ if ( null === $fetched_at ) {
+ delete_transient( self::SNAPSHOT_TRANSIENT );
+ } else {
+ set_transient(
+ self::SNAPSHOT_TRANSIENT,
+ array(
+ 'fetched_at' => $fetched_at,
+ 'slugs' => array_keys( $keyed_specs ),
+ ),
+ DAY_IN_SECONDS
+ );
+ }
+
+ update_option( RemoteInboxNotificationsEngine::STORED_STATE_OPTION_NAME, new \stdClass(), false );
+ add_filter( 'pre_http_request', array( $this, 'block_http_request' ) );
+ }
+
+ /**
+ * Keep the engine from reaching the network.
+ *
+ * @return WP_Error Blocked request.
+ */
+ public function block_http_request(): WP_Error {
+ return new WP_Error( 'http_request_blocked', 'Remote inbox tests must not reach the network.' );
+ }
+
+ /**
+ * Create a note that last changed status at the given time.
+ *
+ * @param string $name Note name.
+ * @param string $source Note source.
+ * @param string $type Note type.
+ * @param string $status Note status.
+ * @param int $date_created Time of the last status change; defaults to a day ago.
+ * @return Note Saved note.
+ */
+ private function create_note(
+ string $name,
+ string $source = 'woocommerce.com',
+ string $type = Note::E_WC_ADMIN_NOTE_UPDATE,
+ string $status = Note::E_WC_ADMIN_NOTE_UNACTIONED,
+ int $date_created = 0
+ ): Note {
+ $note = new Note();
+ $note->set_name( $name );
+ $note->set_title( 'Remote alert' );
+ $note->set_content( 'Remote alert content.' );
+ $note->set_source( $source );
+ $note->set_type( $type );
+ $note->set_status( $status );
+ $note->save();
+ // Creating a note always stamps the current time; only an update keeps an earlier one.
+ $note->set_date_created( $date_created ? $date_created : time() - DAY_IN_SECONDS );
+ $note->save();
+
+ return $note;
+ }
+
+ /**
+ * Read a note's stored status.
+ *
+ * @param Note $note Note to read.
+ * @return string Stored status.
+ */
+ private function get_status( Note $note ): string {
+ return ( new Note( $note->get_id() ) )->get_status();
+ }
+
+ /**
+ * Create a minimal remote alert spec.
+ *
+ * @param string $slug Spec slug.
+ * @param string|null $source Spec source, or null to omit it.
+ * @return object Remote spec.
+ */
+ private function create_spec( string $slug, ?string $source = 'woocommerce.com' ): object {
+ $spec = (object) array(
+ 'slug' => $slug,
+ 'status' => Note::E_WC_ADMIN_NOTE_UNACTIONED,
+ 'type' => Note::E_WC_ADMIN_NOTE_UPDATE,
+ 'locales' => array(
+ (object) array(
+ 'locale' => 'en_US',
+ 'title' => 'Remote alert',
+ 'content' => 'Remote alert content.',
+ ),
+ ),
+ );
+ if ( null !== $source ) {
+ $spec->source = $source;
+ }
+
+ return $spec;
+ }
}