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