diff --git a/.github/changelog/2223-from-description b/.github/changelog/2223-from-description new file mode 100644 index 000000000..572701e6b --- /dev/null +++ b/.github/changelog/2223-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Standardized notification handling with new hooks for better extensibility and consistency. diff --git a/includes/class-notification.php b/includes/class-notification.php index 68283133c..df70f1061 100644 --- a/includes/class-notification.php +++ b/includes/class-notification.php @@ -9,6 +9,8 @@ /** * Notification class. + * + * @deprecated unreleased Use action hooks like 'activitypub_handled_{type}' instead. */ class Notification { /** @@ -48,6 +50,8 @@ class Notification { * @param int $target The WordPress User-Id. */ public function __construct( $type, $actor, $activity, $target ) { + \_deprecated_class( __CLASS__, 'unreleased', 'Use action hooks like "activitypub_handled_{type}" instead.' ); + $this->type = $type; $this->actor = $actor; $this->object = $activity; @@ -63,15 +67,19 @@ public function send() { /** * Action to send ActivityPub notifications. * + * @deprecated unreleased Use "activitypub_handled_{$type}" instead. + * * @param Notification $instance The notification object. */ - do_action( 'activitypub_notification', $this ); + \do_action_deprecated( 'activitypub_notification', array( $this ), 'unreleased', "activitypub_handled_{$type}" ); /** * Type-specific action to send ActivityPub notifications. * + * @deprecated unreleased Use "activitypub_handled_{$type}" instead. + * * @param Notification $instance The notification object. */ - do_action( "activitypub_notification_{$type}", $this ); + \do_action_deprecated( "activitypub_notification_{$type}", array( $this ), 'unreleased', "activitypub_handled_{$type}" ); } } diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 0452a1ea3..64bc16447 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -69,13 +69,13 @@ public static function add_follower( $user_id, $actor ) { /** * Remove a Follower. * - * @param int $post_id The ID of the remote Actor. - * @param int $user_id The ID of the WordPress User. + * @param \WP_Post|int $post_or_id The ID of the remote Actor. + * @param int $user_id The ID of the WordPress User. * * @return bool True on success, false on failure. */ - public static function remove( $post_id, $user_id ) { - $post = \get_post( $post_id ); + public static function remove( $post_or_id, $user_id ) { + $post = \get_post( $post_or_id ); if ( ! $post ) { return false; @@ -93,7 +93,7 @@ public static function remove( $post_id, $user_id ) { */ \do_action( 'activitypub_followers_pre_remove_follower', $post, $user_id, Remote_Actors::get_actor( $post ) ); - return \delete_post_meta( $post_id, self::FOLLOWER_META_KEY, $user_id ); + return \delete_post_meta( $post->ID, self::FOLLOWER_META_KEY, $user_id ); } /** diff --git a/includes/collection/class-interactions.php b/includes/collection/class-interactions.php index 68be8a12e..f532ecf00 100644 --- a/includes/collection/class-interactions.php +++ b/includes/collection/class-interactions.php @@ -89,7 +89,7 @@ public static function update_comment( $activity ) { * * @param array $activity Activity array. * - * @return array|false Comment data or `false` on failure. + * @return array|string|int|\WP_Error|false Comment data or `false` on failure. */ public static function add_reaction( $activity ) { $url = object_to_uri( $activity['object'] ); diff --git a/includes/handler/class-accept.php b/includes/handler/class-accept.php index a42d42835..fbe33da8e 100644 --- a/includes/handler/class-accept.php +++ b/includes/handler/class-accept.php @@ -10,7 +10,6 @@ use Activitypub\Collection\Following; use Activitypub\Collection\Outbox; use Activitypub\Collection\Remote_Actors; -use Activitypub\Notification; use function Activitypub\object_to_uri; @@ -22,19 +21,8 @@ class Accept { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( - 'activitypub_inbox_accept', - array( self::class, 'handle_accept' ), - 10, - 2 - ); - - \add_filter( - 'activitypub_validate_object', - array( self::class, 'validate_object' ), - 10, - 3 - ); + \add_action( 'activitypub_inbox_accept', array( self::class, 'handle_accept' ), 10, 2 ); + \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); } /** @@ -60,16 +48,18 @@ public static function handle_accept( $accept, $user_id ) { return; } - Following::accept( $actor_post, $user_id ); - - // Send notification. - $notification = new Notification( - 'accept', - $actor_post->guid, - $accept, - $user_id - ); - $notification->send(); + $result = Following::accept( $actor_post, $user_id ); + $success = ! \is_wp_error( $result ); + + /** + * Fires after an ActivityPub Accept activity has been handled. + * + * @param array $accept The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param \WP_Post|\WP_Error $result The remote actor post or error. + */ + \do_action( 'activitypub_handled_accept', $accept, $user_id, $success, $result ); } /** diff --git a/includes/handler/class-announce.php b/includes/handler/class-announce.php index 06b6b3c4d..58b8d79dd 100644 --- a/includes/handler/class-announce.php +++ b/includes/handler/class-announce.php @@ -23,12 +23,7 @@ class Announce { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( - 'activitypub_inbox_announce', - array( self::class, 'handle_announce' ), - 10, - 3 - ); + \add_action( 'activitypub_inbox_announce', array( self::class, 'handle_announce' ), 10, 3 ); } /** @@ -106,21 +101,22 @@ public static function maybe_save_announce( $activity, $user_id ) { return; } - $state = Interactions::add_reaction( $activity ); - $reaction = null; + $success = false; + $result = Interactions::add_reaction( $activity ); - if ( $state && ! is_wp_error( $state ) ) { - $reaction = get_comment( $state ); + if ( $result && ! is_wp_error( $result ) ) { + $success = true; + $result = get_comment( $result ); } /** - * Fires after an Announce has been saved. + * Fires after an ActivityPub Announce activity has been handled. * - * @param array $activity The activity-object. - * @param int $user_id The id of the local blog-user. - * @param mixed $state The state of the reaction. - * @param mixed $reaction The reaction. + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param array|string|int|\WP_Error|false $result The WP_Comment object of the created announce/repost comment, or null if creation failed. */ - do_action( 'activitypub_handled_announce', $activity, $user_id, $state, $reaction ); + \do_action( 'activitypub_handled_announce', $activity, $user_id, $success, $result ); } } diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index d2344ea19..255307908 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -61,22 +61,23 @@ public static function handle_create( $activity, $user_id, $activity_object = nu return; } - $state = Interactions::add_comment( $activity ); - $reaction = null; + $success = false; + $result = Interactions::add_comment( $activity ); - if ( $state && ! \is_wp_error( $state ) ) { - $reaction = \get_comment( $state ); + if ( $result && ! \is_wp_error( $result ) ) { + $success = true; + $result = \get_comment( $result ); } /** - * Fires after a Create activity has been handled. + * Fires after an ActivityPub Create activity has been handled. * - * @param array $activity The activity-object. - * @param int $user_id The id of the local blog-user. - * @param \WP_Comment|\WP_Error $state The comment object or WP_Error. - * @param \WP_Comment|\WP_Error|null $reaction The reaction object or WP_Error. + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param array|string|int|\WP_Error|false $result The WP_Comment object of the created comment, or null if creation failed. */ - \do_action( 'activitypub_handled_create', $activity, $user_id, $state, $reaction ); + \do_action( 'activitypub_handled_create', $activity, $user_id, $success, $result ); } /** diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php index 48439a077..3c58e68ad 100644 --- a/includes/handler/class-delete.php +++ b/includes/handler/class-delete.php @@ -21,7 +21,7 @@ class Delete { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ) ); + \add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ), 10, 2 ); \add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 ); \add_action( 'activitypub_delete_actor_interactions', array( self::class, 'delete_interactions' ) ); @@ -33,9 +33,12 @@ public static function init() { * Handles "Delete" requests. * * @param array $activity The delete activity. + * @param int $user_id The local user ID. */ - public static function handle_delete( $activity ) { + public static function handle_delete( $activity, $user_id ) { $object_type = $activity['object']['type'] ?? ''; + $success = false; + $result = null; switch ( $object_type ) { /* @@ -48,7 +51,7 @@ public static function handle_delete( $activity ) { case 'Organization': case 'Service': case 'Application': - self::maybe_delete_follower( $activity ); + $result = self::maybe_delete_follower( $activity ); break; /* @@ -63,7 +66,7 @@ public static function handle_delete( $activity ) { case 'Video': case 'Event': case 'Document': - self::maybe_delete_interaction( $activity ); + $result = self::maybe_delete_interaction( $activity ); break; /* @@ -72,7 +75,7 @@ public static function handle_delete( $activity ) { * @see: https://www.w3.org/TR/activitystreams-vocabulary/#dfn-tombstone */ case 'Tombstone': - self::maybe_delete_interaction( $activity ); + $result = self::maybe_delete_interaction( $activity ); break; /* @@ -88,34 +91,52 @@ public static function handle_delete( $activity ) { // Check if Object is an Actor. if ( $activity['actor'] === $activity['object'] ) { - self::maybe_delete_follower( $activity ); + $result = self::maybe_delete_follower( $activity ); } else { // Assume an interaction otherwise. - self::maybe_delete_interaction( $activity ); + $result = self::maybe_delete_interaction( $activity ); } // Maybe handle Delete Activity for other Object Types. break; } + + $success = (bool) $result; + + /** + * Fires after an ActivityPub Delete activity has been handled. + * + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param mixed|null $result The result of the delete operation (e.g., WP_Comment object or deletion status). + */ + \do_action( 'activitypub_handled_delete', $activity, $user_id, $success, $result ); } /** * Delete a Follower if Actor-URL is a Tombstone. * * @param array $activity The delete activity. + * + * @return bool True on success, false otherwise. */ public static function maybe_delete_follower( $activity ) { $follower = Remote_Actors::get_by_uri( $activity['actor'] ); // Verify that Actor is deleted. if ( ! is_wp_error( $follower ) && Tombstone::exists( $activity['actor'] ) ) { - Remote_Actors::delete( $follower->ID ); + $state = Remote_Actors::delete( $follower->ID ); self::maybe_delete_interactions( $activity ); } + + return $state ?? false; } /** * Delete Reactions if Actor-URL is a Tombstone. * * @param array $activity The delete activity. + * + * @return bool True on success, false otherwise. */ public static function maybe_delete_interactions( $activity ) { // Verify that Actor is deleted. @@ -125,13 +146,19 @@ public static function maybe_delete_interactions( $activity ) { 'activitypub_delete_actor_interactions', array( $activity['actor'] ) ); + + return true; } + + return false; } /** * Delete comments from an Actor. * * @param string $actor The URL of the actor whose comments to delete. + * + * @return bool True on success, false otherwise. */ public static function delete_interactions( $actor ) { $comments = Interactions::get_interactions_by_actor( $actor ); @@ -139,12 +166,20 @@ public static function delete_interactions( $actor ) { foreach ( $comments as $comment ) { wp_delete_comment( $comment, true ); } + + if ( $comments ) { + return true; + } else { + return false; + } } /** * Delete a Reaction if URL is a Tombstone. * * @param array $activity The delete activity. + * + * @return bool True on success, false otherwise. */ public static function maybe_delete_interaction( $activity ) { if ( is_array( $activity['object'] ) ) { @@ -159,7 +194,11 @@ public static function maybe_delete_interaction( $activity ) { foreach ( $comments as $comment ) { wp_delete_comment( $comment->comment_ID, true ); } + + return true; } + + return false; } /** diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index ba1ecba25..1c8478f7d 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -10,7 +10,6 @@ use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; -use Activitypub\Notification; use function Activitypub\add_to_outbox; @@ -23,7 +22,7 @@ class Follow { */ public static function init() { \add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 ); - \add_action( 'activitypub_followers_post_follow', array( self::class, 'queue_accept' ), 10, 4 ); + \add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 ); } /** @@ -44,46 +43,51 @@ public static function handle_follow( $activity, $user_id ) { $activity['actor'] ); - if ( \is_wp_error( $remote_actor ) ) { - return $remote_actor; - } + $success = ! \is_wp_error( $remote_actor ); - $remote_actor = \get_post( $remote_actor ); + if ( ! \is_wp_error( $remote_actor ) ) { + $remote_actor = \get_post( $remote_actor ); + } /** * Fires after a new follower has been added. * + * @deprecated unreleased Use "activitypub_handled_follow" instead. + * * @param string $actor The URL of the actor (follower) who initiated the follow. * @param array $activity The complete activity data of the follow request. * @param int $user_id The ID of the WordPress user being followed. * @param \WP_Post|\WP_Error $remote_actor The Actor object containing the new follower's data. */ - do_action( 'activitypub_followers_post_follow', $activity['actor'], $activity, $user_id, $remote_actor ); + \do_action_deprecated( 'activitypub_followers_post_follow', array( $activity['actor'], $activity, $user_id, $remote_actor ), 'unreleased', 'activitypub_handled_follow' ); - // Send notification. - $notification = new Notification( - 'follow', - $remote_actor->guid, - $activity, - $user_id - ); - $notification->send(); + /** + * Fires after a Follow activity has been handled. + * + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param \WP_Post|\WP_Error $remote_actor The remote actor/follower, or WP_Error if failed. + */ + \do_action( 'activitypub_handled_follow', $activity, $user_id, $success, $remote_actor ); } /** * Send Accept response. * - * @param string $actor The Actor URL. - * @param array $activity_object The Activity object. - * @param int $user_id The ID of the WordPress User. - * @param \WP_Post|\WP_Error $remote_actor The Actor object. + * @param array $activity_object The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param \WP_Post|\WP_Error $remote_actor The remote actor/follower, or WP_Error if failed. */ - public static function queue_accept( $actor, $activity_object, $user_id, $remote_actor ) { + public static function queue_accept( $activity_object, $user_id, $success, $remote_actor ) { if ( \is_wp_error( $remote_actor ) ) { // Impossible to send a "Reject" because we can not get the Remote-Inbox. return; } + $actor = $activity_object['actor']; + // Only send minimal data. $activity_object = array_intersect_key( $activity_object, diff --git a/includes/handler/class-inbox.php b/includes/handler/class-inbox.php index c84b92166..ea1207afe 100644 --- a/includes/handler/class-inbox.php +++ b/includes/handler/class-inbox.php @@ -34,6 +34,8 @@ public static function init() { * @param Activity|\WP_Error $activity The Activity object. */ public static function handle_inbox_requests( $data, $user_id, $type, $activity ) { + $success = true; + /** * Filters the activity types to persist in the inbox. * @@ -43,31 +45,37 @@ public static function handle_inbox_requests( $data, $user_id, $type, $activity $activity_types = \array_map( 'strtolower', $activity_types ); if ( ! \in_array( \strtolower( $type ), $activity_types, true ) ) { - return; + $success = false; + $id = new \WP_Error( 'activitypub_inbox_ignored', 'Activity type not configured to be persisted in inbox.' ); } - /** - * Filters the object types to persist in the inbox. - * - * @param array $object_types The object types to persist in the inbox. - */ - $object_types = \apply_filters( 'activitypub_persist_inbox_object_types', Base_Object::TYPES ); - $object_types = \array_map( 'strtolower', $object_types ); + if ( $success ) { + /** + * Filters the object types to persist in the inbox. + * + * @param array $object_types The object types to persist in the inbox. + */ + $object_types = \apply_filters( 'activitypub_persist_inbox_object_types', Base_Object::TYPES ); + $object_types = \array_map( 'strtolower', $object_types ); - if ( isset( $data['object']['type'] ) && ! \in_array( \strtolower( $data['object']['type'] ), $object_types, true ) ) { - return; + if ( isset( $data['object']['type'] ) && ! \in_array( \strtolower( $data['object']['type'] ), $object_types, true ) ) { + $success = false; + $id = new \WP_Error( 'activitypub_inbox_ignored', 'Activity type not configured to be persisted in inbox.' ); + } } - $id = Inbox_Collection::add( $activity, $user_id ); + if ( $success ) { + $id = Inbox_Collection::add( $activity, $user_id ); + } /** - * Fires after an inbox item has been handled. + * Fires after an ActivityPub Inbox activity has been handled. * - * @param array $data The data array. - * @param int $user_id The ID of the local blog user. - * @param \WP_Error|int $id The ID of the inbox item. - * @param Activity|\WP_Error $activity The Activity object. + * @param array $data The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param \WP_Error|int $id The ID of the inbox item that was created, or WP_Error if failed. */ - \do_action( 'activitypub_handled_inbox', $data, $user_id, $id, $activity ); + \do_action( 'activitypub_handled_inbox', $data, $user_id, $success, $id ); } } diff --git a/includes/handler/class-like.php b/includes/handler/class-like.php index dbd66f956..0288133b7 100644 --- a/includes/handler/class-like.php +++ b/includes/handler/class-like.php @@ -46,22 +46,23 @@ public static function handle_like( $like, $user_id ) { return; } - $state = Interactions::add_reaction( $like ); - $reaction = null; + $success = false; + $result = Interactions::add_reaction( $like ); - if ( $state && ! is_wp_error( $state ) ) { - $reaction = get_comment( $state ); + if ( $result && ! is_wp_error( $result ) ) { + $success = true; + $result = get_comment( $result ); } /** - * Fires after a Like has been handled. + * Fires after an ActivityPub Like activity has been handled. * - * @param array $like The Activity array. - * @param int $user_id The ID of the local blog user. - * @param mixed $state The state of the reaction. - * @param mixed $reaction The reaction object. + * @param array $like The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param array|false|int|string|\WP_Comment|\WP_Error $result The WP_Comment object of the created like comment, or null if creation failed. */ - do_action( 'activitypub_handled_like', $like, $user_id, $state, $reaction ); + \do_action( 'activitypub_handled_like', $like, $user_id, $success, $result ); } /** diff --git a/includes/handler/class-move.php b/includes/handler/class-move.php index a1073c28c..d2aa948b9 100644 --- a/includes/handler/class-move.php +++ b/includes/handler/class-move.php @@ -25,7 +25,7 @@ class Move { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( 'activitypub_inbox_move', array( self::class, 'handle_move' ) ); + \add_action( 'activitypub_inbox_move', array( self::class, 'handle_move' ), 10, 2 ); \add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) ); } @@ -33,8 +33,9 @@ public static function init() { * Handle Move requests. * * @param array $activity The JSON "Move" Activity. + * @param int $user_id The local user ID. */ - public static function handle_move( $activity ) { + public static function handle_move( $activity, $user_id ) { $target_uri = self::extract_target( $activity ); $origin_uri = self::extract_origin( $activity ); @@ -53,20 +54,16 @@ public static function handle_move( $activity ) { $target_object = Remote_Actors::get_by_uri( $target_uri ); $origin_object = Remote_Actors::get_by_uri( $origin_uri ); + $result = null; + $success = false; /* * If the new target is followed, but the origin is not, * everything is fine, so we can return. */ if ( ! \is_wp_error( $target_object ) && \is_wp_error( $origin_object ) ) { - return; - } - - /* - * If the new target is not followed, but the origin is, - * update the origin follower to the new target. - */ - if ( \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) { + $success = false; + } elseif ( \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $wpdb->update( @@ -78,16 +75,9 @@ public static function handle_move( $activity ) { // Clear the cache. \wp_cache_delete( $origin_object->ID, 'posts' ); - Remote_Actors::upsert( $target_json ); - - return; - } - - /* - * If the new target is followed, and the origin is followed, - * move users and delete the origin follower. - */ - if ( ! \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) { + $success = true; + $result = Remote_Actors::upsert( $target_json ); + } elseif ( ! \is_wp_error( $target_object ) && ! \is_wp_error( $origin_object ) ) { $origin_users = \get_post_meta( $origin_object->ID, Followers::FOLLOWER_META_KEY, false ); $target_users = \get_post_meta( $target_object->ID, Followers::FOLLOWER_META_KEY, false ); @@ -98,8 +88,19 @@ public static function handle_move( $activity ) { \add_post_meta( $target_object->ID, Followers::FOLLOWER_META_KEY, $user_id ); } - \wp_delete_post( $origin_object->ID ); + $success = true; + $result = \wp_delete_post( $origin_object->ID ); } + + /** + * Fires after an ActivityPub Move activity has been handled. + * + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID, or null if not applicable. + * @param bool $success True on success, false otherwise. + * @param mixed $result The result of the operation (e.g., post ID, WP_Error, or status). + */ + \do_action( 'activitypub_handled_move', $activity, $user_id, $success, $result ); } /** diff --git a/includes/handler/class-reject.php b/includes/handler/class-reject.php index 33ae7421d..3f5d51795 100644 --- a/includes/handler/class-reject.php +++ b/includes/handler/class-reject.php @@ -10,7 +10,6 @@ use Activitypub\Collection\Following; use Activitypub\Collection\Outbox; use Activitypub\Collection\Remote_Actors; -use Activitypub\Notification; use function Activitypub\object_to_uri; @@ -22,19 +21,8 @@ class Reject { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( - 'activitypub_inbox_reject', - array( self::class, 'handle_reject' ), - 10, - 2 - ); - - \add_filter( - 'activitypub_validate_object', - array( self::class, 'validate_object' ), - 10, - 3 - ); + \add_action( 'activitypub_inbox_reject', array( self::class, 'handle_reject' ), 10, 2 ); + \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); } /** @@ -75,16 +63,18 @@ private static function reject_follow( $reject, $user_id ) { return; } - Following::reject( $actor_post, $user_id ); - - // Send notification. - $notification = new Notification( - 'reject', - $actor_post->guid, - $reject, - $user_id - ); - $notification->send(); + $result = Following::reject( $actor_post, $user_id ); + $success = ! \is_wp_error( $result ); + + /** + * Fires after an ActivityPub Reject activity has been handled. + * + * @param array $reject The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param \WP_Post|\WP_Error $result Actor post on success, WP_Error on failure. + */ + \do_action( 'activitypub_handled_reject', $reject, $user_id, $success, $result ); } /** diff --git a/includes/handler/class-undo.php b/includes/handler/class-undo.php index 1e2759180..45433cc25 100644 --- a/includes/handler/class-undo.php +++ b/includes/handler/class-undo.php @@ -9,6 +9,7 @@ use Activitypub\Collection\Actors; use Activitypub\Collection\Followers; +use Activitypub\Collection\Remote_Actors; use Activitypub\Comment; use function Activitypub\object_to_uri; @@ -21,12 +22,8 @@ class Undo { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( - 'activitypub_inbox_undo', - array( self::class, 'handle_undo' ), - 10, - 2 - ); + \add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo' ), 10, 2 ); + \add_action( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); } /** @@ -36,52 +33,91 @@ public static function init() { * @param int|null $user_id The ID of the user who initiated the "Undo" activity. */ public static function handle_undo( $activity, $user_id ) { - if ( - ! isset( $activity['object']['type'] ) || - ! isset( $activity['object']['object'] ) - ) { - return; - } - - $type = $activity['object']['type']; - $state = false; + $type = $activity['object']['type']; + $success = false; + $result = null; // Handle "Unfollow" requests. if ( 'Follow' === $type ) { $user_id = Actors::get_id_by_resource( object_to_uri( $activity['object']['object'] ) ); - if ( \is_wp_error( $user_id ) ) { - // If we can not find a user, we can not initiate a follow process. - return; - } + if ( ! \is_wp_error( $user_id ) ) { + $post = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) ); - $actor = object_to_uri( $activity['actor'] ); - $state = Followers::remove_follower( $user_id, $actor ); + if ( ! \is_wp_error( $post ) ) { + $success = Followers::remove( $post, $user_id ); + } + } } // Handle "Undo" requests for "Like" and "Create" activities. if ( in_array( $type, array( 'Like', 'Create', 'Announce' ), true ) ) { - if ( ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) { - return; - } + if ( ! ACTIVITYPUB_DISABLE_INCOMING_INTERACTIONS ) { + $object_id = object_to_uri( $activity['object'] ); + $result = Comment::object_id_to_comment( esc_url_raw( $object_id ) ); - $object_id = object_to_uri( $activity['object'] ); - $comment = Comment::object_id_to_comment( esc_url_raw( $object_id ) ); - - if ( empty( $comment ) ) { - return; + if ( empty( $result ) ) { + $success = false; + } else { + $success = \wp_delete_comment( $result, true ); + } } - - $state = wp_delete_comment( $comment, true ); } /** - * Fires after an "Undo" activity has been handled. + * Fires after an ActivityPub Undo activity has been handled. * - * @param array $activity The JSON "Undo" Activity. - * @param int|null $user_id The ID of the user who initiated the "Undo" activity otherwise null. - * @param mixed $state The state of the "Undo" activity. + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false on failure. + * @param \WP_Comment|string $result The target, based on the activity that is being undone. */ - do_action( 'activitypub_handled_undo', $activity, $user_id, $state ); + \do_action( 'activitypub_handled_undo', $activity, $user_id, $success, $result ); + } + + /** + * Validate the object. + * + * @param bool $valid The validation state. + * @param string $param The object parameter. + * @param \WP_REST_Request $request The request object. + * + * @return bool The validation state: true if valid, false if not. + */ + public static function validate_object( $valid, $param, $request ) { + $json_params = $request->get_json_params(); + + if ( empty( $json_params['type'] ) ) { + return false; + } + + if ( + 'Undo' !== $json_params['type'] || + \is_wp_error( $request ) + ) { + return $valid; + } + + $required_attributes = array( + 'actor', + 'object', + ); + + if ( ! empty( \array_diff( $required_attributes, \array_keys( $json_params ) ) ) ) { + return false; + } + + $required_object_attributes = array( + 'id', + 'type', + 'actor', + 'object', + ); + + if ( ! empty( \array_diff( $required_object_attributes, \array_keys( $json_params['object'] ) ) ) ) { + return false; + } + + return $valid; } } diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php index e14ad9f28..7ff72ac2a 100644 --- a/includes/handler/class-update.php +++ b/includes/handler/class-update.php @@ -20,18 +20,16 @@ class Update { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( - 'activitypub_inbox_update', - array( self::class, 'handle_update' ) - ); + \add_action( 'activitypub_inbox_update', array( self::class, 'handle_update' ), 10, 2 ); } /** * Handle "Update" requests. * * @param array $activity The Activity object. + * @param int $user_id The user ID. Always null for Update activities. */ - public static function handle_update( $activity ) { + public static function handle_update( $activity, $user_id ) { $object_type = $activity['object']['type'] ?? ''; switch ( $object_type ) { @@ -45,7 +43,7 @@ public static function handle_update( $activity ) { case 'Organization': case 'Service': case 'Application': - self::update_actor( $activity ); + self::update_actor( $activity, $user_id ); break; /* @@ -60,7 +58,7 @@ public static function handle_update( $activity ) { case 'Video': case 'Event': case 'Document': - self::update_interaction( $activity ); + self::update_interaction( $activity, $user_id ); break; /* @@ -77,35 +75,37 @@ public static function handle_update( $activity ) { * Update an Interaction. * * @param array $activity The Activity object. + * @param int $user_id The user ID. Always null for Update activities. */ - public static function update_interaction( $activity ) { + public static function update_interaction( $activity, $user_id ) { $comment_data = Interactions::update_comment( $activity ); - $reaction = null; + $success = false; if ( ! empty( $comment_data['comment_ID'] ) ) { - $state = 1; - $reaction = \get_comment( $comment_data['comment_ID'] ); + $success = true; + $result = \get_comment( $comment_data['comment_ID'] ); } else { - $state = $comment_data; + $result = $comment_data; } /** - * Fires after an Update activity has been handled. + * Fires after an ActivityPub Update activity has been handled. * - * @param array $activity The complete Update activity data. - * @param null $user Always null for Update activities. - * @param int|array $state 1 if comment was updated successfully, error data otherwise. - * @param \WP_Comment|null $reaction The updated comment object if successful, null otherwise. + * @param array $activity The ActivityPub activity data. + * @param int $user_id The local user ID. + * @param bool $success True on success, false otherwise. + * @param array|string|int|\WP_Error|false $result The updated comment, or null if update failed. */ - \do_action( 'activitypub_handled_update', $activity, null, $state, $reaction ); + \do_action( 'activitypub_handled_update', $activity, $user_id, $success, $result ); } /** * Update an Actor. * * @param array $activity The Activity object. + * @param int $user_id The user ID. Always null for Update activities. */ - public static function update_actor( $activity ) { + public static function update_actor( $activity, $user_id ) { // Update cache. $actor = get_remote_metadata_by_actor( $activity['actor'], false ); @@ -113,6 +113,16 @@ public static function update_actor( $activity ) { return; } - Remote_Actors::upsert( $actor ); + $state = Remote_Actors::upsert( $actor ); + + /** + * Fires after an ActivityPub Update activity has been handled. + * + * @param array $activity The ActivityPub activity data. + * @param int. $user_id The local user ID. + * @param int|\WP_Error $state Actor post ID on success, WP_Error on failure. + * @param array $actor Remote actor meta data. + */ + \do_action( 'activitypub_handled_update', $activity, $user_id, $state, $actor ); } } diff --git a/integration/class-stream-connector.php b/integration/class-stream-connector.php index a2504fe44..a99f684e6 100644 --- a/integration/class-stream-connector.php +++ b/integration/class-stream-connector.php @@ -33,7 +33,7 @@ class Stream_Connector extends \WP_Stream\Connector { * @var array */ public $actions = array( - 'activitypub_notification_follow', + 'activitypub_handled_follow', 'activitypub_sent_to_inbox', 'activitypub_outbox_processing_complete', 'activitypub_outbox_processing_batch_complete', @@ -109,24 +109,30 @@ public function action_links( $links, $record ) { } /** - * Callback for activitypub_notification_follow. + * Callback for activitypub_handled_follow. * - * @param \Activitypub\Notification $notification The notification object. + * @param array $activity The ActivityPub activity data. + * @param int|null $user_id The local user ID, or null if not applicable. + * @param mixed $state Status or WP_Error object indicating the result of the follow handling. + * @param \WP_Post|null $context The WP_Post object representing the remote actor/follower. */ - public function callback_activitypub_notification_follow( $notification ) { + public function callback_activitypub_handled_follow( $activity, $user_id, $state, $context ) { + $actor_url = \is_object( $context ) && ! \is_wp_error( $context ) ? $context->guid : $activity['actor']; + $this->log( - sprintf( + \sprintf( // translators: %s is a URL. - __( 'New Follower: %s', 'activitypub' ), - $notification->actor + \__( 'New Follower: %s', 'activitypub' ), + $actor_url ), array( - 'notification' => \wp_json_encode( $notification, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), + 'activity' => \wp_json_encode( $activity, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), + 'remote_actor' => \wp_json_encode( $context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), ), null, 'notification', - $notification->type, - $notification->target + 'follow', + $user_id ); } diff --git a/tests/includes/class-test-functions.php b/tests/includes/class-test-functions.php index 96e37d6da..2cdee032c 100644 --- a/tests/includes/class-test-functions.php +++ b/tests/includes/class-test-functions.php @@ -749,6 +749,7 @@ public function test_follow() { array( 'post_type' => \Activitypub\Collection\Outbox::POST_TYPE, 'post_status' => 'any', + 'author' => $user_id, ) ); diff --git a/tests/includes/handler/class-test-accept.php b/tests/includes/handler/class-test-accept.php index ede0d035a..c8cb39a43 100644 --- a/tests/includes/handler/class-test-accept.php +++ b/tests/includes/handler/class-test-accept.php @@ -46,63 +46,79 @@ public static function wpTearDownAfterClass() { } /** - * Test validate_object returns false if type is missing. + * Test validate_object with various scenarios. * + * @dataProvider validate_object_provider * @covers ::validate_object - */ - public function test_validate_object_missing_type() { - $request = $this->createMock( 'WP_REST_Request' ); - $request->method( 'get_json_params' )->willReturn( array() ); - $this->assertFalse( Accept::validate_object( true, 'param', $request ) ); - } - - /** - * Test validate_object returns true if type is not Accept. * - * @covers ::validate_object + * @param array $request_data The request data to test. + * @param bool $input_valid The input valid state. + * @param bool $expected_result The expected validation result. + * @param string $description Description of the test case. */ - public function test_validate_object_type_not_accept() { + public function test_validate_object( $request_data, $input_valid, $expected_result, $description ) { $request = $this->createMock( 'WP_REST_Request' ); - $request->method( 'get_json_params' )->willReturn( array( 'type' => 'Follow' ) ); - $this->assertTrue( Accept::validate_object( true, 'param', $request ) ); - } + $request->method( 'get_json_params' )->willReturn( $request_data ); - /** - * Test validate_object returns false if required fields are missing. - * - * @covers ::validate_object - */ - public function test_validate_object_missing_required_fields() { - $request = $this->createMock( 'WP_REST_Request' ); - $request->method( 'get_json_params' )->willReturn( - array( - 'type' => 'Accept', - 'actor' => 'foo', - ) - ); - $this->assertFalse( Accept::validate_object( true, 'param', $request ) ); + $result = Accept::validate_object( $input_valid, 'param', $request ); + + $this->assertEquals( $expected_result, $result, $description ); } /** - * Test validate_object returns true if all checks pass. + * Data provider for validate_object tests. * - * @covers ::validate_object + * @return array Test cases with request data, input valid state, expected result, and description. */ - public function test_validate_object_success() { - $request = $this->createMock( 'WP_REST_Request' ); - $request->method( 'get_json_params' )->willReturn( - array( - 'type' => 'Accept', - 'actor' => 'foo', - 'object' => array( - 'id' => 'bar', + public function validate_object_provider() { + return array( + // Invalid cases. + 'missing_type' => array( + array(), + true, + false, + 'Should return false when type is missing', + ), + 'missing_required_fields' => array( + array( + 'type' => 'Accept', + 'actor' => 'foo', + ), + true, + false, + 'Should return false when required fields are missing', + ), + // Valid cases - non-Accept type should pass through. + 'type_not_accept' => array( + array( 'type' => 'Follow' ), + true, + true, + 'Should return true when type is not Accept', + ), + // Valid Accept activity. + 'valid_accept_activity' => array( + array( + 'type' => 'Accept', 'actor' => 'foo', - 'type' => 'Follow', - 'object' => 'foo', + 'object' => array( + 'id' => 'bar', + 'actor' => 'foo', + 'type' => 'Follow', + 'object' => 'foo', + ), ), - ) + true, + true, + 'Should return true for valid Accept activity', + ), + // Test with input_valid false. + 'input_valid_false' => array( + array( 'type' => 'Follow' ), + false, + false, + 'Should preserve input_valid when type is not Accept', + ), ); - $this->assertTrue( Accept::validate_object( true, 'param', $request ) ); } /** diff --git a/tests/includes/handler/class-test-follow.php b/tests/includes/handler/class-test-follow.php index 35a4b6b27..dbffa9067 100644 --- a/tests/includes/handler/class-test-follow.php +++ b/tests/includes/handler/class-test-follow.php @@ -38,6 +38,24 @@ public static function wpSetUpBeforeClass( $factory ) { ); } + /** + * Clean up after each test. + */ + public function tear_down() { + // Clean up any outbox posts. + _delete_all_posts(); + + // Remove any HTTP mocking filters. + \remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + \remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + + // Remove action hooks. + \remove_all_actions( 'activitypub_followers_post_follow' ); + \remove_all_actions( 'activitypub_handled_follow' ); + + parent::tear_down(); + } + /** * Clean up after tests. */ @@ -46,53 +64,114 @@ public static function wpTearDownAfterClass() { } /** - * Test handle_follow method. + * Test handle_follow method with different scenarios. * + * @dataProvider handle_follow_provider * @covers ::handle_follow + * + * @param mixed $target_user_id The user ID being followed (int or 'test_user'). + * @param string $actor_url The actor URL following. + * @param string $expected_response Expected response type ('Accept', 'Reject', or 'none'). + * @param bool $should_add_follower Whether follower should be added. + * @param string $description Description of the test case. */ - public function test_handle_follow() { - $local_actor = Actors::get_by_id( Actors::APPLICATION_USER_ID ); - $actor = 'https://example.com/actor'; + public function test_handle_follow( $target_user_id, $actor_url, $expected_response, $should_add_follower, $description ) { + // Resolve user ID if needed. + if ( 'test_user' === $target_user_id ) { + $target_user_id = self::$user_id; + } + // Mock HTTP requests for actor metadata if needed. + if ( $should_add_follower ) { + \add_filter( + 'pre_get_remote_metadata_by_actor', + function () use ( $actor_url ) { + return array( + 'id' => $actor_url, + 'actor' => $actor_url, + 'type' => 'Person', + 'inbox' => str_replace( '/actor', '/inbox', $actor_url ), + ); + } + ); + } + + $local_actor = Actors::get_by_id( $target_user_id ); $activity_object = array( - 'id' => 'https://example.com/activity/123', + 'id' => $actor_url . '/activity/123', 'type' => 'Follow', - 'actor' => $actor, + 'actor' => $actor_url, 'object' => $local_actor->get_id(), ); - Follow::handle_follow( $activity_object, Actors::APPLICATION_USER_ID ); + // Track followers count before. + $followers_before = Followers::get_followers( $target_user_id ); + $followers_count_before = count( $followers_before ); - $outbox_posts = \get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'pending', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - array( - 'key' => '_activitypub_activity_type', - 'value' => 'Accept', - ), - ), - ) - ); - $this->assertEmpty( $outbox_posts ); + Follow::handle_follow( $activity_object, $target_user_id ); - $outbox_posts = \get_posts( - array( - 'post_type' => Outbox::POST_TYPE, - 'post_status' => 'pending', - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - array( - 'key' => '_activitypub_activity_type', - 'value' => 'Reject', + // Check if follower was added. + if ( $should_add_follower ) { + $followers_after = Followers::get_followers( $target_user_id ); + $followers_count_after = count( $followers_after ); + $this->assertEquals( $followers_count_before + 1, $followers_count_after, $description . ' - Follower should be added' ); + } else { + $followers_after = Followers::get_followers( $target_user_id ); + $followers_count_after = count( $followers_after ); + $this->assertEquals( $followers_count_before, $followers_count_after, $description . ' - Follower should not be added' ); + } + + // Check outbox for expected response. + if ( 'none' !== $expected_response ) { + $outbox_posts = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => $expected_response, + ), ), - ), - ) - ); - $this->assertNotEmpty( $outbox_posts ); + ) + ); + $this->assertNotEmpty( $outbox_posts, $description . ' - Should create ' . $expected_response . ' response' ); + } + // Clean up. _delete_all_posts(); + \remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + } + + /** + * Data provider for handle_follow tests. + * + * @return array Test cases with user ID, actor URL, expected response, should add follower, and description. + */ + public function handle_follow_provider() { + return array( + 'application_user_follow' => array( + Actors::APPLICATION_USER_ID, + 'https://example.com/actor', + 'Reject', + false, + 'Following application user should be rejected', + ), + 'regular_user_follow' => array( + 'test_user', + 'https://example.com/regular-actor', + 'Accept', + true, + 'Following regular user should be accepted', + ), + 'subdomain_actor_follow' => array( + 'test_user', + 'https://social.example.com/users/actor', + 'Accept', + true, + 'Following with subdomain actor should work', + ), + ); } /** @@ -112,7 +191,7 @@ public function test_queue_accept() { // Test with WP_Error follower - should not create outbox entry. $wp_error = new \WP_Error( 'test_error', 'Test Error' ); - Follow::queue_accept( $actor, $activity_object, self::$user_id, $wp_error ); + Follow::queue_accept( $activity_object, self::$user_id, true, $wp_error ); $outbox_posts = \get_posts( array( @@ -148,7 +227,7 @@ function () use ( $actor ) { ); $remote_actor = \get_post( $remote_actor ); - Follow::queue_accept( $actor, $activity_object, self::$user_id, $remote_actor ); + Follow::queue_accept( $activity_object, self::$user_id, $remote_actor, $remote_actor ); $outbox_posts = \get_posts( array( @@ -187,4 +266,210 @@ function () use ( $actor ) { wp_delete_post( $remote_actor->ID, true ); remove_all_filters( 'pre_get_remote_metadata_by_actor' ); } + + /** + * Test queue_reject method. + * + * @covers ::queue_reject + */ + public function test_queue_reject() { + $actor_url = 'https://example.com/reject-actor'; + $activity_object = array( + 'id' => $actor_url . '/activity/456', + 'type' => 'Follow', + 'actor' => $actor_url, + 'object' => Actors::get_by_id( self::$user_id )->get_id(), + ); + + Follow::queue_reject( $activity_object, self::$user_id ); + + // Check that a Reject activity was queued. + $outbox_posts = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'author' => self::$user_id, + 'post_status' => 'pending', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + + $this->assertCount( 1, $outbox_posts, 'One Reject outbox entry should be created' ); + + $outbox_post = $outbox_posts[0]; + $activity_type = \get_post_meta( $outbox_post->ID, '_activitypub_activity_type', true ); + $activity_json = \json_decode( $outbox_post->post_content, true ); + $visibility = \get_post_meta( $outbox_post->ID, 'activitypub_content_visibility', true ); + + // Verify outbox entry. + $this->assertEquals( 'Reject', $activity_type ); + $this->assertEquals( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, $visibility ); + $this->assertEquals( 'Follow', $activity_json['object']['type'] ); + $this->assertEquals( array( $actor_url ), $activity_json['to'] ); + $this->assertEquals( $actor_url, $activity_json['object']['actor'] ); + + // Clean up. + wp_delete_post( $outbox_post->ID, true ); + } + + /** + * Test that deprecated hook still fires for backward compatibility. + * + * @covers ::handle_follow + */ + public function test_deprecated_hook_fires() { + // Expect the deprecation notice. + $this->setExpectedDeprecated( 'activitypub_followers_post_follow' ); + $hook_fired = false; + $hook_actor = null; + $hook_activity = null; + $hook_user_id = null; + $hook_remote_actor = null; + + // Hook into the deprecated action. + \add_action( + 'activitypub_followers_post_follow', + function ( $actor, $activity, $user_id, $remote_actor ) use ( &$hook_fired, &$hook_actor, &$hook_activity, &$hook_user_id, &$hook_remote_actor ) { + $hook_fired = true; + $hook_actor = $actor; + $hook_activity = $activity; + $hook_user_id = $user_id; + $hook_remote_actor = $remote_actor; + }, + 10, + 4 + ); + + $actor_url = 'https://example.com/deprecated-test-actor'; + + // Mock HTTP requests for actor metadata. + \add_filter( + 'pre_get_remote_metadata_by_actor', + function () use ( $actor_url ) { + return array( + 'id' => $actor_url, + 'actor' => $actor_url, + 'type' => 'Person', + 'inbox' => str_replace( '/deprecated-test-actor', '/inbox', $actor_url ), + ); + } + ); + + $activity_object = array( + 'id' => $actor_url . '/activity/deprecated', + 'type' => 'Follow', + 'actor' => $actor_url, + 'object' => Actors::get_by_id( self::$user_id )->get_id(), + ); + + Follow::handle_follow( $activity_object, self::$user_id ); + + // Verify deprecated hook fired. + $this->assertTrue( $hook_fired, 'Deprecated hook should fire' ); + $this->assertEquals( $actor_url, $hook_actor ); + $this->assertEquals( $activity_object, $hook_activity ); + $this->assertEquals( self::$user_id, $hook_user_id ); + $this->assertInstanceOf( \WP_Post::class, $hook_remote_actor ); + + // Clean up outbox posts. + $outbox_posts = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'author' => self::$user_id, + 'post_status' => 'any', + ) + ); + foreach ( $outbox_posts as $post ) { + wp_delete_post( $post->ID, true ); + } + + // Clean up hooks and filters. + \remove_all_actions( 'activitypub_followers_post_follow' ); + \remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + if ( $hook_remote_actor instanceof \WP_Post ) { + wp_delete_post( $hook_remote_actor->ID, true ); + } + } + + /** + * Test new hook fires correctly. + * + * @covers ::handle_follow + */ + public function test_new_hook_fires() { + $hook_fired = false; + $hook_activity = null; + $hook_user_id = null; + $hook_success = null; + $hook_remote_actor = null; + + // Hook into the new action. + \add_action( + 'activitypub_handled_follow', + function ( $activity, $user_id, $success, $remote_actor ) use ( &$hook_fired, &$hook_activity, &$hook_user_id, &$hook_success, &$hook_remote_actor ) { + $hook_fired = true; + $hook_activity = $activity; + $hook_user_id = $user_id; + $hook_success = $success; + $hook_remote_actor = $remote_actor; + }, + 10, + 4 + ); + + $actor_url = 'https://example.com/new-hook-test-actor'; + + // Mock HTTP requests for actor metadata. + \add_filter( + 'pre_get_remote_metadata_by_actor', + function () use ( $actor_url ) { + return array( + 'id' => $actor_url, + 'actor' => $actor_url, + 'type' => 'Person', + 'inbox' => str_replace( '/new-hook-test-actor', '/inbox', $actor_url ), + ); + } + ); + + $activity_object = array( + 'id' => $actor_url . '/activity/new-hook', + 'type' => 'Follow', + 'actor' => $actor_url, + 'object' => Actors::get_by_id( self::$user_id )->get_id(), + ); + + Follow::handle_follow( $activity_object, self::$user_id ); + + // Verify new hook fired. + $this->assertTrue( $hook_fired, 'New hook should fire' ); + $this->assertEquals( $activity_object, $hook_activity ); + $this->assertEquals( self::$user_id, $hook_user_id ); + $this->assertTrue( $hook_success ); + $this->assertInstanceOf( \WP_Post::class, $hook_remote_actor ); + + // Clean up outbox posts. + $outbox_posts = \get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'author' => self::$user_id, + 'post_status' => 'any', + ) + ); + foreach ( $outbox_posts as $post ) { + wp_delete_post( $post->ID, true ); + } + + // Clean up hooks and filters. + \remove_all_actions( 'activitypub_handled_follow' ); + \remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + if ( $hook_remote_actor instanceof \WP_Post ) { + wp_delete_post( $hook_remote_actor->ID, true ); + } + } } diff --git a/tests/includes/handler/class-test-inbox.php b/tests/includes/handler/class-test-inbox.php index 81b6cc516..59f7c4a02 100644 --- a/tests/includes/handler/class-test-inbox.php +++ b/tests/includes/handler/class-test-inbox.php @@ -14,61 +14,101 @@ */ class Test_Inbox extends \WP_UnitTestCase { /** - * Test handle_inbox_requests. + * Test handle_inbox_requests with various activity scenarios. + * + * @dataProvider inbox_requests_provider + * + * @param array $activity_data The activity data to test. + * @param string $activity_type The activity type. + * @param bool $expected_success The expected success result. + * @param string $description Description of the test case. */ - public function test_handle_inbox_requests() { - $filter_called = false; + public function test_handle_inbox_requests( $activity_data, $activity_type, $expected_success, $description ) { + $was_successful = false; \add_filter( 'activitypub_handled_inbox', - function ( $response ) use ( &$filter_called ) { - $filter_called = true; - return $response; - } + function ( $data, $user_id, $success ) use ( &$was_successful ) { + $was_successful = $success; + return $data; + }, + 10, + 3 ); - $data = array( - 'id' => 'https://example.com/activity/1', - 'type' => 'Create', - 'object' => array( - 'id' => 'https://example.com/object/1', - 'type' => 'Note', - ), - 'actor' => 'https://example.com/actor/1', - ); $user_id = 1; - $type = 'Create'; - $activity = \Activitypub\Activity\Activity::init_from_array( $data ); - - Inbox::handle_inbox_requests( $data, $user_id, $type, $activity ); - - $this->assertTrue( $filter_called ); - - $filter_called = false; - - $data['object']['type'] = 'Person'; - $activity = \Activitypub\Activity\Activity::init_from_array( $data ); - Inbox::handle_inbox_requests( $data, $user_id, $type, $activity ); - - $this->assertFalse( $filter_called ); - - $filter_called = false; - - $data['type'] = 'Delete'; - $data['object']['type'] = 'Article'; - $type = 'Delete'; - $activity = \Activitypub\Activity\Activity::init_from_array( $data ); - Inbox::handle_inbox_requests( $data, $user_id, $type, $activity ); + $activity = \Activitypub\Activity\Activity::init_from_array( $activity_data ); - $this->assertFalse( $filter_called ); + Inbox::handle_inbox_requests( $activity_data, $user_id, $activity_type, $activity ); - $filter_called = false; + $this->assertEquals( $expected_success, $was_successful, $description ); - $data['type'] = 'Update'; - $type = 'Update'; - $activity = \Activitypub\Activity\Activity::init_from_array( $data ); - Inbox::handle_inbox_requests( $data, $user_id, $type, $activity ); + \remove_all_filters( 'activitypub_handled_inbox' ); + } - $this->assertTrue( $filter_called ); + /** + * Data provider for inbox requests tests. + * + * @return array Test cases with activity data, type, expected success, and description. + */ + public function inbox_requests_provider() { + return array( + 'create_note_success' => array( + array( + 'id' => 'https://example.com/activity/1', + 'type' => 'Create', + 'object' => array( + 'id' => 'https://example.com/object/1', + 'type' => 'Note', + ), + 'actor' => 'https://example.com/actor/1', + ), + 'Create', + true, + 'Should handle Create activity with Note object successfully', + ), + 'create_person_failure' => array( + array( + 'id' => 'https://example.com/activity/2', + 'type' => 'Create', + 'object' => array( + 'id' => 'https://example.com/object/2', + 'type' => 'Person', + ), + 'actor' => 'https://example.com/actor/2', + ), + 'Create', + false, + 'Should not handle Create activity with Person object', + ), + 'delete_article_failure' => array( + array( + 'id' => 'https://example.com/activity/3', + 'type' => 'Delete', + 'object' => array( + 'id' => 'https://example.com/object/3', + 'type' => 'Article', + ), + 'actor' => 'https://example.com/actor/3', + ), + 'Delete', + false, + 'Should not handle Delete activity with Article object', + ), + 'update_article_success' => array( + array( + 'id' => 'https://example.com/activity/4', + 'type' => 'Update', + 'object' => array( + 'id' => 'https://example.com/object/4', + 'type' => 'Article', + ), + 'actor' => 'https://example.com/actor/4', + ), + 'Update', + true, + 'Should handle Update activity successfully', + ), + ); } } diff --git a/tests/includes/handler/class-test-like.php b/tests/includes/handler/class-test-like.php index bd5ef347f..62da8dccd 100644 --- a/tests/includes/handler/class-test-like.php +++ b/tests/includes/handler/class-test-like.php @@ -107,22 +107,191 @@ public function create_test_object() { } /** - * Test handle like. + * Test handle_like with different scenarios. * + * @dataProvider handle_like_provider * @covers ::handle_like + * + * @param array $activity_data The like activity data. + * @param bool $should_create_comment Whether a comment should be created. + * @param string $description Description of the test case. */ - public function test_handle_like() { - $object = $this->create_test_object(); - Like::handle_like( $object, $this->user_id ); + public function test_handle_like( $activity_data, $should_create_comment, $description ) { + // Create the activity using provided data or defaults. + $activity = array_merge( $this->create_test_object(), $activity_data ); + + // Get comment count before. + $comments_before = \get_comments( + array( + 'type' => 'like', + 'post_id' => $this->post_id, + ) + ); + $count_before = count( $comments_before ); + + // Process the like. + Like::handle_like( $activity, $this->user_id ); - $args = array( - 'type' => 'like', - 'post_id' => $this->post_id, + // Check comment count after. + $comments_after = \get_comments( + array( + 'type' => 'like', + 'post_id' => $this->post_id, + ) ); + $count_after = count( $comments_after ); + + if ( $should_create_comment ) { + $this->assertEquals( $count_before + 1, $count_after, $description . ' - Should create like comment' ); + $this->assertInstanceOf( 'WP_Comment', $comments_after[0], $description . ' - Should create WP_Comment object' ); + } else { + $this->assertEquals( $count_before, $count_after, $description . ' - Should not create like comment' ); + } + } + + /** + * Data provider for handle_like tests. + * + * @return array Test cases with activity data, expected result, and description. + */ + public function handle_like_provider() { + return array( + 'valid_like' => array( + array(), // Use default test object. + true, + 'Valid like activity should create comment', + ), + 'like_with_different_id' => array( + array( + 'id' => 'https://example.com/different-like-id', + ), + true, + 'Like with different ID should create comment', + ), + 'like_empty_object' => array( + array( + 'object' => '', + ), + false, + 'Like with empty object should not create comment', + ), + 'like_null_object' => array( + array( + 'object' => null, + ), + false, + 'Like with null object should not create comment', + ), + ); + } + + /** + * Test duplicate like handling. + * + * @covers ::handle_like + */ + public function test_handle_like_duplicate() { + $activity = array_merge( + $this->create_test_object(), + array( 'id' => 'https://example.com/duplicate-test' ) + ); + + // Process the like first time. + Like::handle_like( $activity, $this->user_id ); + + $comments_after_first = \get_comments( + array( + 'type' => 'like', + 'post_id' => $this->post_id, + ) + ); + $count_after_first = count( $comments_after_first ); + + // Process the same like again. + Like::handle_like( $activity, $this->user_id ); + + $comments_after_second = \get_comments( + array( + 'type' => 'like', + 'post_id' => $this->post_id, + ) + ); + $count_after_second = count( $comments_after_second ); + + $this->assertEquals( $count_after_first, $count_after_second, 'Duplicate like should not create additional comment' ); + } + + /** + * Test handle_like action hook fires. + * + * @covers ::handle_like + */ + public function test_handle_like_action_hook() { + $hook_fired = false; + $hook_activity = null; + $hook_user_id = null; + $hook_success = null; + $hook_result = null; + + \add_action( + 'activitypub_handled_like', + function ( $activity, $user_id, $success, $result ) use ( &$hook_fired, &$hook_activity, &$hook_user_id, &$hook_success, &$hook_result ) { + $hook_fired = true; + $hook_activity = $activity; + $hook_user_id = $user_id; + $hook_success = $success; + $hook_result = $result; + }, + 10, + 4 + ); + + $activity = $this->create_test_object(); + Like::handle_like( $activity, $this->user_id ); + + // Verify hook was fired. + $this->assertTrue( $hook_fired, 'Action hook should be fired' ); + $this->assertEquals( $activity, $hook_activity, 'Activity data should match' ); + $this->assertEquals( $this->user_id, $hook_user_id, 'User ID should match' ); + $this->assertTrue( $hook_success, 'Success should be true' ); + $this->assertInstanceOf( 'WP_Comment', $hook_result, 'Result should be WP_Comment' ); + + // Clean up. + \remove_all_actions( 'activitypub_handled_like' ); + } + + /** + * Test outbox_activity method with Like activity. + * + * @covers ::outbox_activity + */ + public function test_outbox_activity() { + $activity = new \Activitypub\Activity\Activity(); + $activity->set_type( 'Like' ); + $activity->set_object( array( 'id' => 'https://example.com/post/123' ) ); + + $result = Like::outbox_activity( $activity ); + + // Verify the object was converted to URI. + $this->assertSame( $activity, $result, 'Should return the same activity object' ); + $this->assertEquals( 'https://example.com/post/123', $activity->get_object(), 'Object should be converted to URI' ); + } + + /** + * Test outbox_activity with non-Like activity. + * + * @covers ::outbox_activity + */ + public function test_outbox_activity_non_like() { + $activity = new \Activitypub\Activity\Activity(); + $activity->set_type( 'Follow' ); + $activity->set_object( array( 'id' => 'https://example.com/user/123' ) ); - $query = new \WP_Comment_Query( $args ); - $result = $query->comments; + $original_object = $activity->get_object(); + $result = Like::outbox_activity( $activity ); - $this->assertInstanceOf( 'WP_Comment', $result[0] ); + // Verify the object was not changed for non-Like activities. + $this->assertSame( $activity, $result, 'Should return the same activity object' ); + $this->assertEquals( $original_object, $activity->get_object(), 'Object should remain unchanged for non-Like activity' ); } } diff --git a/tests/includes/handler/class-test-move.php b/tests/includes/handler/class-test-move.php index d1e9ea9b4..8d3ee2310 100644 --- a/tests/includes/handler/class-test-move.php +++ b/tests/includes/handler/class-test-move.php @@ -114,7 +114,7 @@ public function test_handle_move_with_target_and_origin() { 'object' => $target, ); - Move::handle_move( $activity ); + Move::handle_move( $activity, 1 ); $old_follower = Remote_Actors::get_by_uri( $origin ); $updated_follower = Remote_Actors::get_by_uri( $target ); @@ -170,7 +170,7 @@ public function test_handle_move_with_invalid_target() { 'object' => $target, ); - Move::handle_move( $activity ); + Move::handle_move( $activity, 1 ); // Assert that the original follower still exists and wasn't modified. $existing_follower = Followers::get_follower( $this->user_id, $origin ); @@ -213,7 +213,7 @@ public function test_handle_move_without_target_or_origin() { 'type' => 'Move', ); - Move::handle_move( $activity ); + Move::handle_move( $activity, 1 ); // Verify that no followers were added or removed. $final_followers = Followers::get_followers( $this->user_id ); @@ -300,12 +300,7 @@ public function test_handle_move_with_existing_target_and_origin() { }; // Mock the HTTP request. - add_filter( - 'pre_http_request', - $filter, - 10, - 3 - ); + add_filter( 'pre_http_request', $filter, 10, 3 ); $activity = array( 'type' => 'Move', @@ -313,7 +308,7 @@ public function test_handle_move_with_existing_target_and_origin() { 'object' => $target, ); - Move::handle_move( $activity ); + Move::handle_move( $activity, 1 ); // Check if the user IDs were moved correctly. $target_users = \get_post_meta( $target_id, Followers::FOLLOWER_META_KEY, false ); diff --git a/tests/includes/handler/class-test-reject.php b/tests/includes/handler/class-test-reject.php index 22319c9b4..349590718 100644 --- a/tests/includes/handler/class-test-reject.php +++ b/tests/includes/handler/class-test-reject.php @@ -47,63 +47,79 @@ public static function wpTearDownAfterClass() { } /** - * Test validate_object returns false if type is missing. + * Test validate_object with various scenarios. * + * @dataProvider validate_object_provider * @covers ::validate_object - */ - public function test_validate_object_missing_type() { - $request = $this->createMock( 'WP_REST_Request' ); - $request->method( 'get_json_params' )->willReturn( array() ); - $this->assertFalse( Reject::validate_object( true, 'param', $request ) ); - } - - /** - * Test validate_object returns true if type is not Reject. * - * @covers ::validate_object + * @param array $request_data The request data to test. + * @param bool $input_valid The input valid state. + * @param bool $expected_result The expected validation result. + * @param string $description Description of the test case. */ - public function test_validate_object_type_not_accept() { + public function test_validate_object( $request_data, $input_valid, $expected_result, $description ) { $request = $this->createMock( 'WP_REST_Request' ); - $request->method( 'get_json_params' )->willReturn( array( 'type' => 'Follow' ) ); - $this->assertTrue( Reject::validate_object( true, 'param', $request ) ); - } + $request->method( 'get_json_params' )->willReturn( $request_data ); - /** - * Test validate_object returns false if required fields are missing. - * - * @covers ::validate_object - */ - public function test_validate_object_missing_required_fields() { - $request = $this->createMock( 'WP_REST_Request' ); - $request->method( 'get_json_params' )->willReturn( - array( - 'type' => 'Reject', - 'actor' => 'foo', - ) - ); - $this->assertFalse( Reject::validate_object( true, 'param', $request ) ); + $result = Reject::validate_object( $input_valid, 'param', $request ); + + $this->assertEquals( $expected_result, $result, $description ); } /** - * Test validate_object returns true if all checks pass. + * Data provider for validate_object tests. * - * @covers ::validate_object + * @return array Test cases with request data, input valid state, expected result, and description. */ - public function test_validate_object_success() { - $request = $this->createMock( 'WP_REST_Request' ); - $request->method( 'get_json_params' )->willReturn( - array( - 'type' => 'Reject', - 'actor' => 'foo', - 'object' => array( - 'id' => 'bar', + public function validate_object_provider() { + return array( + // Invalid cases. + 'missing_type' => array( + array(), + true, + false, + 'Should return false when type is missing', + ), + 'missing_required_fields' => array( + array( + 'type' => 'Reject', + 'actor' => 'foo', + ), + true, + false, + 'Should return false when required fields are missing', + ), + // Valid cases - non-Reject type should pass through. + 'type_not_reject' => array( + array( 'type' => 'Follow' ), + true, + true, + 'Should return true when type is not Reject', + ), + // Valid Reject activity. + 'valid_reject_activity' => array( + array( + 'type' => 'Reject', 'actor' => 'foo', - 'type' => 'Follow', - 'object' => 'foo', + 'object' => array( + 'id' => 'bar', + 'actor' => 'foo', + 'type' => 'Follow', + 'object' => 'foo', + ), ), - ) + true, + true, + 'Should return true for valid Reject activity', + ), + // Test with input_valid false. + 'input_valid_false' => array( + array( 'type' => 'Follow' ), + false, + false, + 'Should preserve input_valid when type is not Reject', + ), ); - $this->assertTrue( Reject::validate_object( true, 'param', $request ) ); } /** diff --git a/tests/includes/handler/class-test-undo.php b/tests/includes/handler/class-test-undo.php new file mode 100644 index 000000000..b3e57e81c --- /dev/null +++ b/tests/includes/handler/class-test-undo.php @@ -0,0 +1,469 @@ +user->create( + array( + 'role' => 'author', + ) + ); + } + + /** + * Clean up after each test. + */ + public function tear_down() { + // Remove any HTTP mocking filters. + \remove_all_filters( 'pre_get_remote_metadata_by_actor' ); + parent::tear_down(); + } + + /** + * Test handle_undo with follow activities. + * + * @dataProvider follow_undo_provider + * @covers ::handle_undo + * + * @param string $actor_url The actor URL to test with. + * @param string $description Description of the test case. + */ + public function test_handle_undo_follow( $actor_url, $description ) { + // Mock HTTP requests for actor metadata. + \add_filter( + 'pre_get_remote_metadata_by_actor', + function () use ( $actor_url ) { + return array( + 'id' => $actor_url, + 'type' => 'Person', + 'name' => 'Test Actor', + 'preferredUsername' => 'testactor', + 'inbox' => $actor_url . '/inbox', + 'outbox' => $actor_url . '/outbox', + 'url' => $actor_url, + ); + } + ); + + // Add follower first. + $add_result = Followers::add_follower( self::$user_id, $actor_url ); + $this->assertIsInt( $add_result, $description . ' - Adding follower should return post ID' ); + + // Verify follower was added. + $followers = Followers::get_followers( self::$user_id ); + $this->assertNotEmpty( $followers, $description . ' - Should have followers after adding one' ); + + $user_actor = Actors::get_by_id( self::$user_id ); + $user_actor_url = $user_actor->get_id(); + + // Verify user actor URL exists. + $this->assertNotEmpty( $user_actor_url, $description . ' - User actor URL should not be empty' ); + + // Create undo follow activity. + $activity = array( + 'type' => 'Undo', + 'actor' => $actor_url, + 'object' => array( + 'type' => 'Follow', + 'actor' => $actor_url, + 'object' => $user_actor_url, + ), + ); + + // Process the undo. + Undo::handle_undo( $activity, self::$user_id ); + + // Verify follower was removed. + $followers_after = Followers::get_followers( self::$user_id ); + $this->assertEmpty( $followers_after, $description . ' - Should have no followers after undo' ); + } + + /** + * Data provider for follow undo tests. + * + * @return array Test cases with actor URLs and descriptions. + */ + public function follow_undo_provider() { + return array( + 'basic_follow' => array( + 'https://example.com/test-actor', + 'Basic follow undo should remove follower', + ), + 'follow_with_subdomain' => array( + 'https://social.example.com/users/testactor', + 'Follow undo with subdomain should work', + ), + 'follow_with_path' => array( + 'https://example.com/users/testactor', + 'Follow undo with user path should work', + ), + ); + } + + /** + * Test handle_undo with comment-based activities (Like, Create, Announce). + * + * @dataProvider comment_undo_provider + * @covers ::handle_undo + * + * @param string $activity_type The type of activity to undo. + * @param string $comment_content The content for the comment. + * @param string $source_id The source ID for the comment. + * @param string $description Description of the test case. + */ + public function test_handle_undo_comment_activities( $activity_type, $comment_content, $source_id, $description ) { + // Create a post for the comment. + $post_id = $this->factory->post->create( + array( + 'post_author' => self::$user_id, + ) + ); + + // Create the comment with metadata. + $comment_id = $this->factory->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_content' => $comment_content, + ) + ); + \add_comment_meta( $comment_id, 'source_id', $source_id, true ); + \add_comment_meta( $comment_id, 'protocol', 'activitypub', true ); + + // Verify comment exists. + $comment = \get_comment( $comment_id ); + $this->assertNotNull( $comment, $description . ' - Comment should exist before undo' ); + + // Create undo activity. + $activity = array( + 'type' => 'Undo', + 'actor' => 'https://example.com/actor', + 'object' => array( + 'type' => $activity_type, + 'id' => $source_id, + ), + ); + + // Verify the comment can be found by source_id before processing. + $found_comment = Comment::object_id_to_comment( $source_id ); + $this->assertNotFalse( $found_comment, $description . ' - Comment should be found by source_id before undo' ); + + // Process the undo. + Undo::handle_undo( $activity, self::$user_id ); + + // Verify comment was deleted. + $comment_after = \get_comment( $comment_id ); + $this->assertNull( $comment_after, $description . ' - Comment should be deleted after undo' ); + } + + /** + * Data provider for comment-based undo tests. + * + * @return array Test cases with activity type, comment content, source ID, and description. + */ + public function comment_undo_provider() { + return array( + 'undo_like' => array( + 'Like', + '👍', + 'https://example.com/like/123', + 'Undo Like activity should delete like comment', + ), + 'undo_create' => array( + 'Create', + 'Test comment', + 'https://example.com/note/123', + 'Undo Create activity should delete created comment', + ), + 'undo_announce' => array( + 'Announce', + 'Shared a post', + 'https://example.com/announce/456', + 'Undo Announce activity should delete announce comment', + ), + ); + } + + /** + * Test handle_undo action hook is fired. + * + * @covers ::handle_undo + */ + public function test_handle_undo_action_hook() { + $action_fired = false; + $activity_data = null; + $user_id_data = null; + $state_data = null; + + \add_action( + 'activitypub_handled_undo', + function ( $activity, $user_id, $state ) use ( &$action_fired, &$activity_data, &$user_id_data, &$state_data ) { + $action_fired = true; + $activity_data = $activity; + $user_id_data = $user_id; + $state_data = $state; + }, + 10, + 3 + ); + + // Test with a valid follow activity that should fire the hook. + $actor = 'https://example.com/test-actor'; + + // Mock HTTP requests for actor metadata. + \add_filter( + 'pre_get_remote_metadata_by_actor', + function () use ( $actor ) { + return array( + 'id' => $actor, + 'type' => 'Person', + 'name' => 'Test Actor', + 'preferredUsername' => 'testactor', + 'inbox' => $actor . '/inbox', + 'outbox' => $actor . '/outbox', + 'url' => $actor, + ); + } + ); + + Followers::add_follower( self::$user_id, $actor ); + + $user_actor = Actors::get_by_id( self::$user_id ); + $user_actor_url = $user_actor->get_id(); + + $activity = array( + 'type' => 'Undo', + 'actor' => $actor, + 'object' => array( + 'type' => 'Follow', + 'actor' => $actor, + 'object' => $user_actor_url, + ), + ); + + Undo::handle_undo( $activity, self::$user_id ); + + $this->assertTrue( $action_fired ); + $this->assertEquals( $activity, $activity_data ); + $this->assertEquals( self::$user_id, $user_id_data ); + // State can be false if follower removal fails, but action should still fire. + $this->assertTrue( isset( $state_data ) ); + } + + /** + * Test validate_object with various scenarios. + * + * @dataProvider validate_object_provider + * @covers ::validate_object + * + * @param array $request_data The request data to test. + * @param bool $input_valid The input valid state. + * @param bool $expected_result The expected validation result. + * @param string $description Description of the test case. + */ + public function test_validate_object( $request_data, $input_valid, $expected_result, $description ) { + $request = $this->create_mock_request( $request_data ); + $result = Undo::validate_object( $input_valid, 'object', $request ); + + $this->assertEquals( $expected_result, $result, $description ); + } + + /** + * Data provider for validate_object tests. + * + * @return array Test cases with request data, input valid state, expected result, and description. + */ + public function validate_object_provider() { + $valid_undo_activity = array( + 'type' => 'Undo', + 'actor' => 'https://example.com/actor', + 'object' => array( + 'id' => 'https://example.com/activity/123', + 'type' => 'Follow', + 'actor' => 'https://example.com/actor', + 'object' => 'https://example.com/target', + ), + ); + + return array( + // Valid cases. + 'valid_undo_activity' => array( + $valid_undo_activity, + true, + true, + 'Valid Undo activity should pass validation', + ), + + // Non-Undo activities should preserve original state. + 'non_undo_activity_preserves_true' => array( + array( + 'type' => 'Create', + 'actor' => 'https://example.com/actor', + 'object' => array( + 'type' => 'Note', + 'content' => 'Hello world', + ), + ), + true, + true, + 'Non-Undo activity should preserve original valid state (true)', + ), + 'non_undo_activity_preserves_false' => array( + array( + 'type' => 'Create', + 'actor' => 'https://example.com/actor', + 'object' => array( + 'type' => 'Note', + 'content' => 'Hello world', + ), + ), + false, + false, + 'Non-Undo activity should preserve original valid state (false)', + ), + + // Invalid cases - missing top-level fields. + 'empty_json_params' => array( + array(), + true, + false, + 'Empty JSON params should fail validation', + ), + 'missing_type' => array( + array( + 'actor' => 'https://example.com/actor', + 'object' => array( + 'id' => 'https://example.com/activity/123', + 'type' => 'Follow', + 'actor' => 'https://example.com/actor', + 'object' => 'https://example.com/target', + ), + ), + true, + false, + 'Missing type should fail validation', + ), + 'missing_actor' => array( + array( + 'type' => 'Undo', + 'object' => array( + 'id' => 'https://example.com/activity/123', + 'type' => 'Follow', + 'actor' => 'https://example.com/actor', + 'object' => 'https://example.com/target', + ), + ), + true, + false, + 'Missing actor should fail validation', + ), + 'missing_object' => array( + array( + 'type' => 'Undo', + 'actor' => 'https://example.com/actor', + ), + true, + false, + 'Missing object should fail validation', + ), + + // Invalid cases - missing object fields. + 'missing_object_id' => array( + array( + 'type' => 'Undo', + 'actor' => 'https://example.com/actor', + 'object' => array( + 'type' => 'Follow', + 'actor' => 'https://example.com/actor', + 'object' => 'https://example.com/target', + ), + ), + true, + false, + 'Missing object.id should fail validation', + ), + 'missing_object_type' => array( + array( + 'type' => 'Undo', + 'actor' => 'https://example.com/actor', + 'object' => array( + 'id' => 'https://example.com/activity/123', + 'actor' => 'https://example.com/actor', + 'object' => 'https://example.com/target', + ), + ), + true, + false, + 'Missing object.type should fail validation', + ), + 'missing_object_actor' => array( + array( + 'type' => 'Undo', + 'actor' => 'https://example.com/actor', + 'object' => array( + 'id' => 'https://example.com/activity/123', + 'type' => 'Follow', + 'object' => 'https://example.com/target', + ), + ), + true, + false, + 'Missing object.actor should fail validation', + ), + 'missing_object_object' => array( + array( + 'type' => 'Undo', + 'actor' => 'https://example.com/actor', + 'object' => array( + 'id' => 'https://example.com/activity/123', + 'type' => 'Follow', + 'actor' => 'https://example.com/actor', + ), + ), + true, + false, + 'Missing object.object should fail validation', + ), + ); + } + + /** + * Create a mock WP_REST_Request object for testing. + * + * @param array $json_params The JSON parameters to return. + * @return \WP_REST_Request Mock request object. + */ + private function create_mock_request( $json_params ) { + $request = $this->createMock( \WP_REST_Request::class ); + $request->method( 'get_json_params' )->willReturn( $json_params ); + return $request; + } +} diff --git a/tests/includes/handler/class-test-update.php b/tests/includes/handler/class-test-update.php index 76a17926b..0cf157eb7 100644 --- a/tests/includes/handler/class-test-update.php +++ b/tests/includes/handler/class-test-update.php @@ -36,116 +36,122 @@ public function set_up() { } /** - * Test updating an actor. + * Test updating an actor with various scenarios. * + * @dataProvider update_actor_provider * @covers ::update_actor + * + * @param array $activity_data The activity data. + * @param mixed $http_response The HTTP response to mock. + * @param string $expected_outcome The expected test outcome. + * @param string $description Description of the test case. */ - public function test_update_actor() { - // Prepare test data. - $actor_url = 'https://example.com/users/testuser'; - $activity = array( - 'type' => 'Update', - 'actor' => $actor_url, - 'object' => array( - 'type' => 'Person', - 'id' => $actor_url, - 'name' => 'Test User', - 'preferredUsername' => 'testuser', - 'inbox' => 'https://example.com/users/testuser/inbox', - 'outbox' => 'https://example.com/users/testuser/outbox', - 'followers' => 'https://example.com/users/testuser/followers', - 'following' => 'https://example.com/users/testuser/following', - 'publicKey' => array( - 'id' => $actor_url . '#main-key', - 'owner' => $actor_url, - 'publicKeyPem' => '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\nF8YWepMN9K/B8xwKRI7P4x4w6c+4S8FRRvJOyJr3xhXvCgFNSM+a2v1rYMRLKIAa\nPJUZ1jPKGrPDv/zc25eFoMB1YqSq1FozYh+zdsEtiXj4Nd4o0rn3OnFAHYeYiroJ\nQkEYy4pV3CCXZODhYwvwPmJUZ4/uJVWJHlN6Og==\n-----END PUBLIC KEY-----', - ), - ), - ); + public function test_update_actor( $activity_data, $http_response, $expected_outcome, $description ) { + $actor_url = $activity_data['actor']; - $fake_request = function () use ( $activity ) { + $fake_request = function () use ( $http_response ) { + if ( is_wp_error( $http_response ) ) { + return $http_response; + } return array( 'response' => array( 'code' => 200 ), - 'body' => wp_json_encode( $activity['object'] ), + 'body' => wp_json_encode( $http_response ), ); }; - // Mock of get_remote_metadata_by_actor function. + // Mock HTTP request. \add_filter( 'pre_http_request', $fake_request, 10 ); // Execute the update_actor method. - Update::update_actor( $activity ); - - // Check that the follower was correctly updated. - $follower = Remote_Actors::get_by_uri( $actor_url ); - - $this->assertNotNull( $follower ); - - $follower_initial = Remote_Actors::get_actor( Followers::add_follower( $this->user_id, $actor_url ) ); - $follower_from_db = Remote_Actors::get_actor( Remote_Actors::get_by_uri( $actor_url ) ); - - $this->assertInstanceOf( Actor::class, $follower_initial ); - $this->assertInstanceOf( Actor::class, $follower_from_db ); - $this->assertEquals( $follower_initial->get_id(), $follower_from_db->get_id() ); - $this->assertEquals( 'Test User', $follower_from_db->get_name() ); - - remove_filter( 'pre_http_request', $fake_request, 10 ); - - $activity['object']['name'] = 'Updated Name'; - - $fake_request = function () use ( $activity ) { - return array( - 'response' => array( 'code' => 200 ), - 'body' => wp_json_encode( $activity['object'] ), - ); - }; - - // Mock of get_remote_metadata_by_actor function. - \add_filter( 'pre_http_request', $fake_request, 10 ); + Update::update_actor( $activity_data, 1 ); - Update::update_actor( $activity ); + // Verify results based on expected outcome. + if ( 'error' === $expected_outcome ) { + $follower = Remote_Actors::get_by_uri( $actor_url ); + $this->assertWPError( $follower, $description ); + } else { + // For successful updates, add follower first then test update. + Followers::add_follower( $this->user_id, $actor_url ); - \clean_post_cache( $follower_initial->get_id() ); + $follower = Remote_Actors::get_by_uri( $actor_url ); + $this->assertNotNull( $follower, $description ); - $follower = Remote_Actors::get_by_uri( $actor_url ); - $follower = Remote_Actors::get_actor( $follower ); + $follower_actor = Remote_Actors::get_actor( $follower ); + $this->assertInstanceOf( Actor::class, $follower_actor, $description ); - $this->assertInstanceOf( Actor::class, $follower ); - $this->assertEquals( $activity['object']['name'], $follower->get_name() ); - $this->assertEquals( $activity['object']['preferredUsername'], $follower->get_preferred_username() ); - $this->assertEquals( $activity['object']['inbox'], $follower->get_inbox() ); + if ( isset( $http_response['name'] ) ) { + $this->assertEquals( $http_response['name'], $follower_actor->get_name(), $description ); + } + } \remove_filter( 'pre_http_request', $fake_request, 10 ); } /** - * Test updating a non-existent actor. + * Data provider for update_actor tests. * - * @covers ::update_actor + * @return array Test cases with activity data, HTTP response, expected outcome, and description. */ - public function test_update_nonexistent_actor() { - $activity = array( - 'type' => 'Update', - 'actor' => 'https://example.com/nonexistent', - 'object' => array( - 'type' => 'Person', + public function update_actor_provider() { + $valid_actor_object = array( + 'type' => 'Person', + 'id' => 'https://example.com/users/testuser', + 'name' => 'Test User', + 'preferredUsername' => 'testuser', + 'inbox' => 'https://example.com/users/testuser/inbox', + 'outbox' => 'https://example.com/users/testuser/outbox', + 'followers' => 'https://example.com/users/testuser/followers', + 'following' => 'https://example.com/users/testuser/following', + 'publicKey' => array( + 'id' => 'https://example.com/users/testuser#main-key', + 'owner' => 'https://example.com/users/testuser', + 'publicKeyPem' => '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Rdj53hR4AdsiRcqt1Fd\nF8YWepMN9K/B8xwKRI7P4x4w6c+4S8FRRvJOyJr3xhXvCgFNSM+a2v1rYMRLKIAa\nPJUZ1jPKGrPDv/zc25eFoMB1YqSq1FozYh+zdsEtiXj4Nd4o0rn3OnFAHYeYiroJ\nQkEYy4pV3CCXZODhYwvwPmJUZ4/uJVWJHlN6Og==\n-----END PUBLIC KEY-----', ), ); - $fake_request = function () { - return new \WP_Error( 'not_found', 'Actor not found' ); - }; - - // Mock of get_remote_metadata_by_actor function to return an error. - \add_filter( 'pre_http_request', $fake_request, 10 ); - - // Execute the update_actor method. - Update::update_actor( $activity ); - - // Check that no follower was created. - $follower = Remote_Actors::get_by_uri( 'https://example.com/nonexistent' ); - $this->assertWPError( $follower ); - - remove_filter( 'pre_http_request', $fake_request, 10 ); + return array( + 'valid_actor_update' => array( + array( + 'type' => 'Update', + 'actor' => 'https://example.com/users/testuser', + 'object' => $valid_actor_object, + ), + $valid_actor_object, + 'success', + 'Should successfully update valid actor', + ), + 'updated_name' => array( + array( + 'type' => 'Update', + 'actor' => 'https://example.com/users/testuser2', + 'object' => array_merge( + $valid_actor_object, + array( + 'id' => 'https://example.com/users/testuser2', + 'name' => 'Updated Name', + ) + ), + ), + array_merge( + $valid_actor_object, + array( + 'id' => 'https://example.com/users/testuser2', + 'name' => 'Updated Name', + ) + ), + 'success', + 'Should successfully update actor name', + ), + 'nonexistent_actor' => array( + array( + 'type' => 'Update', + 'actor' => 'https://example.com/nonexistent', + 'object' => array( 'type' => 'Person' ), + ), + new \WP_Error( 'not_found', 'Actor not found' ), + 'error', + 'Should handle non-existent actor gracefully', + ), + ); } } diff --git a/tests/includes/transformer/class-test-post.php b/tests/includes/transformer/class-test-post.php index dc7371729..b01f1704d 100644 --- a/tests/includes/transformer/class-test-post.php +++ b/tests/includes/transformer/class-test-post.php @@ -70,129 +70,99 @@ public function test_get_type_returns_configured_type_when_option_set() { } /** - * Test that the get_type method returns note for short content. + * Test get_type method with various scenarios. * + * @dataProvider get_type_provider * @covers ::get_type - */ - public function test_get_type_returns_note_for_short_content() { - $post_id = $this->factory->post->create( - array( - 'post_title' => 'Test Post', - 'post_content' => 'Short content', - ) - ); - $post = get_post( $post_id ); - - $transformer = new Post( $post ); - $type = $this->reflection_method->invoke( $transformer ); - - $this->assertSame( 'Note', $type ); - } - - /** - * Test that the get_type method returns note for posts without title. * - * @covers ::get_type + * @param array $post_data The post data to create. + * @param string $post_format The post format to set (or null). + * @param string $expected_type The expected ActivityPub type. + * @param string $description Description of the test case. */ - public function test_get_type_returns_note_for_posts_without_title() { - $post_id = $this->factory->post->create( - array( - 'post_title' => '', - 'post_content' => str_repeat( 'Long content. ', 100 ), - ) - ); - $post = get_post( $post_id ); - - $transformer = new Post( $post ); - $type = $this->reflection_method->invoke( $transformer ); + public function test_get_type( $post_data, $post_format, $expected_type, $description ) { + $post_id = $this->factory->post->create( $post_data ); - $this->assertSame( 'Note', $type ); - } + if ( $post_format ) { + set_post_format( $post_id, $post_format ); + } - /** - * Test that the get_type method returns article for standard post format. - * - * @covers ::get_type - */ - public function test_get_type_returns_article_for_standard_post_format() { - $post_id = $this->factory->post->create( - array( - 'post_title' => 'Test Post', - 'post_content' => str_repeat( 'Long content. ', 100 ), - 'post_type' => 'post', - ) - ); - set_post_format( $post_id, 'standard' ); $post = get_post( $post_id ); $transformer = new Post( $post ); $type = $this->reflection_method->invoke( $transformer ); - $this->assertSame( 'Article', $type ); + $this->assertSame( $expected_type, $type, $description ); } /** - * Test that the get_type method returns page for page post type. + * Data provider for get_type tests. * - * @covers ::get_type + * @return array Test cases with post data, post format, expected type, and description. */ - public function test_get_type_returns_page_for_page_post_type() { - $post_id = $this->factory->post->create( - array( - 'post_title' => 'Test Page', - 'post_content' => str_repeat( 'Long content. ', 100 ), - 'post_type' => 'page', - ) - ); - $post = get_post( $post_id ); - - $transformer = new Post( $post ); - $type = $this->reflection_method->invoke( $transformer ); - - $this->assertSame( 'Page', $type ); - } + public function get_type_provider() { + $long_content = str_repeat( 'Long content. ', 100 ); - /** - * Test that the get_type method returns note for non-standard post format. - * - * @covers ::get_type - */ - public function test_get_type_returns_note_for_non_standard_post_format() { - $post_id = $this->factory->post->create( - array( - 'post_title' => 'Test Post', - 'post_content' => str_repeat( 'Long content. ', 100 ), - 'post_type' => 'post', - ) - ); - set_post_format( $post_id, 'aside' ); - $post = get_post( $post_id ); - - $transformer = new Post( $post ); - $type = $this->reflection_method->invoke( $transformer ); - - $this->assertSame( 'Note', $type ); - } - - /** - * Test that the get_type method returns note for missing post format. - * - * @covers ::get_type - */ - public function test_get_type_handles_missing_post_format() { - $post_id = $this->factory->post->create( - array( - 'post_title' => 'Test Post', - 'post_content' => str_repeat( 'Long content. ', 100 ), - 'post_type' => 'post', - ) + return array( + 'short_content' => array( + array( + 'post_title' => 'Test Post', + 'post_content' => 'Short content', + ), + null, + 'Note', + 'Should return Note for short content', + ), + 'no_title' => array( + array( + 'post_title' => '', + 'post_content' => $long_content, + ), + null, + 'Note', + 'Should return Note for posts without title', + ), + 'standard_post_format' => array( + array( + 'post_title' => 'Test Post', + 'post_content' => $long_content, + 'post_type' => 'post', + ), + 'standard', + 'Article', + 'Should return Article for standard post format', + ), + 'page_post_type' => array( + array( + 'post_title' => 'Test Page', + 'post_content' => $long_content, + 'post_type' => 'page', + ), + null, + 'Page', + 'Should return Page for page post type', + ), + 'aside_post_format' => array( + array( + 'post_title' => 'Test Post', + 'post_content' => $long_content, + 'post_type' => 'post', + ), + 'aside', + 'Note', + 'Should return Note for non-standard post format', + ), + 'default_post_format' => array( + array( + 'post_title' => 'Test Post', + 'post_content' => $long_content, + 'post_type' => 'post', + ), + null, + 'Article', + 'Should return Article for default post format', + ), ); - $post = get_post( $post_id ); - - $transformer = new Post( $post ); - $type = $this->reflection_method->invoke( $transformer ); - - $this->assertSame( 'Article', $type ); } /** diff --git a/tests/integration/class-test-stream-connector.php b/tests/integration/class-test-stream-connector.php new file mode 100644 index 000000000..bb690a212 --- /dev/null +++ b/tests/integration/class-test-stream-connector.php @@ -0,0 +1,811 @@ +user->create( + array( + 'role' => 'author', + 'display_name' => 'Test Author', + ) + ); + + self::$post_id = $factory->post->create( + array( + 'post_author' => self::$user_id, + 'post_title' => 'Test Post for Stream Connector', + ) + ); + + self::$comment_id = $factory->comment->create( + array( + 'comment_post_ID' => self::$post_id, + 'comment_content' => 'Test comment for Stream Connector', + ) + ); + } + + /** + * Clean up after tests. + */ + public static function wpTearDownAfterClass() { + wp_delete_comment( self::$comment_id, true ); + wp_delete_post( self::$post_id, true ); + wp_delete_user( self::$user_id ); + } + + /** + * Check if Stream plugin dependencies are available. + * + * @return bool True if Stream plugin is available, false otherwise. + */ + protected function is_stream_available() { + return class_exists( 'WP_Stream\Connector' ); + } + + /** + * Set up each test. + */ + public function set_up() { + parent::set_up(); + $this->stream_connector = new \Activitypub\Integration\Stream_Connector(); + } + + /** + * Test the Stream connector registration hook behavior. + * + * @covers \Activitypub\Integration\register_stream_connector + */ + public function test_stream_connector_registration() { + $initial_classes = array( 'existing_connector' ); + + // Test registration when Stream plugin is available. + if ( $this->is_stream_available() ) { + $result = apply_filters( 'wp_stream_connectors', $initial_classes ); + + // Should have our connector added. + $this->assertGreaterThan( count( $initial_classes ), count( $result ) ); + + // Find our connector in the result. + $activitypub_connector = null; + foreach ( $result as $connector ) { + if ( is_object( $connector ) && property_exists( $connector, 'name' ) && 'activitypub' === $connector->name ) { + $activitypub_connector = $connector; + break; + } + } + + $this->assertNotNull( $activitypub_connector, 'ActivityPub connector should be registered when Stream plugin is available' ); + $this->assertInstanceOf( 'Activitypub\Integration\Stream_Connector', $activitypub_connector ); + } else { + // When Stream plugin is not available, the filter should return unchanged classes. + $result = apply_filters( 'wp_stream_connectors', $initial_classes ); + $this->assertEquals( $initial_classes, $result ); + } + } + + /** + * Test connector basic properties when Stream plugin is available. + * + * @covers ::get_label + * @covers ::get_context_labels + * @covers ::get_action_labels + */ + public function test_connector_properties() { + if ( ! $this->is_stream_available() ) { + $this->markTestSkipped( 'Stream plugin is not available.' ); + } + + // Test connector name. + $this->assertEquals( 'activitypub', $this->stream_connector->name ); + + // Test actions array. + $expected_actions = array( + 'activitypub_handled_follow', + 'activitypub_sent_to_inbox', + 'activitypub_outbox_processing_complete', + 'activitypub_outbox_processing_batch_complete', + ); + $this->assertEquals( $expected_actions, $this->stream_connector->actions ); + + // Test label. + $this->assertEquals( 'ActivityPub', $this->stream_connector->get_label() ); + + // Test context labels. + $this->assertEquals( array(), $this->stream_connector->get_context_labels() ); + + // Test action labels. + $expected_action_labels = array( + 'processed' => 'Processed', + ); + $this->assertEquals( $expected_action_labels, $this->stream_connector->get_action_labels() ); + } + + /** + * Test action_links method with various scenarios. + * + * @dataProvider action_links_provider + * @covers ::action_links + * + * @param string $action The record action. + * @param array $meta_data The meta data to set on the record. + * @param int $expected_count The expected number of links. + * @param string $description Description of the test case. + */ + public function test_action_links( $action, $meta_data, $expected_count, $description ) { + if ( ! $this->is_stream_available() ) { + $this->markTestSkipped( 'Stream plugin is not available.' ); + } + // Create a mock record. + $record = $this->createMock( '\WP_Stream\Record' ); + $record->action = $action; + + // Mock the get_meta method. + $record->method( 'get_meta' )->willReturnCallback( + function ( $key ) use ( $meta_data ) { + return isset( $meta_data[ $key ] ) ? $meta_data[ $key ] : ''; + } + ); + + $links = array( 'existing_link' => 'http://example.com' ); + $result = $this->stream_connector->action_links( $links, $record ); + + $this->assertCount( $expected_count, $result, $description ); + $this->assertArrayHasKey( 'existing_link', $result, 'Should preserve existing links' ); + } + + /** + * Data provider for action_links tests. + * + * @return array Test cases. + */ + public function action_links_provider() { + return array( + 'processed_with_error' => array( + 'processed', + array( + 'error' => wp_json_encode( array( 'message' => 'Test error' ) ), + 'debug' => '', + ), + 2, // Existing + error. + 'Should add error link for processed action with error', + ), + 'processed_with_debug' => array( + 'processed', + array( + 'error' => '', + 'debug' => wp_json_encode( array( 'test' => 'debug data' ) ), + ), + 2, // Existing + debug. + 'Should add debug link for processed action with debug', + ), + 'processed_with_both' => array( + 'processed', + array( + 'error' => wp_json_encode( array( 'message' => 'Test error' ) ), + 'debug' => wp_json_encode( array( 'test' => 'debug data' ) ), + ), + 3, // Existing + error + debug. + 'Should add both error and debug links', + ), + 'processed_without_data' => array( + 'processed', + array( + 'error' => '', + 'debug' => '', + ), + 1, // Only existing. + 'Should not add links when no error or debug data', + ), + 'non_processed_action' => array( + 'other_action', + array( + 'error' => wp_json_encode( array( 'message' => 'Test error' ) ), + ), + 1, // Only existing. + 'Should not add links for non-processed actions', + ), + ); + } + + /** + * Test callback_activitypub_handled_follow method. + * + * @covers ::callback_activitypub_handled_follow + */ + public function test_callback_activitypub_handled_follow() { + if ( ! $this->is_stream_available() ) { + $this->markTestSkipped( 'Stream plugin is not available.' ); + } + + $activity = array( + 'type' => 'Follow', + 'actor' => 'https://example.com/actor', + 'object' => 'https://local.example.com/author/1', + ); + + $context = (object) array( + 'guid' => 'https://example.com/actor', + ); + + // Capture the log call. + $logged_data = null; + $stream_connector = $this->createPartialMock( Stream_Connector::class, array( 'log' ) ); + $stream_connector->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $meta, $object_id, $context_type, $action, $user_id ) use ( &$logged_data ) { + $logged_data = array( + 'message' => $message, + 'meta' => $meta, + 'object_id' => $object_id, + 'context_type' => $context_type, + 'action' => $action, + 'user_id' => $user_id, + ); + } + ); + + $stream_connector->callback_activitypub_handled_follow( $activity, self::$user_id, true, $context ); + + $this->assertNotNull( $logged_data, 'Should have logged the follow event' ); + $this->assertStringContainsString( 'New Follower: https://example.com/actor', $logged_data['message'] ); + $this->assertEquals( 'notification', $logged_data['context_type'] ); + $this->assertEquals( 'follow', $logged_data['action'] ); + $this->assertEquals( self::$user_id, $logged_data['user_id'] ); + $this->assertArrayHasKey( 'activity', $logged_data['meta'] ); + $this->assertArrayHasKey( 'remote_actor', $logged_data['meta'] ); + } + + /** + * Test callback_activitypub_handled_follow with error context. + * + * @covers ::callback_activitypub_handled_follow + */ + public function test_callback_activitypub_handled_follow_with_error() { + if ( ! $this->is_stream_available() ) { + $this->markTestSkipped( 'Stream plugin is not available.' ); + } + + $activity = array( + 'type' => 'Follow', + 'actor' => 'https://example.com/actor', + 'object' => 'https://local.example.com/author/1', + ); + + $context = new \WP_Error( 'follow_error', 'Follow processing failed' ); + + // Capture the log call. + $logged_data = null; + $stream_connector = $this->createPartialMock( Stream_Connector::class, array( 'log' ) ); + $stream_connector->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $meta ) use ( &$logged_data ) { + $logged_data = array( + 'message' => $message, + 'meta' => $meta, + ); + } + ); + + $stream_connector->callback_activitypub_handled_follow( $activity, self::$user_id, false, $context ); + + $this->assertStringContainsString( 'New Follower: https://example.com/actor', $logged_data['message'] ); + } + + /** + * Test callback_activitypub_outbox_processing_complete method. + * + * @covers ::callback_activitypub_outbox_processing_complete + */ + public function test_callback_activitypub_outbox_processing_complete() { + if ( ! $this->is_stream_available() ) { + $this->markTestSkipped( 'Stream plugin is not available.' ); + } + + // Create a mock outbox post. + $outbox_post_id = $this->factory->post->create( + array( + 'post_type' => 'ap_outbox', + 'post_title' => get_permalink( self::$post_id ), + ) + ); + + $inboxes = array( 'https://example.com/inbox' ); + $json = '{"type":"Create","object":{"type":"Note"}}'; + + // Capture the log call. + $logged_data = null; + $stream_connector = $this->createPartialMock( Stream_Connector::class, array( 'log' ) ); + $stream_connector->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $meta, $object_id, $object_type, $action ) use ( &$logged_data ) { + $logged_data = array( + 'message' => $message, + 'meta' => $meta, + 'object_id' => $object_id, + 'object_type' => $object_type, + 'action' => $action, + ); + } + ); + + $stream_connector->callback_activitypub_outbox_processing_complete( + $inboxes, + $json, + self::$user_id, + $outbox_post_id + ); + + $this->assertNotNull( $logged_data, 'Should have logged the outbox processing complete event' ); + $this->assertStringContainsString( 'Outbox processing complete:', $logged_data['message'] ); + $this->assertEquals( 'processed', $logged_data['action'] ); + $this->assertArrayHasKey( 'debug', $logged_data['meta'] ); + + // Clean up. + wp_delete_post( $outbox_post_id, true ); + } + + /** + * Test callback_activitypub_outbox_processing_batch_complete method. + * + * @covers ::callback_activitypub_outbox_processing_batch_complete + */ + public function test_callback_activitypub_outbox_processing_batch_complete() { + if ( ! $this->is_stream_available() ) { + $this->markTestSkipped( 'Stream plugin is not available.' ); + } + + // Create a mock outbox post. + $outbox_post_id = $this->factory->post->create( + array( + 'post_type' => 'ap_outbox', + 'post_title' => get_permalink( self::$post_id ), + ) + ); + + $inboxes = array( 'https://example.com/inbox' ); + $json = '{"type":"Create","object":{"type":"Note"}}'; + $batch_size = 10; + $offset = 0; + + // Capture the log call. + $logged_data = null; + $stream_connector = $this->createPartialMock( Stream_Connector::class, array( 'log' ) ); + $stream_connector->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $meta, $object_id, $object_type, $action ) use ( &$logged_data ) { + $logged_data = array( + 'message' => $message, + 'meta' => $meta, + 'object_id' => $object_id, + 'object_type' => $object_type, + 'action' => $action, + ); + } + ); + + $stream_connector->callback_activitypub_outbox_processing_batch_complete( + $inboxes, + $json, + self::$user_id, + $outbox_post_id, + $batch_size, + $offset + ); + + $this->assertNotNull( $logged_data, 'Should have logged the batch processing complete event' ); + $this->assertStringContainsString( 'Outbox processing batch complete:', $logged_data['message'] ); + $this->assertEquals( 'processed', $logged_data['action'] ); + $this->assertArrayHasKey( 'debug', $logged_data['meta'] ); + + $debug_data = json_decode( $logged_data['meta']['debug'], true ); + $this->assertEquals( $batch_size, $debug_data['batch_size'] ); + $this->assertEquals( $offset, $debug_data['offset'] ); + + // Clean up. + wp_delete_post( $outbox_post_id, true ); + } + + /** + * Test prepare_outbox_data_for_response method with various scenarios. + * + * @dataProvider prepare_outbox_data_provider + * @covers ::prepare_outbox_data_for_response + * + * @param string $post_title The outbox post title (URL). + * @param array $expected_data The expected response data. + * @param string $description Description of the test case. + */ + public function test_prepare_outbox_data_for_response( $post_title, $expected_data, $description ) { + // Create a mock outbox post. + $outbox_post = (object) array( + 'ID' => 123, + 'post_type' => 'ap_outbox', + 'post_title' => $post_title, + ); + + $reflection = new \ReflectionClass( Stream_Connector::class ); + $method = $reflection->getMethod( 'prepare_outbox_data_for_response' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->stream_connector, $outbox_post ); + + $this->assertEquals( $expected_data['type'], $result['type'], $description . ' - type' ); + $this->assertEquals( $expected_data['id'], $result['id'], $description . ' - id' ); + + if ( isset( $expected_data['title'] ) ) { + $this->assertEquals( $expected_data['title'], $result['title'], $description . ' - title' ); + } + } + + /** + * Data provider for prepare_outbox_data_for_response tests. + * + * @return array Test cases. + */ + public function prepare_outbox_data_provider() { + // Since data providers run before wpSetUpBeforeClass, we need to handle this differently. + // For now, let's test the fallback behavior and known cases. + return array( + + 'application_user_url' => array( + 'http://localhost:8889/?author=-1', + array( + 'id' => 123, // Should fallback to outbox post ID since -1 might not be recognized. + 'type' => 'ap_outbox', // Should fallback to outbox type. + 'title' => 'http://localhost:8889/?author=-1', // Should fallback to URL as title. + ), + 'Should handle application user URL correctly (fallback behavior)', + ), + 'unknown_url' => array( + 'https://unknown.example.com/path', + array( + 'id' => 123, // The outbox post ID. + 'type' => 'ap_outbox', + 'title' => 'https://unknown.example.com/path', + ), + 'Should fallback to outbox post data for unknown URLs', + ), + ); + } + + /** + * Test prepare_outbox_data_for_response with actual post URL. + * + * @covers ::prepare_outbox_data_for_response + */ + public function test_prepare_outbox_data_for_response_post_url() { + // Create a mock outbox post with actual post URL. + $post_url = get_permalink( self::$post_id ); + $outbox_post = (object) array( + 'ID' => 123, + 'post_type' => 'ap_outbox', + 'post_title' => $post_url, + ); + + $reflection = new \ReflectionClass( Stream_Connector::class ); + $method = $reflection->getMethod( 'prepare_outbox_data_for_response' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->stream_connector, $outbox_post ); + + // If url_to_postid works, it should return the post data, otherwise fallback to outbox data. + if ( url_to_postid( $post_url ) === self::$post_id ) { + $this->assertEquals( 'post', $result['type'] ); + $this->assertEquals( self::$post_id, $result['id'] ); + $this->assertEquals( 'Test Post for Stream Connector', $result['title'] ); + } else { + // Fallback to outbox post data. + $this->assertEquals( 'ap_outbox', $result['type'] ); + $this->assertEquals( 123, $result['id'] ); + } + } + + /** + * Test prepare_outbox_data_for_response with actual comment URL. + * + * @covers ::prepare_outbox_data_for_response + */ + public function test_prepare_outbox_data_for_response_comment_url() { + // Create a mock outbox post with actual comment URL. + $comment_url = get_comment_link( self::$comment_id ); + $outbox_post = (object) array( + 'ID' => 123, + 'post_type' => 'ap_outbox', + 'post_title' => $comment_url, + ); + + $reflection = new \ReflectionClass( Stream_Connector::class ); + $method = $reflection->getMethod( 'prepare_outbox_data_for_response' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->stream_connector, $outbox_post ); + + // Check what the comment URL looks like and determine expected behavior. + if ( \function_exists( '\Activitypub\url_to_commentid' ) ) { + $comment_id_from_url = \Activitypub\url_to_commentid( $comment_url ); + if ( $comment_id_from_url === self::$comment_id ) { + // Comment ID was parsed correctly. + $this->assertEquals( 'comments', $result['type'] ); + $this->assertEquals( self::$comment_id, $result['id'] ); + $this->assertEquals( 'Test comment for Stream Connector', $result['title'] ); + } else { + // Check if it's being parsed as a post URL instead. + $post_id_from_url = url_to_postid( $comment_url ); + if ( $post_id_from_url === self::$post_id ) { + // Comment URL is being parsed as post URL. + $this->assertEquals( 'post', $result['type'] ); + $this->assertEquals( self::$post_id, $result['id'] ); + } else { + // Fallback to outbox post data. + $this->assertEquals( 'ap_outbox', $result['type'] ); + $this->assertEquals( 123, $result['id'] ); + } + } + } else { + // Function doesn't exist, check if url_to_postid recognizes it. + $post_id_from_url = url_to_postid( $comment_url ); + if ( $post_id_from_url === self::$post_id ) { + // Comment URL is being parsed as post URL. + $this->assertEquals( 'post', $result['type'] ); + $this->assertEquals( self::$post_id, $result['id'] ); + } else { + // Fallback to outbox post data. + $this->assertEquals( 'ap_outbox', $result['type'] ); + $this->assertEquals( 123, $result['id'] ); + } + } + } + + /** + * Test prepare_outbox_data_for_response with actual author URL. + * + * @covers ::prepare_outbox_data_for_response + */ + public function test_prepare_outbox_data_for_response_author_url() { + // Create a mock outbox post with actual author URL. + $author_url = get_author_posts_url( self::$user_id ); + $outbox_post = (object) array( + 'ID' => 123, + 'post_type' => 'ap_outbox', + 'post_title' => $author_url, + ); + + $reflection = new \ReflectionClass( Stream_Connector::class ); + $method = $reflection->getMethod( 'prepare_outbox_data_for_response' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->stream_connector, $outbox_post ); + + // If url_to_authorid works, it should return the author data, otherwise fallback to outbox data. + if ( \function_exists( '\Activitypub\url_to_authorid' ) ) { + $author_id = \Activitypub\url_to_authorid( $author_url ); + if ( $author_id === self::$user_id ) { + $this->assertEquals( 'profiles', $result['type'] ); + $this->assertEquals( self::$user_id, $result['id'] ); + $this->assertEquals( 'Test Author', $result['title'] ); + } else { + // Fallback to outbox post data. + $this->assertEquals( 'ap_outbox', $result['type'] ); + $this->assertEquals( 123, $result['id'] ); + } + } else { + // Function doesn't exist, should fallback to outbox data. + $this->assertEquals( 'ap_outbox', $result['type'] ); + $this->assertEquals( 123, $result['id'] ); + } + } + + /** + * Test prepare_outbox_data_for_response with blog user URL. + * + * @covers ::prepare_outbox_data_for_response + */ + public function test_prepare_outbox_data_for_response_blog_user_url() { + // Test blog user URL - this may work differently in different environments. + $blog_user_url = 'http://localhost:8889/?author=0'; + $outbox_post = (object) array( + 'ID' => 123, + 'post_type' => 'ap_outbox', + 'post_title' => $blog_user_url, + ); + + $reflection = new \ReflectionClass( Stream_Connector::class ); + $method = $reflection->getMethod( 'prepare_outbox_data_for_response' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->stream_connector, $outbox_post ); + + // Check if url_to_authorid recognized the blog user URL. + if ( \function_exists( '\Activitypub\url_to_authorid' ) ) { + $author_id = \Activitypub\url_to_authorid( $blog_user_url ); + if ( 0 === $author_id ) { + // Blog user URL was recognized correctly. + $this->assertEquals( 'profiles', $result['type'] ); + $this->assertEquals( 0, $result['id'] ); + $this->assertEquals( 'Blog User', $result['title'] ); + } else { + // Blog user URL was not recognized, should fallback to outbox data. + $this->assertEquals( 'ap_outbox', $result['type'] ); + $this->assertEquals( 123, $result['id'] ); + $this->assertEquals( $blog_user_url, $result['title'] ); + } + } else { + // Function doesn't exist, should fallback to outbox data. + $this->assertEquals( 'ap_outbox', $result['type'] ); + $this->assertEquals( 123, $result['id'] ); + $this->assertEquals( $blog_user_url, $result['title'] ); + } + } + + /** + * Test that the Stream Connector properly extends WP_Stream\Connector. + * + * This test ensures the class hierarchy is correct. + */ + public function test_class_hierarchy() { + $this->assertInstanceOf( '\WP_Stream\Connector', $this->stream_connector ); + } +} + +// Mock WP_Stream\Connector if it doesn't exist. +if ( ! class_exists( 'WP_Stream\Connector' ) ) { + // phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound + /** + * Mock WP_Stream Connector class for testing. + * + * @package Activitypub + * + * phpcs:ignore WordPress.Files.FileName.InvalidClassFileName + */ + class WP_Stream_Connector_Mock { + /** + * Connector name. + * + * @var string + */ + public $name = ''; + + /** + * Actions registered for this connector. + * + * @var array + */ + public $actions = array(); + + /** + * Get connector label. + * + * @return string + */ + public function get_label() { + return ''; + } + + /** + * Get context labels. + * + * @return array + */ + public function get_context_labels() { + return array(); + } + + /** + * Get action labels. + * + * @return array + */ + public function get_action_labels() { + return array(); + } + + /** + * Check if dependency is satisfied. + * + * @return bool + */ + public function is_dependency_satisfied() { + return true; + } + + /** + * Log activity. + * + * @param array $args Log arguments. + */ + public function log( $args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + // Mock implementation - parameter intentionally unused. + } + } + + // Create the namespace aliases. + class_alias( 'Activitypub\Tests\Integration\WP_Stream_Connector_Mock', 'WP_Stream\Connector' ); +} + +// Mock WP_Stream\Record if it doesn't exist. +if ( ! class_exists( 'WP_Stream\Record' ) ) { + // phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound + /** + * Mock WP_Stream Record class for testing. + * + * @package Activitypub + * + * phpcs:ignore WordPress.Files.FileName.InvalidClassFileName + */ + class WP_Stream_Record_Mock { + /** + * Record action. + * + * @var string + */ + public $action = ''; + + /** + * Get meta value. + * + * @param string $key Meta key. + * @param bool $single Whether to return single value. + * + * @return string + */ + public function get_meta( $key, $single = false ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + // Mock implementation - parameters intentionally unused. + return ''; + } + } + + // Create the namespace aliases. + class_alias( 'Activitypub\Tests\Integration\WP_Stream_Record_Mock', 'WP_Stream\Record' ); +}