@@ -935,4 +935,215 @@ public function test_regular_inbox_action_with_shared_inbox_context() {
935935 \remove_action ( 'activitypub_inbox ' , $ inbox_action );
936936 \delete_option ( 'activitypub_actor_mode ' );
937937 }
938+
939+ /**
940+ * Test get_local_recipients combines followers and explicit recipients for public activities.
941+ *
942+ * @covers ::get_local_recipients
943+ */
944+ public function test_get_local_recipients_public_activity_with_explicit_recipients () {
945+ // Enable actor mode to allow user actors.
946+ \update_option ( 'activitypub_actor_mode ' , ACTIVITYPUB_ACTOR_MODE );
947+
948+ // Create test users (authors have activitypub capability by default).
949+ $ follower_user_id = self ::factory ()->user ->create ( array ( 'role ' => 'author ' ) );
950+ $ mentioned_user_id = self ::factory ()->user ->create ( array ( 'role ' => 'author ' ) );
951+
952+ // Get actor IDs.
953+ $ mentioned_actor = Actors::get_by_id ( $ mentioned_user_id );
954+ $ mentioned_actor_id = $ mentioned_actor ->get_id ();
955+
956+ // Create a remote actor and make follower_user follow them.
957+ $ remote_actor_url = 'https://example.com/actor/combined-test ' ;
958+
959+ // Mock the remote actor fetch.
960+ $ remote_object_filter = function ( $ pre , $ url ) use ( $ remote_actor_url ) {
961+ if ( $ url === $ remote_actor_url ) {
962+ return array (
963+ '@context ' => 'https://www.w3.org/ns/activitystreams ' ,
964+ 'id ' => $ remote_actor_url ,
965+ 'type ' => 'Person ' ,
966+ 'preferredUsername ' => 'testactor ' ,
967+ 'name ' => 'Test Actor ' ,
968+ 'inbox ' => 'https://example.com/actor/combined-test/inbox ' ,
969+ );
970+ }
971+ return $ pre ;
972+ };
973+ \add_filter ( 'activitypub_pre_http_get_remote_object ' , $ remote_object_filter , 10 , 2 );
974+
975+ $ remote_actor = \Activitypub \Collection \Remote_Actors::fetch_by_uri ( $ remote_actor_url );
976+
977+ // Make follower_user follow the remote actor.
978+ \add_post_meta ( $ remote_actor ->ID , '_activitypub_followed_by ' , $ follower_user_id );
979+
980+ // Public activity that explicitly addresses mentioned_user (who is NOT a follower).
981+ $ activity = array (
982+ 'type ' => 'Create ' ,
983+ 'actor ' => $ remote_actor_url ,
984+ 'to ' => array (
985+ 'https://www.w3.org/ns/activitystreams#Public ' ,
986+ $ mentioned_actor_id ,
987+ ),
988+ );
989+
990+ // Use reflection to test the private method.
991+ $ reflection = new \ReflectionClass ( $ this ->inbox_controller );
992+ $ method = $ reflection ->getMethod ( 'get_local_recipients ' );
993+ if ( \PHP_VERSION_ID < 80100 ) {
994+ $ method ->setAccessible ( true );
995+ }
996+
997+ $ result = $ method ->invoke ( $ this ->inbox_controller , $ activity );
998+
999+ // Should return BOTH the follower AND the explicitly mentioned user.
1000+ $ this ->assertNotEmpty ( $ result , 'Should return recipients for public activity with explicit addressing ' );
1001+ $ this ->assertContains ( $ follower_user_id , $ result , 'Should contain follower ' );
1002+ $ this ->assertContains ( $ mentioned_user_id , $ result , 'Should contain explicitly mentioned user ' );
1003+
1004+ // Verify no duplicates if someone is both a follower and explicitly mentioned.
1005+ $ this ->assertEquals ( count ( $ result ), count ( array_unique ( $ result ) ), 'Should not contain duplicate user IDs ' );
1006+
1007+ // Clean up.
1008+ \delete_option ( 'activitypub_actor_mode ' );
1009+ \remove_filter ( 'activitypub_pre_http_get_remote_object ' , $ remote_object_filter );
1010+ }
1011+
1012+ /**
1013+ * Test get_local_recipients deduplicates when user is both follower and explicit recipient.
1014+ *
1015+ * @covers ::get_local_recipients
1016+ */
1017+ public function test_get_local_recipients_deduplicates_follower_and_explicit () {
1018+ // Enable actor mode to allow user actors.
1019+ \update_option ( 'activitypub_actor_mode ' , ACTIVITYPUB_ACTOR_MODE );
1020+
1021+ // Create test user (author has activitypub capability by default).
1022+ $ user_id = self ::factory ()->user ->create ( array ( 'role ' => 'author ' ) );
1023+
1024+ // Get actor ID.
1025+ $ user_actor = Actors::get_by_id ( $ user_id );
1026+ $ user_actor_id = $ user_actor ->get_id ();
1027+
1028+ // Create a remote actor and make user follow them.
1029+ $ remote_actor_url = 'https://example.com/actor/dedup-test ' ;
1030+
1031+ // Mock the remote actor fetch.
1032+ $ remote_object_filter = function ( $ pre , $ url ) use ( $ remote_actor_url ) {
1033+ if ( $ url === $ remote_actor_url ) {
1034+ return array (
1035+ '@context ' => 'https://www.w3.org/ns/activitystreams ' ,
1036+ 'id ' => $ remote_actor_url ,
1037+ 'type ' => 'Person ' ,
1038+ 'preferredUsername ' => 'testactor ' ,
1039+ 'name ' => 'Test Actor ' ,
1040+ 'inbox ' => 'https://example.com/actor/dedup-test/inbox ' ,
1041+ );
1042+ }
1043+ return $ pre ;
1044+ };
1045+ \add_filter ( 'activitypub_pre_http_get_remote_object ' , $ remote_object_filter , 10 , 2 );
1046+
1047+ $ remote_actor = \Activitypub \Collection \Remote_Actors::fetch_by_uri ( $ remote_actor_url );
1048+
1049+ // Make user follow the remote actor.
1050+ \add_post_meta ( $ remote_actor ->ID , '_activitypub_followed_by ' , $ user_id );
1051+
1052+ // Public activity that ALSO explicitly addresses the same user.
1053+ $ activity = array (
1054+ 'type ' => 'Create ' ,
1055+ 'actor ' => $ remote_actor_url ,
1056+ 'to ' => array (
1057+ 'https://www.w3.org/ns/activitystreams#Public ' ,
1058+ $ user_actor_id , // User is both follower AND explicitly addressed.
1059+ ),
1060+ );
1061+
1062+ // Use reflection to test the private method.
1063+ $ reflection = new \ReflectionClass ( $ this ->inbox_controller );
1064+ $ method = $ reflection ->getMethod ( 'get_local_recipients ' );
1065+ if ( \PHP_VERSION_ID < 80100 ) {
1066+ $ method ->setAccessible ( true );
1067+ }
1068+
1069+ $ result = $ method ->invoke ( $ this ->inbox_controller , $ activity );
1070+
1071+ // Should return the user only once (no duplicates).
1072+ $ this ->assertContains ( $ user_id , $ result , 'Should contain user ' );
1073+
1074+ // Count occurrences of user_id in result.
1075+ $ occurrences = count ( array_keys ( $ result , $ user_id , true ) );
1076+ $ this ->assertEquals ( 1 , $ occurrences , 'User should appear exactly once in result ' );
1077+
1078+ // Clean up.
1079+ \delete_option ( 'activitypub_actor_mode ' );
1080+ \remove_filter ( 'activitypub_pre_http_get_remote_object ' , $ remote_object_filter );
1081+ }
1082+
1083+ /**
1084+ * Test get_local_recipients for non-public activity with followers.
1085+ *
1086+ * @covers ::get_local_recipients
1087+ */
1088+ public function test_get_local_recipients_non_public_activity_ignores_followers () {
1089+ // Enable actor mode to allow user actors.
1090+ \update_option ( 'activitypub_actor_mode ' , ACTIVITYPUB_ACTOR_MODE );
1091+
1092+ // Create test users.
1093+ $ follower_user_id = self ::factory ()->user ->create ( array ( 'role ' => 'author ' ) );
1094+ $ mentioned_user_id = self ::factory ()->user ->create ( array ( 'role ' => 'author ' ) );
1095+
1096+ // Get actor ID for mentioned user.
1097+ $ mentioned_actor = Actors::get_by_id ( $ mentioned_user_id );
1098+ $ mentioned_actor_id = $ mentioned_actor ->get_id ();
1099+
1100+ // Create a remote actor and make follower_user follow them.
1101+ $ remote_actor_url = 'https://example.com/actor/non-public-test ' ;
1102+
1103+ // Mock the remote actor fetch.
1104+ $ remote_object_filter = function ( $ pre , $ url ) use ( $ remote_actor_url ) {
1105+ if ( $ url === $ remote_actor_url ) {
1106+ return array (
1107+ '@context ' => 'https://www.w3.org/ns/activitystreams ' ,
1108+ 'id ' => $ remote_actor_url ,
1109+ 'type ' => 'Person ' ,
1110+ 'preferredUsername ' => 'testactor ' ,
1111+ 'name ' => 'Test Actor ' ,
1112+ 'inbox ' => 'https://example.com/actor/non-public-test/inbox ' ,
1113+ );
1114+ }
1115+ return $ pre ;
1116+ };
1117+ \add_filter ( 'activitypub_pre_http_get_remote_object ' , $ remote_object_filter , 10 , 2 );
1118+
1119+ $ remote_actor = \Activitypub \Collection \Remote_Actors::fetch_by_uri ( $ remote_actor_url );
1120+
1121+ // Make follower_user follow the remote actor.
1122+ \add_post_meta ( $ remote_actor ->ID , '_activitypub_followed_by ' , $ follower_user_id );
1123+
1124+ // Non-public activity (direct message) that explicitly addresses only mentioned_user.
1125+ $ activity = array (
1126+ 'type ' => 'Create ' ,
1127+ 'actor ' => $ remote_actor_url ,
1128+ 'to ' => array ( $ mentioned_actor_id ), // Only mentioned user, NOT public.
1129+ );
1130+
1131+ // Use reflection to test the private method.
1132+ $ reflection = new \ReflectionClass ( $ this ->inbox_controller );
1133+ $ method = $ reflection ->getMethod ( 'get_local_recipients ' );
1134+ if ( \PHP_VERSION_ID < 80100 ) {
1135+ $ method ->setAccessible ( true );
1136+ }
1137+
1138+ $ result = $ method ->invoke ( $ this ->inbox_controller , $ activity );
1139+
1140+ // Should return ONLY the mentioned user, NOT the follower.
1141+ $ this ->assertNotEmpty ( $ result , 'Should return explicitly addressed recipient ' );
1142+ $ this ->assertContains ( $ mentioned_user_id , $ result , 'Should contain mentioned user ' );
1143+ $ this ->assertNotContains ( $ follower_user_id , $ result , 'Should NOT contain follower for non-public activity ' );
1144+
1145+ // Clean up.
1146+ \delete_option ( 'activitypub_actor_mode ' );
1147+ \remove_filter ( 'activitypub_pre_http_get_remote_object ' , $ remote_object_filter );
1148+ }
9381149}
0 commit comments