Skip to content
Open
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
55 changes: 55 additions & 0 deletions includes/persistence/class-wpdb-notification-repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,59 @@ public function purge_orphans(): int {
// TODO: Implement query.
return 0;
}

/**
* Update state timestamps for a user's notification.
*
* @param int $id Notification message ID.
* @param int $user_id ID of the notification owner.
* @param array $state State timestamp fields to update.
*
* @return int|false One when the notification exists, zero when it does not, or false on error.
*/
public function update_state( $id, $user_id, array $state ) {
global $wpdb;

$allowed_fields = array( 'displayed_at', 'dismissed_at' );
$data = array();
$formats = array();

foreach ( $allowed_fields as $field ) {
if ( array_key_exists( $field, $state ) ) {
$data[ $field ] = $state[ $field ] ? $state[ $field ]->format( 'Y-m-d H:i:s' ) : null;
$formats[] = '%s';
}
}

if ( empty( $data ) ) {
return false;
}

$table = $wpdb->prefix . 'notifications_queue';
$exists = $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The table name is generated internally.
"SELECT 1 FROM {$table} WHERE message_id = %d AND user_id = %d LIMIT 1",
$id,
$user_id
)
);

if ( null === $exists ) {
return 0;
}

$result = $wpdb->update(
$table,
$data,
array(
'message_id' => $id,
'user_id' => $user_id,
),
$formats,
array( '%d', '%d' )
);

return false === $result ? false : 1;
}
}
13 changes: 13 additions & 0 deletions includes/persistence/interface-notification-repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -148,5 +148,18 @@ public function remove( int $message_id, int $user_id ): bool;
*
* @return int Number of messages removed.
*/
public function add( Notifications\Notification $notification );

/**
* Update state timestamps for a user's notification.
*
* @param int $id Notification message ID.
* @param int $user_id ID of the notification owner.
* @param array $state State timestamp fields to update.
*
* @return int|false One when the notification exists, zero when it does not, or false on error.
*/
public function update_state( $id, $user_id, array $state );

public function purge_orphans(): int;
}
131 changes: 130 additions & 1 deletion includes/restapi/class-notification-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,28 @@

namespace WP\Notifications\REST;

use DateTimeImmutable;
use DateTimeZone;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP\Notifications\Persistence\Notification_Repository;
use WP\Notifications\Persistence\Wpdb_Notification_Repository;

/**
* REST API Notification Controller class
*/
class Notification_Controller extends WP_REST_Controller {

/**
* Notification persistence.
*
* @var Notification_Repository
*/
private $repository;

/**
* Namespace for the REST endpoint.
*
Expand All @@ -32,6 +43,15 @@ class Notification_Controller extends WP_REST_Controller {
*/
public const NOTIFICATION_BASE = 'notifications';

/**
* Create a notification controller.
*
* @param Notification_Repository|null $repository Notification persistence.
*/
public function __construct( ?Notification_Repository $repository = null ) {
$this->repository = $repository ? $repository : new Wpdb_Notification_Repository();
}

/**
* Register REST routes
*
Expand All @@ -52,6 +72,116 @@ public function register_routes(): void {
),
false
);

register_rest_route(
self::NAMESPACE,
self::NOTIFICATION_BASE . '/(?P<id>[\d]+)',
array(
array(
'methods' => 'PUT',
'args' => array(
'id' => array(
'description' => __( 'Unique identifier for the notification.' ),
'type' => 'integer',
'minimum' => 1,
),
'dismissed_at' => array(
'description' => __( 'The datetime the notification was dismissed, in UTC time.' ),
'type' => array( 'string', 'null' ),
'format' => 'date-time',
),
'displayed_at' => array(
'description' => __( 'The datetime the notification was displayed, in UTC time.' ),
'type' => array( 'string', 'null' ),
'format' => 'date-time',
),
),
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'update_item_permissions_check' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
),
false
);
}

/**
* Checks whether the request can update a notification.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return true|WP_Error True when allowed, otherwise an error.
*/
public function update_item_permissions_check( $request ) {
if ( ! is_user_logged_in() ) {
return new WP_Error(
'rest_notifications_login_required',
__( 'Sorry, you must be logged in to update notifications.' ),
array( 'status' => 401 )
);
}

return true;
}

/**
* Update a notification for the current user.
*
* @param WP_REST_Request $request Received REST request.
*
* @return WP_REST_Response|WP_Error REST response or WP Error.
*/
public function update_item( $request ) {
$state = array();

foreach ( array( 'displayed_at', 'dismissed_at' ) as $field ) {
if ( $request->has_param( $field ) ) {
$state[ $field ] = null;

if ( null !== $request[ $field ] ) {
$date = new DateTimeImmutable( $request[ $field ] );
$state[ $field ] = $date->setTimezone( new DateTimeZone( 'UTC' ) );
}
}
}

if ( empty( $state ) ) {
return new WP_Error(
'rest_notification_state_required',
__( 'At least one notification state field is required.' ),
array( 'status' => 400 )
);
}

$result = $this->repository->update_state(
$request['id'],
get_current_user_id(),
$state
);

if ( false === $result ) {
return new WP_Error(
'rest_notification_update_failed',
__( 'The notification could not be updated.' ),
array( 'status' => 500 )
);
}

if ( 0 === $result ) {
return new WP_Error(
'rest_notification_not_found',
__( 'The notification could not be found.' ),
array( 'status' => 404 )
);
}

$response = array( 'id' => (int) $request['id'] );

foreach ( $state as $field => $date ) {
$response[ $field ] = $date ? $date->format( DATE_RFC3339 ) : null;
}

return rest_ensure_response( $response );
}

/**
Expand Down Expand Up @@ -157,7 +287,6 @@ public function get_item_schema(): array {
'type' => array( 'string', 'null' ),
'format' => 'date-time',
'context' => array( 'view', 'embed' ),
'readonly' => true,
),
'expires_at' => array(
'description' => __( 'The datetime the notification expires, in UTC time.' ),
Expand Down
121 changes: 119 additions & 2 deletions tests/phpunit/tests/rest-api/test-notification-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,35 @@
* Unit tests covering WP/Notifications/Notification_Controller functionality.
*/
class WP_Test_REST_Notification_Controller extends WP_Test_REST_Controller_Testcase {
private $user_id;

public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();

require_once WP_FEATURE_NOTIFICATION_PLUGIN_DIR . '/includes/class-activator.php';
WP\Notifications\Activator::create_tables_v1();
}

public function set_up() {
parent::set_up();

global $wpdb;

$wpdb->query( "DELETE FROM {$wpdb->prefix}notifications_queue" );
$this->user_id = self::factory()->user->create();
wp_set_current_user( $this->user_id );
}

public function tear_down() {
wp_set_current_user( 0 );

parent::tear_down();
}

public function test_register_routes() {
$this->markTestSkipped( 'TODO Implement' );
$routes = rest_get_server()->get_routes();

$this->assertArrayHasKey( '/wp-notifications/v1/notifications/(?P<id>[\d]+)', $routes );
}

public function test_context_param() {
Expand All @@ -24,7 +51,97 @@ public function test_create_item() {
}

public function test_update_item() {
$this->markTestSkipped( 'TODO Implement' );
global $wpdb;

$wpdb->insert(
$wpdb->prefix . 'notifications_queue',
array(
'message_id' => 123,
'user_id' => $this->user_id,
),
array( '%d', '%d' )
);

$request = new WP_REST_Request( 'PUT', '/wp-notifications/v1/notifications/123' );
$request->set_body_params(
array(
'dismissed_at' => '2026-10-15T12:30:00+02:00',
'displayed_at' => '2026-10-15T12:00:00+02:00',
)
);
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 200, $response->get_status() );
$this->assertSame(
'2026-10-15 10:30:00',
$wpdb->get_var(
$wpdb->prepare(
"SELECT dismissed_at FROM {$wpdb->prefix}notifications_queue WHERE message_id = %d AND user_id = %d",
123,
$this->user_id
)
)
);
$this->assertSame(
'2026-10-15 10:00:00',
$wpdb->get_var(
$wpdb->prepare(
"SELECT displayed_at FROM {$wpdb->prefix}notifications_queue WHERE message_id = %d AND user_id = %d",
123,
$this->user_id
)
)
);
}

public function test_update_item_requires_a_state_field() {
$request = new WP_REST_Request( 'PUT', '/wp-notifications/v1/notifications/123' );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 400, $response->get_status() );
}

public function test_update_item_requires_authentication() {
wp_set_current_user( 0 );

$request = new WP_REST_Request( 'PUT', '/wp-notifications/v1/notifications/123' );
$request->set_body_params(
array( 'dismissed_at' => '2026-10-15T12:30:00Z' )
);
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 401, $response->get_status() );
}

public function test_update_item_does_not_update_another_users_notification() {
global $wpdb;

$other_user_id = self::factory()->user->create();
$wpdb->insert(
$wpdb->prefix . 'notifications_queue',
array(
'message_id' => 123,
'user_id' => $other_user_id,
),
array( '%d', '%d' )
);

$request = new WP_REST_Request( 'PUT', '/wp-notifications/v1/notifications/123' );
$request->set_body_params(
array( 'dismissed_at' => '2026-10-15T12:30:00Z' )
);
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 404, $response->get_status() );
$this->assertNull(
$wpdb->get_var(
$wpdb->prepare(
"SELECT dismissed_at FROM {$wpdb->prefix}notifications_queue WHERE message_id = %d AND user_id = %d",
123,
$other_user_id
)
)
);
}

public function test_delete_item() {
Expand Down
Loading