Browse Source

consolidate bos/chat router interfaces

Mike 2 years ago
parent
commit
22424cd5fb

+ 3 - 6
.mockery.yaml

@@ -35,15 +35,12 @@ packages:
       OServiceChatHandler:
         config:
           filename: "oservice_chat_mock_test.go"
+      Router:
+        config:
+          filename: "router_mock_test.go"
       UserManager:
         config:
           filename: "user_manager_mock_test.go"
-      BOSRouter:
-        config:
-          filename: "bos_router_mock_test.go"
-      ChatServiceRouter:
-        config:
-          filename: "chat_service_router_mock_test.go"
   github.com/mk6i/retro-aim-server/handler:
     interfaces:
       FeedbagManager:

+ 11 - 16
cmd/server/main.go

@@ -52,10 +52,11 @@ func main() {
 		icbmHandler := handler.NewICBMService(sessionManager, feedbagStore)
 
 		server.BOSService{
-			AuthHandler:       authHandler,
-			OServiceBOSRouter: server.NewOServiceRouterForBOS(logger, oserviceHandler, oserviceBOSHandler),
-			Config:            cfg,
-			BOSRouter: server.BOSRootRouter{
+			AuthHandler:        authHandler,
+			OServiceBOSHandler: oserviceBOSHandler,
+			Config:             cfg,
+			Logger:             logger,
+			Router: server.BOSRouter{
 				AlertRouter:       server.NewAlertRouter(logger),
 				BuddyRouter:       server.NewBuddyRouter(logger, buddyHandler),
 				ChatNavRouter:     server.NewChatNavRouter(chatNavHandler, logger),
@@ -63,10 +64,6 @@ func main() {
 				ICBMRouter:        server.NewICBMRouter(logger, icbmHandler),
 				LocateRouter:      server.NewLocateRouter(locateHandler, logger),
 				OServiceBOSRouter: server.NewOServiceRouterForBOS(logger, oserviceHandler, oserviceBOSHandler),
-				Config:            cfg,
-				RouteLogger: server.RouteLogger{
-					Logger: logger,
-				},
 			},
 		}.Start()
 		wg.Done()
@@ -79,14 +76,14 @@ func main() {
 		oserviceChatHandler := handler.NewOServiceServiceForChat(*oserviceHandler, chatRegistry)
 
 		server.ChatService{
-			AuthHandler: authHandler,
-			ChatServiceRouter: server.ChatServiceRooterRouter{
+			AuthHandler:         authHandler,
+			Config:              cfg,
+			Logger:              logger,
+			OServiceChatHandler: oserviceChatHandler,
+			Router: server.ChatServiceRouter{
 				ChatRouter:         server.NewChatRouter(logger, chatHandler),
-				Config:             cfg,
 				OServiceChatRouter: server.NewOServiceRouterForChat(logger, oserviceHandler, oserviceChatHandler),
 			},
-			Config:             cfg,
-			OServiceChatRouter: server.NewOServiceRouterForChat(logger, oserviceHandler, oserviceChatHandler),
 		}.Start()
 		wg.Done()
 	}(logger)
@@ -97,9 +94,7 @@ func main() {
 		server.BUCPAuthService{
 			AuthHandler: authHandler,
 			Config:      cfg,
-			RouteLogger: server.RouteLogger{
-				Logger: logger,
-			},
+			Logger:      logger,
 		}.Start()
 		wg.Done()
 	}(logger)

+ 2 - 2
handler/chat.go

@@ -23,7 +23,7 @@ type ChatService struct {
 // user to the other chat room participants. It returns the same
 // oscar.ChatChannelMsgToClient message back to the user if the chat reflection
 // TLV flag is set, otherwise return nil.
-func (s ChatService) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, chatID string, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error) {
+func (s ChatService) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error) {
 	frameOut := oscar.SNACFrame{
 		FoodGroup: oscar.Chat,
 		SubGroup:  oscar.ChatChannelMsgToClient,
@@ -37,7 +37,7 @@ func (s ChatService) ChannelMsgToHostHandler(ctx context.Context, sess *state.Se
 	}
 	bodyOut.Append(oscar.NewTLV(oscar.ChatTLVSenderInformation, sess.TLVUserInfo()))
 
-	_, chatSessMgr, err := s.chatRegistry.Retrieve(chatID)
+	_, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatID())
 	if err != nil {
 		return nil, err
 	}

+ 2 - 1
handler/chat_nav.go

@@ -82,7 +82,8 @@ func (s ChatNavService) CreateRoomHandler(_ context.Context, sess *state.Session
 	s.chatRegistry.Register(room, chatSessMgr)
 
 	// add user to chat room
-	chatSessMgr.AddSession(sess.ID(), sess.ScreenName())
+	chatSess := chatSessMgr.AddSession(sess.ID(), sess.ScreenName())
+	chatSess.SetChatID(room.Cookie)
 
 	return oscar.SNACMessage{
 		Frame: oscar.SNACFrame{

+ 9 - 4
handler/chat_nav_test.go

@@ -11,13 +11,14 @@ import (
 )
 
 func TestChatNavService_CreateRoomHandler(t *testing.T) {
-	userSess := newTestSession("user-screen-name", sessOptCannedID)
+	bosSess := newTestSession("user-screen-name", sessOptCannedID)
+	chatSess := &state.Session{}
 
 	chatRegistry := state.NewChatRegistry()
 
 	sessionManager := newMockSessionManager(t)
-	sessionManager.EXPECT().AddSession(userSess.ID(), userSess.ScreenName()).
-		Return(&state.Session{})
+	sessionManager.EXPECT().AddSession(bosSess.ID(), bosSess.ScreenName()).
+		Return(chatSess)
 
 	newChatRoom := func() state.ChatRoom {
 		return state.ChatRoom{
@@ -45,9 +46,11 @@ func TestChatNavService_CreateRoomHandler(t *testing.T) {
 	}
 
 	svc := NewChatNavService(nil, chatRegistry, newChatRoom, newChatSessMgr)
-	outputSNAC, err := svc.CreateRoomHandler(context.Background(), userSess, inFrame, inBody)
+	outputSNAC, err := svc.CreateRoomHandler(context.Background(), bosSess, inFrame, inBody)
 	assert.NoError(t, err)
 
+	assert.Equal(t, chatSess.ChatID(), newChatRoom().Cookie)
+
 	expectChatRoom := state.ChatRoom{
 		Cookie:         "dummy-cookie",
 		CreateTime:     time.UnixMilli(0),
@@ -58,6 +61,8 @@ func TestChatNavService_CreateRoomHandler(t *testing.T) {
 	}
 	chatRoom, _, err := chatRegistry.Retrieve("dummy-cookie")
 	assert.NoError(t, err)
+
+	// assert the user session is linked to the chat room
 	assert.Equal(t, expectChatRoom, chatRoom)
 
 	expectSNAC := oscar.SNACMessage{

+ 11 - 10
handler/chat_test.go

@@ -28,8 +28,9 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 		wantErr                  error
 	}{
 		{
-			name:        "send chat room message, expect acknowledgement to sender client",
-			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime),
+			name: "send chat room message, expect acknowledgement to sender client",
+			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime,
+				sessOptChatID("the-chat-id")),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					RequestID: 1234,
@@ -96,8 +97,9 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 			},
 		},
 		{
-			name:        "send chat room message, don't expect acknowledgement to sender client",
-			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime),
+			name: "send chat room message, don't expect acknowledgement to sender client",
+			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime,
+				sessOptChatID("the-chat-id")),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					RequestID: 1234,
@@ -141,8 +143,9 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 			},
 		},
 		{
-			name:        "send chat room message, fail due to missing chat room",
-			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime),
+			name: "send chat room message, fail due to missing chat room",
+			userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime,
+				sessOptChatID("the-chat-id")),
 			inputSNAC: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					RequestID: 1234,
@@ -174,8 +177,6 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			chatID := "the-chat-id"
-
 			chatSessMgr := newMockChatMessageRelayer(t)
 			if tc.mockParams.chatRegistryRetrieveParams.err == nil {
 				chatSessMgr.EXPECT().
@@ -188,8 +189,8 @@ func TestChatService_ChannelMsgToHostHandler(t *testing.T) {
 				Return(state.ChatRoom{}, chatSessMgr, tc.mockParams.chatRegistryRetrieveParams.err)
 
 			svc := NewChatService(chatRegistry)
-			outputSNAC, err := svc.ChannelMsgToHostHandler(context.Background(), tc.userSession, chatID,
-				tc.inputSNAC.Frame, tc.inputSNAC.Body.(oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost))
+			outputSNAC, err := svc.ChannelMsgToHostHandler(context.Background(), tc.userSession, tc.inputSNAC.Frame,
+				tc.inputSNAC.Body.(oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost))
 			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})

+ 5 - 4
handler/oservice.go

@@ -354,9 +354,10 @@ func (s OServiceServiceForBOS) ServiceRequestHandler(_ context.Context, sess *st
 
 	room, chatSessMgr, err := s.chatRegistry.Retrieve(string(roomSnac.Cookie))
 	if err != nil {
-		return oscar.SNACMessage{}, server.ErrUnsupportedSubGroup
+		return oscar.SNACMessage{}, err
 	}
-	chatSessMgr.(SessionManager).AddSession(sess.ID(), sess.ScreenName())
+	chatSess := chatSessMgr.(SessionManager).AddSession(sess.ID(), sess.ScreenName())
+	chatSess.SetChatID(room.Cookie)
 
 	return oscar.SNACMessage{
 		Frame: oscar.SNACFrame{
@@ -465,8 +466,8 @@ func (s OServiceServiceForChat) WriteOServiceHostOnline() oscar.SNACMessage {
 //   - 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)
+func (s OServiceServiceForChat) ClientOnlineHandler(ctx context.Context, sess *state.Session) error {
+	room, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatID())
 	if err != nil {
 		return err
 	}

+ 10 - 7
handler/oservice_test.go

@@ -13,7 +13,7 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestReceiveAndSendServiceRequest(t *testing.T) {
+func TestOServiceServiceForBOS_ServiceRequestHandler(t *testing.T) {
 	cases := []struct {
 		// name is the unit test name
 		name string
@@ -100,7 +100,7 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 			},
 		},
 		{
-			name: "request info for connecting to non-existent chat room, return SNAC error",
+			name: "request info for connecting to non-existent chat room, return ErrChatRoomNotFound",
 			cfg: config.Config{
 				OSCARHost: "127.0.0.1",
 				ChatPort:  1234,
@@ -124,7 +124,7 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 					},
 				},
 			},
-			expectErr: server.ErrUnsupportedSubGroup,
+			expectErr: state.ErrChatRoomNotFound,
 		},
 	}
 
@@ -135,10 +135,11 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 			//
 			sessionManager := newMockSessionManager(t)
 			chatRegistry := state.NewChatRegistry()
+			chatSess := &state.Session{}
 			if tc.chatRoom != nil {
 				sessionManager.EXPECT().
 					AddSession(tc.userSession.ID(), tc.userSession.ScreenName()).
-					Return(&state.Session{}).
+					Return(chatSess).
 					Maybe()
 				chatRegistry.Register(*tc.chatRoom, sessionManager)
 			}
@@ -155,6 +156,8 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 			if tc.expectErr != nil {
 				return
 			}
+			// assert the user session is linked to the chat room
+			assert.Equal(t, chatSess.ChatID(), tc.chatRoom.Cookie)
 			//
 			// verify output
 			//
@@ -809,8 +812,8 @@ func TestOServiceServiceForBOS_ClientOnlineHandler(t *testing.T) {
 }
 
 func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
-	chatter1 := newTestSession("chatter-1")
-	chatter2 := newTestSession("chatter-2")
+	chatter1 := newTestSession("chatter-1", sessOptChatID("the-cookie"))
+	chatter2 := newTestSession("chatter-2", sessOptChatID("the-cookie"))
 	chatRoom := state.ChatRoom{
 		Cookie:         "the-cookie",
 		DetailLevel:    1,
@@ -935,7 +938,7 @@ func TestOServiceServiceForChat_ClientOnlineHandler(t *testing.T) {
 				messageRelayer: chatMessageRelayer,
 			}, chatRegistry)
 
-			haveErr := svc.ClientOnlineHandler(nil, tt.joiningChatter, chatRoom.Cookie)
+			haveErr := svc.ClientOnlineHandler(nil, tt.joiningChatter)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 		})
 	}

+ 7 - 0
handler/test_helpers.go

@@ -248,6 +248,13 @@ func sessOptCannedSignonTime(session *state.Session) {
 	session.SetSignonTime(time.UnixMilli(1696790127565))
 }
 
+// sessOptChatID sets chat ID on the session object
+func sessOptChatID(chatID string) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetChatID(chatID)
+	}
+}
+
 // sessOptCannedSignonTime sets the invisible flag to true on the session
 // object
 func sessOptInvisible(session *state.Session) {

+ 5 - 3
server/alert.go

@@ -2,24 +2,26 @@ package server
 
 import (
 	"context"
+	"io"
 	"log/slog"
 
 	"github.com/mk6i/retro-aim-server/oscar"
+	"github.com/mk6i/retro-aim-server/state"
 )
 
 func NewAlertRouter(logger *slog.Logger) AlertRouter {
 	return AlertRouter{
-		RouteLogger: RouteLogger{
+		routeLogger: routeLogger{
 			Logger: logger,
 		},
 	}
 }
 
 type AlertRouter struct {
-	RouteLogger
+	routeLogger
 }
 
-func (rt *AlertRouter) RouteAlert(ctx context.Context, inFrame oscar.SNACFrame) error {
+func (rt AlertRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.AlertNotifyCapabilities:
 		fallthrough

+ 1 - 1
server/alert_test.go

@@ -69,7 +69,7 @@ func TestAlertRouter_RouteAlert(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(1)
 
-			err := router.RouteAlert(context.Background(), tc.input.Frame)
+			err := router.Route(context.Background(), nil, tc.input.Frame, nil, nil, nil)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return

+ 27 - 55
server/bos_router.go

@@ -2,25 +2,21 @@ package server
 
 import (
 	"context"
-	"errors"
 	"io"
 
-	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 )
 
-// BOSRootRouter routes client connections to the OSCAR food group routers.
-type BOSRootRouter struct {
-	AlertRouter
-	BuddyRouter
-	ChatNavRouter
-	config.Config
-	FeedbagRouter
-	ICBMRouter
-	LocateRouter
-	OServiceBOSRouter
-	RouteLogger
+// BOSRouter routes client connections to the OSCAR food group routers.
+type BOSRouter struct {
+	AlertRouter       Router
+	BuddyRouter       Router
+	ChatNavRouter     Router
+	FeedbagRouter     Router
+	ICBMRouter        Router
+	LocateRouter      Router
+	OServiceBOSRouter Router
 }
 
 // Route routes connections to the following food groups:
@@ -32,47 +28,23 @@ type BOSRootRouter struct {
 // - ICBM
 // - Locate
 // - OService
-func (rt BOSRootRouter) Route(ctx context.Context, sess *state.Session, r io.Reader, w io.Writer, sequence *uint32) error {
-	inFrame := oscar.SNACFrame{}
-	if err := oscar.Unmarshal(&inFrame, r); err != nil {
-		return err
+func (rt BOSRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+	switch inFrame.FoodGroup {
+	case oscar.OService:
+		return rt.OServiceBOSRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	case oscar.Locate:
+		return rt.LocateRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	case oscar.Buddy:
+		return rt.BuddyRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	case oscar.ICBM:
+		return rt.ICBMRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	case oscar.ChatNav:
+		return rt.ChatNavRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	case oscar.Feedbag:
+		return rt.FeedbagRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	case oscar.Alert:
+		return rt.AlertRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	default:
+		return ErrUnsupportedSubGroup
 	}
-
-	err := func() error {
-		switch inFrame.FoodGroup {
-		case oscar.OService:
-			return rt.RouteOService(ctx, sess, inFrame, r, w, sequence)
-		case oscar.Locate:
-			return rt.RouteLocate(ctx, sess, inFrame, r, w, sequence)
-		case oscar.Buddy:
-			return rt.RouteBuddy(ctx, inFrame, r, w, sequence)
-		case oscar.ICBM:
-			return rt.RouteICBM(ctx, sess, inFrame, r, w, sequence)
-		case oscar.ChatNav:
-			return rt.RouteChatNav(ctx, sess, inFrame, r, w, sequence)
-		case oscar.Feedbag:
-			return rt.RouteFeedbag(ctx, sess, inFrame, r, w, sequence)
-		case oscar.BUCP:
-			return ErrUnsupportedSubGroup
-		case oscar.Alert:
-			return rt.RouteAlert(ctx, inFrame)
-		default:
-			return ErrUnsupportedSubGroup
-		}
-	}()
-
-	if err != nil {
-		rt.logRequestError(ctx, inFrame, err)
-		if errors.Is(err, ErrUnsupportedSubGroup) {
-			if err1 := sendInvalidSNACErr(inFrame, w, sequence); err1 != nil {
-				err = errors.Join(err1, err)
-			}
-			if rt.Config.FailFast {
-				panic(err.Error())
-			}
-			return nil
-		}
-	}
-
-	return err
 }

+ 0 - 89
server/bos_router_mock_test.go

@@ -1,89 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-	io "io"
-
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-)
-
-// mockBOSRouter is an autogenerated mock type for the BOSRouter type
-type mockBOSRouter struct {
-	mock.Mock
-}
-
-type mockBOSRouter_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockBOSRouter) EXPECT() *mockBOSRouter_Expecter {
-	return &mockBOSRouter_Expecter{mock: &_m.Mock}
-}
-
-// Route provides a mock function with given fields: ctx, sess, r, w, sequence
-func (_m *mockBOSRouter) Route(ctx context.Context, sess *state.Session, r io.Reader, w io.Writer, sequence *uint32) error {
-	ret := _m.Called(ctx, sess, r, w, sequence)
-
-	if len(ret) == 0 {
-		panic("no return value specified for Route")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, io.Reader, io.Writer, *uint32) error); ok {
-		r0 = rf(ctx, sess, r, w, sequence)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockBOSRouter_Route_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Route'
-type mockBOSRouter_Route_Call struct {
-	*mock.Call
-}
-
-// Route is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - r io.Reader
-//   - w io.Writer
-//   - sequence *uint32
-func (_e *mockBOSRouter_Expecter) Route(ctx interface{}, sess interface{}, r interface{}, w interface{}, sequence interface{}) *mockBOSRouter_Route_Call {
-	return &mockBOSRouter_Route_Call{Call: _e.mock.On("Route", ctx, sess, r, w, sequence)}
-}
-
-func (_c *mockBOSRouter_Route_Call) Run(run func(ctx context.Context, sess *state.Session, r io.Reader, w io.Writer, sequence *uint32)) *mockBOSRouter_Route_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(io.Reader), args[3].(io.Writer), args[4].(*uint32))
-	})
-	return _c
-}
-
-func (_c *mockBOSRouter_Route_Call) Return(_a0 error) *mockBOSRouter_Route_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockBOSRouter_Route_Call) RunAndReturn(run func(context.Context, *state.Session, io.Reader, io.Writer, *uint32) error) *mockBOSRouter_Route_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockBOSRouter creates a new instance of mockBOSRouter. 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 newMockBOSRouter(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockBOSRouter {
-	mock := &mockBOSRouter{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 151 - 0
server/bos_router_test.go

@@ -0,0 +1,151 @@
+package server
+
+import (
+	"io"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/oscar"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestBOSRouter_Route(t *testing.T) {
+	tests := []struct {
+		name      string
+		frame     oscar.SNACFrame
+		newRouter func(t *testing.T) BOSRouter
+		err       error
+	}{
+		{
+			name: "OService no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.OService,
+			},
+		},
+		{
+			name: "Locate no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Locate,
+			},
+		},
+		{
+			name: "Buddy no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Buddy,
+			},
+		},
+		{
+			name: "ICBM no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.ICBM,
+			},
+		},
+		{
+			name: "ChatNav no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.ChatNav,
+			},
+		},
+		{
+			name: "Feedbag no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Feedbag,
+			},
+		},
+		{
+			name: "Alert no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Alert,
+			},
+		},
+		{
+			name: "OService with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.OService,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "Locate with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Locate,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "Buddy with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Buddy,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "ICBM with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.ICBM,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "ChatNav with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.ChatNav,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "Feedbag with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Feedbag,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "Alert with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Alert,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "Chat (unsupported route), expect error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Chat,
+			},
+			err: ErrUnsupportedSubGroup,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fnNewRouter := func() Router {
+				fgRouter := newMockRouter(t)
+				fgRouter.EXPECT().
+					Route(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return(tt.err)
+				return fgRouter
+			}
+
+			router := BOSRouter{}
+
+			switch tt.frame.FoodGroup {
+			case oscar.OService:
+				router.OServiceBOSRouter = fnNewRouter()
+			case oscar.Locate:
+				router.LocateRouter = fnNewRouter()
+			case oscar.Buddy:
+				router.BuddyRouter = fnNewRouter()
+			case oscar.ICBM:
+				router.ICBMRouter = fnNewRouter()
+			case oscar.ChatNav:
+				router.ChatNavRouter = fnNewRouter()
+			case oscar.Feedbag:
+				router.FeedbagRouter = fnNewRouter()
+			case oscar.Alert:
+				router.AlertRouter = fnNewRouter()
+			}
+
+			err := router.Route(nil, nil, tt.frame, nil, nil, nil)
+			assert.ErrorIs(t, err, tt.err)
+		})
+	}
+}

+ 5 - 21
server/bos_service.go

@@ -4,31 +4,22 @@ import (
 	"context"
 	"errors"
 	"io"
+	"log/slog"
 	"net"
 	"os"
 
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
 )
 
-// BOSRouter is the interface that defines the entrypoint to the BOS service.
-type BOSRouter interface {
-	// Route unmarshalls the SNAC frame header from the reader stream to
-	// determine which food group to route to. The remainder of the reader
-	// stream is passed on to the food group routers for the final SNAC body
-	// extraction. Each response sent to the client via the writer stream
-	// increments the sequence number.
-	Route(ctx context.Context, sess *state.Session, r io.Reader, w io.Writer, sequence *uint32) error
-}
-
 // BOSService provides client connection lifecycle management for the BOS
 // service.
 type BOSService struct {
 	AuthHandler
-	BOSRouter
+	Logger *slog.Logger
+	OServiceBOSHandler
+	Router
 	config.Config
-	OServiceBOSRouter
 }
 
 // Start starts a TCP server and listens for connections. The initial
@@ -99,12 +90,5 @@ func (rt BOSService) handleNewConnection(ctx context.Context, rwc io.ReadWriteCl
 		return err
 	}
 
-	fnClientReqHandler := func(ctx context.Context, r io.Reader, w io.Writer, seq *uint32) error {
-		return rt.Route(ctx, sess, r, w, seq)
-	}
-	fnAlertHandler := func(ctx context.Context, msg oscar.SNACMessage, w io.Writer, seq *uint32) error {
-		return sendSNAC(msg.Frame, msg.Body, seq, w)
-	}
-	dispatchIncomingMessages(ctx, sess, seq, rwc, rt.Logger, fnClientReqHandler, fnAlertHandler)
-	return nil
+	return dispatchIncomingMessages(ctx, sess, seq, rwc, rt.Logger, rt.Router, sendSNAC, rt.Config)
 }

+ 13 - 7
server/bos_service_test.go

@@ -100,15 +100,21 @@ func TestBOSService_handleNewConnection(t *testing.T) {
 			Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{},
 		})
 
-	bosRouter := newMockBOSRouter(t)
-	bosRouter.EXPECT().
-		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything).
-		Return(nil)
+	router := newMockRouter(t)
+	router.EXPECT().
+		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) {
+			assert.Equal(t, oscar.SNACFrame{
+				FoodGroup: oscar.OService,
+				SubGroup:  oscar.OServiceClientOnline,
+			}, inFrame)
+		}).Return(nil)
 
 	rt := BOSService{
-		AuthHandler:       authHandler,
-		OServiceBOSRouter: NewOServiceRouterForBOS(slog.Default(), nil, bosHandler),
-		BOSRouter:         bosRouter,
+		AuthHandler:        authHandler,
+		OServiceBOSHandler: bosHandler,
+		Router:             router,
+		Logger:             slog.Default(),
 	}
 	rwc := pipeRWC{
 		PipeReader: clientReader,

+ 2 - 1
server/bucp.go

@@ -3,6 +3,7 @@ package server
 import (
 	"context"
 	"io"
+	"log/slog"
 	"net"
 	"os"
 
@@ -26,7 +27,7 @@ type AuthHandler interface {
 type BUCPAuthService struct {
 	AuthHandler
 	config.Config
-	RouteLogger
+	Logger *slog.Logger
 }
 
 // Start creates a TCP server that implements the BUCP authentication flow. It

+ 1 - 3
server/bucp_test.go

@@ -90,9 +90,7 @@ func TestBUCPAuthService_handleNewConnection(t *testing.T) {
 
 	rt := BUCPAuthService{
 		AuthHandler: authHandler,
-		RouteLogger: RouteLogger{
-			Logger: slog.Default(),
-		},
+		Logger:      slog.Default(),
 	}
 	rwc := pipeRWC{
 		PipeReader: clientReader,

+ 4 - 3
server/buddy.go

@@ -6,6 +6,7 @@ import (
 	"log/slog"
 
 	"github.com/mk6i/retro-aim-server/oscar"
+	"github.com/mk6i/retro-aim-server/state"
 )
 
 type BuddyHandler interface {
@@ -15,7 +16,7 @@ type BuddyHandler interface {
 func NewBuddyRouter(logger *slog.Logger, buddyHandler BuddyHandler) BuddyRouter {
 	return BuddyRouter{
 		BuddyHandler: buddyHandler,
-		RouteLogger: RouteLogger{
+		routeLogger: routeLogger{
 			Logger: logger,
 		},
 	}
@@ -23,10 +24,10 @@ func NewBuddyRouter(logger *slog.Logger, buddyHandler BuddyHandler) BuddyRouter
 
 type BuddyRouter struct {
 	BuddyHandler
-	RouteLogger
+	routeLogger
 }
 
-func (rt *BuddyRouter) RouteBuddy(ctx context.Context, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt BuddyRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.BuddyRightsQuery:
 		inSNAC := oscar.SNAC_0x03_0x02_BuddyRightsQuery{}

+ 1 - 21
server/buddy_test.go

@@ -2,7 +2,6 @@ package server
 
 import (
 	"bytes"
-	"log/slog"
 	"testing"
 
 	"github.com/mk6i/retro-aim-server/config"
@@ -82,7 +81,7 @@ func TestBuddyRouter_RouteBuddy(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(0)
 
-			err := router.RouteBuddy(nil, tc.input.Frame, bufIn, bufOut, &seq)
+			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return
@@ -111,22 +110,3 @@ func TestBuddyRouter_RouteBuddy(t *testing.T) {
 		})
 	}
 }
-
-func TestNewBuddyRouter(t *testing.T) {
-	type args struct {
-		logger       *slog.Logger
-		buddyHandler BuddyHandler
-	}
-	tests := []struct {
-		name string
-		args args
-		want BuddyRouter
-	}{
-		// TODO: Add test cases.
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			assert.Equalf(t, tt.want, NewBuddyRouter(tt.args.logger, tt.args.buddyHandler), "NewBuddyRouter(%v, %v)", tt.args.logger, tt.args.buddyHandler)
-		})
-	}
-}

+ 5 - 5
server/chat.go

@@ -10,13 +10,13 @@ import (
 )
 
 type ChatHandler interface {
-	ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, chatID string, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)
+	ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)
 }
 
 func NewChatRouter(logger *slog.Logger, chatHandler ChatHandler) ChatRouter {
 	return ChatRouter{
 		ChatHandler: chatHandler,
-		RouteLogger: RouteLogger{
+		routeLogger: routeLogger{
 			Logger: logger,
 		},
 	}
@@ -24,17 +24,17 @@ func NewChatRouter(logger *slog.Logger, chatHandler ChatHandler) ChatRouter {
 
 type ChatRouter struct {
 	ChatHandler
-	RouteLogger
+	routeLogger
 }
 
-func (rt *ChatRouter) RouteChat(ctx context.Context, sess *state.Session, chatID string, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt ChatRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.ChatChannelMsgToHost:
 		inBody := oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost{}
 		if err := oscar.Unmarshal(&inBody, r); err != nil {
 			return err
 		}
-		outSNAC, err := rt.ChannelMsgToHostHandler(ctx, sess, chatID, inFrame, inBody)
+		outSNAC, err := rt.ChannelMsgToHostHandler(ctx, sess, inFrame, inBody)
 		if err != nil {
 			return err
 		}

+ 14 - 15
server/chat_mock_test.go

@@ -24,9 +24,9 @@ func (_m *mockChatHandler) EXPECT() *mockChatHandler_Expecter {
 	return &mockChatHandler_Expecter{mock: &_m.Mock}
 }
 
-// ChannelMsgToHostHandler provides a mock function with given fields: ctx, sess, chatID, inFrame, inBody
-func (_m *mockChatHandler) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, chatID string, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error) {
-	ret := _m.Called(ctx, sess, chatID, inFrame, inBody)
+// ChannelMsgToHostHandler provides a mock function with given fields: ctx, sess, inFrame, inBody
+func (_m *mockChatHandler) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error) {
+	ret := _m.Called(ctx, sess, inFrame, inBody)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ChannelMsgToHostHandler")
@@ -34,19 +34,19 @@ func (_m *mockChatHandler) ChannelMsgToHostHandler(ctx context.Context, sess *st
 
 	var r0 *oscar.SNACMessage
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, string, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)); ok {
-		return rf(ctx, sess, chatID, inFrame, inBody)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)); ok {
+		return rf(ctx, sess, inFrame, inBody)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, string, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) *oscar.SNACMessage); ok {
-		r0 = rf(ctx, sess, chatID, inFrame, inBody)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) *oscar.SNACMessage); ok {
+		r0 = rf(ctx, sess, inFrame, inBody)
 	} else {
 		if ret.Get(0) != nil {
 			r0 = ret.Get(0).(*oscar.SNACMessage)
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, string, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) error); ok {
-		r1 = rf(ctx, sess, chatID, inFrame, inBody)
+	if rf, ok := ret.Get(1).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) error); ok {
+		r1 = rf(ctx, sess, inFrame, inBody)
 	} else {
 		r1 = ret.Error(1)
 	}
@@ -62,16 +62,15 @@ type mockChatHandler_ChannelMsgToHostHandler_Call struct {
 // ChannelMsgToHostHandler is a helper method to define mock.On call
 //   - ctx context.Context
 //   - sess *state.Session
-//   - chatID string
 //   - inFrame oscar.SNACFrame
 //   - inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost
-func (_e *mockChatHandler_Expecter) ChannelMsgToHostHandler(ctx interface{}, sess interface{}, chatID interface{}, inFrame interface{}, inBody interface{}) *mockChatHandler_ChannelMsgToHostHandler_Call {
-	return &mockChatHandler_ChannelMsgToHostHandler_Call{Call: _e.mock.On("ChannelMsgToHostHandler", ctx, sess, chatID, inFrame, inBody)}
+func (_e *mockChatHandler_Expecter) ChannelMsgToHostHandler(ctx interface{}, sess interface{}, inFrame interface{}, inBody interface{}) *mockChatHandler_ChannelMsgToHostHandler_Call {
+	return &mockChatHandler_ChannelMsgToHostHandler_Call{Call: _e.mock.On("ChannelMsgToHostHandler", ctx, sess, inFrame, inBody)}
 }
 
-func (_c *mockChatHandler_ChannelMsgToHostHandler_Call) Run(run func(ctx context.Context, sess *state.Session, chatID string, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost)) *mockChatHandler_ChannelMsgToHostHandler_Call {
+func (_c *mockChatHandler_ChannelMsgToHostHandler_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost)) *mockChatHandler_ChannelMsgToHostHandler_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(string), args[3].(oscar.SNACFrame), args[4].(oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost))
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost))
 	})
 	return _c
 }
@@ -81,7 +80,7 @@ func (_c *mockChatHandler_ChannelMsgToHostHandler_Call) Return(_a0 *oscar.SNACMe
 	return _c
 }
 
-func (_c *mockChatHandler_ChannelMsgToHostHandler_Call) RunAndReturn(run func(context.Context, *state.Session, string, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)) *mockChatHandler_ChannelMsgToHostHandler_Call {
+func (_c *mockChatHandler_ChannelMsgToHostHandler_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)) *mockChatHandler_ChannelMsgToHostHandler_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 3 - 3
server/chat_nav.go

@@ -18,7 +18,7 @@ type ChatNavHandler interface {
 func NewChatNavRouter(handler ChatNavHandler, logger *slog.Logger) ChatNavRouter {
 	return ChatNavRouter{
 		ChatNavHandler: handler,
-		RouteLogger: RouteLogger{
+		routeLogger: routeLogger{
 			Logger: logger,
 		},
 	}
@@ -26,10 +26,10 @@ func NewChatNavRouter(handler ChatNavHandler, logger *slog.Logger) ChatNavRouter
 
 type ChatNavRouter struct {
 	ChatNavHandler
-	RouteLogger
+	routeLogger
 }
 
-func (rt *ChatNavRouter) RouteChatNav(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt ChatNavRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.ChatNavRequestChatRights:
 		outSNAC := rt.RequestChatRightsHandler(ctx, inFrame)

+ 1 - 1
server/chat_nav_test.go

@@ -134,7 +134,7 @@ func TestChatNavRouter_RouteChatNavRouter(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(0)
 
-			err := router.RouteChatNav(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
+			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return

+ 5 - 21
server/chat_service.go

@@ -5,32 +5,23 @@ import (
 	"context"
 	"errors"
 	"io"
+	"log/slog"
 	"net"
 	"os"
 
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
-	"github.com/mk6i/retro-aim-server/state"
 )
 
-// ChatServiceRouter is the interface that defines the entrypoint to the BOS service.
-type ChatServiceRouter interface {
-	// Route unmarshalls the SNAC frame header from the reader stream to
-	// determine which food group to route to. The remainder of the reader
-	// stream is passed on to the food group routers for the final SNAC body
-	// extraction. Each response sent to the client via the writer stream
-	// increments the sequence number.
-	Route(ctx context.Context, sess *state.Session, r io.Reader, w io.Writer, sequence *uint32, chatID string) error
-}
-
 // ChatService represents a service that implements a chat room session.
 // Clients connect to this service upon creating a chat room or being invited
 // to a chat room.
 type ChatService struct {
 	AuthHandler
-	ChatServiceRouter
+	OServiceChatHandler
+	Router
 	config.Config
-	OServiceChatRouter
+	Logger *slog.Logger
 }
 
 // Start creates a TCP server that implements that chat flow.
@@ -103,13 +94,6 @@ func (rt ChatService) handleNewConnection(ctx context.Context, rwc io.ReadWriteC
 		return err
 	}
 
-	fnClientReqHandler := func(ctx context.Context, r io.Reader, w io.Writer, seq *uint32) error {
-		return rt.Route(ctx, chatSess, r, w, seq, chatID)
-	}
-	fnAlertHandler := func(ctx context.Context, msg oscar.SNACMessage, w io.Writer, seq *uint32) error {
-		return sendSNAC(msg.Frame, msg.Body, seq, w)
-	}
 	ctx = context.WithValue(ctx, "screenName", chatSess.ScreenName())
-	dispatchIncomingMessages(ctx, chatSess, seq, rwc, rt.Logger, fnClientReqHandler, fnAlertHandler)
-	return nil
+	return dispatchIncomingMessages(ctx, chatSess, seq, rwc, rt.Logger, rt.Router, sendSNAC, rt.Config)
 }

+ 11 - 36
server/chat_service_router.go

@@ -2,49 +2,24 @@ package server
 
 import (
 	"context"
-	"errors"
 	"io"
 
-	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 )
 
-type ChatServiceRooterRouter struct {
-	ChatRouter
-	config.Config
-	OServiceChatRouter
+type ChatServiceRouter struct {
+	ChatRouter         Router
+	OServiceChatRouter Router
 }
 
-func (rt ChatServiceRooterRouter) Route(ctx context.Context, sess *state.Session, r io.Reader, w io.Writer, sequence *uint32, chatID string) error {
-	inFrame := oscar.SNACFrame{}
-	if err := oscar.Unmarshal(&inFrame, r); err != nil {
-		return err
+func (rt ChatServiceRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+	switch inFrame.FoodGroup {
+	case oscar.OService:
+		return rt.OServiceChatRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	case oscar.Chat:
+		return rt.ChatRouter.Route(ctx, sess, inFrame, r, w, sequence)
+	default:
+		return ErrUnsupportedSubGroup
 	}
-
-	err := func() error {
-		switch inFrame.FoodGroup {
-		case oscar.OService:
-			return rt.RouteOService(ctx, sess, chatID, inFrame, r, w, sequence)
-		case oscar.Chat:
-			return rt.RouteChat(ctx, sess, chatID, inFrame, r, w, sequence)
-		default:
-			return ErrUnsupportedSubGroup
-		}
-	}()
-
-	if err != nil {
-		rt.logRequestError(ctx, inFrame, err)
-		if errors.Is(err, ErrUnsupportedSubGroup) {
-			if err1 := sendInvalidSNACErr(inFrame, w, sequence); err1 != nil {
-				err = errors.Join(err1, err)
-			}
-			if rt.Config.FailFast {
-				panic(err.Error())
-			}
-			return nil
-		}
-	}
-
-	return err
 }

+ 0 - 90
server/chat_service_router_mock_test.go

@@ -1,90 +0,0 @@
-// Code generated by mockery v2.38.0. DO NOT EDIT.
-
-package server
-
-import (
-	context "context"
-	io "io"
-
-	mock "github.com/stretchr/testify/mock"
-
-	state "github.com/mk6i/retro-aim-server/state"
-)
-
-// mockChatServiceRouter is an autogenerated mock type for the ChatServiceRouter type
-type mockChatServiceRouter struct {
-	mock.Mock
-}
-
-type mockChatServiceRouter_Expecter struct {
-	mock *mock.Mock
-}
-
-func (_m *mockChatServiceRouter) EXPECT() *mockChatServiceRouter_Expecter {
-	return &mockChatServiceRouter_Expecter{mock: &_m.Mock}
-}
-
-// Route provides a mock function with given fields: ctx, sess, r, w, sequence, chatID
-func (_m *mockChatServiceRouter) Route(ctx context.Context, sess *state.Session, r io.Reader, w io.Writer, sequence *uint32, chatID string) error {
-	ret := _m.Called(ctx, sess, r, w, sequence, chatID)
-
-	if len(ret) == 0 {
-		panic("no return value specified for Route")
-	}
-
-	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, io.Reader, io.Writer, *uint32, string) error); ok {
-		r0 = rf(ctx, sess, r, w, sequence, chatID)
-	} else {
-		r0 = ret.Error(0)
-	}
-
-	return r0
-}
-
-// mockChatServiceRouter_Route_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Route'
-type mockChatServiceRouter_Route_Call struct {
-	*mock.Call
-}
-
-// Route is a helper method to define mock.On call
-//   - ctx context.Context
-//   - sess *state.Session
-//   - r io.Reader
-//   - w io.Writer
-//   - sequence *uint32
-//   - chatID string
-func (_e *mockChatServiceRouter_Expecter) Route(ctx interface{}, sess interface{}, r interface{}, w interface{}, sequence interface{}, chatID interface{}) *mockChatServiceRouter_Route_Call {
-	return &mockChatServiceRouter_Route_Call{Call: _e.mock.On("Route", ctx, sess, r, w, sequence, chatID)}
-}
-
-func (_c *mockChatServiceRouter_Route_Call) Run(run func(ctx context.Context, sess *state.Session, r io.Reader, w io.Writer, sequence *uint32, chatID string)) *mockChatServiceRouter_Route_Call {
-	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(io.Reader), args[3].(io.Writer), args[4].(*uint32), args[5].(string))
-	})
-	return _c
-}
-
-func (_c *mockChatServiceRouter_Route_Call) Return(_a0 error) *mockChatServiceRouter_Route_Call {
-	_c.Call.Return(_a0)
-	return _c
-}
-
-func (_c *mockChatServiceRouter_Route_Call) RunAndReturn(run func(context.Context, *state.Session, io.Reader, io.Writer, *uint32, string) error) *mockChatServiceRouter_Route_Call {
-	_c.Call.Return(run)
-	return _c
-}
-
-// newMockChatServiceRouter creates a new instance of mockChatServiceRouter. 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 newMockChatServiceRouter(t interface {
-	mock.TestingT
-	Cleanup(func())
-}) *mockChatServiceRouter {
-	mock := &mockChatServiceRouter{}
-	mock.Mock.Test(t)
-
-	t.Cleanup(func() { mock.AssertExpectations(t) })
-
-	return mock
-}

+ 76 - 0
server/chat_service_router_test.go

@@ -0,0 +1,76 @@
+package server
+
+import (
+	"io"
+	"testing"
+
+	"github.com/mk6i/retro-aim-server/oscar"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+)
+
+func TestChatServiceRouter_Route(t *testing.T) {
+	tests := []struct {
+		name      string
+		frame     oscar.SNACFrame
+		newRouter func(t *testing.T) BOSRouter
+		err       error
+	}{
+		{
+			name: "OService no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.OService,
+			},
+		},
+		{
+			name: "Chat no error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Chat,
+			},
+		},
+		{
+			name: "OService with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.OService,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "Chat with error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.Chat,
+			},
+			err: io.EOF,
+		},
+		{
+			name: "ICBM (unsupported route), expect error",
+			frame: oscar.SNACFrame{
+				FoodGroup: oscar.ICBM,
+			},
+			err: ErrUnsupportedSubGroup,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fnNewRouter := func() Router {
+				fgRouter := newMockRouter(t)
+				fgRouter.EXPECT().
+					Route(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+					Return(tt.err)
+				return fgRouter
+			}
+
+			router := ChatServiceRouter{}
+
+			switch tt.frame.FoodGroup {
+			case oscar.OService:
+				router.OServiceChatRouter = fnNewRouter()
+			case oscar.Chat:
+				router.ChatRouter = fnNewRouter()
+			}
+
+			err := router.Route(nil, nil, tt.frame, nil, nil, nil)
+			assert.ErrorIs(t, err, tt.err)
+		})
+	}
+}

+ 5 - 4
server/chat_service_test.go

@@ -90,15 +90,16 @@ func TestChatService_handleNewConnection(t *testing.T) {
 			Body: oscar.SNAC_0x01_0x03_OServiceHostOnline{},
 		})
 
-	bosRouter := newMockChatServiceRouter(t)
+	bosRouter := newMockRouter(t)
 	bosRouter.EXPECT().
 		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
 		Return(nil)
 
 	rt := ChatService{
-		AuthHandler:        authHandler,
-		OServiceChatRouter: NewOServiceRouterForChat(slog.Default(), nil, chatHandler),
-		ChatServiceRouter:  bosRouter,
+		AuthHandler:         authHandler,
+		Logger:              slog.Default(),
+		OServiceChatHandler: chatHandler,
+		Router:              bosRouter,
 	}
 	rwc := pipeRWC{
 		PipeReader: clientReader,

+ 2 - 2
server/chat_test.go

@@ -75,7 +75,7 @@ func TestChatRouter_RouteChat(t *testing.T) {
 		t.Run(tc.name, func(t *testing.T) {
 			svc := newMockChatHandler(t)
 			svc.EXPECT().
-				ChannelMsgToHostHandler(mock.Anything, mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
+				ChannelMsgToHostHandler(mock.Anything, mock.Anything, tc.input.Frame, tc.input.Body).
 				Return(tc.output, tc.handlerErr).
 				Maybe()
 
@@ -87,7 +87,7 @@ func TestChatRouter_RouteChat(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(0)
 
-			err := router.RouteChat(nil, nil, "", tc.input.Frame, bufIn, bufOut, &seq)
+			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return

+ 53 - 23
server/connection.go

@@ -4,9 +4,11 @@ import (
 	"bytes"
 	"context"
 	"errors"
+	"fmt"
 	"io"
 	"log/slog"
 
+	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 )
@@ -15,14 +17,19 @@ var (
 	ErrUnsupportedSubGroup = errors.New("unimplemented subgroup, your client version may be unsupported")
 )
 
-type (
-	incomingMessage struct {
-		flap    oscar.FLAPFrame
-		payload *bytes.Buffer
-	}
-	alertHandler     func(ctx context.Context, msg oscar.SNACMessage, w io.Writer, u *uint32) error
-	clientReqHandler func(ctx context.Context, r io.Reader, w io.Writer, u *uint32) error
-)
+// Router is the interface for methods that route food group requests.
+type Router interface {
+	Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error
+}
+
+// snacSender is the function that packages a SNAC frame and body into a FLAP
+// message and writes it to the output writer.
+type snacSender func(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error
+
+type incomingMessage struct {
+	flap    oscar.FLAPFrame
+	payload *bytes.Buffer
+}
 
 func sendSNAC(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error {
 	snacBuf := &bytes.Buffer{}
@@ -108,7 +115,15 @@ func consumeFLAPFrames(r io.Reader, msgCh chan incomingMessage, errCh chan error
 	}
 }
 
-func dispatchIncomingMessages(ctx context.Context, sess *state.Session, seq uint32, rw io.ReadWriter, logger *slog.Logger, fn clientReqHandler, alertHandler alertHandler) {
+// dispatchIncomingMessages receives incoming messages and sends them to the
+// appropriate message handler. Messages from the client are sent to the
+// router. Messages relayed from the user session are forwarded to the client.
+// This function ensures that the same sequence number is incremented for both
+// types of messages. The function terminates upon receiving a connection error
+// or when the session closes.
+//
+// todo: this method has too many params and should be folded into a new type
+func dispatchIncomingMessages(ctx context.Context, sess *state.Session, seq uint32, rw io.ReadWriter, logger *slog.Logger, router Router, sendSNAC snacSender, config config.Config) error {
 	// buffered so that the go routine has room to exit
 	msgCh := make(chan incomingMessage, 1)
 	readErrCh := make(chan error, 1)
@@ -120,33 +135,48 @@ func dispatchIncomingMessages(ctx context.Context, sess *state.Session, seq uint
 
 	for {
 		select {
-		case m := <-msgCh:
+		case m, ok := <-msgCh:
+			if !ok {
+				return nil
+			}
 			switch m.flap.FrameType {
 			case oscar.FLAPFrameData:
+				inFrame := oscar.SNACFrame{}
+				if err := oscar.Unmarshal(&inFrame, m.payload); err != nil {
+					return err
+				}
 				// route a client request to the appropriate service handler. the
 				// handler may write a response to the client connection.
-				if err := fn(ctx, m.payload, rw, &seq); err != nil {
-					return
+				if err := router.Route(ctx, sess, inFrame, m.payload, rw, &seq); err != nil {
+					logRequestError(ctx, logger, inFrame, err)
+					if errors.Is(err, ErrUnsupportedSubGroup) {
+						if err1 := sendInvalidSNACErr(inFrame, rw, &seq); err1 != nil {
+							return errors.Join(err1, err)
+						}
+						if config.FailFast {
+							panic(err.Error())
+						}
+						break
+					}
+					return err
 				}
 			case oscar.FLAPFrameSignon:
-				logger.ErrorContext(ctx, "shouldn't get FLAPFrameSignon", "flap", m.flap)
+				return fmt.Errorf("shouldn't get FLAPFrameSignon. flap: %v", m.flap)
 			case oscar.FLAPFrameError:
-				logger.ErrorContext(ctx, "got FLAPFrameError", "flap", m.flap)
-				return
+				return fmt.Errorf("got FLAPFrameError. flap: %v", m.flap)
 			case oscar.FLAPFrameSignoff:
 				logger.InfoContext(ctx, "got FLAPFrameSignoff", "flap", m.flap)
-				return
+				return nil
 			case oscar.FLAPFrameKeepAlive:
 				logger.DebugContext(ctx, "keepalive heartbeat")
 			default:
-				logger.ErrorContext(ctx, "got unknown FLAP frame type", "flap", m.flap)
-				return
+				return fmt.Errorf("got unknown FLAP frame type. flap: %v", m.flap)
 			}
 		case m := <-sess.ReceiveMessage():
 			// forward a notification sent from another client to this client
-			if err := alertHandler(ctx, m, rw, &seq); err != nil {
+			if err := sendSNAC(m.Frame, m.Body, &seq, rw); err != nil {
 				logRequestError(ctx, logger, m.Frame, err)
-				return
+				return err
 			}
 			logRequest(ctx, logger, m.Frame, m.Body)
 		case <-sess.Closed():
@@ -159,14 +189,14 @@ func dispatchIncomingMessages(ctx context.Context, sess *state.Session, seq uint
 				PayloadLength: uint16(0),
 			}
 			if err := oscar.Marshal(flap, rw); err != nil {
-				logger.ErrorContext(ctx, "unable to gracefully disconnect user", "err", err)
+				return fmt.Errorf("unable to gracefully disconnect user. %w", err)
 			}
-			return
+			return nil
 		case err := <-readErrCh:
 			if !errors.Is(io.EOF, err) {
 				logger.ErrorContext(ctx, "client disconnected with error", "err", err)
 			}
-			return
+			return nil
 		}
 	}
 }

+ 34 - 33
server/connection_test.go

@@ -12,6 +12,7 @@ import (
 	"github.com/mk6i/retro-aim-server/oscar"
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 )
 
 func TestHandleChatConnection_Notification(t *testing.T) {
@@ -46,16 +47,15 @@ func TestHandleChatConnection_Notification(t *testing.T) {
 		},
 	}
 
-	routeSig := func(ctx context.Context, buf io.Reader, w io.Writer, u *uint32) error {
-		return nil
-	}
-
 	wg := sync.WaitGroup{}
 	wg.Add(len(msgIn))
 
 	var msgOut []oscar.SNACMessage
-	alertHandler := func(ctx context.Context, msg oscar.SNACMessage, w io.Writer, u *uint32) error {
-		msgOut = append(msgOut, msg)
+	alertHandler := func(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error {
+		msgOut = append(msgOut, oscar.SNACMessage{
+			Frame: frame,
+			Body:  body,
+		})
 		wg.Done()
 		return nil
 	}
@@ -72,7 +72,7 @@ func TestHandleChatConnection_Notification(t *testing.T) {
 		sessionManager.RelayToScreenName(ctx, "bob", msg)
 	}
 
-	dispatchIncomingMessages(ctx, sess, uint32(0), rw, logger, routeSig, alertHandler)
+	assert.NoError(t, dispatchIncomingMessages(ctx, sess, uint32(0), rw, logger, nil, alertHandler, config.Config{}))
 
 	assert.Equal(t, msgIn, msgOut)
 }
@@ -86,37 +86,33 @@ func TestHandleChatConnection_ClientRequestFLAP(t *testing.T) {
 	sessionManager := state.NewInMemorySessionManager(logger)
 	sess := sessionManager.AddSession("bob-sess-id", "bob")
 
-	payloads := [][]byte{
-		{'a', 'b', 'c', 'd'},
-		{'e', 'f', 'g', 'h'},
+	payloads := []oscar.SNACFrame{
+		{FoodGroup: oscar.ICBM, SubGroup: oscar.ICBMChannelMsgToClient},
+		{FoodGroup: oscar.ChatNav, SubGroup: oscar.ChatNavNavInfo},
 	}
 
 	pr, pw := io.Pipe()
 	_, pw2 := io.Pipe()
 	go func() {
 		for _, buf := range payloads {
-			flap := oscar.FLAPFrame{
-				StartMarker:   42,
-				FrameType:     oscar.FLAPFrameData,
-				PayloadLength: uint16(len(buf)),
-			}
-			assert.NoError(t, oscar.Marshal(flap, pw))
-			assert.NoError(t, oscar.Marshal(buf, pw))
+			var seq uint32
+			sendSNAC(buf, struct{}{}, &seq, pw)
 		}
 	}()
 
-	var msgOut [][]byte
+	var msgOut []oscar.SNACFrame
 	wg := sync.WaitGroup{}
 	wg.Add(len(payloads))
 
-	routeSig := func(ctx context.Context, buf io.Reader, w io.Writer, u *uint32) error {
-		var err error
-		b, err := io.ReadAll(buf)
-		msgOut = append(msgOut, b)
-		wg.Done()
-		return err
-	}
-	alertHandler := func(ctx context.Context, msg oscar.SNACMessage, w io.Writer, u *uint32) error {
+	router := newMockRouter(t)
+	router.EXPECT().
+		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) {
+			msgOut = append(msgOut, inFrame)
+			wg.Done()
+		}).Return(nil)
+
+	alertHandler := func(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error {
 		return nil
 	}
 
@@ -127,7 +123,7 @@ func TestHandleChatConnection_ClientRequestFLAP(t *testing.T) {
 		pw.Close()
 	}()
 
-	dispatchIncomingMessages(ctx, sess, uint32(0), rw, logger, routeSig, alertHandler)
+	assert.NoError(t, dispatchIncomingMessages(ctx, sess, uint32(0), rw, logger, router, alertHandler, config.Config{}))
 
 	assert.Equal(t, payloads, msgOut)
 }
@@ -141,14 +137,19 @@ func TestHandleChatConnection_SessionClosed(t *testing.T) {
 	sessionManager := state.NewInMemorySessionManager(logger)
 	sess := sessionManager.AddSession("bob-sess-id", "bob")
 
-	routeSig := func(ctx context.Context, buf io.Reader, w io.Writer, u *uint32) error {
+	router := newMockRouter(t)
+	router.EXPECT().
+		Route(mock.Anything, sess, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+		Run(func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) {
+			t.Fatal("not expecting any output")
+		}).
+		Maybe().
+		Return(nil)
+
+	alertHandler := func(frame oscar.SNACFrame, body any, sequence *uint32, w io.Writer) error {
 		t.Fatal("not expecting any output")
 		return nil
 	}
-	alertHandler := func(ctx context.Context, msg oscar.SNACMessage, w io.Writer, u *uint32) error {
-		t.Fatal("not expecting any alerts")
-		return nil
-	}
 
 	pr1, _ := io.Pipe()
 	pr2, pw2 := io.Pipe()
@@ -162,7 +163,7 @@ func TestHandleChatConnection_SessionClosed(t *testing.T) {
 	}
 	sess.Close()
 
-	go dispatchIncomingMessages(ctx, sess, 0, in, logger, routeSig, alertHandler)
+	go dispatchIncomingMessages(ctx, sess, 0, in, logger, router, alertHandler, config.Config{})
 
 	flap := oscar.FLAPFrame{}
 	assert.NoError(t, oscar.Unmarshal(&flap, pr2))

+ 3 - 3
server/feedbag.go

@@ -22,7 +22,7 @@ type FeedbagHandler interface {
 func NewFeedbagRouter(logger *slog.Logger, handler FeedbagHandler) FeedbagRouter {
 	return FeedbagRouter{
 		FeedbagHandler: handler,
-		RouteLogger: RouteLogger{
+		routeLogger: routeLogger{
 			Logger: logger,
 		},
 	}
@@ -30,10 +30,10 @@ func NewFeedbagRouter(logger *slog.Logger, handler FeedbagHandler) FeedbagRouter
 
 type FeedbagRouter struct {
 	FeedbagHandler
-	RouteLogger
+	routeLogger
 }
 
-func (rt FeedbagRouter) RouteFeedbag(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt FeedbagRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.FeedbagRightsQuery:
 		inBody := oscar.SNAC_0x13_0x02_FeedbagRightsQuery{}

+ 1 - 1
server/feedbag_test.go

@@ -277,7 +277,7 @@ func TestFeedbagRouter_RouteFeedbag(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(0)
 
-			err := router.RouteFeedbag(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
+			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return

+ 3 - 3
server/icbm.go

@@ -19,7 +19,7 @@ type ICBMHandler interface {
 func NewICBMRouter(logger *slog.Logger, handler ICBMHandler) ICBMRouter {
 	return ICBMRouter{
 		ICBMHandler: handler,
-		RouteLogger: RouteLogger{
+		routeLogger: routeLogger{
 			Logger: logger,
 		},
 	}
@@ -27,10 +27,10 @@ func NewICBMRouter(logger *slog.Logger, handler ICBMHandler) ICBMRouter {
 
 type ICBMRouter struct {
 	ICBMHandler
-	RouteLogger
+	routeLogger
 }
 
-func (rt *ICBMRouter) RouteICBM(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt ICBMRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.ICBMAddParameters:
 		inBody := oscar.SNAC_0x04_0x02_ICBMAddParameters{}

+ 1 - 1
server/icbm_test.go

@@ -180,7 +180,7 @@ func TestICBMRouter_RouteICBM(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(1)
 
-			err := router.RouteICBM(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
+			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return

+ 3 - 3
server/locate.go

@@ -20,7 +20,7 @@ type LocateHandler interface {
 func NewLocateRouter(handler LocateHandler, logger *slog.Logger) LocateRouter {
 	return LocateRouter{
 		LocateHandler: handler,
-		RouteLogger: RouteLogger{
+		routeLogger: routeLogger{
 			Logger: logger,
 		},
 	}
@@ -28,10 +28,10 @@ func NewLocateRouter(handler LocateHandler, logger *slog.Logger) LocateRouter {
 
 type LocateRouter struct {
 	LocateHandler
-	RouteLogger
+	routeLogger
 }
 
-func (rt LocateRouter) RouteLocate(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt LocateRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.LocateRightsQuery:
 		outSNAC := rt.RightsQueryHandler(ctx, inFrame)

+ 1 - 1
server/locate_test.go

@@ -216,7 +216,7 @@ func TestLocateRouter_RouteLocate(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(1)
 
-			err := router.RouteLocate(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
+			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return

+ 18 - 18
server/logging.go

@@ -50,14 +50,14 @@ func NewLogger(cfg config.Config) *slog.Logger {
 			return a
 		},
 	}
-	return slog.New(Handler{slog.NewTextHandler(os.Stdout, opts)})
+	return slog.New(handler{slog.NewTextHandler(os.Stdout, opts)})
 }
 
-type Handler struct {
+type handler struct {
 	slog.Handler
 }
 
-func (h Handler) Handle(ctx context.Context, r slog.Record) error {
+func (h handler) Handle(ctx context.Context, r slog.Record) error {
 	if sn := ctx.Value("screenName"); sn != nil {
 		r.AddAttrs(slog.Attr{Key: "screenName", Value: slog.StringValue(sn.(string))})
 	}
@@ -67,31 +67,31 @@ func (h Handler) Handle(ctx context.Context, r slog.Record) error {
 	return h.Handler.Handle(ctx, r)
 }
 
-func (h Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
-	return Handler{h.Handler.WithAttrs(attrs)}
+func (h handler) WithAttrs(attrs []slog.Attr) slog.Handler {
+	return handler{h.Handler.WithAttrs(attrs)}
 }
 
-func (h Handler) WithGroup(name string) slog.Handler {
+func (h handler) WithGroup(name string) slog.Handler {
 	return h.Handler.WithGroup(name)
 }
 
-type RouteLogger struct {
+type routeLogger struct {
 	Logger *slog.Logger
 }
 
-func (rt RouteLogger) logRequestAndResponse(ctx context.Context, inFrame oscar.SNACFrame, inSNAC any, outFrame oscar.SNACFrame, outSNAC any) {
+func (rt routeLogger) logRequestAndResponse(ctx context.Context, inFrame oscar.SNACFrame, inSNAC any, outFrame oscar.SNACFrame, outSNAC any) {
 	msg := "client request -> server response"
 	switch {
 	case rt.Logger.Enabled(ctx, LevelTrace):
-		rt.Logger.LogAttrs(ctx, LevelTrace, msg, SNACLogGroupWithPayload("request", inFrame, inSNAC),
-			SNACLogGroupWithPayload("response", outFrame, outSNAC))
+		rt.Logger.LogAttrs(ctx, LevelTrace, msg, snacLogGroupWithPayload("request", inFrame, inSNAC),
+			snacLogGroupWithPayload("response", outFrame, outSNAC))
 	case rt.Logger.Enabled(ctx, slog.LevelDebug):
-		rt.Logger.LogAttrs(ctx, slog.LevelDebug, msg, SNACLogGroup("request", inFrame),
-			SNACLogGroup("response", outFrame))
+		rt.Logger.LogAttrs(ctx, slog.LevelDebug, msg, snacLogGroup("request", inFrame),
+			snacLogGroup("response", outFrame))
 	}
 }
 
-func (rt RouteLogger) logRequestError(ctx context.Context, inFrame oscar.SNACFrame, err error) {
+func (rt routeLogger) logRequestError(ctx context.Context, inFrame oscar.SNACFrame, err error) {
 	logRequestError(ctx, rt.Logger, inFrame, err)
 }
 
@@ -105,7 +105,7 @@ func logRequestError(ctx context.Context, logger *slog.Logger, inFrame oscar.SNA
 	)
 }
 
-func (rt RouteLogger) logRequest(ctx context.Context, inFrame oscar.SNACFrame, inSNAC any) {
+func (rt routeLogger) logRequest(ctx context.Context, inFrame oscar.SNACFrame, inSNAC any) {
 	logRequest(ctx, rt.Logger, inFrame, inSNAC)
 }
 
@@ -113,20 +113,20 @@ func logRequest(ctx context.Context, logger *slog.Logger, inFrame oscar.SNACFram
 	const msg = "client request"
 	switch {
 	case logger.Enabled(ctx, LevelTrace):
-		logger.LogAttrs(ctx, LevelTrace, msg, SNACLogGroupWithPayload("request", inFrame, inSNAC))
+		logger.LogAttrs(ctx, LevelTrace, msg, snacLogGroupWithPayload("request", inFrame, inSNAC))
 	case logger.Enabled(ctx, slog.LevelDebug):
-		logger.LogAttrs(ctx, slog.LevelDebug, msg, slog.Group("request", SNACLogGroup("request", inFrame)))
+		logger.LogAttrs(ctx, slog.LevelDebug, msg, slog.Group("request", snacLogGroup("request", inFrame)))
 	}
 }
 
-func SNACLogGroup(key string, outFrame oscar.SNACFrame) slog.Attr {
+func snacLogGroup(key string, outFrame oscar.SNACFrame) slog.Attr {
 	return slog.Group(key,
 		slog.String("food_group", oscar.FoodGroupStr(outFrame.FoodGroup)),
 		slog.String("sub_group", oscar.SubGroupStr(outFrame.FoodGroup, outFrame.SubGroup)),
 	)
 }
 
-func SNACLogGroupWithPayload(key string, outFrame oscar.SNACFrame, outSNAC any) slog.Attr {
+func snacLogGroupWithPayload(key string, outFrame oscar.SNACFrame, outSNAC any) slog.Attr {
 	return slog.Group(key,
 		slog.String("food_group", oscar.FoodGroupStr(outFrame.FoodGroup)),
 		slog.String("sub_group", oscar.SubGroupStr(outFrame.FoodGroup, outFrame.SubGroup)),

+ 10 - 10
server/oservice.go

@@ -29,15 +29,15 @@ type OServiceBOSHandler interface {
 type OServiceChatHandler interface {
 	OServiceHandler
 	WriteOServiceHostOnline() oscar.SNACMessage
-	ClientOnlineHandler(ctx context.Context, sess *state.Session, chatID string) error
+	ClientOnlineHandler(ctx context.Context, sess *state.Session) error
 }
 
 type OServiceRouter struct {
 	OServiceHandler
-	RouteLogger
+	routeLogger
 }
 
-func (rt OServiceRouter) RouteOService(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt OServiceRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.OServiceRateParamsQuery:
 		outSNAC := rt.RateParamsQueryHandler(ctx, inFrame)
@@ -90,7 +90,7 @@ func NewOServiceRouterForBOS(logger *slog.Logger, oserviceHandler OServiceHandle
 	return OServiceBOSRouter{
 		OServiceRouter: OServiceRouter{
 			OServiceHandler: oserviceHandler,
-			RouteLogger: RouteLogger{
+			routeLogger: routeLogger{
 				Logger: logger,
 			},
 		},
@@ -103,7 +103,7 @@ type OServiceBOSRouter struct {
 	OServiceBOSHandler
 }
 
-func (rt OServiceBOSRouter) RouteOService(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt OServiceBOSRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.OServiceServiceRequest:
 		inBody := oscar.SNAC_0x01_0x04_OServiceServiceRequest{}
@@ -128,7 +128,7 @@ func (rt OServiceBOSRouter) RouteOService(ctx context.Context, sess *state.Sessi
 		rt.logRequest(ctx, inFrame, inBody)
 		return rt.OServiceBOSHandler.ClientOnlineHandler(ctx, inBody, sess)
 	default:
-		return rt.OServiceRouter.RouteOService(ctx, sess, inFrame, r, w, sequence)
+		return rt.OServiceRouter.Route(ctx, sess, inFrame, r, w, sequence)
 	}
 }
 
@@ -136,7 +136,7 @@ func NewOServiceRouterForChat(logger *slog.Logger, oserviceHandler OServiceHandl
 	return OServiceChatRouter{
 		OServiceRouter: OServiceRouter{
 			OServiceHandler: oserviceHandler,
-			RouteLogger: RouteLogger{
+			routeLogger: routeLogger{
 				Logger: logger,
 			},
 		},
@@ -149,7 +149,7 @@ type OServiceChatRouter struct {
 	OServiceChatHandler
 }
 
-func (rt OServiceChatRouter) RouteOService(ctx context.Context, sess *state.Session, chatID string, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+func (rt OServiceChatRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
 	switch inFrame.SubGroup {
 	case oscar.OServiceServiceRequest:
 		return sendInvalidSNACErr(inFrame, w, sequence)
@@ -160,8 +160,8 @@ 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, sess, chatID)
+		return rt.OServiceChatHandler.ClientOnlineHandler(ctx, sess)
 	default:
-		return rt.OServiceRouter.RouteOService(ctx, sess, inFrame, r, w, sequence)
+		return rt.OServiceRouter.Route(ctx, sess, inFrame, r, w, sequence)
 	}
 }

+ 10 - 11
server/oservice_chat_mock_test.go

@@ -24,17 +24,17 @@ func (_m *mockOServiceChatHandler) EXPECT() *mockOServiceChatHandler_Expecter {
 	return &mockOServiceChatHandler_Expecter{mock: &_m.Mock}
 }
 
-// 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)
+// ClientOnlineHandler provides a mock function with given fields: ctx, sess
+func (_m *mockOServiceChatHandler) ClientOnlineHandler(ctx context.Context, sess *state.Session) error {
+	ret := _m.Called(ctx, sess)
 
 	if len(ret) == 0 {
 		panic("no return value specified for ClientOnlineHandler")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, string) error); ok {
-		r0 = rf(ctx, sess, chatID)
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok {
+		r0 = rf(ctx, sess)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -50,14 +50,13 @@ type mockOServiceChatHandler_ClientOnlineHandler_Call struct {
 // ClientOnlineHandler is a helper method to define mock.On call
 //   - ctx context.Context
 //   - sess *state.Session
-//   - chatID string
-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 (_e *mockOServiceChatHandler_Expecter) ClientOnlineHandler(ctx interface{}, sess interface{}) *mockOServiceChatHandler_ClientOnlineHandler_Call {
+	return &mockOServiceChatHandler_ClientOnlineHandler_Call{Call: _e.mock.On("ClientOnlineHandler", ctx, sess)}
 }
 
-func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) Run(run func(ctx context.Context, sess *state.Session, chatID string)) *mockOServiceChatHandler_ClientOnlineHandler_Call {
+func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) Run(run func(ctx context.Context, sess *state.Session)) *mockOServiceChatHandler_ClientOnlineHandler_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(*state.Session), args[2].(string))
+		run(args[0].(context.Context), args[1].(*state.Session))
 	})
 	return _c
 }
@@ -67,7 +66,7 @@ func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) Return(_a0 error) *m
 	return _c
 }
 
-func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) RunAndReturn(run func(context.Context, *state.Session, string) error) *mockOServiceChatHandler_ClientOnlineHandler_Call {
+func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockOServiceChatHandler_ClientOnlineHandler_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 3 - 3
server/oservice_test.go

@@ -261,7 +261,7 @@ func TestOServiceRouter_RouteOService_ForBOS(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(1)
 
-			err := router.RouteOService(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
+			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return
@@ -527,7 +527,7 @@ func TestOServiceRouter_RouteOService_ForChat(t *testing.T) {
 
 			svcBOS := newMockOServiceChatHandler(t)
 			svcBOS.EXPECT().
-				ClientOnlineHandler(mock.Anything, mock.Anything, mock.Anything).
+				ClientOnlineHandler(mock.Anything, mock.Anything).
 				Return(tc.handlerErr).
 				Maybe()
 
@@ -539,7 +539,7 @@ func TestOServiceRouter_RouteOService_ForChat(t *testing.T) {
 			bufOut := &bytes.Buffer{}
 			seq := uint32(1)
 
-			err := router.RouteOService(nil, nil, "", tc.input.Frame, bufIn, bufOut, &seq)
+			err := router.Route(nil, nil, tc.input.Frame, bufIn, bufOut, &seq)
 			assert.ErrorIs(t, err, tc.expectErr)
 			if tc.expectErr != nil {
 				return

+ 92 - 0
server/router_mock_test.go

@@ -0,0 +1,92 @@
+// Code generated by mockery v2.38.0. DO NOT EDIT.
+
+package server
+
+import (
+	context "context"
+	io "io"
+
+	mock "github.com/stretchr/testify/mock"
+
+	oscar "github.com/mk6i/retro-aim-server/oscar"
+
+	state "github.com/mk6i/retro-aim-server/state"
+)
+
+// mockRouter is an autogenerated mock type for the Router type
+type mockRouter struct {
+	mock.Mock
+}
+
+type mockRouter_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockRouter) EXPECT() *mockRouter_Expecter {
+	return &mockRouter_Expecter{mock: &_m.Mock}
+}
+
+// Route provides a mock function with given fields: ctx, sess, inFrame, r, w, sequence
+func (_m *mockRouter) Route(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32) error {
+	ret := _m.Called(ctx, sess, inFrame, r, w, sequence)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Route")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, io.Reader, io.Writer, *uint32) error); ok {
+		r0 = rf(ctx, sess, inFrame, r, w, sequence)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockRouter_Route_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Route'
+type mockRouter_Route_Call struct {
+	*mock.Call
+}
+
+// Route is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sess *state.Session
+//   - inFrame oscar.SNACFrame
+//   - r io.Reader
+//   - w io.Writer
+//   - sequence *uint32
+func (_e *mockRouter_Expecter) Route(ctx interface{}, sess interface{}, inFrame interface{}, r interface{}, w interface{}, sequence interface{}) *mockRouter_Route_Call {
+	return &mockRouter_Route_Call{Call: _e.mock.On("Route", ctx, sess, inFrame, r, w, sequence)}
+}
+
+func (_c *mockRouter_Route_Call) Run(run func(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, r io.Reader, w io.Writer, sequence *uint32)) *mockRouter_Route_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(*state.Session), args[2].(oscar.SNACFrame), args[3].(io.Reader), args[4].(io.Writer), args[5].(*uint32))
+	})
+	return _c
+}
+
+func (_c *mockRouter_Route_Call) Return(_a0 error) *mockRouter_Route_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockRouter_Route_Call) RunAndReturn(run func(context.Context, *state.Session, oscar.SNACFrame, io.Reader, io.Writer, *uint32) error) *mockRouter_Route_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockRouter creates a new instance of mockRouter. 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 newMockRouter(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockRouter {
+	mock := &mockRouter{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 15 - 0
state/session.go

@@ -28,6 +28,7 @@ const (
 // methods may be safely accessed by multiple goroutines.
 type Session struct {
 	awayMessage string
+	chatID      string
 	closed      bool
 	id          string
 	idle        bool
@@ -140,6 +141,20 @@ func (s *Session) AwayMessage() string {
 	return s.awayMessage
 }
 
+// SetChatID sets the chatID for the chat room the user is currently in.
+func (s *Session) SetChatID(chatID string) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.chatID = chatID
+}
+
+// ChatID gets the chatID for the chat room the user is currently in.
+func (s *Session) ChatID() string {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.chatID
+}
+
 // TLVUserInfo returns a TLV list containing session information required by
 // multiple SNAC message types that convey user information.
 func (s *Session) TLVUserInfo() oscar.TLVUserInfo {

+ 6 - 91
state/session_test.go

@@ -52,97 +52,12 @@ func TestSession_SetAndGetScreenName(t *testing.T) {
 	assert.Equal(t, sn, s.ScreenName())
 }
 
-func TestSession_SendMessage(t *testing.T) {
-	type fields struct {
-		awayMessage string
-		closed      bool
-		id          string
-		idle        bool
-		idleTime    time.Time
-		invisible   bool
-		msgCh       chan oscar.SNACMessage
-		mutex       sync.RWMutex
-		screenName  string
-		signonTime  time.Time
-		stopCh      chan struct{}
-		warning     uint16
-	}
-	type args struct {
-		msg oscar.SNACMessage
-	}
-	tests := []struct {
-		name   string
-		fields fields
-		args   args
-		want   SessSendStatus
-	}{
-		// TODO: Add test cases.
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			s := &Session{
-				awayMessage: tt.fields.awayMessage,
-				closed:      tt.fields.closed,
-				id:          tt.fields.id,
-				idle:        tt.fields.idle,
-				idleTime:    tt.fields.idleTime,
-				invisible:   tt.fields.invisible,
-				msgCh:       tt.fields.msgCh,
-				mutex:       tt.fields.mutex,
-				screenName:  tt.fields.screenName,
-				signonTime:  tt.fields.signonTime,
-				stopCh:      tt.fields.stopCh,
-				warning:     tt.fields.warning,
-			}
-			assert.Equalf(t, tt.want, s.RelayMessage(tt.args.msg), "RelayMessage(%v)", tt.args.msg)
-		})
-	}
-}
-
-func TestSession_SetAwayMessage(t *testing.T) {
-	type fields struct {
-		awayMessage string
-		closed      bool
-		id          string
-		idle        bool
-		idleTime    time.Time
-		invisible   bool
-		msgCh       chan oscar.SNACMessage
-		mutex       sync.RWMutex
-		screenName  string
-		signonTime  time.Time
-		stopCh      chan struct{}
-		warning     uint16
-	}
-	type args struct {
-		awayMessage string
-	}
-	tests := []struct {
-		name   string
-		fields fields
-		args   args
-	}{
-		// TODO: Add test cases.
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			s := &Session{
-				awayMessage: tt.fields.awayMessage,
-				closed:      tt.fields.closed,
-				id:          tt.fields.id,
-				idle:        tt.fields.idle,
-				idleTime:    tt.fields.idleTime,
-				invisible:   tt.fields.invisible,
-				msgCh:       tt.fields.msgCh,
-				mutex:       tt.fields.mutex,
-				screenName:  tt.fields.screenName,
-				signonTime:  tt.fields.signonTime,
-				stopCh:      tt.fields.stopCh,
-				warning:     tt.fields.warning,
-			}
-			s.SetAwayMessage(tt.args.awayMessage)
-		})
-	}
+func TestSession_SetAndGetChatID(t *testing.T) {
+	s := NewSession()
+	assert.Empty(t, s.ChatID())
+	sn := "the-chat-id"
+	s.SetChatID(sn)
+	assert.Equal(t, sn, s.ChatID())
 }
 
 func TestSession_TLVUserInfo(t *testing.T) {