Commit aa3e97c25c for woocommerce

commit aa3e97c25c572300a6b8dfc64bda1909700fe1a8
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Fri Feb 20 08:42:31 2026 +0000

    Add Notification base class and PendingNotificationStore. (#63347)

    * Add Notification base class and PendingNotificationStore.

    - Add abstract Notification class with to_payload() for subclasses.
    - Add PendingNotificationStore with dedup and shutdown dispatch.
    - Store uses register() flag pattern, enabled via PushNotifications::on_init().
    - Dispatch fires wc_push_notifications_dispatch action hook.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
new file mode 100644
index 0000000000..655b431d1d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Notifications/Notification.php
@@ -0,0 +1,116 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Notifications;
+
+use InvalidArgumentException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Base class for push notifications.
+ *
+ * Each notification type (e.g. new order, new review) extends this class
+ * and implements `to_payload()` with its own title, message, icon, and meta.
+ *
+ * @since 10.7.0
+ */
+abstract class Notification {
+	/**
+	 * The notification type.
+	 *
+	 * @var string
+	 */
+	private string $type;
+
+	/**
+	 * The ID of the resource this notification is about (e.g. order ID, comment
+	 * ID).
+	 *
+	 * @var int
+	 */
+	private int $resource_id;
+
+	/**
+	 * Creates a new Notification instance.
+	 *
+	 * @param string $type        The notification type.
+	 * @param int    $resource_id The resource ID.
+	 *
+	 * @throws InvalidArgumentException If any argument is invalid.
+	 *
+	 * @since 10.7.0
+	 */
+	public function __construct( string $type, int $resource_id ) {
+		if ( '' === trim( $type ) ) {
+			throw new InvalidArgumentException( 'Notification type must not be empty.' );
+		}
+
+		if ( $resource_id <= 0 ) {
+			throw new InvalidArgumentException( 'Notification resource_id must be positive.' );
+		}
+
+		$this->type        = trim( $type );
+		$this->resource_id = $resource_id;
+	}
+
+	/**
+	 * Returns the WPCOM-ready payload for this notification.
+	 *
+	 * Returns null if the underlying resource no longer exists.
+	 *
+	 * @return array|null
+	 *
+	 * @since 10.7.0
+	 */
+	abstract public function to_payload(): ?array;
+
+	/**
+	 * Returns the notification data as an array.
+	 *
+	 * @return array{type: string, resource_id: int}
+	 *
+	 * @since 10.7.0
+	 */
+	public function to_array(): array {
+		return array(
+			'type'        => $this->type,
+			'resource_id' => $this->resource_id,
+		);
+	}
+
+	/**
+	 * Returns a unique identifier for this notification, used for
+	 * deduplication.
+	 *
+	 * @return string
+	 *
+	 * @since 10.7.0
+	 */
+	public function get_identifier(): string {
+		return sprintf( '%s_%s_%s', get_current_blog_id(), $this->type, $this->resource_id );
+	}
+
+	/**
+	 * Gets the notification type.
+	 *
+	 * @return string
+	 *
+	 * @since 10.7.0
+	 */
+	public function get_type(): string {
+		return $this->type;
+	}
+
+	/**
+	 * Gets the resource ID.
+	 *
+	 * @return int
+	 *
+	 * @since 10.7.0
+	 */
+	public function get_resource_id(): int {
+		return $this->resource_id;
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index bc4d75aa7a..247375c861 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -9,6 +9,7 @@ defined( 'ABSPATH' ) || exit;
 use Automattic\Jetpack\Connection\Manager as JetpackConnectionManager;
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
 use Automattic\WooCommerce\Utilities\FeaturesUtil;
 use WC_Logger;
@@ -70,8 +71,7 @@ class PushNotifications {
 		$this->register_post_types();

 		wc_get_container()->get( PushTokenRestController::class )->register();
-
-		// Library endpoints and scheduled tasks will be registered here.
+		wc_get_container()->get( PendingNotificationStore::class )->register();
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
new file mode 100644
index 0000000000..85f60b517c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -0,0 +1,143 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+
+/**
+ * Store that collects notifications during a request and dispatches them all on
+ * on shutdown. Should be accessed from the container (`wc_get_container`) to
+ * ensure store is shared by all usage.
+ *
+ * Notifications are keyed by `{type}_{resource_id}` (with blog ID from
+ * `get_current_blog_id()`) to prevent duplicates within a single request.
+ *
+ * @since 10.7.0
+ */
+class PendingNotificationStore {
+	/**
+	 * Whether the store is enabled and accepting notifications.
+	 *
+	 * @var bool
+	 */
+	private bool $enabled = false;
+
+	/**
+	 * Pending notifications keyed by identifier.
+	 *
+	 * @var array<string, Notification>
+	 */
+	private array $pending = array();
+
+	/**
+	 * Whether the shutdown hook has been registered.
+	 *
+	 * @var bool
+	 */
+	private bool $shutdown_registered = false;
+
+	/**
+	 * Enables the store so it accepts notifications.
+	 *
+	 * Called from PushNotifications::on_init() after enablement checks pass.
+	 *
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	public function register(): void {
+		$this->enabled = true;
+	}
+
+	/**
+	 * Adds a notification to the pending store.
+	 *
+	 * Duplicate notifications (same type and resource ID) within a single
+	 * request are silently ignored. The shutdown hook is registered on the
+	 * first call.
+	 *
+	 * @param Notification $notification The notification to add.
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	public function add( Notification $notification ): void {
+		if ( ! $this->enabled ) {
+			return;
+		}
+
+		$key = $notification->get_identifier();
+
+		if ( isset( $this->pending[ $key ] ) ) {
+			return;
+		}
+
+		$this->pending[ $key ] = $notification;
+
+		if ( ! $this->shutdown_registered ) {
+			add_action( 'shutdown', array( $this, 'dispatch_all' ) );
+			$this->shutdown_registered = true;
+		}
+	}
+
+	/**
+	 * Dispatches all pending notifications by firing an action hook.
+	 *
+	 * Called on shutdown. Fires the `wc_push_notifications_dispatch` action
+	 * with the array of pending notifications, then clears the store.
+	 *
+	 * @return void
+	 *
+	 * @since 10.7.0
+	 */
+	public function dispatch_all(): void {
+		if ( empty( $this->pending ) ) {
+			return;
+		}
+
+		$notifications = array_values( $this->pending );
+
+		/**
+		 * Fires when pending push notifications are ready to be dispatched.
+		 *
+		 * @param Notification[] $notifications The notifications to dispatch.
+		 *
+		 * @since 10.7.0
+		 *
+		 * The call to dispatch the notifications will go here.
+		 */
+
+		/**
+		 * Store is single-use per request lifecycle, so disable it and clear
+		 * pending notifications.
+		 */
+		$this->enabled = false;
+		$this->pending = array();
+	}
+
+	/**
+	 * Returns the number of pending notifications.
+	 *
+	 * @return int
+	 *
+	 * @since 10.7.0
+	 */
+	public function count(): int {
+		return count( $this->pending );
+	}
+
+	/**
+	 * Returns all pending notifications.
+	 *
+	 * @return Notification[]
+	 *
+	 * @since 10.7.0
+	 */
+	public function get_all(): array {
+		return array_values( $this->pending );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
new file mode 100644
index 0000000000..8b045b197e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Notifications/NotificationTest.php
@@ -0,0 +1,122 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Notifications;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use InvalidArgumentException;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Notification class.
+ */
+class NotificationTest extends WC_Unit_Test_Case {
+	/**
+	 * @testdox Should store and return the notification type.
+	 */
+	public function test_get_type(): void {
+		$notification = $this->create_notification( 'store_order', 42 );
+
+		$this->assertSame( 'store_order', $notification->get_type() );
+	}
+
+	/**
+	 * @testdox Should store and return the resource ID.
+	 */
+	public function test_get_resource_id(): void {
+		$notification = $this->create_notification( 'store_order', 42 );
+
+		$this->assertSame( 42, $notification->get_resource_id() );
+	}
+
+	/**
+	 * @testdox Should return an identifier combining blog ID, type, and resource ID.
+	 */
+	public function test_get_identifier(): void {
+		$notification = $this->create_notification( 'store_order', 42 );
+
+		$this->assertSame( get_current_blog_id() . '_store_order_42', $notification->get_identifier() );
+	}
+
+	/**
+	 * @testdox Should return different identifiers for different resource IDs.
+	 */
+	public function test_get_identifier_differs_by_resource_id(): void {
+		$order  = $this->create_notification( 'store_order', 42 );
+		$review = $this->create_notification( 'store_order', 43 );
+
+		$this->assertNotSame( $order->get_identifier(), $review->get_identifier() );
+	}
+
+	/**
+	 * @testdox Should return different identifiers for different types with the same resource ID.
+	 */
+	public function test_get_identifier_differs_by_type(): void {
+		$order  = $this->create_notification( 'store_order', 42 );
+		$review = $this->create_notification( 'store_review', 42 );
+
+		$this->assertNotSame( $order->get_identifier(), $review->get_identifier() );
+	}
+
+	/**
+	 * @testdox Should return notification data as an array.
+	 */
+	public function test_to_array(): void {
+		$notification = $this->create_notification( 'store_review', 99 );
+
+		$result = $notification->to_array();
+
+		$this->assertArrayHasKey( 'type', $result );
+		$this->assertSame( 'store_review', $result['type'] );
+		$this->assertArrayHasKey( 'resource_id', $result );
+		$this->assertSame( 99, $result['resource_id'] );
+	}
+
+	/**
+	 * @testdox Should throw when type is empty.
+	 */
+	public function test_throws_for_empty_type(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		$this->create_notification( '', 1 );
+	}
+
+	/**
+	 * @testdox Should throw when resource_id is zero.
+	 */
+	public function test_throws_for_zero_resource_id(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		$this->create_notification( 'store_order', 0 );
+	}
+
+	/**
+	 * @testdox Should throw when resource_id is negative.
+	 */
+	public function test_throws_for_negative_resource_id(): void {
+		$this->expectException( InvalidArgumentException::class );
+
+		$this->create_notification( 'store_order', -1 );
+	}
+
+	/**
+	 * Creates a concrete Notification instance for testing.
+	 *
+	 * @param string $type        The notification type.
+	 * @param int    $resource_id The resource ID.
+	 * @return Notification
+	 */
+	private function create_notification( string $type, int $resource_id ): Notification {
+		return new class( $type, $resource_id ) extends Notification {
+			/**
+			 * Returns a test payload.
+			 *
+			 * @return array
+			 */
+			public function to_payload(): array {
+				return array( 'test' => true );
+			}
+		};
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
new file mode 100644
index 0000000000..636252926a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/PendingNotificationStoreTest.php
@@ -0,0 +1,171 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the PendingNotificationStore class.
+ */
+class PendingNotificationStoreTest extends WC_Unit_Test_Case {
+
+	/**
+	 * An instance of PendingNotificationStore.
+	 *
+	 * @var PendingNotificationStore
+	 */
+	private $store;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->store = new PendingNotificationStore();
+		$this->store->register();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		remove_action( 'shutdown', array( $this->store, 'dispatch_all' ) );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should add a notification to the store.
+	 */
+	public function test_add_stores_notification(): void {
+		$notification = $this->create_notification( 'store_order', 42 );
+
+		$this->store->add( $notification );
+
+		$this->assertSame( 1, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should deduplicate notifications with the same type and resource ID.
+	 */
+	public function test_add_deduplicates_same_type_and_resource(): void {
+		$first  = $this->create_notification( 'store_order', 42 );
+		$second = $this->create_notification( 'store_order', 42 );
+
+		$this->store->add( $first );
+		$this->store->add( $second );
+
+		$this->assertSame( 1, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should store notifications with different types separately.
+	 */
+	public function test_add_allows_different_types_for_same_resource(): void {
+		$order  = $this->create_notification( 'store_order', 42 );
+		$review = $this->create_notification( 'store_review', 42 );
+
+		$this->store->add( $order );
+		$this->store->add( $review );
+
+		$this->assertSame( 2, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should store notifications with different resource IDs separately.
+	 */
+	public function test_add_allows_same_type_for_different_resources(): void {
+		$order_1 = $this->create_notification( 'store_order', 42 );
+		$order_2 = $this->create_notification( 'store_order', 43 );
+
+		$this->store->add( $order_1 );
+		$this->store->add( $order_2 );
+
+		$this->assertSame( 2, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should not add notifications when store has not been registered.
+	 */
+	public function test_add_does_nothing_when_not_registered(): void {
+		$store = new PendingNotificationStore();
+
+		$store->add( $this->create_notification( 'store_order', 42 ) );
+
+		$this->assertSame( 0, $store->count() );
+	}
+
+	/**
+	 * @testdox Should register shutdown hook only once regardless of how many notifications are added.
+	 */
+	public function test_add_registers_shutdown_hook_once(): void {
+		$this->store->add( $this->create_notification( 'store_order', 1 ) );
+		$this->store->add( $this->create_notification( 'store_order', 2 ) );
+		$this->store->add( $this->create_notification( 'store_order', 3 ) );
+
+		$hook_count = 0;
+
+		global $wp_filter;
+
+		if ( isset( $wp_filter['shutdown'] ) ) {
+			foreach ( $wp_filter['shutdown']->callbacks as $callbacks ) {
+				foreach ( $callbacks as $callback ) {
+					if ( is_array( $callback['function'] ) && $callback['function'][0] === $this->store ) {
+						++$hook_count;
+					}
+				}
+			}
+		}
+
+		$this->assertSame( 1, $hook_count, 'Shutdown hook should be registered exactly once' );
+	}
+
+	/**
+	 * @testdox Should clear pending notifications after dispatch.
+	 */
+	public function test_dispatch_all_clears_store(): void {
+		$this->store->add( $this->create_notification( 'store_order', 1 ) );
+
+		$this->store->dispatch_all();
+
+		$this->assertSame( 0, $this->store->count() );
+	}
+
+	/**
+	 * @testdox Should return all pending notifications via get_all.
+	 */
+	public function test_get_all_returns_pending_notifications(): void {
+		$this->store->add( $this->create_notification( 'store_order', 1 ) );
+		$this->store->add( $this->create_notification( 'store_review', 2 ) );
+
+		$all = $this->store->get_all();
+
+		$this->assertCount( 2, $all );
+		$this->assertSame( 1, $all[0]->get_resource_id() );
+		$this->assertSame( 2, $all[1]->get_resource_id() );
+	}
+
+	/**
+	 * Creates a concrete Notification instance for testing.
+	 *
+	 * @param string $type        The notification type.
+	 * @param int    $resource_id The resource ID.
+	 * @return Notification
+	 */
+	private function create_notification( string $type, int $resource_id ): Notification {
+		return new class( $type, $resource_id ) extends Notification {
+			/**
+			 * Returns a test payload.
+			 *
+			 * @return array
+			 */
+			public function to_payload(): array {
+				return array( 'test' => true );
+			}
+		};
+	}
+}