Ver Fonte

converted buddy arrival/departure funcs to methods

This commit converts the following functions into methods on the Buddy
service:

- broadcastArrival
- broadcastDeparture
- unicastArrival
- unicastDeparture

These functions required extensive mocking of the MessageRelayer,
FeedbagManager, and LegacyBuddyListManager for each call.

Now that these functions are methods, it's easier to mock them rather
than mock all of their dependencies. This reduces a lot of effort needed
to write unit tests for functions that send buddy arrival/departure
notifications.
Mike há 2 anos atrás
pai
commit
331b64f519

+ 3 - 0
.mockery.yaml

@@ -104,3 +104,6 @@ packages:
       CookieIssuer:
         config:
           filename: "mock_cookie_issuer_test.go"
+      BuddyBroadcaster:
+        config:
+          filename: "mock_buddy_broadcaster_test.go"

+ 16 - 13
cmd/server/main.go

@@ -52,16 +52,16 @@ func main() {
 	}()
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "BOS")
-		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker)
-		bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore, adjListBuddyListStore)
 		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
-		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore, adjListBuddyListStore, logger, cookieBaker)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, buddyService)
+		bartService := foodgroup.NewBARTService(logger, feedbagStore, buddyService)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, adjListBuddyListStore, logger, cookieBaker, buddyService)
 		oServiceServiceForBOS := foodgroup.NewOServiceServiceForBOS(*oServiceService, chatRegistry)
-		locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore)
+		locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore, buddyService)
 		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
 		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
-		feedbagService := foodgroup.NewFeedbagService(logger, sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore)
-		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore, adjListBuddyListStore)
+		feedbagService := foodgroup.NewFeedbagService(logger, sessionManager, feedbagStore, feedbagStore, buddyService)
+		icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore, buddyService)
 		foodgroupService := foodgroup.NewPermitDenyService()
 
 		oscar.BOSServer{
@@ -87,8 +87,9 @@ func main() {
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT")
-		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker)
-		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore, adjListBuddyListStore, logger, cookieBaker)
+		buddyService := foodgroup.NewBuddyService(nil, feedbagStore, adjListBuddyListStore)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, buddyService)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, adjListBuddyListStore, logger, cookieBaker, buddyService)
 		chatService := foodgroup.NewChatService(chatRegistry)
 		oServiceServiceForChat := foodgroup.NewOServiceServiceForChat(*oServiceService, chatRegistry)
 
@@ -108,8 +109,9 @@ func main() {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT_NAV")
 		sessionManager := state.NewInMemorySessionManager(logger)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker)
-		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore, adjListBuddyListStore, logger, cookieBaker)
+		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, buddyService)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, adjListBuddyListStore, logger, cookieBaker, buddyService)
 		oServiceServiceForChatNav := foodgroup.NewOServiceServiceForChatNav(*oServiceService, chatRegistry)
 		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
 		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
@@ -131,8 +133,9 @@ func main() {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "ALERT")
 		sessionManager := state.NewInMemorySessionManager(logger)
-		authService := foodgroup.NewAuthService(cfg, sessionManager, sessionManager, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker)
-		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, feedbagStore, adjListBuddyListStore, logger, cookieBaker)
+		buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore)
+		authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, buddyService)
+		oServiceService := foodgroup.NewOServiceService(cfg, sessionManager, adjListBuddyListStore, logger, cookieBaker, buddyService)
 		oServiceServiceForAlert := foodgroup.NewOServiceServiceForAlert(*oServiceService)
 
 		oscar.BOSServer{
@@ -151,7 +154,7 @@ func main() {
 	}(logger)
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "AUTH")
-		authHandler := foodgroup.NewAuthService(cfg, sessionManager, nil, feedbagStore, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker)
+		authHandler := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, nil)
 
 		oscar.AuthServer{
 			AuthService:   authHandler,

+ 1 - 1
config/settings.env

@@ -34,7 +34,7 @@ export FAIL_FAST=false
 
 # Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn',
 # 'error'.
-export LOG_LEVEL=debug
+export LOG_LEVEL=info
 
 # The hostname that AIM clients connect to in order to reach OSCAR services
 # (auth, BOS, BUCP, etc). Make sure the hostname is reachable by all clients.

+ 5 - 8
foodgroup/auth.go

@@ -18,22 +18,20 @@ import (
 func NewAuthService(
 	cfg config.Config,
 	sessionManager SessionManager,
-	messageRelayer MessageRelayer,
-	feedbagManager FeedbagManager,
 	userManager UserManager,
 	chatRegistry ChatRegistry,
 	legacyBuddyListManager LegacyBuddyListManager,
 	cookieIssuer CookieIssuer,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) *AuthService {
 	return &AuthService{
 		chatRegistry:           chatRegistry,
 		config:                 cfg,
-		feedbagManager:         feedbagManager,
 		legacyBuddyListManager: legacyBuddyListManager,
-		messageRelayer:         messageRelayer,
 		sessionManager:         sessionManager,
 		userManager:            userManager,
 		cookieIssuer:           cookieIssuer,
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 	}
 }
 
@@ -41,14 +39,13 @@ func NewAuthService(
 // supports both FLAP (AIM v1.0-v3.0) and BUCP (AIM v3.5-v5.9) authentication
 // modes.
 type AuthService struct {
+	buddyUpdateBroadcaster BuddyBroadcaster
 	chatRegistry           ChatRegistry
 	config                 config.Config
-	feedbagManager         FeedbagManager
+	cookieIssuer           CookieIssuer
 	legacyBuddyListManager LegacyBuddyListManager
-	messageRelayer         MessageRelayer
 	sessionManager         SessionManager
 	userManager            UserManager
-	cookieIssuer           CookieIssuer
 }
 
 // RegisterChatSession creates and returns a chat room session.
@@ -74,7 +71,7 @@ func (s AuthService) RegisterBOSSession(sessionID string) (*state.Session, error
 // Signout removes this user's session and notifies users who have this user on
 // their buddy list about this user's departure.
 func (s AuthService) Signout(ctx context.Context, sess *state.Session) error {
-	if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+	if err := s.buddyUpdateBroadcaster.BroadcastBuddyDeparted(ctx, sess); err != nil {
 		return err
 	}
 	s.sessionManager.RemoveSession(sess)

+ 16 - 75
foodgroup/auth_test.go

@@ -958,7 +958,7 @@ func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) {
 
 	cookieIssuer := newMockCookieIssuer(t)
 
-	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry, nil, cookieIssuer)
+	svc := NewAuthService(config.Config{}, nil, nil, chatRegistry, nil, cookieIssuer, nil)
 
 	have, err := svc.RegisterChatSession(buf.Bytes())
 	assert.NoError(t, err)
@@ -982,7 +982,7 @@ func TestAuthService_RegisterBOSSession_ChatNotFound(t *testing.T) {
 		Return(state.ChatRoom{}, nil, state.ErrChatRoomNotFound)
 
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry, nil, cookieIssuer)
+	svc := NewAuthService(config.Config{}, nil, nil, chatRegistry, nil, cookieIssuer, nil)
 
 	_, err := svc.RegisterChatSession(loginCookie.Bytes())
 	assert.ErrorIs(t, err, state.ErrChatRoomNotFound)
@@ -998,7 +998,7 @@ func TestAuthService_RegisterBOSSession_HappyPath(t *testing.T) {
 
 	cookieIssuer := newMockCookieIssuer(t)
 
-	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil, cookieIssuer)
+	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, cookieIssuer, nil)
 
 	have, err := svc.RegisterBOSSession(sess.ScreenName())
 	assert.NoError(t, err)
@@ -1015,7 +1015,7 @@ func TestAuthService_RegisterBOSSession_SessionNotFound(t *testing.T) {
 
 	cookieIssuer := newMockCookieIssuer(t)
 
-	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, nil, nil, cookieIssuer)
+	svc := NewAuthService(config.Config{}, sessionManager, nil, nil, nil, cookieIssuer, nil)
 
 	have, err := svc.RegisterBOSSession(sess.ScreenName())
 	assert.NoError(t, err)
@@ -1157,7 +1157,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 
 			cookieIssuer := newMockCookieIssuer(t)
 
-			svc := NewAuthService(config.Config{}, nil, nil, nil, nil, chatRegistry, nil, cookieIssuer)
+			svc := NewAuthService(config.Config{}, nil, nil, chatRegistry, nil, cookieIssuer, nil)
 
 			err := svc.SignoutChat(nil, tt.userSession)
 			assert.ErrorIs(t, err, tt.wantErr)
@@ -1173,8 +1173,6 @@ func TestAuthService_Signout(t *testing.T) {
 		name string
 		// userSession is the session of the user signing out
 		userSession *state.Session
-		// chatRoom is the chat room user is exiting
-		chatRoom state.ChatRoom
 		// wantErr is the error we expect from the method
 		wantErr error
 		// mockParams is the list of params sent to mocks that satisfy this
@@ -1184,9 +1182,6 @@ func TestAuthService_Signout(t *testing.T) {
 		{
 			name:        "user signs out of chat room, room is empty after user leaves",
 			userSession: sess,
-			chatRoom: state.ChatRoom{
-				Cookie: "the-chat-cookie",
-			},
 			mockParams: mockParams{
 				sessionManagerParams: sessionManagerParams{
 					removeSessionParams: removeSessionParams{
@@ -1195,80 +1190,25 @@ func TestAuthService_Signout(t *testing.T) {
 						},
 					},
 				},
-				feedbagManagerParams: feedbagManagerParams{
-					adjacentUsersParams: adjacentUsersParams{
-						{
-							screenName: "user_screen_name",
-							users:      []string{"friend1", "friend2"},
-						},
-					},
-				},
 				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
 					deleteUserParams: deleteUserParams{
 						{
 							userScreenName: "user_screen_name",
 						},
 					},
-					whoAddedUserParams: whoAddedUserParams{
-						{
-							userScreenName: "user_screen_name",
-						},
-					},
 				},
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNamesParams: relayToScreenNamesParams{
-						{
-							screenNames: []string{"friend1", "friend2"},
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyDeparted,
-								},
-								Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
-									TLVUserInfo: wire.TLVUserInfo{
-										ScreenName:   sess.ScreenName(),
-										WarningLevel: sess.Warning(),
-									},
-								},
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name:        "user signs out of chat room, feedbag lookup returns error",
-			userSession: sess,
-			chatRoom: state.ChatRoom{
-				Cookie: "the-chat-cookie",
-			},
-			mockParams: mockParams{
-				feedbagManagerParams: feedbagManagerParams{
-					adjacentUsersParams: adjacentUsersParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyDepartedParams: broadcastBuddyDepartedParams{
 						{
 							screenName: "user_screen_name",
-							users:      []string{"friend1", "friend2"},
-							err:        io.EOF,
 						},
 					},
 				},
 			},
-			wantErr: io.EOF,
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.mockParams.relayToScreenNamesParams {
-				messageRelayer.EXPECT().
-					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
-			}
-			feedbagManager := newMockFeedbagManager(t)
-			for _, params := range tt.mockParams.adjacentUsersParams {
-				feedbagManager.EXPECT().
-					AdjacentUsers(params.screenName).
-					Return(params.users, params.err)
-			}
 			sessionManager := newMockSessionManager(t)
 			for _, params := range tt.mockParams.removeSessionParams {
 				sessionManager.EXPECT().RemoveSession(params.sess)
@@ -1277,15 +1217,16 @@ func TestAuthService_Signout(t *testing.T) {
 			for _, params := range tt.mockParams.deleteUserParams {
 				legacyBuddyListManager.EXPECT().DeleteUser(params.userScreenName)
 			}
-			for _, params := range tt.mockParams.whoAddedUserParams {
-				legacyBuddyListManager.EXPECT().
-					WhoAddedUser(params.userScreenName).
-					Return(params.result)
+			buddyUpdateBroadcaster := newMockBuddyBroadcaster(t)
+			for _, params := range tt.mockParams.broadcastBuddyDepartedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyDeparted(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
 			}
-
-			cookieIssuer := newMockCookieIssuer(t)
-
-			svc := NewAuthService(config.Config{}, sessionManager, messageRelayer, feedbagManager, nil, nil, legacyBuddyListManager, cookieIssuer)
+			svc := NewAuthService(config.Config{}, sessionManager, nil, nil, legacyBuddyListManager, nil, buddyUpdateBroadcaster)
 
 			err := svc.Signout(nil, tt.userSession)
 			assert.ErrorIs(t, err, tt.wantErr)

+ 4 - 8
foodgroup/bart.go

@@ -22,22 +22,18 @@ var blankGIF = []byte{
 // errKnownIconsOnly indicates that a non-known buddy icon was requested
 var errKnownIconsOnly = errors.New("can only satisfy requests for known icons")
 
-func NewBARTService(logger *slog.Logger, bartManager BARTManager, messageRelayer MessageRelayer, feedbagManager FeedbagManager, legacyBuddyListManager LegacyBuddyListManager) BARTService {
+func NewBARTService(logger *slog.Logger, bartManager BARTManager, buddyUpdateBroadcaster BuddyBroadcaster) BARTService {
 	return BARTService{
 		bartManager:            bartManager,
-		feedbagManager:         feedbagManager,
-		legacyBuddyListManager: legacyBuddyListManager,
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		logger:                 logger,
-		messageRelayer:         messageRelayer,
 	}
 }
 
 type BARTService struct {
 	bartManager            BARTManager
-	feedbagManager         FeedbagManager
-	legacyBuddyListManager LegacyBuddyListManager
+	buddyUpdateBroadcaster BuddyBroadcaster
 	logger                 *slog.Logger
-	messageRelayer         MessageRelayer
 }
 
 func (s BARTService) UpsertItem(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x10_0x02_BARTUploadQuery) (wire.SNACMessage, error) {
@@ -53,7 +49,7 @@ func (s BARTService) UpsertItem(ctx context.Context, sess *state.Session, inFram
 
 	s.logger.DebugContext(ctx, "successfully uploaded buddy icon", "hash", fmt.Sprintf("%x", hash))
 
-	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
 		return wire.SNACMessage{}, err
 	}
 

+ 14 - 67
foodgroup/bart_test.go

@@ -38,19 +38,6 @@ func TestBARTService_UpsertItem(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				feedbagManagerParams: feedbagManagerParams{
-					adjacentUsersParams: adjacentUsersParams{
-						{
-							screenName: "user_screen_name",
-							users:      []string{"friend1"},
-						},
-					},
-					feedbagParams: feedbagParams{
-						{
-							screenName: "user_screen_name",
-						},
-					},
-				},
 				bartManagerParams: bartManagerParams{
 					bartManagerUpsertParams: bartManagerUpsertParams{
 						{
@@ -59,26 +46,10 @@ func TestBARTService_UpsertItem(t *testing.T) {
 						},
 					},
 				},
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNamesParams: relayToScreenNamesParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							screenNames: []string{"friend1"},
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("user_screen_name").TLVUserInfo(),
-								},
-							},
-						},
-					},
-				},
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
-						{
-							userScreenName: "user_screen_name",
+							screenName: "user_screen_name",
 						},
 					},
 				},
@@ -105,46 +76,22 @@ func TestBARTService_UpsertItem(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
-			for _, params := range tc.mockParams.feedbagManagerParams.feedbagUpsertParams {
-				feedbagManager.EXPECT().
-					FeedbagUpsert(params.screenName, params.items).
-					Return(nil)
-			}
-			for _, params := range tc.mockParams.feedbagManagerParams.adjacentUsersParams {
-				feedbagManager.EXPECT().
-					AdjacentUsers(params.screenName).
-					Return(params.users, params.err)
-			}
-			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
-				feedbagManager.EXPECT().Feedbag(params.screenName).Return(params.results, nil)
-			}
-			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
-				messageRelayer.EXPECT().
-					RelayToScreenName(mock.Anything, params.screenName, params.message)
-			}
-			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNamesParams {
-				messageRelayer.EXPECT().
-					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
-			}
 			bartManager := newMockBARTManager(t)
-			for _, params := range tc.mockParams.bartManagerParams.bartManagerUpsertParams {
+			for _, params := range tc.mockParams.bartManagerUpsertParams {
 				bartManager.EXPECT().
 					BARTUpsert(params.itemHash, params.payload).
 					Return(nil)
 			}
-			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
-			for _, params := range tc.mockParams.deleteUserParams {
-				legacyBuddyListManager.EXPECT().DeleteUser(params.userScreenName)
-			}
-			for _, params := range tc.mockParams.whoAddedUserParams {
-				legacyBuddyListManager.EXPECT().
-					WhoAddedUser(params.userScreenName).
-					Return(params.result)
+			buddyUpdateBroadcaster := newMockBuddyBroadcaster(t)
+			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
 			}
-
-			svc := NewBARTService(slog.Default(), bartManager, messageRelayer, feedbagManager, legacyBuddyListManager)
+			svc := NewBARTService(slog.Default(), bartManager, buddyUpdateBroadcaster)
 
 			output, err := svc.UpsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x10_0x02_BARTUploadQuery))
@@ -289,7 +236,7 @@ func TestBARTService_RetrieveItem(t *testing.T) {
 					Return(params.result, nil)
 			}
 
-			svc := NewBARTService(slog.Default(), bartManager, nil, nil, nil)
+			svc := NewBARTService(slog.Default(), bartManager, nil)
 
 			output, err := svc.RetrieveItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x10_0x04_BARTDownloadQuery))

+ 44 - 58
foodgroup/buddy.go

@@ -68,7 +68,7 @@ func (s BuddyService) AddBuddies(ctx context.Context, sess *state.Session, inBod
 			continue
 		}
 		// notify that buddy is online
-		if err := unicastArrival(ctx, buddy, sess, s.messageRelayer, s.feedbagManager); err != nil {
+		if err := s.UnicastBuddyArrived(ctx, buddy, sess); err != nil {
 			return err
 		}
 	}
@@ -81,30 +81,48 @@ func (s BuddyService) DelBuddies(_ context.Context, sess *state.Session, inBody
 	}
 }
 
-// broadcastArrival sends the latest user info to the user's adjacent users.
+// UnicastBuddyArrived sends the latest user info to a particular user.
 // While updates are sent via the wire.BuddyArrived SNAC, the message is not
 // only used to indicate the user coming online. It can also notify changes to
 // buddy icons, warning levels, invisibility status, etc.
-func broadcastArrival(
-	ctx context.Context,
-	sess *state.Session,
-	messageRelayer MessageRelayer,
-	feedbagManager FeedbagManager,
-	legacyBuddyListManager LegacyBuddyListManager,
-) error {
+func (s BuddyService) UnicastBuddyArrived(ctx context.Context, from *state.Session, to *state.Session) error {
+	userInfo := from.TLVUserInfo()
+	icon, err := getBuddyIconRefFromFeedbag(from, s.feedbagManager)
+	switch {
+	case err != nil:
+		return err
+	case icon != nil:
+		userInfo.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, *icon))
+	}
+	s.messageRelayer.RelayToScreenName(ctx, to.ScreenName(), wire.SNACMessage{
+		Frame: wire.SNACFrame{
+			FoodGroup: wire.Buddy,
+			SubGroup:  wire.BuddyArrived,
+		},
+		Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+			TLVUserInfo: userInfo,
+		},
+	})
+	return nil
+}
 
+// BroadcastBuddyArrived sends the latest user info to the user's adjacent users.
+// While updates are sent via the wire.BuddyArrived SNAC, the message is not
+// only used to indicate the user coming online. It can also notify changes to
+// buddy icons, warning levels, invisibility status, etc.
+func (s BuddyService) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
 	// find users who have this user on their server-side buddy list
-	recipients, err := feedbagManager.AdjacentUsers(sess.ScreenName())
+	recipients, err := s.feedbagManager.AdjacentUsers(sess.ScreenName())
 	if err != nil {
 		return err
 	}
 
 	// find users who have this user on their client-side buddy list
-	legacyUsers := legacyBuddyListManager.WhoAddedUser(sess.ScreenName())
+	legacyUsers := s.legacyBuddyListManager.WhoAddedUser(sess.ScreenName())
 	recipients = append(recipients, legacyUsers...)
 
 	userInfo := sess.TLVUserInfo()
-	icon, err := getBuddyIconRefFromFeedbag(sess, feedbagManager)
+	icon, err := getBuddyIconRefFromFeedbag(sess, s.feedbagManager)
 	switch {
 	case err != nil:
 		return err
@@ -112,7 +130,7 @@ func broadcastArrival(
 		userInfo.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, *icon))
 	}
 
-	messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
+	s.messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyArrived,
@@ -138,6 +156,13 @@ func getBuddyIconRefFromFeedbag(sess *state.Session, feedbagManager FeedbagManag
 		if item.ClassID != wire.FeedbagClassIdBart {
 			continue
 		}
+		bartType, err := extractBARTItemType(item)
+		if err != nil {
+			return nil, err
+		}
+		if bartType != wire.BARTTypesBuddyIcon {
+			continue
+		}
 		b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo)
 		if !hasBuf {
 			return nil, errors.New("unable to extract icon payload")
@@ -146,13 +171,6 @@ func getBuddyIconRefFromFeedbag(sess *state.Session, feedbagManager FeedbagManag
 		if err := wire.Unmarshal(&bartInfo, bytes.NewBuffer(b)); err != nil {
 			return nil, err
 		}
-		bartType, err := extractBARTItemType(item)
-		if err != nil {
-			return nil, err
-		}
-		if bartType != wire.BARTTypesBuddyIcon {
-			continue
-		}
 		return &wire.BARTID{
 			Type: bartType,
 			BARTInfo: wire.BARTInfo{
@@ -179,23 +197,16 @@ func extractBARTItemType(item wire.FeedbagItem) (uint16, error) {
 	return bartType, nil
 }
 
-func broadcastDeparture(
-	ctx context.Context,
-	sess *state.Session,
-	messageRelayer MessageRelayer,
-	feedbagManager FeedbagManager,
-	legacyBuddyListManager LegacyBuddyListManager,
-) error {
-
-	recipients, err := feedbagManager.AdjacentUsers(sess.ScreenName())
+func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
+	recipients, err := s.feedbagManager.AdjacentUsers(sess.ScreenName())
 	if err != nil {
 		return err
 	}
 
-	legacyUsers := legacyBuddyListManager.WhoAddedUser(sess.ScreenName())
+	legacyUsers := s.legacyBuddyListManager.WhoAddedUser(sess.ScreenName())
 	recipients = append(recipients, legacyUsers...)
 
-	messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
+	s.messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyDeparted,
@@ -213,33 +224,8 @@ func broadcastDeparture(
 	return nil
 }
 
-// unicastArrival sends the latest user info to a particular user.
-// While updates are sent via the wire.BuddyArrived SNAC, the message is not
-// only used to indicate the user coming online. It can also notify changes to
-// buddy icons, warning levels, invisibility status, etc.
-func unicastArrival(ctx context.Context, from *state.Session, to *state.Session, messageRelayer MessageRelayer, feedbagManager FeedbagManager) error {
-	userInfo := from.TLVUserInfo()
-	icon, err := getBuddyIconRefFromFeedbag(from, feedbagManager)
-	switch {
-	case err != nil:
-		return err
-	case icon != nil:
-		userInfo.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, *icon))
-	}
-	messageRelayer.RelayToScreenName(ctx, to.ScreenName(), wire.SNACMessage{
-		Frame: wire.SNACFrame{
-			FoodGroup: wire.Buddy,
-			SubGroup:  wire.BuddyArrived,
-		},
-		Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-			TLVUserInfo: userInfo,
-		},
-	})
-	return nil
-}
-
-func unicastDeparture(ctx context.Context, from *state.Session, to *state.Session, messageRelayer MessageRelayer) {
-	messageRelayer.RelayToScreenName(ctx, to.ScreenName(), wire.SNACMessage{
+func (s BuddyService) UnicastBuddyDeparted(ctx context.Context, from *state.Session, to *state.Session) {
+	s.messageRelayer.RelayToScreenName(ctx, to.ScreenName(), wire.SNACMessage{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyDeparted,

+ 426 - 0
foodgroup/buddy_test.go

@@ -1,6 +1,7 @@
 package foodgroup
 
 import (
+	"strconv"
 	"testing"
 
 	"github.com/stretchr/testify/mock"
@@ -237,3 +238,428 @@ func TestBuddyService_DelBuddies(t *testing.T) {
 		})
 	}
 }
+
+func TestBuddyService_BroadcastBuddyArrived(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// sourceSession is the session of the user
+		userSession *state.Session
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:        "send buddy arrival notification to users who have user_screen_name on their server-side or client-side buddy list",
+			userSession: newTestSession("user_screen_name"),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					adjacentUsersParams: adjacentUsersParams{
+						{
+							screenName: "user_screen_name",
+							users:      []string{"friend1"},
+						},
+					},
+					feedbagParams: feedbagParams{
+						{
+							screenName: "user_screen_name",
+							results:    []wire.FeedbagItem{},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNamesParams: relayToScreenNamesParams{
+						{
+							screenNames: []string{"friend1", "friend2"},
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
+								},
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("user_screen_name").TLVUserInfo(),
+								},
+							},
+						},
+					},
+				},
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+							result:         []string{"friend2"},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "send buddy arrival notification containing buddy icon to user who has user_screen_name on their server-side buddy list",
+			userSession: newTestSession("user_screen_name"),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					adjacentUsersParams: adjacentUsersParams{
+						{
+							screenName: "user_screen_name",
+							users:      []string{"friend1"},
+						},
+					},
+					feedbagParams: feedbagParams{
+						{
+							screenName: "user_screen_name",
+							results: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "friend10",
+								},
+								{
+									ClassID: wire.FeedbagClassIdBart,
+									Name:    strconv.Itoa(int(wire.BARTTypesBadgeUrl)),
+								},
+								{
+									ClassID: wire.FeedbagClassIdBart,
+									Name:    strconv.Itoa(int(wire.BARTTypesBuddyIcon)),
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+												Hash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNamesParams: relayToScreenNamesParams{
+						{
+							screenNames: []string{"friend1"},
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
+								},
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: userInfoWithBARTIcon(
+										newTestSession("user_screen_name"),
+										wire.BARTID{
+											Type: wire.BARTTypesBuddyIcon,
+											BARTInfo: wire.BARTInfo{
+												Flags: wire.BARTFlagsKnown,
+												Hash:  []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+											},
+										},
+									),
+								},
+							},
+						},
+					},
+				},
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+							result:         []string{},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagManagerParams.adjacentUsersParams {
+				feedbagManager.EXPECT().
+					AdjacentUsers(params.screenName).
+					Return(params.users, params.err)
+			}
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(params.screenName).
+					Return(params.results, nil)
+			}
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNamesParams {
+				messageRelayer.EXPECT().
+					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
+			}
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tc.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
+
+			svc := NewBuddyService(messageRelayer, feedbagManager, legacyBuddyListManager)
+
+			err := svc.BroadcastBuddyArrived(nil, tc.userSession)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestBuddyService_BroadcastDeparture(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// sourceSession is the session of the user
+		userSession *state.Session
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:        "send buddy departed notification to users who have user_screen_name on their server-side or client-side buddy list",
+			userSession: newTestSession("user_screen_name"),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					adjacentUsersParams: adjacentUsersParams{
+						{
+							screenName: "user_screen_name",
+							users:      []string{"friend1"},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNamesParams: relayToScreenNamesParams{
+						{
+							screenNames: []string{"friend1", "friend2"},
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyDeparted,
+								},
+								Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+									TLVUserInfo: wire.TLVUserInfo{
+										ScreenName:   "user_screen_name",
+										WarningLevel: 0,
+									},
+								},
+							},
+						},
+					},
+				},
+				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
+					whoAddedUserParams: whoAddedUserParams{
+						{
+							userScreenName: "user_screen_name",
+							result:         []string{"friend2"},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagManagerParams.adjacentUsersParams {
+				feedbagManager.EXPECT().
+					AdjacentUsers(params.screenName).
+					Return(params.users, params.err)
+			}
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNamesParams {
+				messageRelayer.EXPECT().
+					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
+			}
+			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
+			for _, params := range tc.mockParams.whoAddedUserParams {
+				legacyBuddyListManager.EXPECT().
+					WhoAddedUser(params.userScreenName).
+					Return(params.result)
+			}
+
+			svc := NewBuddyService(messageRelayer, feedbagManager, legacyBuddyListManager)
+
+			err := svc.BroadcastBuddyDeparted(nil, tc.userSession)
+			assert.NoError(t, err)
+		})
+	}
+}
+
+func TestBuddyService_UnicastBuddyDeparted(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// sourceSession is the session of the user
+		sourceSession *state.Session
+		// destSession is the session of the user receiving the notification
+		destSession *state.Session
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:          "send buddy departed notification to user",
+			sourceSession: newTestSession("src_screen_name"),
+			destSession:   newTestSession("dest_screen_name"),
+			mockParams: mockParams{
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: "dest_screen_name",
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyDeparted,
+								},
+								Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
+									TLVUserInfo: wire.TLVUserInfo{
+										ScreenName:   "src_screen_name",
+										WarningLevel: 0,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			svc := NewBuddyService(messageRelayer, nil, nil)
+
+			svc.UnicastBuddyDeparted(nil, tc.sourceSession, tc.destSession)
+		})
+	}
+}
+
+func TestBuddyService_UnicastBuddyArrived(t *testing.T) {
+	cases := []struct {
+		// name is the unit test name
+		name string
+		// sourceSession is the session of the user
+		sourceSession *state.Session
+		// destSession is the session of the user receiving the notification
+		destSession *state.Session
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:          "send buddy arrival notification to user",
+			sourceSession: newTestSession("src_screen_name"),
+			destSession:   newTestSession("dest_screen_name"),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: "src_screen_name",
+							results:    []wire.FeedbagItem{},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: "dest_screen_name",
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
+								},
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: newTestSession("src_screen_name").TLVUserInfo(),
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:          "send buddy arrival notification containing buddy icon to user",
+			sourceSession: newTestSession("src_screen_name"),
+			destSession:   newTestSession("dest_screen_name"),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					feedbagParams: feedbagParams{
+						{
+							screenName: "src_screen_name",
+							results: []wire.FeedbagItem{
+								{
+									ClassID: wire.FeedbagClassIdBuddy,
+									Name:    "friend10",
+								},
+								{
+									ClassID: wire.FeedbagClassIdBart,
+									Name:    strconv.Itoa(int(wire.BARTTypesBadgeUrl)),
+								},
+								{
+									ClassID: wire.FeedbagClassIdBart,
+									Name:    strconv.Itoa(int(wire.BARTTypesBuddyIcon)),
+									TLVLBlock: wire.TLVLBlock{
+										TLVList: wire.TLVList{
+											wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+												Hash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+											}),
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: "dest_screen_name",
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Buddy,
+									SubGroup:  wire.BuddyArrived,
+								},
+								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
+									TLVUserInfo: userInfoWithBARTIcon(
+										newTestSession("src_screen_name"),
+										wire.BARTID{
+											Type: wire.BARTTypesBuddyIcon,
+											BARTInfo: wire.BARTInfo{
+												Flags: wire.BARTFlagsKnown,
+												Hash:  []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+											},
+										},
+									),
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+				feedbagManager.EXPECT().
+					Feedbag(params.screenName).
+					Return(params.results, nil)
+			}
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
+				messageRelayer.EXPECT().
+					RelayToScreenName(mock.Anything, params.screenName, params.message)
+			}
+
+			svc := NewBuddyService(messageRelayer, feedbagManager, nil)
+
+			err := svc.UnicastBuddyArrived(nil, tc.sourceSession, tc.destSession)
+			assert.NoError(t, err)
+		})
+	}
+}

+ 11 - 11
foodgroup/feedbag.go

@@ -18,12 +18,12 @@ func NewFeedbagService(
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
 	bartManager BARTManager,
-	legacyBuddyListManager LegacyBuddyListManager,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) FeedbagService {
 	return FeedbagService{
 		bartManager:            bartManager,
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		feedbagManager:         feedbagManager,
-		legacyBuddyListManager: legacyBuddyListManager,
 		logger:                 logger,
 		messageRelayer:         messageRelayer,
 	}
@@ -33,8 +33,8 @@ func NewFeedbagService(
 // handles buddy list management.
 type FeedbagService struct {
 	bartManager            BARTManager
+	buddyUpdateBroadcaster BuddyBroadcaster
 	feedbagManager         FeedbagManager
-	legacyBuddyListManager LegacyBuddyListManager
 	logger                 *slog.Logger
 	messageRelayer         MessageRelayer
 }
@@ -202,7 +202,7 @@ func (s FeedbagService) UpsertItem(ctx context.Context, sess *state.Session, inF
 			if buddy == nil || buddy.Invisible() {
 				continue
 			}
-			if err := unicastArrival(ctx, buddy, sess, s.messageRelayer, s.feedbagManager); err != nil {
+			if err := s.buddyUpdateBroadcaster.UnicastBuddyArrived(ctx, buddy, sess); err != nil {
 				return wire.SNACMessage{}, err
 			}
 		case wire.FeedbagClassIDDeny: // block buddy
@@ -214,9 +214,9 @@ func (s FeedbagService) UpsertItem(ctx context.Context, sess *state.Session, inF
 				continue // blocked buddy is offline, nothing to do here
 			}
 			// alert blocked buddy that current user is offline
-			unicastDeparture(ctx, sess, blockedSess, s.messageRelayer)
+			s.buddyUpdateBroadcaster.UnicastBuddyDeparted(ctx, sess, blockedSess)
 			// tell blocker that blocked user is offline
-			unicastDeparture(ctx, blockedSess, sess, s.messageRelayer)
+			s.buddyUpdateBroadcaster.UnicastBuddyDeparted(ctx, blockedSess, sess)
 		case wire.FeedbagClassIdBart:
 			if err := s.broadcastIconUpdate(ctx, sess, item); err != nil {
 				return wire.SNACMessage{}, err
@@ -256,7 +256,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 		s.logger.DebugContext(ctx, "user is clearing icon",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
-		return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager)
+		return s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess)
 	}
 
 	bid := wire.BARTID{
@@ -277,7 +277,7 @@ func (s FeedbagService) broadcastIconUpdate(ctx context.Context, sess *state.Ses
 		s.logger.DebugContext(ctx, "icon already exists in BART store, don't upload the icon file",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
-		if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+		if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
 			return err
 		}
 	}
@@ -312,13 +312,13 @@ func (s FeedbagService) DeleteItem(ctx context.Context, sess *state.Session, inF
 			}
 			if !sess.Invisible() {
 				// alert unblocked user that current user is online
-				if err := unicastArrival(ctx, sess, unblockedSess, s.messageRelayer, s.feedbagManager); err != nil {
+				if err := s.buddyUpdateBroadcaster.UnicastBuddyArrived(ctx, sess, unblockedSess); err != nil {
 					return wire.SNACMessage{}, err
 				}
 			}
 			if !unblockedSess.Invisible() {
 				// alert current user that unblocked user is online
-				if err := unicastArrival(ctx, unblockedSess, sess, s.messageRelayer, s.feedbagManager); err != nil {
+				if err := s.buddyUpdateBroadcaster.UnicastBuddyArrived(ctx, unblockedSess, sess); err != nil {
 					return wire.SNACMessage{}, err
 				}
 			}
@@ -358,7 +358,7 @@ func (s FeedbagService) Use(ctx context.Context, sess *state.Session) error {
 		if buddy == nil || buddy.Invisible() {
 			continue
 		}
-		if err := unicastArrival(ctx, buddy, sess, s.messageRelayer, s.feedbagManager); err != nil {
+		if err := s.buddyUpdateBroadcaster.UnicastBuddyArrived(ctx, buddy, sess); err != nil {
 			return err
 		}
 	}

+ 130 - 329
foodgroup/feedbag_test.go

@@ -419,14 +419,6 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 							},
 						},
 					},
-					feedbagParams: feedbagParams{
-						{
-							screenName: "buddy_1_online",
-						},
-						{
-							screenName: "buddy_2_online",
-						},
-					},
 				},
 				messageRelayerParams: messageRelayerParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
@@ -439,30 +431,16 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
 						},
 					},
-					relayToScreenNameParams: relayToScreenNameParams{
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					unicastBuddyArrivedParams: unicastBuddyArrivedParams{
 						{
-							screenName: "user_screen_name",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("buddy_1_online", sessOptCannedSignonTime).TLVUserInfo(),
-								},
-							},
+							from: "buddy_1_online",
+							to:   "user_screen_name",
 						},
 						{
-							screenName: "user_screen_name",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("buddy_2_online", sessOptCannedSignonTime).TLVUserInfo(),
-								},
-							},
+							from: "buddy_2_online",
+							to:   "user_screen_name",
 						},
 					},
 				},
@@ -615,36 +593,16 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 							sess:       newTestSession("buddy_1"),
 						},
 					},
-					relayToScreenNameParams: relayToScreenNameParams{
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					unicastBuddyDepartedParams: unicastBuddyDepartedParams{
 						{
-							screenName: "buddy_1",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyDeparted,
-								},
-								Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
-									TLVUserInfo: wire.TLVUserInfo{
-										ScreenName:   "user_screen_name",
-										WarningLevel: 0,
-									},
-								},
-							},
+							from: "user_screen_name",
+							to:   "buddy_1",
 						},
 						{
-							screenName: "user_screen_name",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyDeparted,
-								},
-								Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
-									TLVUserInfo: wire.TLVUserInfo{
-										ScreenName:   "buddy_1",
-										WarningLevel: 0,
-									},
-								},
-							},
+							from: "buddy_1",
+							to:   "user_screen_name",
 						},
 					},
 				},
@@ -912,34 +870,8 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 							},
 						},
 					},
-					adjacentUsersParams: adjacentUsersParams{
-						{
-							screenName: "user_screen_name",
-							users:      []string{"friend1"},
-						},
-					},
-					feedbagParams: feedbagParams{
-						{
-							screenName: "user_screen_name",
-							results: []wire.FeedbagItem{
-								{
-									Name:    "1",
-									ClassID: wire.FeedbagClassIdBart,
-									TLVLBlock: wire.TLVLBlock{
-										TLVList: wire.TLVList{
-											wire.NewTLV(wire.FeedbagAttributesBartInfo,
-												[]byte{
-													wire.BARTFlagsCustom,
-													0x07, // hash len
-													't', 'h', 'e', 'h', 'a', 's', 'h',
-												},
-											),
-										},
-									},
-								},
-							},
-						},
-					},
+					adjacentUsersParams: adjacentUsersParams{},
+					feedbagParams:       feedbagParams{},
 				},
 				messageRelayerParams: messageRelayerParams{
 					relayToScreenNameParams: relayToScreenNameParams{
@@ -962,34 +894,11 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 							},
 						},
 					},
-					relayToScreenNamesParams: relayToScreenNamesParams{
-						{
-							screenNames: []string{"friend1"},
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: userInfoWithBARTIcon(
-										newTestSession("user_screen_name"),
-										wire.BARTID{
-											Type: wire.BARTTypesBuddyIcon,
-											BARTInfo: wire.BARTInfo{
-												Flags: wire.BARTFlagsCustom,
-												Hash:  []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
-											},
-										},
-									),
-								},
-							},
-						},
-					},
 				},
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							userScreenName: "user_screen_name",
+							screenName: "user_screen_name",
 						},
 					},
 				},
@@ -1046,66 +955,11 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 							},
 						},
 					},
-					adjacentUsersParams: adjacentUsersParams{
-						{
-							screenName: "user_screen_name",
-							users:      []string{"friend1"},
-						},
-					},
-					feedbagParams: feedbagParams{
-						{
-							screenName: "user_screen_name",
-							results: []wire.FeedbagItem{
-								{
-									Name:    "1",
-									ClassID: wire.FeedbagClassIdBart,
-									TLVLBlock: wire.TLVLBlock{
-										TLVList: wire.TLVList{
-											wire.NewTLV(wire.FeedbagAttributesBartInfo,
-												append(
-													[]byte{
-														wire.BARTFlagsKnown,
-														uint8(len(wire.GetClearIconHash())),
-													},
-													wire.GetClearIconHash()...,
-												),
-											),
-										},
-									},
-								},
-							},
-						},
-					},
-				},
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNamesParams: relayToScreenNamesParams{
-						{
-							screenNames: []string{"friend1"},
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: userInfoWithBARTIcon(
-										newTestSession("user_screen_name"),
-										wire.BARTID{
-											Type: wire.BARTTypesBuddyIcon,
-											BARTInfo: wire.BARTInfo{
-												Flags: wire.BARTFlagsKnown,
-												Hash:  wire.GetClearIconHash(),
-											},
-										},
-									),
-								},
-							},
-						},
-					},
 				},
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							userScreenName: "user_screen_name",
+							screenName: "user_screen_name",
 						},
 					},
 				},
@@ -1131,11 +985,6 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					FeedbagUpsert(params.screenName, params.items).
 					Return(nil)
 			}
-			for _, params := range tc.mockParams.feedbagManagerParams.adjacentUsersParams {
-				feedbagManager.EXPECT().
-					AdjacentUsers(params.screenName).
-					Return(params.users, params.err)
-			}
 			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
 				feedbagManager.EXPECT().
 					Feedbag(params.screenName).
@@ -1167,14 +1016,40 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					WhoAddedUser(params.userScreenName).
 					Return(params.result)
 			}
-
-			svc := FeedbagService{
-				bartManager:            bartManager,
-				feedbagManager:         feedbagManager,
-				legacyBuddyListManager: legacyBuddyListManager,
-				logger:                 slog.Default(),
-				messageRelayer:         messageRelayer,
+			buddyUpdateBroadcaster := newMockBuddyBroadcaster(t)
+			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
+			}
+			for _, params := range tc.mockParams.unicastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					UnicastBuddyArrived(mock.Anything,
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.from
+						}),
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.to
+						})).
+					Return(nil)
+			}
+			for _, params := range tc.mockParams.unicastBuddyDepartedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					UnicastBuddyDeparted(mock.Anything,
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.from
+						}),
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.to
+						}))
 			}
+
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, bartManager, buddyUpdateBroadcaster)
 			output, err := svc.UpsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items)
 			assert.NoError(t, err)
@@ -1276,17 +1151,6 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 							},
 						},
 					},
-					feedbagParams: feedbagParams{
-						{
-							screenName: "buddy_1_online",
-						},
-						{
-							screenName: "buddy_2_online",
-						},
-						{
-							screenName: "user_screen_name",
-						},
-					},
 				},
 				messageRelayerParams: messageRelayerParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
@@ -1299,54 +1163,24 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
 						},
 					},
-					relayToScreenNameParams: relayToScreenNameParams{
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					unicastBuddyArrivedParams: unicastBuddyArrivedParams{
 						{
-							screenName: "user_screen_name",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("buddy_1_online", sessOptCannedSignonTime).TLVUserInfo(),
-								},
-							},
+							from: "buddy_1_online",
+							to:   "user_screen_name",
 						},
 						{
-							screenName: "buddy_1_online",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("user_screen_name", sessOptCannedSignonTime).TLVUserInfo(),
-								},
-							},
+							from: "buddy_2_online",
+							to:   "user_screen_name",
 						},
 						{
-							screenName: "user_screen_name",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("buddy_2_online", sessOptCannedSignonTime).TLVUserInfo(),
-								},
-							},
+							from: "user_screen_name",
+							to:   "buddy_1_online",
 						},
 						{
-							screenName: "buddy_2_online",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("user_screen_name", sessOptCannedSignonTime).TLVUserInfo(),
-								},
-							},
+							from: "user_screen_name",
+							to:   "buddy_2_online",
 						},
 					},
 				},
@@ -1441,11 +1275,6 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 							},
 						},
 					},
-					feedbagParams: feedbagParams{
-						{
-							screenName: "user_screen_name",
-						},
-					},
 				},
 				messageRelayerParams: messageRelayerParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
@@ -1454,18 +1283,12 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 							sess:       newTestSession("invisible_buddy_online", sessOptInvisible),
 						},
 					},
-					relayToScreenNameParams: relayToScreenNameParams{
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					unicastBuddyArrivedParams: unicastBuddyArrivedParams{
 						{
-							screenName: "invisible_buddy_online",
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("user_screen_name", sessOptCannedSignonTime).TLVUserInfo(),
-								},
-							},
+							from: "user_screen_name",
+							to:   "invisible_buddy_online",
 						},
 					},
 				},
@@ -1491,25 +1314,30 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 					FeedbagDelete(params.screenName, params.items).
 					Return(nil)
 			}
-			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
-				feedbagManager.EXPECT().
-					Feedbag(params.screenName).
-					Return(params.results, nil)
-			}
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
 					Return(params.sess)
 			}
-			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
-				messageRelayer.EXPECT().
-					RelayToScreenName(mock.Anything, params.screenName, params.message)
+			buddyUpdateBroadcast := newMockBuddyBroadcaster(t)
+			for _, params := range tc.mockParams.unicastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcast.EXPECT().
+					UnicastBuddyArrived(mock.Anything,
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.from
+						}),
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.to
+						})).
+					Return(nil)
 			}
 
 			svc := FeedbagService{
-				feedbagManager: feedbagManager,
-				messageRelayer: messageRelayer,
+				buddyUpdateBroadcaster: buddyUpdateBroadcast,
+				feedbagManager:         feedbagManager,
+				messageRelayer:         messageRelayer,
 			}
 			output, err := svc.DeleteItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x0A_FeedbagDeleteItem))
@@ -1528,107 +1356,80 @@ func TestFeedbagService_Use(t *testing.T) {
 		// bodyIn is the SNAC body sent from the arriving user's client to the
 		// server
 		bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
-		// buddiesParams contains params for looking up arriving user's
-		// buddies
-		buddiesParams buddiesParams
-		// retrieveByScreenNameParams contains params for looking up the
-		// session for each of the arriving user's buddies
-		retrieveByScreenNameParams retrieveByScreenNameParams
-		// relayToScreenNameParams contains params for sending arrival
-		// notifications for each of the arriving user's buddies to the
-		// arriving user's client
-		relayToScreenNameParams relayToScreenNameParams
-		// feedbagParams contains params for retrieving a user's feedbag
-		feedbagParams feedbagParams
-		wantErr       error
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		wantErr    error
 	}{
 		{
 			name:   "notify arriving user's buddies of its arrival and populate the arriving user's buddy list",
 			sess:   newTestSession("test-user"),
 			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
-			buddiesParams: buddiesParams{
-				{
-					screenName: "test-user",
-					results:    []string{"buddy1", "buddy3"},
-				},
-			},
-			retrieveByScreenNameParams: retrieveByScreenNameParams{
-				{
-					screenName: "buddy1",
-					sess:       newTestSession("buddy1"),
-				},
-				{
-					screenName: "buddy3",
-					sess:       newTestSession("buddy3"),
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					buddiesParams: buddiesParams{
+						{
+							screenName: "test-user",
+							results:    []string{"buddy1", "buddy3"},
+						},
+					},
 				},
-			},
-			relayToScreenNameParams: relayToScreenNameParams{
-				{
-					screenName: "test-user",
-					message: wire.SNACMessage{
-						Frame: wire.SNACFrame{
-							FoodGroup: wire.Buddy,
-							SubGroup:  wire.BuddyArrived,
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy1",
+							sess:       newTestSession("buddy1"),
 						},
-						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: newTestSession("buddy1").TLVUserInfo(),
+						{
+							screenName: "buddy3",
+							sess:       newTestSession("buddy3"),
 						},
 					},
 				},
-				{
-					screenName: "test-user",
-					message: wire.SNACMessage{
-						Frame: wire.SNACFrame{
-							FoodGroup: wire.Buddy,
-							SubGroup:  wire.BuddyArrived,
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					unicastBuddyArrivedParams: unicastBuddyArrivedParams{
+						{
+							from: "buddy1",
+							to:   "test-user",
 						},
-						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: newTestSession("buddy3").TLVUserInfo(),
+						{
+							from: "buddy3",
+							to:   "test-user",
 						},
 					},
 				},
 			},
-			feedbagParams: feedbagParams{
-				//{
-				//	screenName: "test-user",
-				//	results:    []wire.FeedbagItem{},
-				//},
-				{
-					screenName: "buddy1",
-					results:    []wire.FeedbagItem{},
-				},
-				{
-					screenName: "buddy3",
-					results:    []wire.FeedbagItem{},
-				},
-			},
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			feedbagManager := newMockFeedbagManager(t)
-			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.buddiesParams {
+			for _, params := range tt.mockParams.buddiesParams {
 				feedbagManager.EXPECT().
 					Buddies(params.screenName).
 					Return(params.results, nil)
 			}
-			for _, params := range tt.retrieveByScreenNameParams {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
 					Return(params.sess)
 			}
-			for _, params := range tt.relayToScreenNameParams {
-				messageRelayer.EXPECT().
-					RelayToScreenName(mock.Anything, params.screenName, params.message)
-			}
-			for _, params := range tt.feedbagParams {
-				feedbagManager.EXPECT().
-					Feedbag(params.screenName).
-					Return(params.results, nil)
+			buddyUpdateBroadcast := newMockBuddyBroadcaster(t)
+			for _, params := range tt.mockParams.unicastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcast.EXPECT().
+					UnicastBuddyArrived(mock.Anything,
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.from
+						}),
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.to
+						})).
+					Return(nil)
 			}
 
-			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, nil)
+			svc := NewFeedbagService(slog.Default(), messageRelayer, feedbagManager, nil, buddyUpdateBroadcast)
 
 			haveErr := svc.Use(nil, tt.sess)
 			assert.ErrorIs(t, tt.wantErr, haveErr)

+ 4 - 4
foodgroup/icbm.go

@@ -16,11 +16,11 @@ const (
 func NewICBMService(
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
-	legacyBuddyListManager LegacyBuddyListManager,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) *ICBMService {
 	return &ICBMService{
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		feedbagManager:         feedbagManager,
-		legacyBuddyListManager: legacyBuddyListManager,
 		messageRelayer:         messageRelayer,
 	}
 }
@@ -29,8 +29,8 @@ func NewICBMService(
 // responsible for sending and receiving instant messages and associated
 // functionality such as warning, typing events, etc.
 type ICBMService struct {
+	buddyUpdateBroadcaster BuddyBroadcaster
 	feedbagManager         FeedbagManager
-	legacyBuddyListManager LegacyBuddyListManager
 	messageRelayer         MessageRelayer
 }
 
@@ -255,7 +255,7 @@ func (s ICBMService) EvilRequest(ctx context.Context, sess *state.Session, inFra
 	})
 
 	// inform the warned user's buddies that their warning level has increased
-	if err := broadcastArrival(ctx, recipSess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, recipSess); err != nil {
 		return wire.SNACMessage{}, err
 	}
 

+ 16 - 43
foodgroup/icbm_test.go

@@ -361,7 +361,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 		// recipientBuddies is a list of the recipient's buddies that get
 		// updated warning level
 		recipientBuddies []string
-		broadcastMessage wire.SNACMessage
 		// inputSNAC is the SNAC sent by the sender client
 		inputSNAC wire.SNACMessage
 		// expectSNACToClient is the SNAC sent from the server to the
@@ -379,16 +378,7 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			senderSession:       newTestSession("sender-screen-name"),
 			recipientSession:    newTestSession("recipient-screen-name", sessOptCannedSignonTime),
 			recipientScreenName: "recipient-screen-name",
-			broadcastMessage: wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.Buddy,
-					SubGroup:  wire.BuddyArrived,
-				},
-				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-					TLVUserInfo: newTestSession("recipient-screen-name", sessOptCannedSignonTime, sessOptWarning(evilDeltaAnon)).TLVUserInfo(),
-				},
-			},
-			recipientBuddies: []string{"buddy1", "buddy2"},
+			recipientBuddies:    []string{"buddy1", "buddy2"},
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -419,10 +409,10 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							userScreenName: "recipient-screen-name",
+							screenName: "recipient-screen-name",
 						},
 					},
 				},
@@ -435,15 +425,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			recipientSession:    newTestSession("recipient-screen-name", sessOptCannedSignonTime),
 			recipientScreenName: "recipient-screen-name",
 			recipientBuddies:    []string{"buddy1", "buddy2"},
-			broadcastMessage: wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.Buddy,
-					SubGroup:  wire.BuddyArrived,
-				},
-				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-					TLVUserInfo: newTestSession("recipient-screen-name", sessOptCannedSignonTime, sessOptWarning(evilDelta)).TLVUserInfo(),
-				},
-			},
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
@@ -478,10 +459,10 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							userScreenName: "recipient-screen-name",
+							screenName: "recipient-screen-name",
 						},
 					},
 				},
@@ -632,14 +613,6 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				BlockedState(tc.senderSession.ScreenName(), tc.recipientScreenName).
 				Return(tc.blockedState, nil).
 				Maybe()
-			feedbagManager.EXPECT().
-				AdjacentUsers(tc.recipientScreenName).
-				Return(tc.recipientBuddies, nil).
-				Maybe()
-			feedbagManager.EXPECT().
-				Feedbag(tc.recipientScreenName).
-				Return(nil, nil).
-				Maybe()
 			messageRelayer := newMockMessageRelayer(t)
 			messageRelayer.EXPECT().
 				RetrieveByScreenName(tc.recipientScreenName).
@@ -648,20 +621,20 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			messageRelayer.EXPECT().
 				RelayToScreenName(mock.Anything, tc.recipientScreenName, tc.expectSNACToClient).
 				Maybe()
-			messageRelayer.EXPECT().
-				RelayToScreenNames(mock.Anything, tc.recipientBuddies, tc.broadcastMessage).
-				Maybe()
-			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
-			for _, params := range tc.mockParams.whoAddedUserParams {
-				legacyBuddyListManager.EXPECT().
-					WhoAddedUser(params.userScreenName).
-					Return(params.result)
+			buddyUpdateBroadcaster := newMockBuddyBroadcaster(t)
+			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
 			}
 			//
 			// send input SNAC
 			//
 			senderSession := newTestSession(tc.senderSession.ScreenName())
-			svc := NewICBMService(messageRelayer, feedbagManager, legacyBuddyListManager)
+			svc := NewICBMService(messageRelayer, feedbagManager, buddyUpdateBroadcaster)
 			outputSNAC, err := svc.EvilRequest(nil, senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x08_ICBMEvilRequest))
 			assert.NoError(t, err)

+ 4 - 4
foodgroup/locate.go

@@ -24,11 +24,11 @@ func NewLocateService(
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
 	profileManager ProfileManager,
-	legacyBuddyListManager LegacyBuddyListManager,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) LocateService {
 	return LocateService{
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		feedbagManager:         feedbagManager,
-		legacyBuddyListManager: legacyBuddyListManager,
 		profileManager:         profileManager,
 		sessionManager:         messageRelayer,
 	}
@@ -38,8 +38,8 @@ func NewLocateService(
 // responsible for user profiles, user info lookups, directory information, and
 // keyword lookups.
 type LocateService struct {
+	buddyUpdateBroadcaster BuddyBroadcaster
 	feedbagManager         FeedbagManager
-	legacyBuddyListManager LegacyBuddyListManager
 	profileManager         ProfileManager
 	sessionManager         MessageRelayer
 }
@@ -81,7 +81,7 @@ func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody
 	// broadcast away message change to buddies
 	if awayMsg, hasAwayMsg := inBody.String(wire.LocateTLVTagsInfoUnavailableData); hasAwayMsg {
 		sess.SetAwayMessage(awayMsg)
-		if err := broadcastArrival(ctx, sess, s.sessionManager, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+		if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
 			return err
 		}
 	}

+ 15 - 58
foodgroup/locate_test.go

@@ -4,9 +4,8 @@ import (
 	"context"
 	"testing"
 
-	"github.com/stretchr/testify/mock"
-
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -354,7 +353,7 @@ func TestLocateService_UserInfoQuery(t *testing.T) {
 					WhoAddedUser(params.userScreenName).
 					Return(params.result)
 			}
-			svc := NewLocateService(messageRelayer, feedbagManager, profileManager, legacyBuddyListManager)
+			svc := NewLocateService(messageRelayer, feedbagManager, profileManager, nil)
 			outputSNAC, err := svc.UserInfoQuery(context.Background(), tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x02_0x05_LocateUserInfoQuery))
 			assert.NoError(t, err)
@@ -424,7 +423,7 @@ func TestLocateService_SetInfo(t *testing.T) {
 			},
 			mockParams: mockParams{
 				profileManagerParams: profileManagerParams{
-					upsertProfileParams: upsertProfileParams{
+					setProfileParams: setProfileParams{
 						{
 							screenName: "test-user",
 							body:       "profile-result",
@@ -444,39 +443,10 @@ func TestLocateService_SetInfo(t *testing.T) {
 				},
 			},
 			mockParams: mockParams{
-				messageRelayerParams: messageRelayerParams{
-					relayToScreenNamesParams: relayToScreenNamesParams{
-						{
-							screenNames: []string{"friend1", "friend2"},
-							message: wire.SNACMessage{
-								Frame: wire.SNACFrame{
-									FoodGroup: wire.Buddy,
-									SubGroup:  wire.BuddyArrived,
-								},
-								Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-									TLVUserInfo: newTestSession("user_screen_name", sessOptAwayMessage("this is my away message!")).TLVUserInfo(),
-								},
-							},
-						},
-					},
-				},
-				feedbagManagerParams: feedbagManagerParams{
-					adjacentUsersParams: adjacentUsersParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
 							screenName: "user_screen_name",
-							users:      []string{"friend1", "friend2"},
-						},
-					},
-					feedbagParams: feedbagParams{
-						{
-							screenName: "user_screen_name",
-						},
-					},
-				},
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
-						{
-							userScreenName: "user_screen_name",
 						},
 					},
 				},
@@ -485,35 +455,22 @@ func TestLocateService_SetInfo(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.mockParams.relayToScreenNamesParams {
-				messageRelayer.EXPECT().
-					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
-			}
-			feedbagManager := newMockFeedbagManager(t)
-			for _, params := range tt.mockParams.adjacentUsersParams {
-				feedbagManager.EXPECT().
-					AdjacentUsers(params.screenName).
-					Return(params.users, nil)
-			}
-			for _, params := range tt.mockParams.feedbagParams {
-				feedbagManager.EXPECT().
-					Feedbag(params.screenName).
-					Return(params.results, nil)
-			}
 			profileManager := newMockProfileManager(t)
-			for _, params := range tt.mockParams.upsertProfileParams {
+			for _, params := range tt.mockParams.setProfileParams {
 				profileManager.EXPECT().
 					SetProfile(params.screenName, params.body).
 					Return(nil)
 			}
-			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
-			for _, params := range tt.mockParams.whoAddedUserParams {
-				legacyBuddyListManager.EXPECT().
-					WhoAddedUser(params.userScreenName).
-					Return(params.result)
+			buddyUpdateBroadcaster := newMockBuddyBroadcaster(t)
+			for _, params := range tt.mockParams.broadcastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
 			}
-			svc := NewLocateService(messageRelayer, feedbagManager, profileManager, legacyBuddyListManager)
+			svc := NewLocateService(nil, nil, profileManager, buddyUpdateBroadcaster)
 			assert.Equal(t, tt.wantErr, svc.SetInfo(nil, tt.userSession, tt.inBody))
 		})
 	}

+ 214 - 0
foodgroup/mock_buddy_broadcaster_test.go

@@ -0,0 +1,214 @@
+// Code generated by mockery v2.40.1. DO NOT EDIT.
+
+package foodgroup
+
+import (
+	context "context"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockBuddyBroadcaster is an autogenerated mock type for the BuddyBroadcaster type
+type mockBuddyBroadcaster struct {
+	mock.Mock
+}
+
+type mockBuddyBroadcaster_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBuddyBroadcaster) EXPECT() *mockBuddyBroadcaster_Expecter {
+	return &mockBuddyBroadcaster_Expecter{mock: &_m.Mock}
+}
+
+// BroadcastBuddyArrived provides a mock function with given fields: ctx, sess
+func (_m *mockBuddyBroadcaster) BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error {
+	ret := _m.Called(ctx, sess)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BroadcastBuddyArrived")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
+		r0 = rf(ctx, sess)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyBroadcaster_BroadcastBuddyArrived_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyArrived'
+type mockBuddyBroadcaster_BroadcastBuddyArrived_Call struct {
+	*mock.Call
+}
+
+// BroadcastBuddyArrived is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockBuddyBroadcaster_Expecter) BroadcastBuddyArrived(ctx interface{}, sess interface{}) *mockBuddyBroadcaster_BroadcastBuddyArrived_Call {
+	return &mockBuddyBroadcaster_BroadcastBuddyArrived_Call{Call: _e.mock.On("BroadcastBuddyArrived", ctx, sess)}
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyArrived_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockBuddyBroadcaster_BroadcastBuddyArrived_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyArrived_Call) Return(_a0 error) *mockBuddyBroadcaster_BroadcastBuddyArrived_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyArrived_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockBuddyBroadcaster_BroadcastBuddyArrived_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// BroadcastBuddyDeparted provides a mock function with given fields: ctx, sess
+func (_m *mockBuddyBroadcaster) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
+	ret := _m.Called(ctx, sess)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BroadcastBuddyDeparted")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
+		r0 = rf(ctx, sess)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyBroadcaster_BroadcastBuddyDeparted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BroadcastBuddyDeparted'
+type mockBuddyBroadcaster_BroadcastBuddyDeparted_Call struct {
+	*mock.Call
+}
+
+// BroadcastBuddyDeparted is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+func (_e *mockBuddyBroadcaster_Expecter) BroadcastBuddyDeparted(ctx interface{}, sess interface{}) *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call {
+	return &mockBuddyBroadcaster_BroadcastBuddyDeparted_Call{Call: _e.mock.On("BroadcastBuddyDeparted", ctx, sess)}
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call) Return(_a0 error) *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockBuddyBroadcaster_BroadcastBuddyDeparted_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UnicastBuddyArrived provides a mock function with given fields: ctx, from, to
+func (_m *mockBuddyBroadcaster) UnicastBuddyArrived(ctx context.Context, from *state.Session, to *state.Session) error {
+	ret := _m.Called(ctx, from, to)
+
+	if len(ret) == 0 {
+		panic("no return value specified for UnicastBuddyArrived")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, *state.Session) error); ok {
+		r0 = rf(ctx, from, to)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockBuddyBroadcaster_UnicastBuddyArrived_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnicastBuddyArrived'
+type mockBuddyBroadcaster_UnicastBuddyArrived_Call struct {
+	*mock.Call
+}
+
+// UnicastBuddyArrived is a helper method to define mock.On call
+//   - ctx context.Context
+//   - from *state.Session
+//   - to *state.Session
+func (_e *mockBuddyBroadcaster_Expecter) UnicastBuddyArrived(ctx interface{}, from interface{}, to interface{}) *mockBuddyBroadcaster_UnicastBuddyArrived_Call {
+	return &mockBuddyBroadcaster_UnicastBuddyArrived_Call{Call: _e.mock.On("UnicastBuddyArrived", ctx, from, to)}
+}
+
+func (_c *mockBuddyBroadcaster_UnicastBuddyArrived_Call) Run(run func(ctx context.Context, from *state.Session, to *state.Session)) *mockBuddyBroadcaster_UnicastBuddyArrived_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_UnicastBuddyArrived_Call) Return(_a0 error) *mockBuddyBroadcaster_UnicastBuddyArrived_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_UnicastBuddyArrived_Call) RunAndReturn(run func(context.Context, *state.Session, *state.Session) error) *mockBuddyBroadcaster_UnicastBuddyArrived_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// UnicastBuddyDeparted provides a mock function with given fields: ctx, from, to
+func (_m *mockBuddyBroadcaster) UnicastBuddyDeparted(ctx context.Context, from *state.Session, to *state.Session) {
+	_m.Called(ctx, from, to)
+}
+
+// mockBuddyBroadcaster_UnicastBuddyDeparted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnicastBuddyDeparted'
+type mockBuddyBroadcaster_UnicastBuddyDeparted_Call struct {
+	*mock.Call
+}
+
+// UnicastBuddyDeparted is a helper method to define mock.On call
+//   - ctx context.Context
+//   - from *state.Session
+//   - to *state.Session
+func (_e *mockBuddyBroadcaster_Expecter) UnicastBuddyDeparted(ctx interface{}, from interface{}, to interface{}) *mockBuddyBroadcaster_UnicastBuddyDeparted_Call {
+	return &mockBuddyBroadcaster_UnicastBuddyDeparted_Call{Call: _e.mock.On("UnicastBuddyDeparted", ctx, from, to)}
+}
+
+func (_c *mockBuddyBroadcaster_UnicastBuddyDeparted_Call) Run(run func(ctx context.Context, from *state.Session, to *state.Session)) *mockBuddyBroadcaster_UnicastBuddyDeparted_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(*state.Session))
+	})
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_UnicastBuddyDeparted_Call) Return() *mockBuddyBroadcaster_UnicastBuddyDeparted_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockBuddyBroadcaster_UnicastBuddyDeparted_Call) RunAndReturn(run func(context.Context, *state.Session, *state.Session)) *mockBuddyBroadcaster_UnicastBuddyDeparted_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBuddyBroadcaster creates a new instance of mockBuddyBroadcaster. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockBuddyBroadcaster(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBuddyBroadcaster {
+	mock := &mockBuddyBroadcaster{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 12 - 12
foodgroup/oservice.go

@@ -18,30 +18,30 @@ import (
 func NewOServiceService(
 	cfg config.Config,
 	messageRelayer MessageRelayer,
-	feedbagManager FeedbagManager,
 	legacyBuddyListManager LegacyBuddyListManager,
 	logger *slog.Logger,
 	cookieIssuer CookieIssuer,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) *OServiceService {
 	return &OServiceService{
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		cfg:                    cfg,
-		feedbagManager:         feedbagManager,
+		cookieIssuer:           cookieIssuer,
 		legacyBuddyListManager: legacyBuddyListManager,
-		messageRelayer:         messageRelayer,
 		logger:                 logger,
-		cookieIssuer:           cookieIssuer,
+		messageRelayer:         messageRelayer,
 	}
 }
 
 // OServiceService provides functionality for the OService food group, which
 // provides an assortment of services useful across multiple food groups.
 type OServiceService struct {
+	buddyUpdateBroadcaster BuddyBroadcaster
 	cfg                    config.Config
-	feedbagManager         FeedbagManager
+	cookieIssuer           CookieIssuer
 	legacyBuddyListManager LegacyBuddyListManager
-	messageRelayer         MessageRelayer
 	logger                 *slog.Logger
-	cookieIssuer           CookieIssuer
+	messageRelayer         MessageRelayer
 }
 
 // ClientVersions informs the server what food group versions the client
@@ -428,13 +428,13 @@ func (s OServiceService) SetUserInfoFields(ctx context.Context, sess *state.Sess
 	if status, hasStatus := inBody.Uint32(wire.OServiceUserInfoStatus); hasStatus {
 		if status == wire.OServiceUserStatusAvailable {
 			sess.SetInvisible(false)
-			if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+			if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
 				return wire.SNACMessage{}, err
 			}
 		}
 		if status&wire.OServiceUserStatusInvisible == wire.OServiceUserStatusInvisible {
 			sess.SetInvisible(true)
-			if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+			if err := s.buddyUpdateBroadcaster.BroadcastBuddyDeparted(ctx, sess); err != nil {
 				return wire.SNACMessage{}, err
 			}
 		}
@@ -466,7 +466,7 @@ func (s OServiceService) IdleNotification(ctx context.Context, sess *state.Sessi
 	} else {
 		sess.SetIdle(time.Duration(bodyIn.IdleTime) * time.Second)
 	}
-	return broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager)
+	return s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess)
 }
 
 // SetPrivacyFlags sets client privacy settings. Currently, there's no action
@@ -656,7 +656,7 @@ func (s OServiceServiceForBOS) HostOnline() wire.SNACMessage {
 func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
 	sess.SetSignonComplete()
 
-	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager, s.legacyBuddyListManager); err != nil {
+	if err := s.buddyUpdateBroadcaster.BroadcastBuddyArrived(ctx, sess); err != nil {
 		return err
 	}
 
@@ -667,7 +667,7 @@ func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x0
 		if buddySess == nil || buddySess.Invisible() {
 			continue
 		}
-		if err := unicastArrival(ctx, buddySess, sess, s.messageRelayer, s.feedbagManager); err != nil {
+		if err := s.buddyUpdateBroadcaster.UnicastBuddyArrived(ctx, buddySess, sess); err != nil {
 			return err
 		}
 	}

+ 119 - 296
foodgroup/oservice_test.go

@@ -319,31 +319,11 @@ func TestSetUserInfoFields(t *testing.T) {
 					TLVUserInfo: newTestSession("user_screen_name").TLVUserInfo(),
 				},
 			},
-			broadcastMessage: []struct {
-				recipients []string
-				msg        wire.SNACMessage
-			}{
-				{
-					recipients: []string{"friend1", "friend2"},
-					msg: wire.SNACMessage{
-						Frame: wire.SNACFrame{
-							FoodGroup: wire.Buddy,
-							SubGroup:  wire.BuddyArrived,
-						},
-						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: newTestSession("user_screen_name").TLVUserInfo(),
-						},
-					},
-				},
-			},
-			interestedUserLookups: map[string][]string{
-				"user_screen_name": {"friend1", "friend2"},
-			},
 			mockParams: mockParams{
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							userScreenName: "user_screen_name",
+							screenName: "user_screen_name",
 						},
 					},
 				},
@@ -374,34 +354,11 @@ func TestSetUserInfoFields(t *testing.T) {
 					TLVUserInfo: newTestSession("user_screen_name", sessOptInvisible).TLVUserInfo(),
 				},
 			},
-			broadcastMessage: []struct {
-				recipients []string
-				msg        wire.SNACMessage
-			}{
-				{
-					recipients: []string{"friend1", "friend2"},
-					msg: wire.SNACMessage{
-						Frame: wire.SNACFrame{
-							FoodGroup: wire.Buddy,
-							SubGroup:  wire.BuddyDeparted,
-						},
-						Body: wire.SNAC_0x03_0x0C_BuddyDeparted{
-							TLVUserInfo: wire.TLVUserInfo{
-								ScreenName:   "user_screen_name",
-								WarningLevel: 0,
-							},
-						},
-					},
-				},
-			},
-			interestedUserLookups: map[string][]string{
-				"user_screen_name": {"friend1", "friend2"},
-			},
 			mockParams: mockParams{
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyDepartedParams: broadcastBuddyDepartedParams{
 						{
-							userScreenName: "user_screen_name",
+							screenName: "user_screen_name",
 						},
 					},
 				},
@@ -411,44 +368,30 @@ func TestSetUserInfoFields(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			//
-			// initialize dependencies
-			//
-			feedbagManager := newMockFeedbagManager(t)
-			for user, friends := range tc.interestedUserLookups {
-				feedbagManager.EXPECT().
-					AdjacentUsers(user).
-					Return(friends, nil).
-					Maybe()
-			}
-			feedbagManager.EXPECT().
-				Feedbag(tc.userSession.ScreenName()).
-				Return(nil, nil).
-				Maybe()
-			messageRelayer := newMockMessageRelayer(t)
-			for _, broadcastMsg := range tc.broadcastMessage {
-				messageRelayer.EXPECT().RelayToScreenNames(mock.Anything, broadcastMsg.recipients, broadcastMsg.msg)
+			buddyUpdateBroadcaster := newMockBuddyBroadcaster(t)
+			for _, params := range tc.mockParams.broadcastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
 			}
-			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
-			for _, params := range tc.mockParams.whoAddedUserParams {
-				legacyBuddyListManager.EXPECT().
-					WhoAddedUser(params.userScreenName).
-					Return(params.result)
+			for _, params := range tc.mockParams.broadcastBuddyDepartedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyDeparted(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
 			}
-			cookieIssuer := newMockCookieIssuer(t)
-			//
-			// send input SNAC
-			//
-			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager, legacyBuddyListManager, slog.Default(), cookieIssuer)
+			svc := NewOServiceService(config.Config{}, nil, nil, slog.Default(), nil, buddyUpdateBroadcaster)
 			outputSNAC, err := svc.SetUserInfoFields(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return
 			}
-			//
-			// verify output
-			//
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 	}
@@ -456,7 +399,7 @@ func TestSetUserInfoFields(t *testing.T) {
 
 func TestOServiceService_RateParamsQuery(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewOServiceService(config.Config{}, nil, nil, nil, slog.Default(), cookieIssuer)
+	svc := NewOServiceService(config.Config{}, nil, nil, slog.Default(), cookieIssuer, nil)
 
 	have := svc.RateParamsQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
@@ -1382,7 +1325,7 @@ func TestOServiceService_RateParamsQuery(t *testing.T) {
 
 func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewOServiceServiceForBOS(*NewOServiceService(config.Config{}, nil, nil, nil, slog.Default(), cookieIssuer), nil)
+	svc := NewOServiceServiceForBOS(*NewOServiceService(config.Config{}, nil, nil, slog.Default(), cookieIssuer, nil), nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -1410,7 +1353,7 @@ func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 
 func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewOServiceServiceForChat(*NewOServiceService(config.Config{}, nil, nil, nil, slog.Default(), cookieIssuer), nil)
+	svc := NewOServiceServiceForChat(*NewOServiceService(config.Config{}, nil, nil, slog.Default(), cookieIssuer, nil), nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -1431,7 +1374,7 @@ func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
 
 func TestOServiceService_ClientVersions(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewOServiceService(config.Config{}, nil, nil, nil, slog.Default(), cookieIssuer)
+	svc := NewOServiceService(config.Config{}, nil, nil, slog.Default(), cookieIssuer, nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -1455,7 +1398,7 @@ func TestOServiceService_ClientVersions(t *testing.T) {
 
 func TestOServiceService_UserInfoQuery(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewOServiceService(config.Config{}, nil, nil, nil, slog.Default(), cookieIssuer)
+	svc := NewOServiceService(config.Config{}, nil, nil, slog.Default(), cookieIssuer, nil)
 	sess := newTestSession("test-user")
 
 	want := wire.SNACMessage{
@@ -1479,16 +1422,10 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 		name   string
 		sess   *state.Session
 		bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification
-		// recipientScreenName is the screen name of the user receiving the IM
-		recipientScreenName string
-		// recipientBuddies is a list of the recipient's buddies that get
-		// updated warning level
-		recipientBuddies []string
-		broadcastMessage wire.SNACMessage
-		wantErr          error
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
+		wantErr    error
 	}{
 		{
 			name: "set idle from active",
@@ -1496,22 +1433,11 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 			bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
 				IdleTime: 90,
 			},
-			recipientScreenName: "test-user",
-			recipientBuddies:    []string{"buddy1", "buddy2"},
-			broadcastMessage: wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.Buddy,
-					SubGroup:  wire.BuddyArrived,
-				},
-				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-					TLVUserInfo: newTestSession("test-user", sessOptIdle(90*time.Second)).TLVUserInfo(),
-				},
-			},
 			mockParams: mockParams{
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							userScreenName: "test-user",
+							screenName: "test-user",
 						},
 					},
 				},
@@ -1523,22 +1449,11 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 			bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
 				IdleTime: 0,
 			},
-			recipientScreenName: "test-user",
-			recipientBuddies:    []string{"buddy1", "buddy2"},
-			broadcastMessage: wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.Buddy,
-					SubGroup:  wire.BuddyArrived,
-				},
-				Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-					TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
-				},
-			},
 			mockParams: mockParams{
-				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
-					whoAddedUserParams: whoAddedUserParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
 						{
-							userScreenName: "test-user",
+							screenName: "test-user",
 						},
 					},
 				},
@@ -1547,27 +1462,16 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
-			feedbagManager.EXPECT().
-				AdjacentUsers(tt.recipientScreenName).
-				Return(tt.recipientBuddies, nil).
-				Maybe()
-			feedbagManager.EXPECT().
-				Feedbag(tt.sess.ScreenName()).
-				Return(nil, nil).
-				Maybe()
-			messageRelayer := newMockMessageRelayer(t)
-			messageRelayer.EXPECT().
-				RelayToScreenNames(mock.Anything, tt.recipientBuddies, tt.broadcastMessage).
-				Maybe()
-			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
-			for _, params := range tt.mockParams.whoAddedUserParams {
-				legacyBuddyListManager.EXPECT().
-					WhoAddedUser(params.userScreenName).
-					Return(params.result)
+			buddyUpdateBroadcaster := newMockBuddyBroadcaster(t)
+			for _, params := range tt.mockParams.broadcastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
 			}
-			cookieIssuer := newMockCookieIssuer(t)
-			svc := NewOServiceService(config.Config{}, messageRelayer, feedbagManager, legacyBuddyListManager, slog.Default(), cookieIssuer)
+			svc := NewOServiceService(config.Config{}, nil, nil, slog.Default(), nil, buddyUpdateBroadcaster)
 
 			haveErr := svc.IdleNotification(nil, tt.sess, tt.bodyIn)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
@@ -1576,11 +1480,6 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 }
 
 func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
-	type buddiesLookupParams []struct {
-		screenName string
-		buddies    []string
-	}
-
 	tests := []struct {
 		// name is the name of the test
 		name string
@@ -1589,149 +1488,62 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 		// bodyIn is the SNAC body sent from the arriving user's client to the
 		// server
 		bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
-		// buddyLookupParams contains params for looking up arriving user's
-		// buddies
-		buddyLookupParams buddiesLookupParams
-		// adjacentUsersParams contains params for looking up users who have
-		// the arriving user on their buddy list
-		interestedUsersParams adjacentUsersParams
-		// relayToScreenNamesParams contains params for sending
-		// buddy online notification to users who have the arriving user on
-		// their buddy list
-		relayToScreenNamesParams relayToScreenNamesParams
 		// retrieveByScreenNameParams contains params for looking up the
 		// session for each of the arriving user's buddies
 		retrieveByScreenNameParams retrieveByScreenNameParams
-		// relayToScreenNameParams contains params for sending arrival
-		// notifications for each of the arriving user's buddies to the
-		// arriving user's client
-		relayToScreenNameParams relayToScreenNameParams
-		// feedbagParams contains params for retrieving a user's feedbag
-		feedbagParams feedbagParams
-		wantErr       error
+		wantErr                    error
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
 	}{
 		{
-			name:   "notify feedbag buddies that user is online",
+			name:   "notify feedbag + client-side buddies that user is online, populate client-side buddy list",
 			sess:   newTestSession("test-user"),
 			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
-			interestedUsersParams: adjacentUsersParams{
-				{
-					screenName: "test-user",
-					users:      []string{"buddy1", "buddy2", "buddy3", "buddy4"},
-				},
-			},
-			relayToScreenNamesParams: relayToScreenNamesParams{
-				{
-					screenNames: []string{"buddy1", "buddy2", "buddy3", "buddy4"},
-					message: wire.SNACMessage{
-						Frame: wire.SNACFrame{
-							FoodGroup: wire.Buddy,
-							SubGroup:  wire.BuddyArrived,
-						},
-						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
-						},
-					},
-				},
-			},
-			feedbagParams: feedbagParams{
-				{
-					screenName: "test-user",
-					results:    []wire.FeedbagItem{},
-				},
-			},
 			mockParams: mockParams{
 				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
 					legacyBuddiesParams: legacyBuddiesParams{
 						{
 							userScreenName: "test-user",
+							result:         []string{"buddy1", "buddy2"},
 						},
 					},
-					whoAddedUserParams: whoAddedUserParams{
-						{
-							userScreenName: "test-user",
-						},
-					},
-				},
-			},
-		},
-		{
-			name:   "notify feedbag + client-side buddies that user is online, populate client-side buddy list",
-			sess:   newTestSession("test-user"),
-			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
-			interestedUsersParams: adjacentUsersParams{
-				{
-					screenName: "test-user",
-					users:      []string{"buddy1", "buddy2"},
 				},
-			},
-			relayToScreenNameParams: relayToScreenNameParams{
-				{
-					screenName: "test-user",
-					message: wire.SNACMessage{
-						Frame: wire.SNACFrame{
-							FoodGroup: wire.Buddy,
-							SubGroup:  wire.BuddyArrived,
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
+						{
+							screenName: "buddy1",
+							sess:       newTestSession("buddy1", sessOptCannedSignonTime),
 						},
-						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: newTestSession("buddy1", sessOptCannedSignonTime).TLVUserInfo(),
+						{
+							screenName: "buddy2",
+							sess:       newTestSession("buddy2", sessOptCannedSignonTime),
 						},
 					},
 				},
-				{
-					screenName: "test-user",
-					message: wire.SNACMessage{
-						Frame: wire.SNACFrame{
-							FoodGroup: wire.Buddy,
-							SubGroup:  wire.BuddyArrived,
-						},
-						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: newTestSession("buddy2", sessOptCannedSignonTime).TLVUserInfo(),
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
+						{
+							screenName: "test-user",
 						},
 					},
-				},
-			},
-			relayToScreenNamesParams: relayToScreenNamesParams{
-				{
-					screenNames: []string{"buddy1", "buddy2", "buddy3", "buddy4"},
-					message: wire.SNACMessage{
-						Frame: wire.SNACFrame{
-							FoodGroup: wire.Buddy,
-							SubGroup:  wire.BuddyArrived,
+					unicastBuddyArrivedParams: unicastBuddyArrivedParams{
+						{
+							from: "buddy1",
+							to:   "test-user",
 						},
-						Body: wire.SNAC_0x03_0x0B_BuddyArrived{
-							TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
+						{
+							from: "buddy2",
+							to:   "test-user",
 						},
 					},
 				},
 			},
-			retrieveByScreenNameParams: retrieveByScreenNameParams{
-				{
-					screenName: "buddy1",
-					sess:       newTestSession("buddy1", sessOptCannedSignonTime),
-				},
-				{
-					screenName: "buddy2",
-					sess:       newTestSession("buddy2", sessOptCannedSignonTime),
-				},
-			},
-			feedbagParams: feedbagParams{
-				{
-					screenName: "test-user",
-					results:    []wire.FeedbagItem{},
-				},
-				{
-					screenName: "buddy1",
-					results:    []wire.FeedbagItem{},
-				},
-				{
-					screenName: "buddy2",
-					results:    []wire.FeedbagItem{},
-				},
-			},
+		},
+		{
+			name:   "notify feedbag + client-side buddies that user is online, don't send invisible buddy arrival",
+			sess:   newTestSession("test-user"),
+			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			mockParams: mockParams{
 				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
 					legacyBuddiesParams: legacyBuddiesParams{
@@ -1740,10 +1552,29 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 							result:         []string{"buddy1", "buddy2"},
 						},
 					},
-					whoAddedUserParams: whoAddedUserParams{
+				},
+				messageRelayerParams: messageRelayerParams{
+					retrieveByScreenNameParams: retrieveByScreenNameParams{
 						{
-							userScreenName: "test-user",
-							result:         []string{"buddy3", "buddy4"},
+							screenName: "buddy1",
+							sess:       newTestSession("buddy1", sessOptCannedSignonTime, sessOptInvisible),
+						},
+						{
+							screenName: "buddy2",
+							sess:       newTestSession("buddy2", sessOptCannedSignonTime),
+						},
+					},
+				},
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastBuddyArrivedParams: broadcastBuddyArrivedParams{
+						{
+							screenName: "test-user",
+						},
+					},
+					unicastBuddyArrivedParams: unicastBuddyArrivedParams{
+						{
+							from: "buddy2",
+							to:   "test-user",
 						},
 					},
 				},
@@ -1752,47 +1583,41 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
 			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.interestedUsersParams {
-				feedbagManager.EXPECT().
-					AdjacentUsers(params.screenName).
-					Return(params.users, nil)
-			}
-			for _, params := range tt.relayToScreenNamesParams {
-				messageRelayer.EXPECT().
-					RelayToScreenNames(mock.Anything, params.screenNames, params.message)
-			}
-			for _, params := range tt.retrieveByScreenNameParams {
+			for _, params := range tt.mockParams.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
 					Return(params.sess)
 			}
-			for _, params := range tt.relayToScreenNameParams {
-				messageRelayer.EXPECT().
-					RelayToScreenName(mock.Anything, params.screenName, params.message)
-			}
-			for _, params := range tt.feedbagParams {
-				feedbagManager.EXPECT().
-					Feedbag(params.screenName).
-					Return(params.results, nil)
-			}
 			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
-			for _, params := range tt.mockParams.whoAddedUserParams {
-				legacyBuddyListManager.EXPECT().
-					WhoAddedUser(params.userScreenName).
-					Return(params.result)
-			}
 			for _, params := range tt.mockParams.legacyBuddiesParams {
 				legacyBuddyListManager.EXPECT().
 					Buddies(params.userScreenName).
 					Return(params.result)
 			}
-			svc := NewOServiceServiceForBOS(OServiceService{
-				feedbagManager:         feedbagManager,
-				legacyBuddyListManager: legacyBuddyListManager,
-				messageRelayer:         messageRelayer,
-			}, nil)
+			buddyUpdateBroadcaster := newMockBuddyBroadcaster(t)
+			for _, params := range tt.mockParams.broadcastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					BroadcastBuddyArrived(mock.Anything, mock.MatchedBy(func(s *state.Session) bool {
+						return s.ScreenName() == p.screenName
+					})).
+					Return(nil)
+			}
+			for _, params := range tt.mockParams.unicastBuddyArrivedParams {
+				p := params
+				buddyUpdateBroadcaster.EXPECT().
+					UnicastBuddyArrived(mock.Anything,
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.from
+						}),
+						mock.MatchedBy(func(s *state.Session) bool {
+							return s.ScreenName() == p.to
+						})).
+					Return(p.err)
+			}
+			oservicesvc := NewOServiceService(config.Config{}, messageRelayer, legacyBuddyListManager, slog.Default(), nil, buddyUpdateBroadcaster)
+			svc := NewOServiceServiceForBOS(*oservicesvc, nil)
 
 			haveErr := svc.ClientOnline(nil, tt.bodyIn, tt.sess)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
@@ -1905,7 +1730,6 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
 			chatMessageRelayer := newMockChatMessageRelayer(t)
 			for _, params := range tt.broadcastExcept {
 				chatMessageRelayer.EXPECT().
@@ -1926,7 +1750,6 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 			chatRegistry.Register(chatRoom, chatMessageRelayer)
 
 			svc := NewOServiceServiceForChat(OServiceService{
-				feedbagManager: feedbagManager,
 				messageRelayer: chatMessageRelayer,
 			}, chatRegistry)
 
@@ -1938,7 +1761,7 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 
 func TestOServiceServiceForChatNav_HostOnline(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewOServiceServiceForChatNav(*NewOServiceService(config.Config{}, nil, nil, nil, slog.Default(), cookieIssuer), nil)
+	svc := NewOServiceServiceForChatNav(*NewOServiceService(config.Config{}, nil, nil, slog.Default(), cookieIssuer, nil), nil)
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -1959,7 +1782,7 @@ func TestOServiceServiceForChatNav_HostOnline(t *testing.T) {
 
 func TestOServiceServiceForAlert_HostOnline(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewOServiceServiceForAlert(*NewOServiceService(config.Config{}, nil, nil, nil, slog.Default(), cookieIssuer))
+	svc := NewOServiceServiceForAlert(*NewOServiceService(config.Config{}, nil, nil, slog.Default(), cookieIssuer, nil))
 
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
@@ -1980,7 +1803,7 @@ func TestOServiceServiceForAlert_HostOnline(t *testing.T) {
 
 func TestOServiceService_SetPrivacyFlags(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(t)
-	svc := NewOServiceServiceForAlert(*NewOServiceService(config.Config{}, nil, nil, nil, slog.Default(), cookieIssuer))
+	svc := NewOServiceServiceForAlert(*NewOServiceService(config.Config{}, nil, nil, slog.Default(), cookieIssuer, nil))
 	body := wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags{
 		PrivacyFlags: wire.OServicePrivacyFlagMember | wire.OServicePrivacyFlagIdle,
 	}

+ 43 - 3
foodgroup/test_helpers.go

@@ -20,6 +20,7 @@ type mockParams struct {
 	sessionManagerParams
 	userManagerParams
 	cookieIssuerParams
+	buddyBroadcasterParams
 }
 
 // bartManagerParams is a helper struct that contains mock parameters for
@@ -205,7 +206,7 @@ type relayToScreenNameParams []struct {
 // ProfileManager methods
 type profileManagerParams struct {
 	retrieveProfileParams
-	upsertProfileParams
+	setProfileParams
 }
 
 // retrieveByScreenNameParams is the list of parameters passed at the mock
@@ -216,9 +217,9 @@ type retrieveProfileParams []struct {
 	err        error
 }
 
-// upsertProfileParams is the list of parameters passed at the mock
+// setProfileParams is the list of parameters passed at the mock
 // ProfileManager.UpsertProfile call site
-type upsertProfileParams []struct {
+type setProfileParams []struct {
 	screenName string
 	body       any
 }
@@ -288,6 +289,45 @@ type cookieIssuerParams []struct {
 	err    error
 }
 
+// buddyBroadcasterParams is a helper struct that contains mock parameters for
+// BuddyBroadcaster methods
+type buddyBroadcasterParams struct {
+	broadcastBuddyArrivedParams
+	broadcastBuddyDepartedParams
+	unicastBuddyArrivedParams
+	unicastBuddyDepartedParams
+}
+
+// broadcastBuddyArrivedParams is the list of parameters passed at the mock
+// BuddyBroadcaster.BroadcastBuddyArrived call site
+type broadcastBuddyArrivedParams []struct {
+	screenName string
+	err        error
+}
+
+// broadcastBuddyDepartedParams is the list of parameters passed at the mock
+// BuddyBroadcaster.BroadcastBuddyDeparted call site
+type broadcastBuddyDepartedParams []struct {
+	screenName string
+	err        error
+}
+
+// unicastBuddyArrivedParams is the list of parameters passed at the mock
+// BuddyBroadcaster.UnicastBuddyArrived call site
+type unicastBuddyArrivedParams []struct {
+	from string
+	to   string
+	err  error
+}
+
+// unicastBuddyDepartedParams is the list of parameters passed at the mock
+// BuddyBroadcaster.UnicastBuddyDeparted call site
+type unicastBuddyDepartedParams []struct {
+	from string
+	to   string
+	err  error
+}
+
 // sessOptWarning sets a warning level on the session object
 func sessOptWarning(level uint16) func(session *state.Session) {
 	return func(session *state.Session) {

+ 7 - 0
foodgroup/types.go

@@ -116,3 +116,10 @@ type BARTManager interface {
 type CookieIssuer interface {
 	Issue(data []byte) ([]byte, error)
 }
+
+type BuddyBroadcaster interface {
+	BroadcastBuddyArrived(ctx context.Context, sess *state.Session) error
+	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
+	UnicastBuddyArrived(ctx context.Context, from *state.Session, to *state.Session) error
+	UnicastBuddyDeparted(ctx context.Context, from *state.Session, to *state.Session)
+}