Просмотр исходного кода

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 2 лет назад
Родитель
Сommit
331b64f519

+ 3 - 0
.mockery.yaml

@@ -104,3 +104,6 @@ packages:
       CookieIssuer:
       CookieIssuer:
         config:
         config:
           filename: "mock_cookie_issuer_test.go"
           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) {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "BOS")
 		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)
 		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)
 		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) }
 		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
 		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
 		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()
 		foodgroupService := foodgroup.NewPermitDenyService()
 
 
 		oscar.BOSServer{
 		oscar.BOSServer{
@@ -87,8 +87,9 @@ func main() {
 	}(logger)
 	}(logger)
 	go func(logger *slog.Logger) {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT")
 		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)
 		chatService := foodgroup.NewChatService(chatRegistry)
 		oServiceServiceForChat := foodgroup.NewOServiceServiceForChat(*oServiceService, chatRegistry)
 		oServiceServiceForChat := foodgroup.NewOServiceServiceForChat(*oServiceService, chatRegistry)
 
 
@@ -108,8 +109,9 @@ func main() {
 	go func(logger *slog.Logger) {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "CHAT_NAV")
 		logger = logger.With("svc", "CHAT_NAV")
 		sessionManager := state.NewInMemorySessionManager(logger)
 		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)
 		oServiceServiceForChatNav := foodgroup.NewOServiceServiceForChatNav(*oServiceService, chatRegistry)
 		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
 		newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) }
 		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
 		chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr)
@@ -131,8 +133,9 @@ func main() {
 	go func(logger *slog.Logger) {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "ALERT")
 		logger = logger.With("svc", "ALERT")
 		sessionManager := state.NewInMemorySessionManager(logger)
 		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)
 		oServiceServiceForAlert := foodgroup.NewOServiceServiceForAlert(*oServiceService)
 
 
 		oscar.BOSServer{
 		oscar.BOSServer{
@@ -151,7 +154,7 @@ func main() {
 	}(logger)
 	}(logger)
 	go func(logger *slog.Logger) {
 	go func(logger *slog.Logger) {
 		logger = logger.With("svc", "AUTH")
 		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{
 		oscar.AuthServer{
 			AuthService:   authHandler,
 			AuthService:   authHandler,

+ 1 - 1
config/settings.env

@@ -34,7 +34,7 @@ export FAIL_FAST=false
 
 
 # Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn',
 # Set logging granularity. Possible values: 'trace', 'debug', 'info', 'warn',
 # 'error'.
 # 'error'.
-export LOG_LEVEL=debug
+export LOG_LEVEL=info
 
 
 # The hostname that AIM clients connect to in order to reach OSCAR services
 # 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.
 # (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(
 func NewAuthService(
 	cfg config.Config,
 	cfg config.Config,
 	sessionManager SessionManager,
 	sessionManager SessionManager,
-	messageRelayer MessageRelayer,
-	feedbagManager FeedbagManager,
 	userManager UserManager,
 	userManager UserManager,
 	chatRegistry ChatRegistry,
 	chatRegistry ChatRegistry,
 	legacyBuddyListManager LegacyBuddyListManager,
 	legacyBuddyListManager LegacyBuddyListManager,
 	cookieIssuer CookieIssuer,
 	cookieIssuer CookieIssuer,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) *AuthService {
 ) *AuthService {
 	return &AuthService{
 	return &AuthService{
 		chatRegistry:           chatRegistry,
 		chatRegistry:           chatRegistry,
 		config:                 cfg,
 		config:                 cfg,
-		feedbagManager:         feedbagManager,
 		legacyBuddyListManager: legacyBuddyListManager,
 		legacyBuddyListManager: legacyBuddyListManager,
-		messageRelayer:         messageRelayer,
 		sessionManager:         sessionManager,
 		sessionManager:         sessionManager,
 		userManager:            userManager,
 		userManager:            userManager,
 		cookieIssuer:           cookieIssuer,
 		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
 // supports both FLAP (AIM v1.0-v3.0) and BUCP (AIM v3.5-v5.9) authentication
 // modes.
 // modes.
 type AuthService struct {
 type AuthService struct {
+	buddyUpdateBroadcaster BuddyBroadcaster
 	chatRegistry           ChatRegistry
 	chatRegistry           ChatRegistry
 	config                 config.Config
 	config                 config.Config
-	feedbagManager         FeedbagManager
+	cookieIssuer           CookieIssuer
 	legacyBuddyListManager LegacyBuddyListManager
 	legacyBuddyListManager LegacyBuddyListManager
-	messageRelayer         MessageRelayer
 	sessionManager         SessionManager
 	sessionManager         SessionManager
 	userManager            UserManager
 	userManager            UserManager
-	cookieIssuer           CookieIssuer
 }
 }
 
 
 // RegisterChatSession creates and returns a chat room session.
 // 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
 // Signout removes this user's session and notifies users who have this user on
 // their buddy list about this user's departure.
 // their buddy list about this user's departure.
 func (s AuthService) Signout(ctx context.Context, sess *state.Session) error {
 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
 		return err
 	}
 	}
 	s.sessionManager.RemoveSession(sess)
 	s.sessionManager.RemoveSession(sess)

+ 16 - 75
foodgroup/auth_test.go

@@ -958,7 +958,7 @@ func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) {
 
 
 	cookieIssuer := newMockCookieIssuer(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())
 	have, err := svc.RegisterChatSession(buf.Bytes())
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -982,7 +982,7 @@ func TestAuthService_RegisterBOSSession_ChatNotFound(t *testing.T) {
 		Return(state.ChatRoom{}, nil, state.ErrChatRoomNotFound)
 		Return(state.ChatRoom{}, nil, state.ErrChatRoomNotFound)
 
 
 	cookieIssuer := newMockCookieIssuer(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.RegisterChatSession(loginCookie.Bytes())
 	_, err := svc.RegisterChatSession(loginCookie.Bytes())
 	assert.ErrorIs(t, err, state.ErrChatRoomNotFound)
 	assert.ErrorIs(t, err, state.ErrChatRoomNotFound)
@@ -998,7 +998,7 @@ func TestAuthService_RegisterBOSSession_HappyPath(t *testing.T) {
 
 
 	cookieIssuer := newMockCookieIssuer(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())
 	have, err := svc.RegisterBOSSession(sess.ScreenName())
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1015,7 +1015,7 @@ func TestAuthService_RegisterBOSSession_SessionNotFound(t *testing.T) {
 
 
 	cookieIssuer := newMockCookieIssuer(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())
 	have, err := svc.RegisterBOSSession(sess.ScreenName())
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -1157,7 +1157,7 @@ func TestAuthService_SignoutChat(t *testing.T) {
 
 
 			cookieIssuer := newMockCookieIssuer(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)
 			err := svc.SignoutChat(nil, tt.userSession)
 			assert.ErrorIs(t, err, tt.wantErr)
 			assert.ErrorIs(t, err, tt.wantErr)
@@ -1173,8 +1173,6 @@ func TestAuthService_Signout(t *testing.T) {
 		name string
 		name string
 		// userSession is the session of the user signing out
 		// userSession is the session of the user signing out
 		userSession *state.Session
 		userSession *state.Session
-		// chatRoom is the chat room user is exiting
-		chatRoom state.ChatRoom
 		// wantErr is the error we expect from the method
 		// wantErr is the error we expect from the method
 		wantErr error
 		wantErr error
 		// mockParams is the list of params sent to mocks that satisfy this
 		// 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",
 			name:        "user signs out of chat room, room is empty after user leaves",
 			userSession: sess,
 			userSession: sess,
-			chatRoom: state.ChatRoom{
-				Cookie: "the-chat-cookie",
-			},
 			mockParams: mockParams{
 			mockParams: mockParams{
 				sessionManagerParams: sessionManagerParams{
 				sessionManagerParams: sessionManagerParams{
 					removeSessionParams: removeSessionParams{
 					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{
 				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
 					deleteUserParams: deleteUserParams{
 					deleteUserParams: deleteUserParams{
 						{
 						{
 							userScreenName: "user_screen_name",
 							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",
 							screenName: "user_screen_name",
-							users:      []string{"friend1", "friend2"},
-							err:        io.EOF,
 						},
 						},
 					},
 					},
 				},
 				},
 			},
 			},
-			wantErr: io.EOF,
 		},
 		},
 	}
 	}
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		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)
 			sessionManager := newMockSessionManager(t)
 			for _, params := range tt.mockParams.removeSessionParams {
 			for _, params := range tt.mockParams.removeSessionParams {
 				sessionManager.EXPECT().RemoveSession(params.sess)
 				sessionManager.EXPECT().RemoveSession(params.sess)
@@ -1277,15 +1217,16 @@ func TestAuthService_Signout(t *testing.T) {
 			for _, params := range tt.mockParams.deleteUserParams {
 			for _, params := range tt.mockParams.deleteUserParams {
 				legacyBuddyListManager.EXPECT().DeleteUser(params.userScreenName)
 				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)
 			err := svc.Signout(nil, tt.userSession)
 			assert.ErrorIs(t, err, tt.wantErr)
 			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
 // errKnownIconsOnly indicates that a non-known buddy icon was requested
 var errKnownIconsOnly = errors.New("can only satisfy requests for known icons")
 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{
 	return BARTService{
 		bartManager:            bartManager,
 		bartManager:            bartManager,
-		feedbagManager:         feedbagManager,
-		legacyBuddyListManager: legacyBuddyListManager,
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		logger:                 logger,
 		logger:                 logger,
-		messageRelayer:         messageRelayer,
 	}
 	}
 }
 }
 
 
 type BARTService struct {
 type BARTService struct {
 	bartManager            BARTManager
 	bartManager            BARTManager
-	feedbagManager         FeedbagManager
-	legacyBuddyListManager LegacyBuddyListManager
+	buddyUpdateBroadcaster BuddyBroadcaster
 	logger                 *slog.Logger
 	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) {
 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))
 	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
 		return wire.SNACMessage{}, err
 	}
 	}
 
 

+ 14 - 67
foodgroup/bart_test.go

@@ -38,19 +38,6 @@ func TestBARTService_UpsertItem(t *testing.T) {
 				},
 				},
 			},
 			},
 			mockParams: mockParams{
 			mockParams: mockParams{
-				feedbagManagerParams: feedbagManagerParams{
-					adjacentUsersParams: adjacentUsersParams{
-						{
-							screenName: "user_screen_name",
-							users:      []string{"friend1"},
-						},
-					},
-					feedbagParams: feedbagParams{
-						{
-							screenName: "user_screen_name",
-						},
-					},
-				},
 				bartManagerParams: bartManagerParams{
 				bartManagerParams: bartManagerParams{
 					bartManagerUpsertParams: bartManagerUpsertParams{
 					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 {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 		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)
 			bartManager := newMockBARTManager(t)
-			for _, params := range tc.mockParams.bartManagerParams.bartManagerUpsertParams {
+			for _, params := range tc.mockParams.bartManagerUpsertParams {
 				bartManager.EXPECT().
 				bartManager.EXPECT().
 					BARTUpsert(params.itemHash, params.payload).
 					BARTUpsert(params.itemHash, params.payload).
 					Return(nil)
 					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,
 			output, err := svc.UpsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x10_0x02_BARTUploadQuery))
 				tc.inputSNAC.Body.(wire.SNAC_0x10_0x02_BARTUploadQuery))
@@ -289,7 +236,7 @@ func TestBARTService_RetrieveItem(t *testing.T) {
 					Return(params.result, nil)
 					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,
 			output, err := svc.RetrieveItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x10_0x04_BARTDownloadQuery))
 				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
 			continue
 		}
 		}
 		// notify that buddy is online
 		// 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
 			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
 // 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
 // only used to indicate the user coming online. It can also notify changes to
 // buddy icons, warning levels, invisibility status, etc.
 // 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
 	// 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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
 	// find users who have this user on their client-side buddy list
 	// 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...)
 	recipients = append(recipients, legacyUsers...)
 
 
 	userInfo := sess.TLVUserInfo()
 	userInfo := sess.TLVUserInfo()
-	icon, err := getBuddyIconRefFromFeedbag(sess, feedbagManager)
+	icon, err := getBuddyIconRefFromFeedbag(sess, s.feedbagManager)
 	switch {
 	switch {
 	case err != nil:
 	case err != nil:
 		return err
 		return err
@@ -112,7 +130,7 @@ func broadcastArrival(
 		userInfo.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, *icon))
 		userInfo.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, *icon))
 	}
 	}
 
 
-	messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
+	s.messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyArrived,
 			SubGroup:  wire.BuddyArrived,
@@ -138,6 +156,13 @@ func getBuddyIconRefFromFeedbag(sess *state.Session, feedbagManager FeedbagManag
 		if item.ClassID != wire.FeedbagClassIdBart {
 		if item.ClassID != wire.FeedbagClassIdBart {
 			continue
 			continue
 		}
 		}
+		bartType, err := extractBARTItemType(item)
+		if err != nil {
+			return nil, err
+		}
+		if bartType != wire.BARTTypesBuddyIcon {
+			continue
+		}
 		b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo)
 		b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo)
 		if !hasBuf {
 		if !hasBuf {
 			return nil, errors.New("unable to extract icon payload")
 			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 {
 		if err := wire.Unmarshal(&bartInfo, bytes.NewBuffer(b)); err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
-		bartType, err := extractBARTItemType(item)
-		if err != nil {
-			return nil, err
-		}
-		if bartType != wire.BARTTypesBuddyIcon {
-			continue
-		}
 		return &wire.BARTID{
 		return &wire.BARTID{
 			Type: bartType,
 			Type: bartType,
 			BARTInfo: wire.BARTInfo{
 			BARTInfo: wire.BARTInfo{
@@ -179,23 +197,16 @@ func extractBARTItemType(item wire.FeedbagItem) (uint16, error) {
 	return bartType, nil
 	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 {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	legacyUsers := legacyBuddyListManager.WhoAddedUser(sess.ScreenName())
+	legacyUsers := s.legacyBuddyListManager.WhoAddedUser(sess.ScreenName())
 	recipients = append(recipients, legacyUsers...)
 	recipients = append(recipients, legacyUsers...)
 
 
-	messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
+	s.messageRelayer.RelayToScreenNames(ctx, recipients, wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyDeparted,
 			SubGroup:  wire.BuddyDeparted,
@@ -213,33 +224,8 @@ func broadcastDeparture(
 	return nil
 	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{
 		Frame: wire.SNACFrame{
 			FoodGroup: wire.Buddy,
 			FoodGroup: wire.Buddy,
 			SubGroup:  wire.BuddyDeparted,
 			SubGroup:  wire.BuddyDeparted,

+ 426 - 0
foodgroup/buddy_test.go

@@ -1,6 +1,7 @@
 package foodgroup
 package foodgroup
 
 
 import (
 import (
+	"strconv"
 	"testing"
 	"testing"
 
 
 	"github.com/stretchr/testify/mock"
 	"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,
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
 	feedbagManager FeedbagManager,
 	bartManager BARTManager,
 	bartManager BARTManager,
-	legacyBuddyListManager LegacyBuddyListManager,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) FeedbagService {
 ) FeedbagService {
 	return FeedbagService{
 	return FeedbagService{
 		bartManager:            bartManager,
 		bartManager:            bartManager,
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		feedbagManager:         feedbagManager,
 		feedbagManager:         feedbagManager,
-		legacyBuddyListManager: legacyBuddyListManager,
 		logger:                 logger,
 		logger:                 logger,
 		messageRelayer:         messageRelayer,
 		messageRelayer:         messageRelayer,
 	}
 	}
@@ -33,8 +33,8 @@ func NewFeedbagService(
 // handles buddy list management.
 // handles buddy list management.
 type FeedbagService struct {
 type FeedbagService struct {
 	bartManager            BARTManager
 	bartManager            BARTManager
+	buddyUpdateBroadcaster BuddyBroadcaster
 	feedbagManager         FeedbagManager
 	feedbagManager         FeedbagManager
-	legacyBuddyListManager LegacyBuddyListManager
 	logger                 *slog.Logger
 	logger                 *slog.Logger
 	messageRelayer         MessageRelayer
 	messageRelayer         MessageRelayer
 }
 }
@@ -202,7 +202,7 @@ func (s FeedbagService) UpsertItem(ctx context.Context, sess *state.Session, inF
 			if buddy == nil || buddy.Invisible() {
 			if buddy == nil || buddy.Invisible() {
 				continue
 				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
 				return wire.SNACMessage{}, err
 			}
 			}
 		case wire.FeedbagClassIDDeny: // block buddy
 		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
 				continue // blocked buddy is offline, nothing to do here
 			}
 			}
 			// alert blocked buddy that current user is offline
 			// 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
 			// tell blocker that blocked user is offline
-			unicastDeparture(ctx, blockedSess, sess, s.messageRelayer)
+			s.buddyUpdateBroadcaster.UnicastBuddyDeparted(ctx, blockedSess, sess)
 		case wire.FeedbagClassIdBart:
 		case wire.FeedbagClassIdBart:
 			if err := s.broadcastIconUpdate(ctx, sess, item); err != nil {
 			if err := s.broadcastIconUpdate(ctx, sess, item); err != nil {
 				return wire.SNACMessage{}, err
 				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",
 		s.logger.DebugContext(ctx, "user is clearing icon",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
 		// 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{
 	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",
 		s.logger.DebugContext(ctx, "icon already exists in BART store, don't upload the icon file",
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 			"hash", fmt.Sprintf("%x", btlv.Hash))
 		// tell buddies about the icon update
 		// 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
 			return err
 		}
 		}
 	}
 	}
@@ -312,13 +312,13 @@ func (s FeedbagService) DeleteItem(ctx context.Context, sess *state.Session, inF
 			}
 			}
 			if !sess.Invisible() {
 			if !sess.Invisible() {
 				// alert unblocked user that current user is online
 				// 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
 					return wire.SNACMessage{}, err
 				}
 				}
 			}
 			}
 			if !unblockedSess.Invisible() {
 			if !unblockedSess.Invisible() {
 				// alert current user that unblocked user is online
 				// 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
 					return wire.SNACMessage{}, err
 				}
 				}
 			}
 			}
@@ -358,7 +358,7 @@ func (s FeedbagService) Use(ctx context.Context, sess *state.Session) error {
 		if buddy == nil || buddy.Invisible() {
 		if buddy == nil || buddy.Invisible() {
 			continue
 			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
 			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{
 				messageRelayerParams: messageRelayerParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
@@ -439,30 +431,16 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
 							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"),
 							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{
 				messageRelayerParams: messageRelayerParams{
 					relayToScreenNameParams: relayToScreenNameParams{
 					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).
 					FeedbagUpsert(params.screenName, params.items).
 					Return(nil)
 					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 {
 			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
 				feedbagManager.EXPECT().
 				feedbagManager.EXPECT().
 					Feedbag(params.screenName).
 					Feedbag(params.screenName).
@@ -1167,14 +1016,40 @@ func TestFeedbagService_UpsertItem(t *testing.T) {
 					WhoAddedUser(params.userScreenName).
 					WhoAddedUser(params.userScreenName).
 					Return(params.result)
 					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,
 			output, err := svc.UpsertItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items)
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x08_FeedbagInsertItem).Items)
 			assert.NoError(t, err)
 			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{
 				messageRelayerParams: messageRelayerParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
@@ -1299,54 +1163,24 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 							sess:       newTestSession("buddy_2_online", sessOptCannedSignonTime),
 							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{
 				messageRelayerParams: messageRelayerParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
 					retrieveByScreenNameParams: retrieveByScreenNameParams{
@@ -1454,18 +1283,12 @@ func TestFeedbagService_DeleteItem(t *testing.T) {
 							sess:       newTestSession("invisible_buddy_online", sessOptInvisible),
 							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).
 					FeedbagDelete(params.screenName, params.items).
 					Return(nil)
 					Return(nil)
 			}
 			}
-			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
-				feedbagManager.EXPECT().
-					Feedbag(params.screenName).
-					Return(params.results, nil)
-			}
 			messageRelayer := newMockMessageRelayer(t)
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
 			for _, params := range tc.mockParams.messageRelayerParams.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
 					RetrieveByScreenName(params.screenName).
 					Return(params.sess)
 					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{
 			svc := FeedbagService{
-				feedbagManager: feedbagManager,
-				messageRelayer: messageRelayer,
+				buddyUpdateBroadcaster: buddyUpdateBroadcast,
+				feedbagManager:         feedbagManager,
+				messageRelayer:         messageRelayer,
 			}
 			}
 			output, err := svc.DeleteItem(nil, tc.userSession, tc.inputSNAC.Frame,
 			output, err := svc.DeleteItem(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x13_0x0A_FeedbagDeleteItem))
 				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
 		// bodyIn is the SNAC body sent from the arriving user's client to the
 		// server
 		// server
 		bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
 		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",
 			name:   "notify arriving user's buddies of its arrival and populate the arriving user's buddy list",
 			sess:   newTestSession("test-user"),
 			sess:   newTestSession("test-user"),
 			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			feedbagManager := newMockFeedbagManager(t)
 			feedbagManager := newMockFeedbagManager(t)
-			messageRelayer := newMockMessageRelayer(t)
-			for _, params := range tt.buddiesParams {
+			for _, params := range tt.mockParams.buddiesParams {
 				feedbagManager.EXPECT().
 				feedbagManager.EXPECT().
 					Buddies(params.screenName).
 					Buddies(params.screenName).
 					Return(params.results, nil)
 					Return(params.results, nil)
 			}
 			}
-			for _, params := range tt.retrieveByScreenNameParams {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.retrieveByScreenNameParams {
 				messageRelayer.EXPECT().
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
 					RetrieveByScreenName(params.screenName).
 					Return(params.sess)
 					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)
 			haveErr := svc.Use(nil, tt.sess)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 			assert.ErrorIs(t, tt.wantErr, haveErr)

+ 4 - 4
foodgroup/icbm.go

@@ -16,11 +16,11 @@ const (
 func NewICBMService(
 func NewICBMService(
 	messageRelayer MessageRelayer,
 	messageRelayer MessageRelayer,
 	feedbagManager FeedbagManager,
 	feedbagManager FeedbagManager,
-	legacyBuddyListManager LegacyBuddyListManager,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) *ICBMService {
 ) *ICBMService {
 	return &ICBMService{
 	return &ICBMService{
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		feedbagManager:         feedbagManager,
 		feedbagManager:         feedbagManager,
-		legacyBuddyListManager: legacyBuddyListManager,
 		messageRelayer:         messageRelayer,
 		messageRelayer:         messageRelayer,
 	}
 	}
 }
 }
@@ -29,8 +29,8 @@ func NewICBMService(
 // responsible for sending and receiving instant messages and associated
 // responsible for sending and receiving instant messages and associated
 // functionality such as warning, typing events, etc.
 // functionality such as warning, typing events, etc.
 type ICBMService struct {
 type ICBMService struct {
+	buddyUpdateBroadcaster BuddyBroadcaster
 	feedbagManager         FeedbagManager
 	feedbagManager         FeedbagManager
-	legacyBuddyListManager LegacyBuddyListManager
 	messageRelayer         MessageRelayer
 	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
 	// 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
 		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
 		// recipientBuddies is a list of the recipient's buddies that get
 		// updated warning level
 		// updated warning level
 		recipientBuddies []string
 		recipientBuddies []string
-		broadcastMessage wire.SNACMessage
 		// inputSNAC is the SNAC sent by the sender client
 		// inputSNAC is the SNAC sent by the sender client
 		inputSNAC wire.SNACMessage
 		inputSNAC wire.SNACMessage
 		// expectSNACToClient is the SNAC sent from the server to the
 		// 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"),
 			senderSession:       newTestSession("sender-screen-name"),
 			recipientSession:    newTestSession("recipient-screen-name", sessOptCannedSignonTime),
 			recipientSession:    newTestSession("recipient-screen-name", sessOptCannedSignonTime),
 			recipientScreenName: "recipient-screen-name",
 			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{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -419,10 +409,10 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 				},
 			},
 			},
 			mockParams: mockParams{
 			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),
 			recipientSession:    newTestSession("recipient-screen-name", sessOptCannedSignonTime),
 			recipientScreenName: "recipient-screen-name",
 			recipientScreenName: "recipient-screen-name",
 			recipientBuddies:    []string{"buddy1", "buddy2"},
 			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{
 			inputSNAC: wire.SNACMessage{
 				Frame: wire.SNACFrame{
 				Frame: wire.SNACFrame{
 					RequestID: 1234,
 					RequestID: 1234,
@@ -478,10 +459,10 @@ func TestICBMService_EvilRequest(t *testing.T) {
 				},
 				},
 			},
 			},
 			mockParams: mockParams{
 			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).
 				BlockedState(tc.senderSession.ScreenName(), tc.recipientScreenName).
 				Return(tc.blockedState, nil).
 				Return(tc.blockedState, nil).
 				Maybe()
 				Maybe()
-			feedbagManager.EXPECT().
-				AdjacentUsers(tc.recipientScreenName).
-				Return(tc.recipientBuddies, nil).
-				Maybe()
-			feedbagManager.EXPECT().
-				Feedbag(tc.recipientScreenName).
-				Return(nil, nil).
-				Maybe()
 			messageRelayer := newMockMessageRelayer(t)
 			messageRelayer := newMockMessageRelayer(t)
 			messageRelayer.EXPECT().
 			messageRelayer.EXPECT().
 				RetrieveByScreenName(tc.recipientScreenName).
 				RetrieveByScreenName(tc.recipientScreenName).
@@ -648,20 +621,20 @@ func TestICBMService_EvilRequest(t *testing.T) {
 			messageRelayer.EXPECT().
 			messageRelayer.EXPECT().
 				RelayToScreenName(mock.Anything, tc.recipientScreenName, tc.expectSNACToClient).
 				RelayToScreenName(mock.Anything, tc.recipientScreenName, tc.expectSNACToClient).
 				Maybe()
 				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
 			// send input SNAC
 			//
 			//
 			senderSession := newTestSession(tc.senderSession.ScreenName())
 			senderSession := newTestSession(tc.senderSession.ScreenName())
-			svc := NewICBMService(messageRelayer, feedbagManager, legacyBuddyListManager)
+			svc := NewICBMService(messageRelayer, feedbagManager, buddyUpdateBroadcaster)
 			outputSNAC, err := svc.EvilRequest(nil, senderSession, tc.inputSNAC.Frame,
 			outputSNAC, err := svc.EvilRequest(nil, senderSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x08_ICBMEvilRequest))
 				tc.inputSNAC.Body.(wire.SNAC_0x04_0x08_ICBMEvilRequest))
 			assert.NoError(t, err)
 			assert.NoError(t, err)

+ 4 - 4
foodgroup/locate.go

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

+ 15 - 58
foodgroup/locate_test.go

@@ -4,9 +4,8 @@ import (
 	"context"
 	"context"
 	"testing"
 	"testing"
 
 
-	"github.com/stretchr/testify/mock"
-
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -354,7 +353,7 @@ func TestLocateService_UserInfoQuery(t *testing.T) {
 					WhoAddedUser(params.userScreenName).
 					WhoAddedUser(params.userScreenName).
 					Return(params.result)
 					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,
 			outputSNAC, err := svc.UserInfoQuery(context.Background(), tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x02_0x05_LocateUserInfoQuery))
 				tc.inputSNAC.Body.(wire.SNAC_0x02_0x05_LocateUserInfoQuery))
 			assert.NoError(t, err)
 			assert.NoError(t, err)
@@ -424,7 +423,7 @@ func TestLocateService_SetInfo(t *testing.T) {
 			},
 			},
 			mockParams: mockParams{
 			mockParams: mockParams{
 				profileManagerParams: profileManagerParams{
 				profileManagerParams: profileManagerParams{
-					upsertProfileParams: upsertProfileParams{
+					setProfileParams: setProfileParams{
 						{
 						{
 							screenName: "test-user",
 							screenName: "test-user",
 							body:       "profile-result",
 							body:       "profile-result",
@@ -444,39 +443,10 @@ func TestLocateService_SetInfo(t *testing.T) {
 				},
 				},
 			},
 			},
 			mockParams: mockParams{
 			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",
 							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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		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)
 			profileManager := newMockProfileManager(t)
-			for _, params := range tt.mockParams.upsertProfileParams {
+			for _, params := range tt.mockParams.setProfileParams {
 				profileManager.EXPECT().
 				profileManager.EXPECT().
 					SetProfile(params.screenName, params.body).
 					SetProfile(params.screenName, params.body).
 					Return(nil)
 					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))
 			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(
 func NewOServiceService(
 	cfg config.Config,
 	cfg config.Config,
 	messageRelayer MessageRelayer,
 	messageRelayer MessageRelayer,
-	feedbagManager FeedbagManager,
 	legacyBuddyListManager LegacyBuddyListManager,
 	legacyBuddyListManager LegacyBuddyListManager,
 	logger *slog.Logger,
 	logger *slog.Logger,
 	cookieIssuer CookieIssuer,
 	cookieIssuer CookieIssuer,
+	buddyUpdateBroadcaster BuddyBroadcaster,
 ) *OServiceService {
 ) *OServiceService {
 	return &OServiceService{
 	return &OServiceService{
+		buddyUpdateBroadcaster: buddyUpdateBroadcaster,
 		cfg:                    cfg,
 		cfg:                    cfg,
-		feedbagManager:         feedbagManager,
+		cookieIssuer:           cookieIssuer,
 		legacyBuddyListManager: legacyBuddyListManager,
 		legacyBuddyListManager: legacyBuddyListManager,
-		messageRelayer:         messageRelayer,
 		logger:                 logger,
 		logger:                 logger,
-		cookieIssuer:           cookieIssuer,
+		messageRelayer:         messageRelayer,
 	}
 	}
 }
 }
 
 
 // OServiceService provides functionality for the OService food group, which
 // OServiceService provides functionality for the OService food group, which
 // provides an assortment of services useful across multiple food groups.
 // provides an assortment of services useful across multiple food groups.
 type OServiceService struct {
 type OServiceService struct {
+	buddyUpdateBroadcaster BuddyBroadcaster
 	cfg                    config.Config
 	cfg                    config.Config
-	feedbagManager         FeedbagManager
+	cookieIssuer           CookieIssuer
 	legacyBuddyListManager LegacyBuddyListManager
 	legacyBuddyListManager LegacyBuddyListManager
-	messageRelayer         MessageRelayer
 	logger                 *slog.Logger
 	logger                 *slog.Logger
-	cookieIssuer           CookieIssuer
+	messageRelayer         MessageRelayer
 }
 }
 
 
 // ClientVersions informs the server what food group versions the client
 // 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, hasStatus := inBody.Uint32(wire.OServiceUserInfoStatus); hasStatus {
 		if status == wire.OServiceUserStatusAvailable {
 		if status == wire.OServiceUserStatusAvailable {
 			sess.SetInvisible(false)
 			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
 				return wire.SNACMessage{}, err
 			}
 			}
 		}
 		}
 		if status&wire.OServiceUserStatusInvisible == wire.OServiceUserStatusInvisible {
 		if status&wire.OServiceUserStatusInvisible == wire.OServiceUserStatusInvisible {
 			sess.SetInvisible(true)
 			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
 				return wire.SNACMessage{}, err
 			}
 			}
 		}
 		}
@@ -466,7 +466,7 @@ func (s OServiceService) IdleNotification(ctx context.Context, sess *state.Sessi
 	} else {
 	} else {
 		sess.SetIdle(time.Duration(bodyIn.IdleTime) * time.Second)
 		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
 // 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 {
 func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
 	sess.SetSignonComplete()
 	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
 		return err
 	}
 	}
 
 
@@ -667,7 +667,7 @@ func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x0
 		if buddySess == nil || buddySess.Invisible() {
 		if buddySess == nil || buddySess.Invisible() {
 			continue
 			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
 			return err
 		}
 		}
 	}
 	}

+ 119 - 296
foodgroup/oservice_test.go

@@ -319,31 +319,11 @@ func TestSetUserInfoFields(t *testing.T) {
 					TLVUserInfo: newTestSession("user_screen_name").TLVUserInfo(),
 					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{
 			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(),
 					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{
 			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 {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 		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,
 			outputSNAC, err := svc.SetUserInfoFields(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
 				tc.inputSNAC.Body.(wire.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
 			assert.ErrorIs(t, err, tc.expectErr)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 			if tc.expectErr != nil {
 				return
 				return
 			}
 			}
-			//
-			// verify output
-			//
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 		})
 	}
 	}
@@ -456,7 +399,7 @@ func TestSetUserInfoFields(t *testing.T) {
 
 
 func TestOServiceService_RateParamsQuery(t *testing.T) {
 func TestOServiceService_RateParamsQuery(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(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})
 	have := svc.RateParamsQuery(nil, wire.SNACFrame{RequestID: 1234})
 	want := wire.SNACMessage{
 	want := wire.SNACMessage{
@@ -1382,7 +1325,7 @@ func TestOServiceService_RateParamsQuery(t *testing.T) {
 
 
 func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(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{
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
@@ -1410,7 +1353,7 @@ func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) {
 
 
 func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
 func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(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{
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
@@ -1431,7 +1374,7 @@ func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) {
 
 
 func TestOServiceService_ClientVersions(t *testing.T) {
 func TestOServiceService_ClientVersions(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(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{
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
@@ -1455,7 +1398,7 @@ func TestOServiceService_ClientVersions(t *testing.T) {
 
 
 func TestOServiceService_UserInfoQuery(t *testing.T) {
 func TestOServiceService_UserInfoQuery(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(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")
 	sess := newTestSession("test-user")
 
 
 	want := wire.SNACMessage{
 	want := wire.SNACMessage{
@@ -1479,16 +1422,10 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 		name   string
 		name   string
 		sess   *state.Session
 		sess   *state.Session
 		bodyIn wire.SNAC_0x01_0x11_OServiceIdleNotification
 		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
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		// method's dependencies
 		mockParams mockParams
 		mockParams mockParams
+		wantErr    error
 	}{
 	}{
 		{
 		{
 			name: "set idle from active",
 			name: "set idle from active",
@@ -1496,22 +1433,11 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 			bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
 			bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
 				IdleTime: 90,
 				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{
 			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{
 			bodyIn: wire.SNAC_0x01_0x11_OServiceIdleNotification{
 				IdleTime: 0,
 				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{
 			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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		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)
 			haveErr := svc.IdleNotification(nil, tt.sess, tt.bodyIn)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
@@ -1576,11 +1480,6 @@ func TestOServiceService_IdleNotification(t *testing.T) {
 }
 }
 
 
 func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
-	type buddiesLookupParams []struct {
-		screenName string
-		buddies    []string
-	}
-
 	tests := []struct {
 	tests := []struct {
 		// name is the name of the test
 		// name is the name of the test
 		name string
 		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
 		// bodyIn is the SNAC body sent from the arriving user's client to the
 		// server
 		// server
 		bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline
 		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
 		// retrieveByScreenNameParams contains params for looking up the
 		// session for each of the arriving user's buddies
 		// session for each of the arriving user's buddies
 		retrieveByScreenNameParams retrieveByScreenNameParams
 		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
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		// method's dependencies
 		mockParams mockParams
 		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"),
 			sess:   newTestSession("test-user"),
 			bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			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{
 			mockParams: mockParams{
 				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
 				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
 					legacyBuddiesParams: legacyBuddiesParams{
 					legacyBuddiesParams: legacyBuddiesParams{
 						{
 						{
 							userScreenName: "test-user",
 							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{
 			mockParams: mockParams{
 				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
 				legacyBuddyListManagerParams: legacyBuddyListManagerParams{
 					legacyBuddiesParams: legacyBuddiesParams{
 					legacyBuddiesParams: legacyBuddiesParams{
@@ -1740,10 +1552,29 @@ func TestOServiceServiceForBOS_ClientOnline(t *testing.T) {
 							result:         []string{"buddy1", "buddy2"},
 							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 {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
 			messageRelayer := newMockMessageRelayer(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().
 				messageRelayer.EXPECT().
 					RetrieveByScreenName(params.screenName).
 					RetrieveByScreenName(params.screenName).
 					Return(params.sess)
 					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)
 			legacyBuddyListManager := newMockLegacyBuddyListManager(t)
-			for _, params := range tt.mockParams.whoAddedUserParams {
-				legacyBuddyListManager.EXPECT().
-					WhoAddedUser(params.userScreenName).
-					Return(params.result)
-			}
 			for _, params := range tt.mockParams.legacyBuddiesParams {
 			for _, params := range tt.mockParams.legacyBuddiesParams {
 				legacyBuddyListManager.EXPECT().
 				legacyBuddyListManager.EXPECT().
 					Buddies(params.userScreenName).
 					Buddies(params.userScreenName).
 					Return(params.result)
 					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)
 			haveErr := svc.ClientOnline(nil, tt.bodyIn, tt.sess)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
@@ -1905,7 +1730,6 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 	}
 	}
 	for _, tt := range tests {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
-			feedbagManager := newMockFeedbagManager(t)
 			chatMessageRelayer := newMockChatMessageRelayer(t)
 			chatMessageRelayer := newMockChatMessageRelayer(t)
 			for _, params := range tt.broadcastExcept {
 			for _, params := range tt.broadcastExcept {
 				chatMessageRelayer.EXPECT().
 				chatMessageRelayer.EXPECT().
@@ -1926,7 +1750,6 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 			chatRegistry.Register(chatRoom, chatMessageRelayer)
 			chatRegistry.Register(chatRoom, chatMessageRelayer)
 
 
 			svc := NewOServiceServiceForChat(OServiceService{
 			svc := NewOServiceServiceForChat(OServiceService{
-				feedbagManager: feedbagManager,
 				messageRelayer: chatMessageRelayer,
 				messageRelayer: chatMessageRelayer,
 			}, chatRegistry)
 			}, chatRegistry)
 
 
@@ -1938,7 +1761,7 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) {
 
 
 func TestOServiceServiceForChatNav_HostOnline(t *testing.T) {
 func TestOServiceServiceForChatNav_HostOnline(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(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{
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
@@ -1959,7 +1782,7 @@ func TestOServiceServiceForChatNav_HostOnline(t *testing.T) {
 
 
 func TestOServiceServiceForAlert_HostOnline(t *testing.T) {
 func TestOServiceServiceForAlert_HostOnline(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(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{
 	want := wire.SNACMessage{
 		Frame: wire.SNACFrame{
 		Frame: wire.SNACFrame{
@@ -1980,7 +1803,7 @@ func TestOServiceServiceForAlert_HostOnline(t *testing.T) {
 
 
 func TestOServiceService_SetPrivacyFlags(t *testing.T) {
 func TestOServiceService_SetPrivacyFlags(t *testing.T) {
 	cookieIssuer := newMockCookieIssuer(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{
 	body := wire.SNAC_0x01_0x14_OServiceSetPrivacyFlags{
 		PrivacyFlags: wire.OServicePrivacyFlagMember | wire.OServicePrivacyFlagIdle,
 		PrivacyFlags: wire.OServicePrivacyFlagMember | wire.OServicePrivacyFlagIdle,
 	}
 	}

+ 43 - 3
foodgroup/test_helpers.go

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

+ 7 - 0
foodgroup/types.go

@@ -116,3 +116,10 @@ type BARTManager interface {
 type CookieIssuer interface {
 type CookieIssuer interface {
 	Issue(data []byte) ([]byte, error)
 	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)
+}