@@ -269,6 +269,75 @@ function () use ( $actor ) {
269269 remove_all_filters ( 'pre_get_remote_metadata_by_actor ' );
270270 }
271271
272+ /**
273+ * Test that duplicate follow requests don't trigger notifications.
274+ *
275+ * @covers ::handle_follow
276+ */
277+ public function test_duplicate_follow_no_notification () {
278+ $ actor_url = 'https://example.com/duplicate-actor ' ;
279+
280+ // Mock HTTP requests for actor metadata.
281+ $ mock_actor_callback = function () use ( $ actor_url ) {
282+ return array (
283+ 'id ' => $ actor_url ,
284+ 'actor ' => $ actor_url ,
285+ 'type ' => 'Person ' ,
286+ 'preferredUsername ' => 'duplicateactor ' ,
287+ 'inbox ' => str_replace ( '/actor ' , '/inbox ' , $ actor_url ),
288+ );
289+ };
290+ \add_filter ( 'pre_get_remote_metadata_by_actor ' , $ mock_actor_callback );
291+
292+ $ local_actor = Actors::get_by_id ( self ::$ user_id );
293+ $ activity_object = array (
294+ 'id ' => $ actor_url . '/activity/follow-1 ' ,
295+ 'type ' => 'Follow ' ,
296+ 'actor ' => $ actor_url ,
297+ 'object ' => $ local_actor ->get_id (),
298+ );
299+
300+ // Track calls to the handled_follow action.
301+ $ handled_follow_calls = array ();
302+ $ test_callback = function ( $ activity , $ user_ids , $ success , $ remote_actor ) use ( &$ handled_follow_calls ) {
303+ $ handled_follow_calls [] = array (
304+ 'activity ' => $ activity ,
305+ 'user_ids ' => $ user_ids ,
306+ 'success ' => $ success ,
307+ 'remote_actor ' => $ remote_actor ,
308+ );
309+ };
310+ \add_action ( 'activitypub_handled_follow ' , $ test_callback , 10 , 4 );
311+
312+ // First follow request - should succeed.
313+ Follow::handle_follow ( $ activity_object , self ::$ user_id );
314+
315+ // Verify first follow was successful.
316+ $ this ->assertCount ( 1 , $ handled_follow_calls , 'First follow should trigger the action ' );
317+ $ this ->assertTrue ( $ handled_follow_calls [0 ]['success ' ], 'First follow should be successful ' );
318+
319+ // Verify follower was added.
320+ $ followers = Followers::get_many ( self ::$ user_id );
321+ $ follower_actors = wp_list_pluck ( $ followers , 'guid ' );
322+ $ this ->assertContains ( $ actor_url , $ follower_actors , 'Follower should be added ' );
323+
324+ // Second follow request with a different activity ID (simulating a retry).
325+ $ activity_object ['id ' ] = $ actor_url . '/activity/follow-2 ' ;
326+ Follow::handle_follow ( $ activity_object , self ::$ user_id );
327+
328+ // Verify second follow was not successful (to prevent duplicate notification).
329+ $ this ->assertCount ( 2 , $ handled_follow_calls , 'Second follow should also trigger the action ' );
330+ $ this ->assertFalse ( $ handled_follow_calls [1 ]['success ' ], 'Second follow should NOT be successful to prevent duplicate notification ' );
331+
332+ // Verify follower count didn't change.
333+ $ followers_after = Followers::get_many ( self ::$ user_id );
334+ $ this ->assertCount ( count ( $ followers ), $ followers_after , 'Follower count should not change on duplicate follow ' );
335+
336+ // Clean up.
337+ \remove_filter ( 'pre_get_remote_metadata_by_actor ' , $ mock_actor_callback );
338+ \remove_action ( 'activitypub_handled_follow ' , $ test_callback , 10 );
339+ }
340+
272341 /**
273342 * Test queue_reject method.
274343 *
0 commit comments