Przeglądaj źródła

increase test coverage on OService handler

Mike 2 lat temu
rodzic
commit
1269c54a6c

+ 1 - 7
handler/buddy.go

@@ -46,13 +46,7 @@ func broadcastArrival(ctx context.Context, sess *state.Session, messageRelayer M
 			SubGroup:  oscar.BuddyArrived,
 		},
 		Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
-			TLVUserInfo: oscar.TLVUserInfo{
-				ScreenName:   sess.ScreenName(),
-				WarningLevel: sess.Warning(),
-				TLVBlock: oscar.TLVBlock{
-					TLVList: sess.UserInfo(),
-				},
-			},
+			TLVUserInfo: sess.TLVUserInfo(),
 		},
 	})
 

+ 3 - 3
handler/chat.go

@@ -133,10 +133,10 @@ func sendChatRoomInfoUpdate(ctx context.Context, sess *state.Session, chatMessag
 			SubGroup:  oscar.ChatRoomInfoUpdate,
 		},
 		Body: oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
-			Exchange:       4,
+			Exchange:       room.Exchange,
 			Cookie:         room.Cookie,
-			InstanceNumber: 100,
-			DetailLevel:    2,
+			InstanceNumber: room.InstanceNumber,
+			DetailLevel:    room.DetailLevel,
 			TLVBlock: oscar.TLVBlock{
 				TLVList: room.TLVList(),
 			},

+ 66 - 20
handler/oservice.go

@@ -12,16 +12,26 @@ import (
 	"github.com/mkaminski/goaim/state"
 )
 
+// NewOServiceService creates a new instance of OServiceService.
 func NewOServiceService(cfg server.Config, messageRelayer MessageRelayer, feedbagManager FeedbagManager) *OServiceService {
 	return &OServiceService{cfg: cfg, messageRelayer: messageRelayer, feedbagManager: feedbagManager}
 }
 
+// OServiceService contains handlers for the OService food group.
 type OServiceService struct {
 	cfg            server.Config
 	feedbagManager FeedbagManager
 	messageRelayer MessageRelayer
 }
 
+// ClientVersionsHandler informs the server what food group versions the client
+// supports and returns to the client what food group versions it supports.
+// This method simply regurgitates versions supplied by the client in inBody
+// back to the client in a OServiceHostVersions SNAC. The server doesn't
+// attempt to accommodate any particular food group version. The server
+// implicitly accommodates any food group version for Windows AIM clients 5.x.
+// It returns SNAC oscar.OServiceHostVersions containing the server's supported
+// food group versions.
 func (s OServiceService) ClientVersionsHandler(_ context.Context, frame oscar.SNACFrame, inBody oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.SNACMessage {
 	return oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -36,25 +46,19 @@ func (s OServiceService) ClientVersionsHandler(_ context.Context, frame oscar.SN
 }
 
 // RateParamsQueryHandler returns SNAC rate limits.
-// The purpose of this method is to provide information about rate limits that can be
-// enforced on the server side. The response consists of two main parts: rate classes and
-// rate groups. Rate classes define limits based on specific parameters, while rate groups
-// associate these limits with relevant SNAC types.
+// The purpose of this method is to provide information about rate limits that
+// can be enforced on the server side. The response consists of two main parts:
+// rate classes and rate groups. Rate classes define limits based on specific
+// parameters, while rate groups associate these limits with relevant SNAC
+// types.
 //
-// Note: The current implementation does not enforce server-side rate limiting. Instead,
-// the provided values inform the client about the recommended client-side rate limits.
+// Note: The current implementation does not enforce server-side rate limiting.
+// Instead, the provided values inform the client about the recommended
+// client-side rate limits.
 //
-// The response only contains rate limits for sending Instant Messages (IMs)
-// and chat messages. More refined limits may be added in the future if/when server
-// rate limiting is implemented.
-//
-// Parameters:
-//   - _ (context.Context): The context is not used in this method.
-//   - inFrame (oscar.SNACFrame): The input SNAC frame containing the request details.
-//
-// Returns:
-//
-//	oscar.SNACMessage: The SNAC message containing rate limits.
+// It returns SNAC osca.rOServiceRateParamsReply containing rate limits for
+// sending Instant Messages (IMs) and chat messages. More refined limits may be
+// added in the future if/when server rate limiting is implemented.
 func (s OServiceService) RateParamsQueryHandler(_ context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	return oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -116,7 +120,9 @@ func (s OServiceService) RateParamsQueryHandler(_ context.Context, inFrame oscar
 	}
 }
 
-func (s OServiceService) UserInfoQueryHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame) oscar.SNACMessage {
+// UserInfoQueryHandler returns SNAC oscar.OServiceUserInfoUpdate containing
+// the user's info.
+func (s OServiceService) UserInfoQueryHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	return oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
 			FoodGroup: oscar.OService,
@@ -129,6 +135,11 @@ func (s OServiceService) UserInfoQueryHandler(ctx context.Context, sess *state.S
 	}
 }
 
+// SetUserInfoFieldsHandler sets the user's visibility status to visible or invisible.
+// The visibility status is set according to the inFrame TLV entry under key
+// oscar.OServiceUserInfoStatus. If the value is 0x0000, set invisible. If set
+// to 0x0100, set invisible. Else, return an error for any other value.
+// It returns SNAC oscar.OServiceUserInfoUpdate containing the user's info.
 func (s OServiceService) SetUserInfoFieldsHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.SNACMessage, error) {
 	if status, hasStatus := inBody.GetUint32(oscar.OServiceUserInfoStatus); hasStatus {
 		switch status {
@@ -158,6 +169,9 @@ func (s OServiceService) SetUserInfoFieldsHandler(ctx context.Context, sess *sta
 	}, nil
 }
 
+// IdleNotificationHandler sets the user idle time.
+// Set session idle time to the value of bodyIn.IdleTime. Return a user arrival
+// message to all users who have this user on their buddy list.
 func (s OServiceService) IdleNotificationHandler(ctx context.Context, sess *state.Session, bodyIn oscar.SNAC_0x01_0x11_OServiceIdleNotification) error {
 	if bodyIn.IdleTime == 0 {
 		sess.SetActive()
@@ -172,6 +186,7 @@ func (s OServiceService) IdleNotificationHandler(ctx context.Context, sess *stat
 func (s OServiceService) RateParamsSubAddHandler(context.Context, oscar.SNAC_0x01_0x08_OServiceRateParamsSubAdd) {
 }
 
+// NewOServiceServiceForBOS creates a new instance of OServiceServiceForBOS.
 func NewOServiceServiceForBOS(oserviceService OServiceService, cr *state.ChatRegistry) *OServiceServiceForBOS {
 	return &OServiceServiceForBOS{
 		OServiceService: oserviceService,
@@ -179,11 +194,20 @@ func NewOServiceServiceForBOS(oserviceService OServiceService, cr *state.ChatReg
 	}
 }
 
+// OServiceServiceForBOS contains handlers for the OService food group for the
+// BOS service.
 type OServiceServiceForBOS struct {
 	OServiceService
 	chatRegistry *state.ChatRegistry
 }
 
+// ServiceRequestHandler configures food group settings for the current user.
+// This method only provides services for the Chat food group; return
+// server.ErrUnsupportedSubGroup for any other food group. When the chat food
+// group is specified in inFrame, add user to the chat room specified by TLV
+// 0x01.
+// It returns SNAC oscar.OServiceServiceResponse containing metadata the client
+// needs to connect to the chat service and join the chat room.
 func (s OServiceServiceForBOS) ServiceRequestHandler(_ context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.SNACMessage, error) {
 	if inBody.FoodGroup != oscar.Chat {
 		return oscar.SNACMessage{}, server.ErrUnsupportedSubGroup
@@ -228,6 +252,9 @@ func (s OServiceServiceForBOS) ServiceRequestHandler(_ context.Context, sess *st
 	}, nil
 }
 
+// WriteOServiceHostOnline initiates the BOS protocol sequence.
+// It returns SNAC oscar.OServiceHostOnline containing the list food groups
+// supported by the BOS service.
 func (s OServiceServiceForBOS) WriteOServiceHostOnline() oscar.SNACMessage {
 	return oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -248,6 +275,11 @@ func (s OServiceServiceForBOS) WriteOServiceHostOnline() oscar.SNACMessage {
 	}
 }
 
+// ClientOnlineHandler runs when the current user is ready to join.
+// It performs the following sequence of actions:
+//   - Announce current user's arrival to users who have the current user on
+//     their buddy list.
+//   - Send current user its buddy list
 func (s OServiceServiceForBOS) ClientOnlineHandler(ctx context.Context, _ oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
 	if err := broadcastArrival(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
 		return err
@@ -262,6 +294,7 @@ func (s OServiceServiceForBOS) ClientOnlineHandler(ctx context.Context, _ oscar.
 	return nil
 }
 
+// NewOServiceServiceForChat creates a new instance of OServiceServiceForChat.
 func NewOServiceServiceForChat(oserviceService OServiceService, chatRegistry *state.ChatRegistry) *OServiceServiceForChat {
 	return &OServiceServiceForChat{
 		OServiceService: oserviceService,
@@ -269,11 +302,16 @@ func NewOServiceServiceForChat(oserviceService OServiceService, chatRegistry *st
 	}
 }
 
+// OServiceServiceForChat contains handlers for the OService food group for the
+// Chat service.
 type OServiceServiceForChat struct {
 	OServiceService
 	chatRegistry *state.ChatRegistry
 }
 
+// WriteOServiceHostOnline initiates the Chat protocol sequence.
+// It returns SNAC oscar.OServiceHostOnline containing the list of food groups
+// supported by the Chat service.
 func (s OServiceServiceForChat) WriteOServiceHostOnline() oscar.SNACMessage {
 	return oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -281,12 +319,20 @@ func (s OServiceServiceForChat) WriteOServiceHostOnline() oscar.SNACMessage {
 			SubGroup:  oscar.OServiceHostOnline,
 		},
 		Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{
-			FoodGroups: []uint16{oscar.OService, oscar.Chat},
+			FoodGroups: []uint16{
+				oscar.OService,
+				oscar.Chat,
+			},
 		},
 	}
 }
 
-func (s OServiceServiceForChat) ClientOnlineHandler(ctx context.Context, bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session, chatID string) error {
+// ClientOnlineHandler runs when the current user is ready to join the chat.
+// Trigger the following actions:
+//   - Send current user the chat room metadata
+//   - Announce current user's arrival to other chat room participants
+//   - Send current user the chat room participant list
+func (s OServiceServiceForChat) ClientOnlineHandler(ctx context.Context, sess *state.Session, chatID string) error {
 	room, chatSessMgr, err := s.chatRegistry.Retrieve(chatID)
 	if err != nil {
 		return err

+ 465 - 13
handler/oservice_test.go

@@ -1,10 +1,11 @@
 package handler
 
 import (
-	"github.com/stretchr/testify/mock"
 	"testing"
 	"time"
 
+	"github.com/stretchr/testify/mock"
+
 	"github.com/mkaminski/goaim/oscar"
 	"github.com/mkaminski/goaim/server"
 	"github.com/mkaminski/goaim/state"
@@ -143,12 +144,9 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 			//
 			// send input SNAC
 			//
-			svc := OServiceServiceForBOS{
-				OServiceService: OServiceService{
-					cfg: tc.cfg,
-				},
-				chatRegistry: chatRegistry,
-			}
+			svc := NewOServiceServiceForBOS(OServiceService{
+				cfg: tc.cfg,
+			}, chatRegistry)
 
 			outputSNAC, err := svc.ServiceRequestHandler(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(oscar.SNAC_0x01_0x04_OServiceServiceRequest))
@@ -302,10 +300,7 @@ func TestSetUserInfoFieldsHandler(t *testing.T) {
 			//
 			// send input SNAC
 			//
-			svc := OServiceService{
-				feedbagManager: feedbagManager,
-				messageRelayer: messageRelayer,
-			}
+			svc := NewOServiceService(server.Config{}, messageRelayer, feedbagManager)
 			outputSNAC, err := svc.SetUserInfoFieldsHandler(nil, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields))
 			assert.ErrorIs(t, err, tc.expectErr)
@@ -321,8 +316,7 @@ func TestSetUserInfoFieldsHandler(t *testing.T) {
 }
 
 func TestOServiceService_RateParamsQueryHandler(t *testing.T) {
-
-	svc := OServiceService{}
+	svc := NewOServiceService(server.Config{}, nil, nil)
 
 	have := svc.RateParamsQueryHandler(nil, oscar.SNACFrame{RequestID: 1234})
 	want := oscar.SNACMessage{
@@ -386,3 +380,461 @@ func TestOServiceService_RateParamsQueryHandler(t *testing.T) {
 
 	assert.Equal(t, want, have)
 }
+
+func TestOServiceServiceForBOS_WriteOServiceHostOnline(t *testing.T) {
+	svc := NewOServiceServiceForBOS(*NewOServiceService(server.Config{}, nil, nil), nil)
+
+	want := oscar.SNACMessage{
+		Frame: oscar.SNACFrame{
+			FoodGroup: oscar.OService,
+			SubGroup:  oscar.OServiceHostOnline,
+		},
+		Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{
+			FoodGroups: []uint16{
+				oscar.Alert,
+				oscar.Buddy,
+				oscar.ChatNav,
+				oscar.Feedbag,
+				oscar.ICBM,
+				oscar.Locate,
+				oscar.OService,
+			},
+		},
+	}
+
+	have := svc.WriteOServiceHostOnline()
+	assert.Equal(t, want, have)
+}
+
+func TestOServiceServiceForChat_WriteOServiceHostOnline(t *testing.T) {
+	svc := NewOServiceServiceForChat(*NewOServiceService(server.Config{}, nil, nil), nil)
+
+	want := oscar.SNACMessage{
+		Frame: oscar.SNACFrame{
+			FoodGroup: oscar.OService,
+			SubGroup:  oscar.OServiceHostOnline,
+		},
+		Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{
+			FoodGroups: []uint16{
+				oscar.OService,
+				oscar.Chat,
+			},
+		},
+	}
+
+	have := svc.WriteOServiceHostOnline()
+	assert.Equal(t, want, have)
+}
+
+func TestOServiceService_ClientVersionsHandler(t *testing.T) {
+	svc := NewOServiceService(server.Config{}, nil, nil)
+
+	want := oscar.SNACMessage{
+		Frame: oscar.SNACFrame{
+			FoodGroup: oscar.OService,
+			SubGroup:  oscar.OServiceHostVersions,
+			RequestID: 1234,
+		},
+		Body: oscar.SNAC_0x01_0x18_OServiceHostVersions{
+			Versions: []uint16{5, 6, 7, 8},
+		},
+	}
+
+	have := svc.ClientVersionsHandler(nil, oscar.SNACFrame{
+		RequestID: 1234,
+	}, oscar.SNAC_0x01_0x17_OServiceClientVersions{
+		Versions: []uint16{5, 6, 7, 8},
+	})
+
+	assert.Equal(t, want, have)
+}
+
+func TestOServiceService_UserInfoQueryHandler(t *testing.T) {
+	svc := NewOServiceService(server.Config{}, nil, nil)
+	sess := newTestSession("test-user")
+
+	want := oscar.SNACMessage{
+		Frame: oscar.SNACFrame{
+			FoodGroup: oscar.OService,
+			SubGroup:  oscar.OServiceUserInfoUpdate,
+			RequestID: 1234,
+		},
+		Body: oscar.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
+			TLVUserInfo: sess.TLVUserInfo(),
+		},
+	}
+
+	have := svc.UserInfoQueryHandler(nil, sess, oscar.SNACFrame{RequestID: 1234})
+
+	assert.Equal(t, want, have)
+}
+
+func TestOServiceService_IdleNotificationHandler(t *testing.T) {
+	tests := []struct {
+		name   string
+		sess   *state.Session
+		bodyIn oscar.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 oscar.SNACMessage
+		wantErr          error
+	}{
+		{
+			name: "set idle from active",
+			sess: newTestSession("test-user"),
+			bodyIn: oscar.SNAC_0x01_0x11_OServiceIdleNotification{
+				IdleTime: 90,
+			},
+			recipientScreenName: "test-user",
+			recipientBuddies:    []string{"buddy1", "buddy2"},
+			broadcastMessage: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Buddy,
+					SubGroup:  oscar.BuddyArrived,
+				},
+				Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: newTestSession("test-user", sessOptIdle(90*time.Second)).TLVUserInfo(),
+				},
+			},
+		},
+		{
+			name: "set active from idle",
+			sess: newTestSession("test-user", sessOptIdle(90*time.Second)),
+			bodyIn: oscar.SNAC_0x01_0x11_OServiceIdleNotification{
+				IdleTime: 0,
+			},
+			recipientScreenName: "test-user",
+			recipientBuddies:    []string{"buddy1", "buddy2"},
+			broadcastMessage: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.Buddy,
+					SubGroup:  oscar.BuddyArrived,
+				},
+				Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+					TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			feedbagManager.EXPECT().
+				InterestedUsers(tt.recipientScreenName).
+				Return(tt.recipientBuddies, nil).
+				Maybe()
+			messageRelayer := newMockMessageRelayer(t)
+			messageRelayer.EXPECT().
+				BroadcastToScreenNames(mock.Anything, tt.recipientBuddies, tt.broadcastMessage).
+				Maybe()
+
+			svc := NewOServiceService(server.Config{}, messageRelayer, feedbagManager)
+
+			haveErr := svc.IdleNotificationHandler(nil, tt.sess, tt.bodyIn)
+			assert.ErrorIs(t, tt.wantErr, haveErr)
+		})
+	}
+}
+
+func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
+	type buddiesLookupParams []struct {
+		screenName string
+		buddies    []string
+	}
+	type interestedUsersParams []struct {
+		screenName string
+		users      []string
+	}
+	type broadcastToScreenNamesParams []struct {
+		screenNames []string
+		message     oscar.SNACMessage
+	}
+	type retrieveByScreenNameParams []struct {
+		screenName string
+		sess       *state.Session
+	}
+	type sendToScreenNameParams []struct {
+		screenName string
+		message    oscar.SNACMessage
+	}
+
+	tests := []struct {
+		// name is the name of the test
+		name string
+		// joiningChatter is the session of the arriving user
+		sess *state.Session
+		// bodyIn is the SNAC body sent from the arriving user's client to the
+		// server
+		bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline
+		// buddyLookupParams contains params for looking up arriving user's
+		// buddies
+		buddyLookupParams buddiesLookupParams
+		// interestedUsersParams contains params for looking up users who have
+		// the arriving user on their buddy list
+		interestedUsersParams interestedUsersParams
+		// broadcastToScreenNamesParams contains params for sending
+		// buddy online notification to users who have the arriving user on
+		// their buddy list
+		broadcastToScreenNamesParams broadcastToScreenNamesParams
+		// retrieveByScreenNameParams contains params for looking up the
+		// session for each of the arriving user's buddies
+		retrieveByScreenNameParams retrieveByScreenNameParams
+		// sendToScreenNameParams contains params for sending arrival
+		// notifications for each of the arriving user's buddies to the
+		// arriving user's client
+		sendToScreenNameParams sendToScreenNameParams
+		wantErr                error
+	}{
+		{
+			name:   "notify arriving user's buddies of its arrival and populate the arriving user's buddy list",
+			sess:   newTestSession("test-user"),
+			bodyIn: oscar.SNAC_0x01_0x02_OServiceClientOnline{},
+			interestedUsersParams: interestedUsersParams{
+				{
+					screenName: "test-user",
+					users:      []string{"buddy1", "buddy2", "buddy3", "buddy4"},
+				},
+			},
+			broadcastToScreenNamesParams: broadcastToScreenNamesParams{
+				{
+					screenNames: []string{"buddy1", "buddy2", "buddy3", "buddy4"},
+					message: oscar.SNACMessage{
+						Frame: oscar.SNACFrame{
+							FoodGroup: oscar.Buddy,
+							SubGroup:  oscar.BuddyArrived,
+						},
+						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+							TLVUserInfo: newTestSession("test-user").TLVUserInfo(),
+						},
+					},
+				},
+			},
+			buddyLookupParams: buddiesLookupParams{
+				{
+					screenName: "test-user",
+					buddies:    []string{"buddy1", "buddy3"},
+				},
+			},
+			retrieveByScreenNameParams: retrieveByScreenNameParams{
+				{
+					screenName: "buddy1",
+					sess:       newTestSession("buddy1"),
+				},
+				{
+					screenName: "buddy3",
+					sess:       newTestSession("buddy3"),
+				},
+			},
+			sendToScreenNameParams: sendToScreenNameParams{
+				{
+					screenName: "test-user",
+					message: oscar.SNACMessage{
+						Frame: oscar.SNACFrame{
+							FoodGroup: oscar.Buddy,
+							SubGroup:  oscar.BuddyArrived,
+						},
+						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+							TLVUserInfo: newTestSession("buddy1").TLVUserInfo(),
+						},
+					},
+				},
+				{
+					screenName: "test-user",
+					message: oscar.SNACMessage{
+						Frame: oscar.SNACFrame{
+							FoodGroup: oscar.Buddy,
+							SubGroup:  oscar.BuddyArrived,
+						},
+						Body: oscar.SNAC_0x03_0x0B_BuddyArrived{
+							TLVUserInfo: newTestSession("buddy3").TLVUserInfo(),
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.interestedUsersParams {
+				feedbagManager.EXPECT().
+					InterestedUsers(params.screenName).
+					Return(params.users, nil).
+					Maybe()
+			}
+			for _, params := range tt.broadcastToScreenNamesParams {
+				messageRelayer.EXPECT().
+					BroadcastToScreenNames(mock.Anything, params.screenNames, params.message).
+					Maybe()
+			}
+			for _, params := range tt.buddyLookupParams {
+				feedbagManager.EXPECT().
+					Buddies(params.screenName).
+					Return(params.buddies, nil).
+					Maybe()
+			}
+			for _, params := range tt.retrieveByScreenNameParams {
+				messageRelayer.EXPECT().
+					RetrieveByScreenName(params.screenName).
+					Return(params.sess).
+					Maybe()
+			}
+			for _, params := range tt.sendToScreenNameParams {
+				messageRelayer.EXPECT().
+					SendToScreenName(mock.Anything, params.screenName, params.message).
+					Maybe()
+			}
+
+			svc := NewOServiceServiceForBOS(OServiceService{
+				feedbagManager: feedbagManager,
+				messageRelayer: messageRelayer,
+			}, nil)
+
+			haveErr := svc.ClientOnlineHandler(nil, tt.bodyIn, tt.sess)
+			assert.ErrorIs(t, tt.wantErr, haveErr)
+		})
+	}
+}
+
+func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
+	chatter1 := newTestSession("chatter-1")
+	chatter2 := newTestSession("chatter-2")
+	chatRoom := state.ChatRoom{
+		Cookie:         "the-cookie",
+		DetailLevel:    1,
+		Exchange:       2,
+		InstanceNumber: 3,
+		Name:           "the-chat-room",
+	}
+
+	type participantsParams []*state.Session
+	type broadcastExcept []struct {
+		sess    *state.Session
+		message oscar.SNACMessage
+	}
+	type sendToScreenNameParams []struct {
+		screenName string
+		message    oscar.SNACMessage
+	}
+
+	tests := []struct {
+		// name is the name of the test
+		name string
+		// joiningChatter is the user joining the chat room
+		joiningChatter *state.Session
+		// bodyIn is the SNAC body sent from the arriving user's client to the
+		// server
+		bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline
+		// participantsParams contains all the chat room participants
+		participantsParams participantsParams
+		// broadcastExcept contains params for broadcasting chat arrival to all
+		// chat participants except the user joining
+		broadcastExcept broadcastExcept
+		// sendToScreenNameParams contains params for sending chat room
+		// metadata and chat participant list to joining user
+		sendToScreenNameParams sendToScreenNameParams
+		wantErr                error
+	}{
+		{
+			name:           "upon joining, send chat room metadata and participant list to joining user; alert arrival to existing participants",
+			joiningChatter: chatter1,
+			bodyIn:         oscar.SNAC_0x01_0x02_OServiceClientOnline{},
+			broadcastExcept: broadcastExcept{
+				{
+					sess: chatter1,
+					message: oscar.SNACMessage{
+						Frame: oscar.SNACFrame{
+							FoodGroup: oscar.Chat,
+							SubGroup:  oscar.ChatUsersJoined,
+						},
+						Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
+							Users: []oscar.TLVUserInfo{
+								{
+									ScreenName:   chatter1.ScreenName(),
+									WarningLevel: chatter1.Warning(),
+									TLVBlock: oscar.TLVBlock{
+										TLVList: chatter1.UserInfo(),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			participantsParams: participantsParams{
+				chatter1,
+				chatter2,
+			},
+			sendToScreenNameParams: sendToScreenNameParams{
+				{
+					screenName: chatter1.ScreenName(),
+					message: oscar.SNACMessage{
+						Frame: oscar.SNACFrame{
+							FoodGroup: oscar.Chat,
+							SubGroup:  oscar.ChatRoomInfoUpdate,
+						},
+						Body: oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
+							Exchange:       chatRoom.Exchange,
+							Cookie:         chatRoom.Cookie,
+							InstanceNumber: chatRoom.InstanceNumber,
+							DetailLevel:    chatRoom.DetailLevel,
+							TLVBlock: oscar.TLVBlock{
+								TLVList: chatRoom.TLVList(),
+							},
+						},
+					},
+				},
+				{
+					screenName: chatter1.ScreenName(),
+					message: oscar.SNACMessage{
+						Frame: oscar.SNACFrame{
+							FoodGroup: oscar.Chat,
+							SubGroup:  oscar.ChatUsersJoined,
+						},
+						Body: oscar.SNAC_0x0E_0x03_ChatUsersJoined{
+							Users: []oscar.TLVUserInfo{
+								chatter1.TLVUserInfo(),
+								chatter2.TLVUserInfo(),
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			feedbagManager := newMockFeedbagManager(t)
+			chatMessageRelayer := newMockChatMessageRelayer(t)
+			for _, params := range tt.broadcastExcept {
+				chatMessageRelayer.EXPECT().
+					BroadcastExcept(mock.Anything, params.sess, params.message).
+					Maybe()
+			}
+			chatMessageRelayer.EXPECT().
+				Participants().
+				Return(tt.participantsParams).
+				Maybe()
+			for _, params := range tt.sendToScreenNameParams {
+				chatMessageRelayer.EXPECT().
+					SendToScreenName(mock.Anything, params.screenName, params.message).
+					Maybe()
+			}
+
+			chatRegistry := state.NewChatRegistry()
+			chatRegistry.Register(chatRoom, chatMessageRelayer)
+
+			svc := NewOServiceServiceForChat(OServiceService{
+				feedbagManager: feedbagManager,
+				messageRelayer: chatMessageRelayer,
+			}, chatRegistry)
+
+			haveErr := svc.ClientOnlineHandler(nil, tt.joiningChatter, chatRoom.Cookie)
+			assert.ErrorIs(t, tt.wantErr, haveErr)
+		})
+	}
+}

+ 7 - 0
handler/test_helpers.go

@@ -45,6 +45,13 @@ func sessOptInvisible(session *state.Session) {
 	session.SetInvisible(true)
 }
 
+// sessOptIdle sets the idle flag to dur on the session object
+func sessOptIdle(dur time.Duration) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetIdle(dur)
+	}
+}
+
 // newTestSession creates a session object with 0 or more functional options
 // applied
 func newTestSession(screenName string, options ...func(session *state.Session)) *state.Session {

+ 2 - 2
server/oservice.go

@@ -29,7 +29,7 @@ type OServiceBOSHandler interface {
 type OServiceChatHandler interface {
 	OServiceHandler
 	WriteOServiceHostOnline() oscar.SNACMessage
-	ClientOnlineHandler(ctx context.Context, bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session, chatID string) error
+	ClientOnlineHandler(ctx context.Context, sess *state.Session, chatID string) error
 }
 
 type OServiceRouter struct {
@@ -160,7 +160,7 @@ func (rt OServiceChatRouter) RouteOService(ctx context.Context, sess *state.Sess
 		}
 		rt.Logger.InfoContext(ctx, "user signed on")
 		rt.logRequest(ctx, inFrame, inBody)
-		return rt.OServiceChatHandler.ClientOnlineHandler(ctx, inBody, sess, chatID)
+		return rt.OServiceChatHandler.ClientOnlineHandler(ctx, sess, chatID)
 	default:
 		return rt.OServiceRouter.RouteOService(ctx, sess, inFrame, r, w, sequence)
 	}

+ 10 - 11
server/oservice_chat_mock.go

@@ -24,13 +24,13 @@ func (_m *mockOServiceChatHandler) EXPECT() *mockOServiceChatHandler_Expecter {
 	return &mockOServiceChatHandler_Expecter{mock: &_m.Mock}
 }
 
-// ClientOnlineHandler provides a mock function with given fields: ctx, bodyIn, sess, chatID
-func (_m *mockOServiceChatHandler) ClientOnlineHandler(ctx context.Context, bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session, chatID string) error {
-	ret := _m.Called(ctx, bodyIn, sess, chatID)
+// ClientOnlineHandler provides a mock function with given fields: ctx, sess, chatID
+func (_m *mockOServiceChatHandler) ClientOnlineHandler(ctx context.Context, sess *state.Session, chatID string) error {
+	ret := _m.Called(ctx, sess, chatID)
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNAC_0x01_0x02_OServiceClientOnline, *state.Session, string) error); ok {
-		r0 = rf(ctx, bodyIn, sess, chatID)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, string) error); ok {
+		r0 = rf(ctx, sess, chatID)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -45,16 +45,15 @@ type mockOServiceChatHandler_ClientOnlineHandler_Call struct {
 
 // ClientOnlineHandler is a helper method to define mock.On call
 //   - ctx context.Context
-//   - bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline
 //   - sess *state.Session
 //   - chatID string
-func (_e *mockOServiceChatHandler_Expecter) ClientOnlineHandler(ctx interface{}, bodyIn interface{}, sess interface{}, chatID interface{}) *mockOServiceChatHandler_ClientOnlineHandler_Call {
-	return &mockOServiceChatHandler_ClientOnlineHandler_Call{Call: _e.mock.On("ClientOnlineHandler", ctx, bodyIn, sess, chatID)}
+func (_e *mockOServiceChatHandler_Expecter) ClientOnlineHandler(ctx interface{}, sess interface{}, chatID interface{}) *mockOServiceChatHandler_ClientOnlineHandler_Call {
+	return &mockOServiceChatHandler_ClientOnlineHandler_Call{Call: _e.mock.On("ClientOnlineHandler", ctx, sess, chatID)}
 }
 
-func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) Run(run func(ctx context.Context, bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session, chatID string)) *mockOServiceChatHandler_ClientOnlineHandler_Call {
+func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) Run(run func(ctx context.Context, sess *state.Session, chatID string)) *mockOServiceChatHandler_ClientOnlineHandler_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(oscar.SNAC_0x01_0x02_OServiceClientOnline), args[2].(*state.Session), args[3].(string))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(string))
 	})
 	return _c
 }
@@ -64,7 +63,7 @@ func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) Return(_a0 error) *m
 	return _c
 }
 
-func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) RunAndReturn(run func(context.Context, oscar.SNAC_0x01_0x02_OServiceClientOnline, *state.Session, string) error) *mockOServiceChatHandler_ClientOnlineHandler_Call {
+func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) RunAndReturn(run func(context.Context, *state.Session, string) error) *mockOServiceChatHandler_ClientOnlineHandler_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 1 - 1
server/oservice_test.go

@@ -534,7 +534,7 @@ func TestOServiceRouter_RouteOService_ForChat(t *testing.T) {
 
 			svcBOS := newMockOServiceChatHandler(t)
 			svcBOS.EXPECT().
-				ClientOnlineHandler(mock.Anything, tc.input.Body, mock.Anything, mock.Anything).
+				ClientOnlineHandler(mock.Anything, mock.Anything, mock.Anything).
 				Return(tc.handlerErr).
 				Maybe()