From ce6381850db4774c0ed7317406aea564dece3fe6 Mon Sep 17 00:00:00 2001 From: Mike Date: Wed, 26 Jun 2024 20:56:50 -0400 Subject: [PATCH] impl public chat exchange and persistent rooms This commit adds several improvements to chat functionality. - Users can join public chat rooms on exchange 5 created by the server opator. - Users can join existing chat rooms without invitations by opening a hyperlink. Example: aim:gochat?roomname=testroom&exchange=4 - Chat rooms are persistent across server restarts. AIM clients can automatically rejoin rooms after client disconnect/reconnect. - Server operators can manage chat rooms via the management API. --- .mockery.yaml | 16 +- api.yml | 94 +++++- cmd/server/main.go | 28 +- foodgroup/auth.go | 52 ++- foodgroup/auth_test.go | 135 ++------ foodgroup/chat.go | 22 +- foodgroup/chat_nav.go | 108 ++++--- foodgroup/chat_nav_test.go | 295 ++++++++++++------ foodgroup/chat_test.go | 134 +++----- foodgroup/mock_chat_message_relayer_test.go | 142 ++------- foodgroup/mock_chat_registry_test.go | 167 ---------- foodgroup/mock_chat_room_registry_test.go | 194 ++++++++++++ foodgroup/mock_chat_session_registry_test.go | 117 +++++++ foodgroup/oservice.go | 33 +- foodgroup/oservice_test.go | 218 +++++++------ foodgroup/test_helpers.go | 94 ++++-- foodgroup/types.go | 51 ++- server/http/mgmt_api.go | 165 ++++++++-- server/http/mgmt_api_test.go | 255 +++++++++++++++ server/http/mock_chat_room_creator_test.go | 81 +++++ server/http/mock_chat_room_retriever_test.go | 93 ++++++ .../http/mock_chat_session_retriever_test.go | 83 +++++ server/http/types.go | 62 ++++ server/oscar/auth.go | 2 +- server/oscar/chat.go | 4 +- server/oscar/chat_test.go | 3 +- server/oscar/handler/chat_nav.go | 7 +- server/oscar/handler/chat_nav_test.go | 15 +- server/oscar/handler/mock_chat_nav_test.go | 20 +- server/oscar/handler/types.go | 1 - server/oscar/mock_auth_test.go | 23 +- state/{chat_registry.go => chat.go} | 81 ++--- state/chat_registry_test.go | 165 ---------- state/chat_test.go | 27 ++ state/migrations/0004_chat_rooms.down.sql | 1 + state/migrations/0004_chat_rooms.up.sql | 9 + state/session_manager.go | 110 ++++++- state/session_manager_test.go | 68 +++- state/user_store.go | 117 ++++++- state/user_store_test.go | 180 +++++++++++ 40 files changed, 2373 insertions(+), 1099 deletions(-) delete mode 100644 foodgroup/mock_chat_registry_test.go create mode 100644 foodgroup/mock_chat_room_registry_test.go create mode 100644 foodgroup/mock_chat_session_registry_test.go create mode 100644 server/http/mock_chat_room_creator_test.go create mode 100644 server/http/mock_chat_room_retriever_test.go create mode 100644 server/http/mock_chat_session_retriever_test.go create mode 100644 server/http/types.go delete mode 100644 server/oscar/handler/types.go rename state/{chat_registry.go => chat.go} (53%) delete mode 100644 state/chat_registry_test.go create mode 100644 state/chat_test.go create mode 100644 state/migrations/0004_chat_rooms.down.sql create mode 100644 state/migrations/0004_chat_rooms.up.sql diff --git a/.mockery.yaml b/.mockery.yaml index 751a184b..20521d53 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -25,6 +25,15 @@ packages: SessionRetriever: config: filename: "mock_session_retriever_test.go" + ChatRoomRetriever: + config: + filename: "mock_chat_room_retriever_test.go" + ChatRoomCreator: + config: + filename: "mock_chat_room_creator_test.go" + ChatSessionRetriever: + config: + filename: "mock_chat_session_retriever_test.go" github.com/mk6i/retro-aim-server/server/oscar/handler: interfaces: ResponseWriter: @@ -68,6 +77,9 @@ packages: ProfileManager: config: filename: "mock_profile_manager_test.go" + ChatSessionRegistry: + config: + filename: "mock_chat_session_registry_test.go" SessionManager: config: filename: "mock_session_manager_test.go" @@ -77,9 +89,9 @@ packages: ChatMessageRelayer: config: filename: "mock_chat_message_relayer_test.go" - ChatRegistry: + ChatRoomRegistry: config: - filename: "mock_chat_registry_test.go" + filename: "mock_chat_room_registry_test.go" BARTManager: config: filename: "mock_bart_manager_test.go" diff --git a/api.yml b/api.yml index b523ebb9..0ee95baf 100644 --- a/api.yml +++ b/api.yml @@ -110,4 +110,96 @@ paths: '400': description: Bad request. Invalid input data. '404': - description: User not found. \ No newline at end of file + description: User not found. + + /chat/room/public: + get: + summary: List all public chat rooms + description: Retrieve a list of all public chat rooms in exchange 5. + responses: + '200': + description: Successful response containing a list of chat rooms. + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + description: Name of the chat room. + create_time: + type: string + format: date-time + description: The timestamp when the chat room was created. + participants: + type: array + description: List of participants in the chat room. + items: + type: object + properties: + id: + type: string + description: Unique identifier of the participant. + screen_name: + type: string + description: Screen name of the participant. + + post: + summary: Create a new public chat room + description: Create a new public chat room in exchange 5. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name of the chat room. + responses: + '201': + description: Chat room created successfully. + '400': + description: Bad request. Invalid input data. + '409': + description: Chat room already exists. + + /chat/room/private: + get: + summary: List all private chat rooms + description: Retrieve a list of all private chat rooms in exchange 4. + responses: + '200': + description: Successful response containing a list of chat rooms. + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + description: Name of the chat room. + create_time: + type: string + format: date-time + description: The timestamp when the chat room was created. + creator_id: + type: string + description: The chat room creator user ID. + participants: + type: array + description: List of participants in the chat room. + items: + type: object + properties: + id: + type: string + description: Unique identifier of the participant. + screen_name: + type: string + description: Screen name of the participant. \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 37e31d77..48e23a23 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -40,28 +40,27 @@ func main() { logger := middleware.NewLogger(cfg) sessionManager := state.NewInMemorySessionManager(logger) - chatRegistry := state.NewChatRegistry() + chatSessionManager := state.NewInMemoryChatSessionManager(logger) adjListBuddyListStore := state.NewAdjListBuddyListStore() wg := sync.WaitGroup{} wg.Add(7) go func() { - http.StartManagementAPI(cfg, feedbagStore, sessionManager, logger) + http.StartManagementAPI(cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager, logger) wg.Done() }() go func(logger *slog.Logger) { logger = logger.With("svc", "BOS") - authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore) + authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager) bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore, adjListBuddyListStore) buddyService := foodgroup.NewBuddyService(sessionManager, feedbagStore, adjListBuddyListStore) - newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) } - chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr) + chatNavService := foodgroup.NewChatNavService(logger, feedbagStore, state.NewChatRoom) feedbagService := foodgroup.NewFeedbagService(logger, sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore) foodgroupService := foodgroup.NewPermitDenyService() icbmService := foodgroup.NewICBMService(sessionManager, feedbagStore, adjListBuddyListStore) locateService := foodgroup.NewLocateService(sessionManager, feedbagStore, feedbagStore, adjListBuddyListStore) - oServiceService := foodgroup.NewOServiceServiceForBOS(cfg, sessionManager, adjListBuddyListStore, logger, cookieBaker, chatRegistry, feedbagStore) + oServiceService := foodgroup.NewOServiceServiceForBOS(cfg, sessionManager, adjListBuddyListStore, logger, cookieBaker, feedbagStore, feedbagStore) oscar.BOSServer{ AuthService: authService, @@ -86,9 +85,9 @@ func main() { go func(logger *slog.Logger) { logger = logger.With("svc", "CHAT") sessionManager := state.NewInMemorySessionManager(logger) - authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore) - chatService := foodgroup.NewChatService(chatRegistry) - oServiceService := foodgroup.NewOServiceServiceForChat(cfg, logger, chatRegistry, sessionManager, adjListBuddyListStore, feedbagStore) + authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager) + chatService := foodgroup.NewChatService(chatSessionManager) + oServiceService := foodgroup.NewOServiceServiceForChat(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore, feedbagStore, chatSessionManager) oscar.ChatServer{ AuthService: authService, @@ -105,9 +104,8 @@ func main() { go func(logger *slog.Logger) { logger = logger.With("svc", "CHAT_NAV") sessionManager := state.NewInMemorySessionManager(logger) - authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore) - newChatSessMgr := func() foodgroup.SessionManager { return state.NewInMemorySessionManager(logger) } - chatNavService := foodgroup.NewChatNavService(logger, chatRegistry, state.NewChatRoom, newChatSessMgr) + authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager) + chatNavService := foodgroup.NewChatNavService(logger, feedbagStore, state.NewChatRoom) oServiceService := foodgroup.NewOServiceServiceForChatNav(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore) oscar.BOSServer{ @@ -126,7 +124,7 @@ func main() { go func(logger *slog.Logger) { logger = logger.With("svc", "ALERT") sessionManager := state.NewInMemorySessionManager(logger) - authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore) + authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager) oServiceService := foodgroup.NewOServiceServiceForAlert(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore) oscar.BOSServer{ @@ -146,7 +144,7 @@ func main() { logger = logger.With("svc", "BART") sessionManager := state.NewInMemorySessionManager(logger) bartService := foodgroup.NewBARTService(logger, feedbagStore, sessionManager, feedbagStore, adjListBuddyListStore) - authService := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore) + authService := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, sessionManager, feedbagStore, chatSessionManager) oServiceService := foodgroup.NewOServiceServiceForBART(cfg, logger, sessionManager, adjListBuddyListStore, feedbagStore) oscar.BOSServer{ @@ -164,7 +162,7 @@ func main() { }(logger) go func(logger *slog.Logger) { logger = logger.With("svc", "AUTH") - authHandler := foodgroup.NewAuthService(cfg, sessionManager, feedbagStore, chatRegistry, adjListBuddyListStore, cookieBaker, nil, nil) + authHandler := foodgroup.NewAuthService(cfg, sessionManager, chatSessionManager, feedbagStore, adjListBuddyListStore, cookieBaker, nil, nil, chatSessionManager) oscar.AuthServer{ AuthService: authHandler, diff --git a/foodgroup/auth.go b/foodgroup/auth.go index db449ca3..33388622 100644 --- a/foodgroup/auth.go +++ b/foodgroup/auth.go @@ -18,21 +18,23 @@ import ( func NewAuthService( cfg config.Config, sessionManager SessionManager, + chatSessionRegistry ChatSessionRegistry, userManager UserManager, - chatRegistry ChatRegistry, legacyBuddyListManager LegacyBuddyListManager, cookieBaker CookieBaker, messageRelayer MessageRelayer, feedbagManager FeedbagManager, + chatMessageRelayer ChatMessageRelayer, ) *AuthService { return &AuthService{ buddyUpdateBroadcaster: NewBuddyService(messageRelayer, feedbagManager, legacyBuddyListManager), - chatRegistry: chatRegistry, + chatSessionRegistry: chatSessionRegistry, config: cfg, cookieBaker: cookieBaker, legacyBuddyListManager: legacyBuddyListManager, sessionManager: sessionManager, userManager: userManager, + chatMessageRelayer: chatMessageRelayer, } } @@ -41,15 +43,23 @@ func NewAuthService( // modes. type AuthService struct { buddyUpdateBroadcaster buddyBroadcaster - chatRegistry ChatRegistry + chatMessageRelayer ChatMessageRelayer + chatSessionRegistry ChatSessionRegistry config config.Config cookieBaker CookieBaker legacyBuddyListManager LegacyBuddyListManager sessionManager SessionManager userManager UserManager + chatRoomManager ChatRoomRegistry } -// RegisterChatSession creates and returns a chat room session. +// RegisterChatSession adds a user to a chat room. The authCookie param is an +// opaque token returned by {{OServiceService.ServiceRequest}} that identifies +// the user and chat room. It returns the session object registered in the +// ChatSessionRegistry. +// This method does not verify that the user and chat room exist because it +// implicitly trusts the contents of the token signed by +// {{OServiceService.ServiceRequest}}. func (s AuthService) RegisterChatSession(authCookie []byte) (*state.Session, error) { token, err := s.cookieBaker.Crack(authCookie) if err != nil { @@ -59,23 +69,7 @@ func (s AuthService) RegisterChatSession(authCookie []byte) (*state.Session, err if err := wire.Unmarshal(&c, bytes.NewBuffer(token)); err != nil { return nil, err } - - room, chatSessMgr, err := s.chatRegistry.Retrieve(c.ChatCookie) - if err != nil { - return nil, err - } - u, err := s.userManager.User(state.NewIdentScreenName(c.ScreenName)) - if err != nil { - return nil, fmt.Errorf("failed to retrieve user: %w", err) - } - if u == nil { - return nil, fmt.Errorf("user not found") - } - - chatSess := chatSessMgr.(SessionManager).AddSession(u.DisplayScreenName) - chatSess.SetChatRoomCookie(room.Cookie) - - return chatSess, nil + return s.chatSessionRegistry.AddSession(c.ChatCookie, c.ScreenName), nil } // RegisterBOSSession creates and returns a user's session. @@ -108,18 +102,10 @@ func (s AuthService) Signout(ctx context.Context, sess *state.Session) error { } // SignoutChat removes user from chat room and notifies remaining participants -// of their departure. If user is the last to leave, the chat room is deleted. -func (s AuthService) SignoutChat(ctx context.Context, sess *state.Session) error { - chatRoom, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatRoomCookie()) - if err != nil { - return err - } - alertUserLeft(ctx, sess, chatSessMgr.(ChatMessageRelayer)) - chatSessMgr.(SessionManager).RemoveSession(sess) - if chatSessMgr.(SessionManager).Empty() { - s.chatRegistry.Remove(chatRoom.Cookie) - } - return nil +// of their departure. +func (s AuthService) SignoutChat(ctx context.Context, sess *state.Session) { + alertUserLeft(ctx, sess, s.chatMessageRelayer) + s.chatSessionRegistry.RemoveSession(sess) } // BUCPChallenge processes a BUCP authentication challenge request. It diff --git a/foodgroup/auth_test.go b/foodgroup/auth_test.go index 62764820..7adbc2bd 100644 --- a/foodgroup/auth_test.go +++ b/foodgroup/auth_test.go @@ -936,29 +936,17 @@ func TestAuthService_BUCPChallengeRequest(t *testing.T) { } func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) { - sess := newTestSession("screen-name") - - sessionManager := newMockSessionManager(t) - sessionManager.EXPECT(). - AddSession(sess.DisplayScreenName()). - Return(sess) - - userManager := newMockUserManager(t) - userManager.EXPECT(). - User(sess.IdentScreenName()). - Return(&state.User{ - DisplayScreenName: sess.DisplayScreenName(), - }, nil) + sess := newTestSession("ScreenName") chatCookie := "the-chat-cookie" - chatRegistry := newMockChatRegistry(t) - chatRegistry.EXPECT(). - Retrieve(chatCookie). - Return(state.ChatRoom{}, sessionManager, nil) + chatSessionRegistry := newMockChatSessionRegistry(t) + chatSessionRegistry.EXPECT(). + AddSession(chatCookie, sess.DisplayScreenName()). + Return(sess) c := chatLoginCookie{ ChatCookie: chatCookie, - ScreenName: sess.IdentScreenName().String(), + ScreenName: sess.DisplayScreenName(), } chatCookieBuf := &bytes.Buffer{} assert.NoError(t, wire.Marshal(c, chatCookieBuf)) @@ -969,41 +957,13 @@ func TestAuthService_RegisterChatSession_HappyPath(t *testing.T) { Crack(authCookie). Return(chatCookieBuf.Bytes(), nil) - svc := NewAuthService(config.Config{}, nil, userManager, chatRegistry, nil, cookieBaker, nil, nil) + svc := NewAuthService(config.Config{}, nil, chatSessionRegistry, nil, nil, cookieBaker, nil, nil, nil) have, err := svc.RegisterChatSession(authCookie) assert.NoError(t, err) assert.Equal(t, sess, have) } -func TestAuthService_RegisterChatSession_ChatNotFound(t *testing.T) { - chatCookie := "the-chat-cookie" - sess := newTestSession("screen-name") - - c := chatLoginCookie{ - ChatCookie: chatCookie, - ScreenName: sess.IdentScreenName().String(), - } - loginCookie := &bytes.Buffer{} - assert.NoError(t, wire.Marshal(c, loginCookie)) - - chatRegistry := newMockChatRegistry(t) - chatRegistry.EXPECT(). - Retrieve(chatCookie). - Return(state.ChatRoom{}, nil, state.ErrChatRoomNotFound) - - authCookie := []byte("the-auth-cookie") - cookieBaker := newMockCookieBaker(t) - cookieBaker.EXPECT(). - Crack(authCookie). - Return(loginCookie.Bytes(), nil) - - svc := NewAuthService(config.Config{}, nil, nil, chatRegistry, nil, cookieBaker, nil, nil) - - _, err := svc.RegisterChatSession(authCookie) - assert.ErrorIs(t, err, state.ErrChatRoomNotFound) -} - func TestAuthService_RegisterBOSSession_HappyPath(t *testing.T) { sess := newTestSession("screen-name") @@ -1024,7 +984,7 @@ func TestAuthService_RegisterBOSSession_HappyPath(t *testing.T) { User(sess.IdentScreenName()). Return(&state.User{DisplayScreenName: sess.DisplayScreenName()}, nil) - svc := NewAuthService(config.Config{}, sessionManager, userManager, nil, nil, cookieBaker, nil, nil) + svc := NewAuthService(config.Config{}, sessionManager, nil, userManager, nil, cookieBaker, nil, nil, nil) have, err := svc.RegisterBOSSession(authCookie) assert.NoError(t, err) @@ -1051,7 +1011,7 @@ func TestAuthService_RegisterBOSSession_SessionNotFound(t *testing.T) { User(sess.IdentScreenName()). Return(&state.User{DisplayScreenName: sess.DisplayScreenName()}, nil) - svc := NewAuthService(config.Config{}, sessionManager, userManager, nil, nil, cookieBaker, nil, nil) + svc := NewAuthService(config.Config{}, sessionManager, nil, userManager, nil, cookieBaker, nil, nil, nil) have, err := svc.RegisterBOSSession(authCookie) assert.NoError(t, err) @@ -1059,8 +1019,6 @@ func TestAuthService_RegisterBOSSession_SessionNotFound(t *testing.T) { } func TestAuthService_SignoutChat(t *testing.T) { - sess := newTestSession("", sessOptCannedSignonTime, sessOptChatRoomCookie("the-chat-cookie")) - tests := []struct { // name is the unit test name name string @@ -1068,23 +1026,21 @@ func TestAuthService_SignoutChat(t *testing.T) { userSession *state.Session // chatRoom is the chat room user is exiting chatRoom state.ChatRoom - // wantErr is the error we expect from the method - wantErr error // mockParams is the list of params sent to mocks that satisfy this // method's dependencies mockParams mockParams }{ { name: "user signs out of chat room, room is empty after user leaves", - userSession: sess, + userSession: newTestSession("the-screen-name", sessOptCannedSignonTime, sessOptChatRoomCookie("the-chat-cookie")), chatRoom: state.ChatRoom{ Cookie: "the-chat-cookie", }, mockParams: mockParams{ chatMessageRelayerParams: chatMessageRelayerParams{ - broadcastExceptParams: broadcastExceptParams{ + chatRelayToAllExceptParams: chatRelayToAllExceptParams{ { - except: sess, + screenName: state.NewIdentScreenName("the-screen-name"), message: wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.Chat, @@ -1092,7 +1048,7 @@ func TestAuthService_SignoutChat(t *testing.T) { }, Body: wire.SNAC_0x0E_0x04_ChatUsersLeft{ Users: []wire.TLVUserInfo{ - sess.TLVUserInfo(), + newTestSession("the-screen-name", sessOptCannedSignonTime, sessOptChatRoomCookie("the-chat-cookie")).TLVUserInfo(), }, }, }, @@ -1107,7 +1063,7 @@ func TestAuthService_SignoutChat(t *testing.T) { }, removeSessionParams: removeSessionParams{ { - sess: sess, + screenName: state.NewIdentScreenName("the-screen-name"), }, }, }, @@ -1115,15 +1071,15 @@ func TestAuthService_SignoutChat(t *testing.T) { }, { name: "user signs out of chat room, room is not empty after user leaves", - userSession: sess, + userSession: newTestSession("the-screen-name", sessOptCannedSignonTime, sessOptChatRoomCookie("the-chat-cookie")), chatRoom: state.ChatRoom{ Cookie: "the-chat-cookie", }, mockParams: mockParams{ chatMessageRelayerParams: chatMessageRelayerParams{ - broadcastExceptParams: broadcastExceptParams{ + chatRelayToAllExceptParams: chatRelayToAllExceptParams{ { - except: sess, + screenName: state.NewIdentScreenName("the-screen-name"), message: wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.Chat, @@ -1131,7 +1087,7 @@ func TestAuthService_SignoutChat(t *testing.T) { }, Body: wire.SNAC_0x0E_0x04_ChatUsersLeft{ Users: []wire.TLVUserInfo{ - sess.TLVUserInfo(), + newTestSession("the-screen-name", sessOptCannedSignonTime, sessOptChatRoomCookie("the-chat-cookie")).TLVUserInfo(), }, }, }, @@ -1146,64 +1102,33 @@ func TestAuthService_SignoutChat(t *testing.T) { }, removeSessionParams: removeSessionParams{ { - sess: sess, + screenName: state.NewIdentScreenName("the-screen-name"), }, }, }, }, }, - { - name: "user can't sign out because chat room doesn't exist", - userSession: sess, - chatRoom: state.ChatRoom{ - Cookie: "the-chat-cookie", - }, - wantErr: state.ErrChatRoomNotFound, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { chatMessageRelayer := newMockChatMessageRelayer(t) - for _, params := range tt.mockParams.broadcastExceptParams { + for _, params := range tt.mockParams.chatRelayToAllExceptParams { chatMessageRelayer.EXPECT(). - RelayToAllExcept(nil, params.except, params.message) + RelayToAllExcept(nil, tt.chatRoom.Cookie, params.screenName, params.message) } - - sessionManager := newMockSessionManager(t) - chatRegistry := newMockChatRegistry(t) + sessionManager := newMockChatSessionRegistry(t) for _, params := range tt.mockParams.removeSessionParams { - sessionManager.EXPECT().RemoveSession(params.sess) + sessionManager.EXPECT(). + RemoveSession(matchSession(params.screenName)) } - for _, params := range tt.mockParams.emptyParams { - sessionManager.EXPECT().Empty().Return(params.result) - if params.result { - chatRegistry.EXPECT().Remove(tt.chatRoom.Cookie) - } - } - chatSessionManager := struct { - ChatMessageRelayer - SessionManager - }{ - chatMessageRelayer, - sessionManager, - } - chatRegistry.EXPECT(). - Retrieve(tt.chatRoom.Cookie). - Return(tt.chatRoom, chatSessionManager, tt.wantErr) - cookieBaker := newMockCookieBaker(t) - - svc := NewAuthService(config.Config{}, nil, nil, chatRegistry, nil, cookieBaker, nil, nil) - - err := svc.SignoutChat(nil, tt.userSession) - assert.ErrorIs(t, err, tt.wantErr) + svc := NewAuthService(config.Config{}, nil, sessionManager, nil, nil, nil, nil, nil, chatMessageRelayer) + svc.SignoutChat(nil, tt.userSession) }) } } func TestAuthService_Signout(t *testing.T) { - sess := newTestSession("user_screen_name", sessOptCannedSignonTime) - tests := []struct { // name is the unit test name name string @@ -1217,12 +1142,12 @@ func TestAuthService_Signout(t *testing.T) { }{ { name: "user signs out of chat room, room is empty after user leaves", - userSession: sess, + userSession: newTestSession("user_screen_name", sessOptCannedSignonTime), mockParams: mockParams{ sessionManagerParams: sessionManagerParams{ removeSessionParams: removeSessionParams{ { - sess: sess, + screenName: state.NewIdentScreenName("user_screen_name"), }, }, }, @@ -1247,7 +1172,7 @@ func TestAuthService_Signout(t *testing.T) { t.Run(tt.name, func(t *testing.T) { sessionManager := newMockSessionManager(t) for _, params := range tt.mockParams.removeSessionParams { - sessionManager.EXPECT().RemoveSession(params.sess) + sessionManager.EXPECT().RemoveSession(matchSession(params.screenName)) } legacyBuddyListManager := newMockLegacyBuddyListManager(t) for _, params := range tt.mockParams.deleteUserParams { @@ -1262,7 +1187,7 @@ func TestAuthService_Signout(t *testing.T) { })). Return(nil) } - svc := NewAuthService(config.Config{}, sessionManager, nil, nil, legacyBuddyListManager, nil, nil, nil) + svc := NewAuthService(config.Config{}, sessionManager, nil, nil, legacyBuddyListManager, nil, nil, nil, nil) svc.buddyUpdateBroadcaster = buddyUpdateBroadcaster err := svc.Signout(nil, tt.userSession) diff --git a/foodgroup/chat.go b/foodgroup/chat.go index 42362654..7120b76c 100644 --- a/foodgroup/chat.go +++ b/foodgroup/chat.go @@ -9,16 +9,16 @@ import ( ) // NewChatService creates a new instance of ChatService. -func NewChatService(chatRegistry ChatRegistry) *ChatService { +func NewChatService(chatMessageRelayer ChatMessageRelayer) *ChatService { return &ChatService{ - chatRegistry: chatRegistry, + chatMessageRelayer: chatMessageRelayer, } } // ChatService provides functionality for the Chat food group, which is // responsible for sending and receiving chat messages. type ChatService struct { - chatRegistry ChatRegistry + chatMessageRelayer ChatMessageRelayer } // ChannelMsgToHost relays wire.ChatChannelMsgToClient SNAC sent from a user @@ -50,12 +50,8 @@ func (s ChatService) ChannelMsgToHost(ctx context.Context, sess *state.Session, }, } - _, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatRoomCookie()) - if err != nil { - return nil, err - } // send message to all the participants except sender - chatSessMgr.(ChatMessageRelayer).RelayToAllExcept(ctx, sess, wire.SNACMessage{ + s.chatMessageRelayer.RelayToAllExcept(ctx, sess.ChatRoomCookie(), sess.IdentScreenName(), wire.SNACMessage{ Frame: frameOut, Body: bodyOut, }) @@ -75,13 +71,13 @@ func (s ChatService) ChannelMsgToHost(ctx context.Context, sess *state.Session, func setOnlineChatUsers(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) { snacPayloadOut := wire.SNAC_0x0E_0x03_ChatUsersJoined{} - sessions := chatMessageRelayer.AllSessions() + sessions := chatMessageRelayer.AllSessions(sess.ChatRoomCookie()) for _, uSess := range sessions { snacPayloadOut.Users = append(snacPayloadOut.Users, uSess.TLVUserInfo()) } - chatMessageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), wire.SNACMessage{ + chatMessageRelayer.RelayToScreenName(ctx, sess.ChatRoomCookie(), sess.IdentScreenName(), wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.Chat, SubGroup: wire.ChatUsersJoined, @@ -91,7 +87,7 @@ func setOnlineChatUsers(ctx context.Context, sess *state.Session, chatMessageRel } func alertUserJoined(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) { - chatMessageRelayer.RelayToAllExcept(ctx, sess, wire.SNACMessage{ + chatMessageRelayer.RelayToAllExcept(ctx, sess.ChatRoomCookie(), sess.IdentScreenName(), wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.Chat, SubGroup: wire.ChatUsersJoined, @@ -105,7 +101,7 @@ func alertUserJoined(ctx context.Context, sess *state.Session, chatMessageRelaye } func alertUserLeft(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) { - chatMessageRelayer.RelayToAllExcept(ctx, sess, wire.SNACMessage{ + chatMessageRelayer.RelayToAllExcept(ctx, sess.ChatRoomCookie(), sess.IdentScreenName(), wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.Chat, SubGroup: wire.ChatUsersLeft, @@ -119,7 +115,7 @@ func alertUserLeft(ctx context.Context, sess *state.Session, chatMessageRelayer } func sendChatRoomInfoUpdate(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer, room state.ChatRoom) { - chatMessageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), wire.SNACMessage{ + chatMessageRelayer.RelayToScreenName(ctx, sess.ChatRoomCookie(), sess.IdentScreenName(), wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.Chat, SubGroup: wire.ChatRoomInfoUpdate, diff --git a/foodgroup/chat_nav.go b/foodgroup/chat_nav.go index e7ea45c5..0e944b1c 100644 --- a/foodgroup/chat_nav.go +++ b/foodgroup/chat_nav.go @@ -3,7 +3,7 @@ package foodgroup import ( "context" "errors" - + "fmt" "log/slog" "github.com/mk6i/retro-aim-server/state" @@ -16,7 +16,6 @@ var defaultExchangeCfg = wire.TLVBlock{ wire.NewTLV(wire.ChatRoomTLVClassPerms, uint16(0x0010)), wire.NewTLV(wire.ChatRoomTLVMaxNameLen, uint16(100)), wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)), - wire.NewTLV(wire.ChatRoomTLVRoomName, "default exchange"), wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)), wire.NewTLV(wire.ChatRoomTLVCharSet1, "us-ascii"), wire.NewTLV(wire.ChatRoomTLVLang1, "en"), @@ -26,22 +25,20 @@ var defaultExchangeCfg = wire.TLVBlock{ } // NewChatNavService creates a new instance of NewChatNavService. -func NewChatNavService(logger *slog.Logger, chatRegistry *state.ChatRegistry, newChatRoom func() state.ChatRoom, newChatSessMgr func() SessionManager) *ChatNavService { +func NewChatNavService(logger *slog.Logger, chatRoomManager ChatRoomRegistry, fnNewChatRoom func() state.ChatRoom) *ChatNavService { return &ChatNavService{ - logger: logger, - chatRegistry: chatRegistry, - newChatRoom: newChatRoom, - newChatSessMgr: newChatSessMgr, + logger: logger, + chatRoomManager: chatRoomManager, + fnNewChatRoom: fnNewChatRoom, } } // ChatNavService provides functionality for the ChatNav food group, which // handles chat room creation and serving chat room metadata. type ChatNavService struct { - logger *slog.Logger - chatRegistry *state.ChatRegistry - newChatRoom func() state.ChatRoom - newChatSessMgr func() SessionManager + logger *slog.Logger + chatRoomManager ChatRoomRegistry + fnNewChatRoom func() state.ChatRoom } // RequestChatRights returns SNAC wire.ChatNavNavInfo, which contains chat @@ -58,7 +55,11 @@ func (s ChatNavService) RequestChatRights(_ context.Context, inFrame wire.SNACFr TLVList: wire.TLVList{ wire.NewTLV(wire.ChatNavTLVMaxConcurrentRooms, uint8(10)), wire.NewTLV(wire.ChatNavTLVExchangeInfo, wire.SNAC_0x0D_0x09_TLVExchangeInfo{ - Identifier: 4, + Identifier: state.PrivateExchange, + TLVBlock: defaultExchangeCfg, + }), + wire.NewTLV(wire.ChatNavTLVExchangeInfo, wire.SNAC_0x0D_0x09_TLVExchangeInfo{ + Identifier: state.PublicExchange, TLVBlock: defaultExchangeCfg, }), }, @@ -67,28 +68,47 @@ func (s ChatNavService) RequestChatRights(_ context.Context, inFrame wire.SNACFr } } -// CreateRoom creates a chat room with the current user as the first -// participant. It returns SNAC wire.ChatNavNavInfo, which contains metadata -// for the chat room. +// CreateRoom creates and returns a chat room or returns an existing chat +// room. It returns SNAC wire.ChatNavNavInfo, which contains metadata for the +// chat room. func (s ChatNavService) CreateRoom(_ context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error) { + if err := validateExchange(inBody.Exchange); err != nil { + return wire.SNACMessage{}, err + } + if inBody.Cookie != "create" { + s.logger.Info("got a non-create cookie", "value", inBody.Cookie) + } + name, hasName := inBody.String(wire.ChatRoomTLVRoomName) if !hasName { - return wire.SNACMessage{}, errors.New("unable to find chat name") + return wire.SNACMessage{}, errors.New("unable to find chat name in TLV payload") } - room := s.newChatRoom() - room.DetailLevel = inBody.DetailLevel - room.Exchange = inBody.Exchange - room.InstanceNumber = inBody.InstanceNumber - room.Name = name - - chatSessMgr := s.newChatSessMgr() - - s.chatRegistry.Register(room, chatSessMgr) - - // add user to chat room - chatSess := chatSessMgr.AddSession(sess.DisplayScreenName()) - chatSess.SetChatRoomCookie(room.Cookie) + // todo call ChatRoomByName and CreateChatRoom in a txn + room, err := s.chatRoomManager.ChatRoomByName(inBody.Exchange, name) + + switch { + case errors.Is(err, state.ErrChatRoomNotFound): + if inBody.Exchange == state.PublicExchange { + return wire.SNACMessage{}, fmt.Errorf("community chat rooms can only be created on exchange %d", + state.PrivateExchange) + } + + room = s.fnNewChatRoom() + room.Creator = sess.IdentScreenName() + room.DetailLevel = inBody.DetailLevel + room.Exchange = inBody.Exchange + room.InstanceNumber = inBody.InstanceNumber + room.Name = name + + if err := s.chatRoomManager.CreateChatRoom(room); err != nil { + return wire.SNACMessage{}, fmt.Errorf("unable to create chat room: %w", err) + } + break + case err != nil: + return wire.SNACMessage{}, fmt.Errorf("unable to retrieve chat room chat room %s on exchange %d: %w", + name, inBody.Exchange, err) + } return wire.SNACMessage{ Frame: wire.SNACFrame{ @@ -100,10 +120,10 @@ func (s ChatNavService) CreateRoom(_ context.Context, sess *state.Session, inFra TLVRestBlock: wire.TLVRestBlock{ TLVList: wire.TLVList{ wire.NewTLV(wire.ChatNavTLVRoomInfo, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ - Exchange: inBody.Exchange, + Exchange: room.Exchange, Cookie: room.Cookie, - InstanceNumber: inBody.InstanceNumber, - DetailLevel: inBody.DetailLevel, + InstanceNumber: room.InstanceNumber, + DetailLevel: room.DetailLevel, TLVBlock: wire.TLVBlock{ TLVList: room.TLVList(), }, @@ -117,11 +137,19 @@ func (s ChatNavService) CreateRoom(_ context.Context, sess *state.Session, inFra // RequestRoomInfo returns wire.ChatNavNavInfo, which contains metadata for // the chat room specified in the inFrame.hmacCookie. func (s ChatNavService) RequestRoomInfo(_ context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error) { - room, _, err := s.chatRegistry.Retrieve(inBody.Cookie) - if err != nil { + if err := validateExchange(inBody.Exchange); err != nil { return wire.SNACMessage{}, err } + room, err := s.chatRoomManager.ChatRoomByCookie(inBody.Cookie) + if err != nil { + return wire.SNACMessage{}, fmt.Errorf("unable to find chat room: %w", err) + } + + if room.Exchange != inBody.Exchange { + return wire.SNACMessage{}, errors.New("chat room exchange does not match requested exchange") + } + return wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.ChatNav, @@ -146,7 +174,10 @@ func (s ChatNavService) RequestRoomInfo(_ context.Context, inFrame wire.SNACFram }, nil } -func (s ChatNavService) ExchangeInfo(_ context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) wire.SNACMessage { +func (s ChatNavService) ExchangeInfo(_ context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error) { + if err := validateExchange(inBody.Exchange); err != nil { + return wire.SNACMessage{}, err + } return wire.SNACMessage{ Frame: wire.SNACFrame{ FoodGroup: wire.ChatNav, @@ -164,5 +195,12 @@ func (s ChatNavService) ExchangeInfo(_ context.Context, inFrame wire.SNACFrame, }, }, }, + }, nil +} + +func validateExchange(exchange uint16) error { + if !(exchange == state.PrivateExchange || exchange == state.PublicExchange) { + return fmt.Errorf("only exchanges %d and %d are supported", state.PrivateExchange, state.PublicExchange) } + return nil } diff --git a/foodgroup/chat_nav_test.go b/foodgroup/chat_nav_test.go index 8ea6cf35..20b51a52 100644 --- a/foodgroup/chat_nav_test.go +++ b/foodgroup/chat_nav_test.go @@ -2,6 +2,7 @@ package foodgroup import ( "context" + "log/slog" "testing" "time" @@ -12,113 +13,193 @@ import ( ) func TestChatNavService_CreateRoom(t *testing.T) { - bosSess := newTestSession("user-screen-name") - chatSess := &state.Session{} - - chatRegistry := state.NewChatRegistry() - - sessionManager := newMockSessionManager(t) - sessionManager.EXPECT(). - AddSession(bosSess.DisplayScreenName()). - Return(chatSess) - - newChatRoom := func() state.ChatRoom { - return state.ChatRoom{ - Cookie: "dummy-cookie", - CreateTime: time.UnixMilli(0), - } - } - newChatSessMgr := func() SessionManager { - return sessionManager - } - - inFrame := wire.SNACFrame{ - RequestID: 1234, - } - inBody := wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ - Exchange: 1, - Cookie: "create", // actual canned value sent by AIM client - InstanceNumber: 2, - DetailLevel: 3, - TLVBlock: wire.TLVBlock{ - TLVList: wire.TLVList{ - wire.NewTLV(wire.ChatRoomTLVRoomName, "the-chat-room-name"), - }, - }, - } - - svc := NewChatNavService(nil, chatRegistry, newChatRoom, newChatSessMgr) - outputSNAC, err := svc.CreateRoom(context.Background(), bosSess, inFrame, inBody) - assert.NoError(t, err) - - assert.Equal(t, chatSess.ChatRoomCookie(), newChatRoom().Cookie) - - expectChatRoom := state.ChatRoom{ + basicChatRoom := state.ChatRoom{ Cookie: "dummy-cookie", CreateTime: time.UnixMilli(0), + Creator: state.NewIdentScreenName("the-screen-name"), DetailLevel: 3, - Exchange: 1, + Exchange: 4, InstanceNumber: 2, Name: "the-chat-room-name", } - 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 := wire.SNACMessage{ - Frame: wire.SNACFrame{ - FoodGroup: wire.ChatNav, - SubGroup: wire.ChatNavNavInfo, - RequestID: 1234, + tests := []struct { + name string + chatRoom state.ChatRoom + sess *state.Session + inputSNAC wire.SNACMessage + want wire.SNACMessage + mockParams mockParams + wantErr error + fnNewChatRoom func() state.ChatRoom + }{ + { + name: "create room that already exists", + chatRoom: basicChatRoom, + sess: newTestSession("the-screen-name"), + inputSNAC: wire.SNACMessage{ + Frame: wire.SNACFrame{ + RequestID: 1234, + }, + Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ + Exchange: basicChatRoom.Exchange, + Cookie: "create", // actual canned value sent by AIM client + InstanceNumber: basicChatRoom.InstanceNumber, + DetailLevel: basicChatRoom.DetailLevel, + TLVBlock: wire.TLVBlock{ + TLVList: wire.TLVList{ + wire.NewTLV(wire.ChatRoomTLVRoomName, basicChatRoom.Name), + }, + }, + }, + }, + want: wire.SNACMessage{ + Frame: wire.SNACFrame{ + FoodGroup: wire.ChatNav, + SubGroup: wire.ChatNavNavInfo, + RequestID: 1234, + }, + Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{ + TLVRestBlock: wire.TLVRestBlock{ + TLVList: wire.TLVList{ + wire.NewTLV( + wire.ChatNavRequestRoomInfo, + wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ + Exchange: basicChatRoom.Exchange, + Cookie: basicChatRoom.Cookie, + InstanceNumber: basicChatRoom.InstanceNumber, + DetailLevel: basicChatRoom.DetailLevel, + TLVBlock: wire.TLVBlock{ + TLVList: basicChatRoom.TLVList(), + }, + }, + ), + }, + }, + }, + }, + mockParams: mockParams{ + chatRoomRegistryParams: chatRoomRegistryParams{ + chatRoomByNameParams: chatRoomByNameParams{ + { + exchange: basicChatRoom.Exchange, + name: basicChatRoom.Name, + room: basicChatRoom, + }, + }, + }, + }, }, - Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{ - TLVRestBlock: wire.TLVRestBlock{ - TLVList: wire.TLVList{ - wire.NewTLV( - wire.ChatNavRequestRoomInfo, - wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ - Exchange: chatRoom.Exchange, - Cookie: chatRoom.Cookie, - InstanceNumber: chatRoom.InstanceNumber, - DetailLevel: chatRoom.DetailLevel, - TLVBlock: wire.TLVBlock{ - TLVList: chatRoom.TLVList(), - }, + { + name: "create room that doesn't already exist", + chatRoom: basicChatRoom, + sess: newTestSession("the-screen-name"), + inputSNAC: wire.SNACMessage{ + Frame: wire.SNACFrame{ + RequestID: 1234, + }, + Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ + Exchange: basicChatRoom.Exchange, + Cookie: "create", // actual canned value sent by AIM client + InstanceNumber: basicChatRoom.InstanceNumber, + DetailLevel: basicChatRoom.DetailLevel, + TLVBlock: wire.TLVBlock{ + TLVList: wire.TLVList{ + wire.NewTLV(wire.ChatRoomTLVRoomName, basicChatRoom.Name), + }, + }, + }, + }, + want: wire.SNACMessage{ + Frame: wire.SNACFrame{ + FoodGroup: wire.ChatNav, + SubGroup: wire.ChatNavNavInfo, + RequestID: 1234, + }, + Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{ + TLVRestBlock: wire.TLVRestBlock{ + TLVList: wire.TLVList{ + wire.NewTLV( + wire.ChatNavRequestRoomInfo, + wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ + Exchange: basicChatRoom.Exchange, + Cookie: basicChatRoom.Cookie, + InstanceNumber: basicChatRoom.InstanceNumber, + DetailLevel: basicChatRoom.DetailLevel, + TLVBlock: wire.TLVBlock{ + TLVList: basicChatRoom.TLVList(), + }, + }, + ), + }, + }, + }, + }, + mockParams: mockParams{ + chatRoomRegistryParams: chatRoomRegistryParams{ + chatRoomByNameParams: chatRoomByNameParams{ + { + exchange: basicChatRoom.Exchange, + name: basicChatRoom.Name, + err: state.ErrChatRoomNotFound, + }, + }, + createChatRoomParams: createChatRoomParams{ + { + exchange: basicChatRoom.Exchange, + name: basicChatRoom.Name, + room: basicChatRoom, }, - ), + }, }, }, + fnNewChatRoom: func() state.ChatRoom { + return basicChatRoom + }, }, } - assert.Equal(t, expectSNAC, outputSNAC) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chatRoomRegistry := newMockChatRoomRegistry(t) + for _, params := range tt.mockParams.chatRoomByNameParams { + chatRoomRegistry.EXPECT(). + ChatRoomByName(params.exchange, params.name). + Return(params.room, params.err) + } + for _, params := range tt.mockParams.createChatRoomParams { + chatRoomRegistry.EXPECT(). + CreateChatRoom(params.room). + Return(params.err) + } + + svc := NewChatNavService(slog.Default(), chatRoomRegistry, tt.fnNewChatRoom) + + outputSNAC, err := svc.CreateRoom(context.Background(), tt.sess, tt.inputSNAC.Frame, tt.inputSNAC.Body.(wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate)) + assert.NoError(t, err) + + assert.Equal(t, tt.want, outputSNAC) + }) + } } func TestChatNavService_RequestRoomInfo(t *testing.T) { tests := []struct { - name string - chatRoom state.ChatRoom - // inputSNAC is the SNAC sent from the client to the server - inputSNAC wire.SNACMessage - want wire.SNACMessage - wantErr error + name string + inputSNAC wire.SNACMessage + want wire.SNACMessage + mockParams mockParams + wantErr error }{ { name: "request room info", - chatRoom: state.ChatRoom{ - Cookie: "the-chat-cookie", - DetailLevel: 2, - Exchange: 4, - InstanceNumber: 8, - }, inputSNAC: wire.SNACMessage{ Frame: wire.SNACFrame{ RequestID: 1234, }, Body: wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo{ - Cookie: "the-chat-cookie", + Exchange: state.PrivateExchange, + Cookie: "the-chat-cookie", }, }, want: wire.SNACMessage{ @@ -133,7 +214,7 @@ func TestChatNavService_RequestRoomInfo(t *testing.T) { wire.NewTLV(0x04, wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ Cookie: "the-chat-cookie", DetailLevel: 2, - Exchange: 4, + Exchange: state.PrivateExchange, InstanceNumber: 8, TLVBlock: wire.TLVBlock{ TLVList: state.ChatRoom{Cookie: "the-chat-cookie"}.TLVList(), @@ -143,12 +224,33 @@ func TestChatNavService_RequestRoomInfo(t *testing.T) { }, }, }, + mockParams: mockParams{ + chatRoomRegistryParams: chatRoomRegistryParams{ + chatRoomByCookieParams: chatRoomByCookieParams{ + { + cookie: "the-chat-cookie", + room: state.ChatRoom{ + Cookie: "the-chat-cookie", + DetailLevel: 2, + Exchange: state.PrivateExchange, + InstanceNumber: 8, + }, + }, + }, + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - svc := NewChatNavService(nil, state.NewChatRegistry(), nil, nil) - svc.chatRegistry.Register(tt.chatRoom, nil) + chatRoomRegistry := newMockChatRoomRegistry(t) + for _, params := range tt.mockParams.chatRoomByCookieParams { + chatRoomRegistry.EXPECT(). + ChatRoomByCookie(params.cookie). + Return(params.room, params.err) + } + + svc := NewChatNavService(slog.Default(), chatRoomRegistry, nil) got, err := svc.RequestRoomInfo(nil, tt.inputSNAC.Frame, tt.inputSNAC.Body.(wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo)) assert.ErrorIs(t, err, tt.wantErr) @@ -161,7 +263,7 @@ func TestChatNavService_RequestRoomInfo(t *testing.T) { } func TestChatNavService_RequestChatRights(t *testing.T) { - svc := NewChatNavService(nil, nil, nil, nil) + svc := NewChatNavService(nil, nil, nil) have := svc.RequestChatRights(nil, wire.SNACFrame{RequestID: 1234}) @@ -183,7 +285,22 @@ func TestChatNavService_RequestChatRights(t *testing.T) { wire.NewTLV(wire.ChatRoomTLVClassPerms, uint16(0x0010)), wire.NewTLV(wire.ChatRoomTLVMaxNameLen, uint16(100)), wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)), - wire.NewTLV(wire.ChatRoomTLVRoomName, "default exchange"), + wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)), + wire.NewTLV(wire.ChatRoomTLVCharSet1, "us-ascii"), + wire.NewTLV(wire.ChatRoomTLVLang1, "en"), + wire.NewTLV(wire.ChatRoomTLVCharSet2, "us-ascii"), + wire.NewTLV(wire.ChatRoomTLVLang2, "en"), + }, + }, + }), + wire.NewTLV(wire.ChatNavTLVExchangeInfo, wire.SNAC_0x0D_0x09_TLVExchangeInfo{ + Identifier: 5, + TLVBlock: wire.TLVBlock{ + TLVList: wire.TLVList{ + wire.NewTLV(wire.ChatRoomTLVMaxConcurrentRooms, uint8(10)), + wire.NewTLV(wire.ChatRoomTLVClassPerms, uint16(0x0010)), + wire.NewTLV(wire.ChatRoomTLVMaxNameLen, uint16(100)), + wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)), wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)), wire.NewTLV(wire.ChatRoomTLVCharSet1, "us-ascii"), wire.NewTLV(wire.ChatRoomTLVLang1, "en"), @@ -201,13 +318,14 @@ func TestChatNavService_RequestChatRights(t *testing.T) { } func TestChatNavService_ExchangeInfo(t *testing.T) { - svc := NewChatNavService(nil, nil, nil, nil) + svc := NewChatNavService(nil, nil, nil) frame := wire.SNACFrame{RequestID: 1234} snac := wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo{ Exchange: 4, } - have := svc.ExchangeInfo(nil, frame, snac) + have, err := svc.ExchangeInfo(nil, frame, snac) + assert.NoError(t, err) want := wire.SNACMessage{ Frame: wire.SNACFrame{ @@ -227,7 +345,6 @@ func TestChatNavService_ExchangeInfo(t *testing.T) { wire.NewTLV(wire.ChatRoomTLVClassPerms, uint16(0x0010)), wire.NewTLV(wire.ChatRoomTLVMaxNameLen, uint16(100)), wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)), - wire.NewTLV(wire.ChatRoomTLVRoomName, "default exchange"), wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)), wire.NewTLV(wire.ChatRoomTLVCharSet1, "us-ascii"), wire.NewTLV(wire.ChatRoomTLVLang1, "en"), diff --git a/foodgroup/chat_test.go b/foodgroup/chat_test.go index 851efd77..fefbccb1 100644 --- a/foodgroup/chat_test.go +++ b/foodgroup/chat_test.go @@ -58,26 +58,29 @@ func TestChatService_ChannelMsgToHost(t *testing.T) { }, }, mockParams: mockParams{ - chatRegistryParams: chatRegistryParams{ - chatRegistryRetrieveParams: chatRegistryRetrieveParams{ - cookie: "the-chat-cookie", - }, - }, - }, - expectSNACToParticipants: wire.SNACMessage{ - Frame: wire.SNACFrame{ - FoodGroup: wire.Chat, - SubGroup: wire.ChatChannelMsgToClient, - }, - Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{ - Cookie: 1234, - Channel: 14, - TLVRestBlock: wire.TLVRestBlock{ - TLVList: wire.TLVList{ - wire.NewTLV(wire.ChatTLVSenderInformation, - newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()), - wire.NewTLV(wire.ChatTLVPublicWhisperFlag, []byte{}), - wire.NewTLV(wire.ChatTLVMessageInformation, []byte{}), + chatMessageRelayerParams: chatMessageRelayerParams{ + chatRelayToAllExceptParams: chatRelayToAllExceptParams{ + { + screenName: state.NewIdentScreenName("user_sending_chat_msg"), + cookie: "the-chat-cookie", + message: wire.SNACMessage{ + Frame: wire.SNACFrame{ + FoodGroup: wire.Chat, + SubGroup: wire.ChatChannelMsgToClient, + }, + Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{ + Cookie: 1234, + Channel: 14, + TLVRestBlock: wire.TLVRestBlock{ + TLVList: wire.TLVList{ + wire.NewTLV(wire.ChatTLVSenderInformation, + newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()), + wire.NewTLV(wire.ChatTLVPublicWhisperFlag, []byte{}), + wire.NewTLV(wire.ChatTLVMessageInformation, []byte{}), + }, + }, + }, + }, }, }, }, @@ -128,82 +131,45 @@ func TestChatService_ChannelMsgToHost(t *testing.T) { }, }, mockParams: mockParams{ - chatRegistryParams: chatRegistryParams{ - chatRegistryRetrieveParams: chatRegistryRetrieveParams{ - cookie: "the-chat-cookie", - }, - }, - }, - expectSNACToParticipants: wire.SNACMessage{ - Frame: wire.SNACFrame{ - FoodGroup: wire.Chat, - SubGroup: wire.ChatChannelMsgToClient, - }, - Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{ - Cookie: 1234, - Channel: 14, - TLVRestBlock: wire.TLVRestBlock{ - TLVList: wire.TLVList{ - wire.NewTLV(wire.ChatTLVSenderInformation, - newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()), - wire.NewTLV(wire.ChatTLVPublicWhisperFlag, []byte{}), - wire.NewTLV(wire.ChatTLVMessageInformation, []byte{}), - }, - }, - }, - }, - }, - { - name: "send chat room message, fail due to missing chat room", - userSession: newTestSession("user_sending_chat_msg", sessOptCannedSignonTime, - sessOptChatRoomCookie("the-chat-cookie")), - inputSNAC: wire.SNACMessage{ - Frame: wire.SNACFrame{ - RequestID: 1234, - }, - Body: wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{ - Cookie: 1234, - Channel: 14, - TLVRestBlock: wire.TLVRestBlock{ - TLVList: wire.TLVList{ - { - Tag: wire.ChatTLVPublicWhisperFlag, - Value: []byte{}, - }, - { - Tag: wire.ChatTLVMessageInformation, - Value: []byte{}, + chatMessageRelayerParams: chatMessageRelayerParams{ + chatRelayToAllExceptParams: chatRelayToAllExceptParams{ + { + screenName: state.NewIdentScreenName("user_sending_chat_msg"), + cookie: "the-chat-cookie", + message: wire.SNACMessage{ + Frame: wire.SNACFrame{ + FoodGroup: wire.Chat, + SubGroup: wire.ChatChannelMsgToClient, + }, + Body: wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{ + Cookie: 1234, + Channel: 14, + TLVRestBlock: wire.TLVRestBlock{ + TLVList: wire.TLVList{ + wire.NewTLV(wire.ChatTLVSenderInformation, + newTestSession("user_sending_chat_msg", sessOptCannedSignonTime).TLVUserInfo()), + wire.NewTLV(wire.ChatTLVPublicWhisperFlag, []byte{}), + wire.NewTLV(wire.ChatTLVMessageInformation, []byte{}), + }, + }, + }, }, }, }, }, }, - mockParams: mockParams{ - chatRegistryParams: chatRegistryParams{ - chatRegistryRetrieveParams: chatRegistryRetrieveParams{ - cookie: "the-chat-cookie", - err: state.ErrChatRoomNotFound, - }, - }, - }, - wantErr: state.ErrChatRoomNotFound, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - chatSessMgr := newMockChatMessageRelayer(t) - if tc.mockParams.chatRegistryRetrieveParams.err == nil { - chatSessMgr.EXPECT(). - RelayToAllExcept(mock.Anything, tc.userSession, tc.expectSNACToParticipants) + chatMessageRelayer := newMockChatMessageRelayer(t) + for _, params := range tc.mockParams.chatRelayToAllExceptParams { + chatMessageRelayer.EXPECT(). + RelayToAllExcept(mock.Anything, params.cookie, params.screenName, params.message) } - chatRegistry := newMockChatRegistry(t) - chatRegistry.EXPECT(). - Retrieve(tc.mockParams.chatRegistryRetrieveParams.cookie). - Return(state.ChatRoom{}, chatSessMgr, tc.mockParams.chatRegistryRetrieveParams.err) - - svc := NewChatService(chatRegistry) + svc := NewChatService(chatMessageRelayer) outputSNAC, err := svc.ChannelMsgToHost(context.Background(), tc.userSession, tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x0E_0x05_ChatChannelMsgToHost)) assert.ErrorIs(t, err, tc.wantErr) diff --git a/foodgroup/mock_chat_message_relayer_test.go b/foodgroup/mock_chat_message_relayer_test.go index 6d45859c..fff743c7 100644 --- a/foodgroup/mock_chat_message_relayer_test.go +++ b/foodgroup/mock_chat_message_relayer_test.go @@ -24,17 +24,17 @@ func (_m *mockChatMessageRelayer) EXPECT() *mockChatMessageRelayer_Expecter { return &mockChatMessageRelayer_Expecter{mock: &_m.Mock} } -// AllSessions provides a mock function with given fields: -func (_m *mockChatMessageRelayer) AllSessions() []*state.Session { - ret := _m.Called() +// AllSessions provides a mock function with given fields: chatCookie +func (_m *mockChatMessageRelayer) AllSessions(chatCookie string) []*state.Session { + ret := _m.Called(chatCookie) if len(ret) == 0 { panic("no return value specified for AllSessions") } var r0 []*state.Session - if rf, ok := ret.Get(0).(func() []*state.Session); ok { - r0 = rf() + if rf, ok := ret.Get(0).(func(string) []*state.Session); ok { + r0 = rf(chatCookie) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*state.Session) @@ -50,13 +50,14 @@ type mockChatMessageRelayer_AllSessions_Call struct { } // AllSessions is a helper method to define mock.On call -func (_e *mockChatMessageRelayer_Expecter) AllSessions() *mockChatMessageRelayer_AllSessions_Call { - return &mockChatMessageRelayer_AllSessions_Call{Call: _e.mock.On("AllSessions")} +// - chatCookie string +func (_e *mockChatMessageRelayer_Expecter) AllSessions(chatCookie interface{}) *mockChatMessageRelayer_AllSessions_Call { + return &mockChatMessageRelayer_AllSessions_Call{Call: _e.mock.On("AllSessions", chatCookie)} } -func (_c *mockChatMessageRelayer_AllSessions_Call) Run(run func()) *mockChatMessageRelayer_AllSessions_Call { +func (_c *mockChatMessageRelayer_AllSessions_Call) Run(run func(chatCookie string)) *mockChatMessageRelayer_AllSessions_Call { _c.Call.Run(func(args mock.Arguments) { - run() + run(args[0].(string)) }) return _c } @@ -66,14 +67,14 @@ func (_c *mockChatMessageRelayer_AllSessions_Call) Return(_a0 []*state.Session) return _c } -func (_c *mockChatMessageRelayer_AllSessions_Call) RunAndReturn(run func() []*state.Session) *mockChatMessageRelayer_AllSessions_Call { +func (_c *mockChatMessageRelayer_AllSessions_Call) RunAndReturn(run func(string) []*state.Session) *mockChatMessageRelayer_AllSessions_Call { _c.Call.Return(run) return _c } -// RelayToAllExcept provides a mock function with given fields: ctx, except, msg -func (_m *mockChatMessageRelayer) RelayToAllExcept(ctx context.Context, except *state.Session, msg wire.SNACMessage) { - _m.Called(ctx, except, msg) +// RelayToAllExcept provides a mock function with given fields: ctx, chatCookie, except, msg +func (_m *mockChatMessageRelayer) RelayToAllExcept(ctx context.Context, chatCookie string, except state.IdentScreenName, msg wire.SNACMessage) { + _m.Called(ctx, chatCookie, except, msg) } // mockChatMessageRelayer_RelayToAllExcept_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RelayToAllExcept' @@ -83,15 +84,16 @@ type mockChatMessageRelayer_RelayToAllExcept_Call struct { // RelayToAllExcept is a helper method to define mock.On call // - ctx context.Context -// - except *state.Session +// - chatCookie string +// - except state.IdentScreenName // - msg wire.SNACMessage -func (_e *mockChatMessageRelayer_Expecter) RelayToAllExcept(ctx interface{}, except interface{}, msg interface{}) *mockChatMessageRelayer_RelayToAllExcept_Call { - return &mockChatMessageRelayer_RelayToAllExcept_Call{Call: _e.mock.On("RelayToAllExcept", ctx, except, msg)} +func (_e *mockChatMessageRelayer_Expecter) RelayToAllExcept(ctx interface{}, chatCookie interface{}, except interface{}, msg interface{}) *mockChatMessageRelayer_RelayToAllExcept_Call { + return &mockChatMessageRelayer_RelayToAllExcept_Call{Call: _e.mock.On("RelayToAllExcept", ctx, chatCookie, except, msg)} } -func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) Run(run func(ctx context.Context, except *state.Session, msg wire.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call { +func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) Run(run func(ctx context.Context, chatCookie string, except state.IdentScreenName, msg wire.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(*state.Session), args[2].(wire.SNACMessage)) + run(args[0].(context.Context), args[1].(string), args[2].(state.IdentScreenName), args[3].(wire.SNACMessage)) }) return _c } @@ -101,14 +103,14 @@ func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) Return() *mockChatMessag return _c } -func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) RunAndReturn(run func(context.Context, *state.Session, wire.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call { +func (_c *mockChatMessageRelayer_RelayToAllExcept_Call) RunAndReturn(run func(context.Context, string, state.IdentScreenName, wire.SNACMessage)) *mockChatMessageRelayer_RelayToAllExcept_Call { _c.Call.Return(run) return _c } -// RelayToScreenName provides a mock function with given fields: ctx, screenName, msg -func (_m *mockChatMessageRelayer) RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage) { - _m.Called(ctx, screenName, msg) +// RelayToScreenName provides a mock function with given fields: ctx, chatCookie, recipient, msg +func (_m *mockChatMessageRelayer) RelayToScreenName(ctx context.Context, chatCookie string, recipient state.IdentScreenName, msg wire.SNACMessage) { + _m.Called(ctx, chatCookie, recipient, msg) } // mockChatMessageRelayer_RelayToScreenName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RelayToScreenName' @@ -118,15 +120,16 @@ type mockChatMessageRelayer_RelayToScreenName_Call struct { // RelayToScreenName is a helper method to define mock.On call // - ctx context.Context -// - screenName state.IdentScreenName +// - chatCookie string +// - recipient state.IdentScreenName // - msg wire.SNACMessage -func (_e *mockChatMessageRelayer_Expecter) RelayToScreenName(ctx interface{}, screenName interface{}, msg interface{}) *mockChatMessageRelayer_RelayToScreenName_Call { - return &mockChatMessageRelayer_RelayToScreenName_Call{Call: _e.mock.On("RelayToScreenName", ctx, screenName, msg)} +func (_e *mockChatMessageRelayer_Expecter) RelayToScreenName(ctx interface{}, chatCookie interface{}, recipient interface{}, msg interface{}) *mockChatMessageRelayer_RelayToScreenName_Call { + return &mockChatMessageRelayer_RelayToScreenName_Call{Call: _e.mock.On("RelayToScreenName", ctx, chatCookie, recipient, msg)} } -func (_c *mockChatMessageRelayer_RelayToScreenName_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call { +func (_c *mockChatMessageRelayer_RelayToScreenName_Call) Run(run func(ctx context.Context, chatCookie string, recipient state.IdentScreenName, msg wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(wire.SNACMessage)) + run(args[0].(context.Context), args[1].(string), args[2].(state.IdentScreenName), args[3].(wire.SNACMessage)) }) return _c } @@ -136,90 +139,7 @@ func (_c *mockChatMessageRelayer_RelayToScreenName_Call) Return() *mockChatMessa return _c } -func (_c *mockChatMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call { - _c.Call.Return(run) - return _c -} - -// RelayToScreenNames provides a mock function with given fields: ctx, screenNames, msg -func (_m *mockChatMessageRelayer) RelayToScreenNames(ctx context.Context, screenNames []state.IdentScreenName, msg wire.SNACMessage) { - _m.Called(ctx, screenNames, msg) -} - -// mockChatMessageRelayer_RelayToScreenNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RelayToScreenNames' -type mockChatMessageRelayer_RelayToScreenNames_Call struct { - *mock.Call -} - -// RelayToScreenNames is a helper method to define mock.On call -// - ctx context.Context -// - screenNames []state.IdentScreenName -// - msg wire.SNACMessage -func (_e *mockChatMessageRelayer_Expecter) RelayToScreenNames(ctx interface{}, screenNames interface{}, msg interface{}) *mockChatMessageRelayer_RelayToScreenNames_Call { - return &mockChatMessageRelayer_RelayToScreenNames_Call{Call: _e.mock.On("RelayToScreenNames", ctx, screenNames, msg)} -} - -func (_c *mockChatMessageRelayer_RelayToScreenNames_Call) Run(run func(ctx context.Context, screenNames []state.IdentScreenName, msg wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenNames_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].([]state.IdentScreenName), args[2].(wire.SNACMessage)) - }) - return _c -} - -func (_c *mockChatMessageRelayer_RelayToScreenNames_Call) Return() *mockChatMessageRelayer_RelayToScreenNames_Call { - _c.Call.Return() - return _c -} - -func (_c *mockChatMessageRelayer_RelayToScreenNames_Call) RunAndReturn(run func(context.Context, []state.IdentScreenName, wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenNames_Call { - _c.Call.Return(run) - return _c -} - -// RetrieveByScreenName provides a mock function with given fields: screenName -func (_m *mockChatMessageRelayer) RetrieveByScreenName(screenName state.IdentScreenName) *state.Session { - ret := _m.Called(screenName) - - if len(ret) == 0 { - panic("no return value specified for RetrieveByScreenName") - } - - var r0 *state.Session - if rf, ok := ret.Get(0).(func(state.IdentScreenName) *state.Session); ok { - r0 = rf(screenName) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*state.Session) - } - } - - return r0 -} - -// mockChatMessageRelayer_RetrieveByScreenName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveByScreenName' -type mockChatMessageRelayer_RetrieveByScreenName_Call struct { - *mock.Call -} - -// RetrieveByScreenName is a helper method to define mock.On call -// - screenName state.IdentScreenName -func (_e *mockChatMessageRelayer_Expecter) RetrieveByScreenName(screenName interface{}) *mockChatMessageRelayer_RetrieveByScreenName_Call { - return &mockChatMessageRelayer_RetrieveByScreenName_Call{Call: _e.mock.On("RetrieveByScreenName", screenName)} -} - -func (_c *mockChatMessageRelayer_RetrieveByScreenName_Call) Run(run func(screenName state.IdentScreenName)) *mockChatMessageRelayer_RetrieveByScreenName_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(state.IdentScreenName)) - }) - return _c -} - -func (_c *mockChatMessageRelayer_RetrieveByScreenName_Call) Return(_a0 *state.Session) *mockChatMessageRelayer_RetrieveByScreenName_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *mockChatMessageRelayer_RetrieveByScreenName_Call) RunAndReturn(run func(state.IdentScreenName) *state.Session) *mockChatMessageRelayer_RetrieveByScreenName_Call { +func (_c *mockChatMessageRelayer_RelayToScreenName_Call) RunAndReturn(run func(context.Context, string, state.IdentScreenName, wire.SNACMessage)) *mockChatMessageRelayer_RelayToScreenName_Call { _c.Call.Return(run) return _c } diff --git a/foodgroup/mock_chat_registry_test.go b/foodgroup/mock_chat_registry_test.go deleted file mode 100644 index 2da13ef1..00000000 --- a/foodgroup/mock_chat_registry_test.go +++ /dev/null @@ -1,167 +0,0 @@ -// Code generated by mockery v2.40.1. DO NOT EDIT. - -package foodgroup - -import ( - state "github.com/mk6i/retro-aim-server/state" - mock "github.com/stretchr/testify/mock" -) - -// mockChatRegistry is an autogenerated mock type for the ChatRegistry type -type mockChatRegistry struct { - mock.Mock -} - -type mockChatRegistry_Expecter struct { - mock *mock.Mock -} - -func (_m *mockChatRegistry) EXPECT() *mockChatRegistry_Expecter { - return &mockChatRegistry_Expecter{mock: &_m.Mock} -} - -// Register provides a mock function with given fields: room, sessionManager -func (_m *mockChatRegistry) Register(room state.ChatRoom, sessionManager interface{}) { - _m.Called(room, sessionManager) -} - -// mockChatRegistry_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register' -type mockChatRegistry_Register_Call struct { - *mock.Call -} - -// Register is a helper method to define mock.On call -// - room state.ChatRoom -// - sessionManager interface{} -func (_e *mockChatRegistry_Expecter) Register(room interface{}, sessionManager interface{}) *mockChatRegistry_Register_Call { - return &mockChatRegistry_Register_Call{Call: _e.mock.On("Register", room, sessionManager)} -} - -func (_c *mockChatRegistry_Register_Call) Run(run func(room state.ChatRoom, sessionManager interface{})) *mockChatRegistry_Register_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(state.ChatRoom), args[1].(interface{})) - }) - return _c -} - -func (_c *mockChatRegistry_Register_Call) Return() *mockChatRegistry_Register_Call { - _c.Call.Return() - return _c -} - -func (_c *mockChatRegistry_Register_Call) RunAndReturn(run func(state.ChatRoom, interface{})) *mockChatRegistry_Register_Call { - _c.Call.Return(run) - return _c -} - -// Remove provides a mock function with given fields: cookie -func (_m *mockChatRegistry) Remove(cookie string) { - _m.Called(cookie) -} - -// mockChatRegistry_Remove_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Remove' -type mockChatRegistry_Remove_Call struct { - *mock.Call -} - -// Remove is a helper method to define mock.On call -// - cookie string -func (_e *mockChatRegistry_Expecter) Remove(cookie interface{}) *mockChatRegistry_Remove_Call { - return &mockChatRegistry_Remove_Call{Call: _e.mock.On("Remove", cookie)} -} - -func (_c *mockChatRegistry_Remove_Call) Run(run func(cookie string)) *mockChatRegistry_Remove_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) - }) - return _c -} - -func (_c *mockChatRegistry_Remove_Call) Return() *mockChatRegistry_Remove_Call { - _c.Call.Return() - return _c -} - -func (_c *mockChatRegistry_Remove_Call) RunAndReturn(run func(string)) *mockChatRegistry_Remove_Call { - _c.Call.Return(run) - return _c -} - -// Retrieve provides a mock function with given fields: cookie -func (_m *mockChatRegistry) Retrieve(cookie string) (state.ChatRoom, interface{}, error) { - ret := _m.Called(cookie) - - if len(ret) == 0 { - panic("no return value specified for Retrieve") - } - - var r0 state.ChatRoom - var r1 interface{} - var r2 error - if rf, ok := ret.Get(0).(func(string) (state.ChatRoom, interface{}, error)); ok { - return rf(cookie) - } - if rf, ok := ret.Get(0).(func(string) state.ChatRoom); ok { - r0 = rf(cookie) - } else { - r0 = ret.Get(0).(state.ChatRoom) - } - - if rf, ok := ret.Get(1).(func(string) interface{}); ok { - r1 = rf(cookie) - } else { - if ret.Get(1) != nil { - r1 = ret.Get(1).(interface{}) - } - } - - if rf, ok := ret.Get(2).(func(string) error); ok { - r2 = rf(cookie) - } else { - r2 = ret.Error(2) - } - - return r0, r1, r2 -} - -// mockChatRegistry_Retrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retrieve' -type mockChatRegistry_Retrieve_Call struct { - *mock.Call -} - -// Retrieve is a helper method to define mock.On call -// - cookie string -func (_e *mockChatRegistry_Expecter) Retrieve(cookie interface{}) *mockChatRegistry_Retrieve_Call { - return &mockChatRegistry_Retrieve_Call{Call: _e.mock.On("Retrieve", cookie)} -} - -func (_c *mockChatRegistry_Retrieve_Call) Run(run func(cookie string)) *mockChatRegistry_Retrieve_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(string)) - }) - return _c -} - -func (_c *mockChatRegistry_Retrieve_Call) Return(_a0 state.ChatRoom, _a1 interface{}, _a2 error) *mockChatRegistry_Retrieve_Call { - _c.Call.Return(_a0, _a1, _a2) - return _c -} - -func (_c *mockChatRegistry_Retrieve_Call) RunAndReturn(run func(string) (state.ChatRoom, interface{}, error)) *mockChatRegistry_Retrieve_Call { - _c.Call.Return(run) - return _c -} - -// newMockChatRegistry creates a new instance of mockChatRegistry. 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 newMockChatRegistry(t interface { - mock.TestingT - Cleanup(func()) -}) *mockChatRegistry { - mock := &mockChatRegistry{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/foodgroup/mock_chat_room_registry_test.go b/foodgroup/mock_chat_room_registry_test.go new file mode 100644 index 00000000..563cd607 --- /dev/null +++ b/foodgroup/mock_chat_room_registry_test.go @@ -0,0 +1,194 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package foodgroup + +import ( + state "github.com/mk6i/retro-aim-server/state" + mock "github.com/stretchr/testify/mock" +) + +// mockChatRoomRegistry is an autogenerated mock type for the ChatRoomRegistry type +type mockChatRoomRegistry struct { + mock.Mock +} + +type mockChatRoomRegistry_Expecter struct { + mock *mock.Mock +} + +func (_m *mockChatRoomRegistry) EXPECT() *mockChatRoomRegistry_Expecter { + return &mockChatRoomRegistry_Expecter{mock: &_m.Mock} +} + +// ChatRoomByCookie provides a mock function with given fields: chatCookie +func (_m *mockChatRoomRegistry) ChatRoomByCookie(chatCookie string) (state.ChatRoom, error) { + ret := _m.Called(chatCookie) + + if len(ret) == 0 { + panic("no return value specified for ChatRoomByCookie") + } + + var r0 state.ChatRoom + var r1 error + if rf, ok := ret.Get(0).(func(string) (state.ChatRoom, error)); ok { + return rf(chatCookie) + } + if rf, ok := ret.Get(0).(func(string) state.ChatRoom); ok { + r0 = rf(chatCookie) + } else { + r0 = ret.Get(0).(state.ChatRoom) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(chatCookie) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockChatRoomRegistry_ChatRoomByCookie_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChatRoomByCookie' +type mockChatRoomRegistry_ChatRoomByCookie_Call struct { + *mock.Call +} + +// ChatRoomByCookie is a helper method to define mock.On call +// - chatCookie string +func (_e *mockChatRoomRegistry_Expecter) ChatRoomByCookie(chatCookie interface{}) *mockChatRoomRegistry_ChatRoomByCookie_Call { + return &mockChatRoomRegistry_ChatRoomByCookie_Call{Call: _e.mock.On("ChatRoomByCookie", chatCookie)} +} + +func (_c *mockChatRoomRegistry_ChatRoomByCookie_Call) Run(run func(chatCookie string)) *mockChatRoomRegistry_ChatRoomByCookie_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *mockChatRoomRegistry_ChatRoomByCookie_Call) Return(_a0 state.ChatRoom, _a1 error) *mockChatRoomRegistry_ChatRoomByCookie_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockChatRoomRegistry_ChatRoomByCookie_Call) RunAndReturn(run func(string) (state.ChatRoom, error)) *mockChatRoomRegistry_ChatRoomByCookie_Call { + _c.Call.Return(run) + return _c +} + +// ChatRoomByName provides a mock function with given fields: exchange, name +func (_m *mockChatRoomRegistry) ChatRoomByName(exchange uint16, name string) (state.ChatRoom, error) { + ret := _m.Called(exchange, name) + + if len(ret) == 0 { + panic("no return value specified for ChatRoomByName") + } + + var r0 state.ChatRoom + var r1 error + if rf, ok := ret.Get(0).(func(uint16, string) (state.ChatRoom, error)); ok { + return rf(exchange, name) + } + if rf, ok := ret.Get(0).(func(uint16, string) state.ChatRoom); ok { + r0 = rf(exchange, name) + } else { + r0 = ret.Get(0).(state.ChatRoom) + } + + if rf, ok := ret.Get(1).(func(uint16, string) error); ok { + r1 = rf(exchange, name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockChatRoomRegistry_ChatRoomByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChatRoomByName' +type mockChatRoomRegistry_ChatRoomByName_Call struct { + *mock.Call +} + +// ChatRoomByName is a helper method to define mock.On call +// - exchange uint16 +// - name string +func (_e *mockChatRoomRegistry_Expecter) ChatRoomByName(exchange interface{}, name interface{}) *mockChatRoomRegistry_ChatRoomByName_Call { + return &mockChatRoomRegistry_ChatRoomByName_Call{Call: _e.mock.On("ChatRoomByName", exchange, name)} +} + +func (_c *mockChatRoomRegistry_ChatRoomByName_Call) Run(run func(exchange uint16, name string)) *mockChatRoomRegistry_ChatRoomByName_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(uint16), args[1].(string)) + }) + return _c +} + +func (_c *mockChatRoomRegistry_ChatRoomByName_Call) Return(_a0 state.ChatRoom, _a1 error) *mockChatRoomRegistry_ChatRoomByName_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockChatRoomRegistry_ChatRoomByName_Call) RunAndReturn(run func(uint16, string) (state.ChatRoom, error)) *mockChatRoomRegistry_ChatRoomByName_Call { + _c.Call.Return(run) + return _c +} + +// CreateChatRoom provides a mock function with given fields: chatRoom +func (_m *mockChatRoomRegistry) CreateChatRoom(chatRoom state.ChatRoom) error { + ret := _m.Called(chatRoom) + + if len(ret) == 0 { + panic("no return value specified for CreateChatRoom") + } + + var r0 error + if rf, ok := ret.Get(0).(func(state.ChatRoom) error); ok { + r0 = rf(chatRoom) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockChatRoomRegistry_CreateChatRoom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateChatRoom' +type mockChatRoomRegistry_CreateChatRoom_Call struct { + *mock.Call +} + +// CreateChatRoom is a helper method to define mock.On call +// - chatRoom state.ChatRoom +func (_e *mockChatRoomRegistry_Expecter) CreateChatRoom(chatRoom interface{}) *mockChatRoomRegistry_CreateChatRoom_Call { + return &mockChatRoomRegistry_CreateChatRoom_Call{Call: _e.mock.On("CreateChatRoom", chatRoom)} +} + +func (_c *mockChatRoomRegistry_CreateChatRoom_Call) Run(run func(chatRoom state.ChatRoom)) *mockChatRoomRegistry_CreateChatRoom_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(state.ChatRoom)) + }) + return _c +} + +func (_c *mockChatRoomRegistry_CreateChatRoom_Call) Return(_a0 error) *mockChatRoomRegistry_CreateChatRoom_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockChatRoomRegistry_CreateChatRoom_Call) RunAndReturn(run func(state.ChatRoom) error) *mockChatRoomRegistry_CreateChatRoom_Call { + _c.Call.Return(run) + return _c +} + +// newMockChatRoomRegistry creates a new instance of mockChatRoomRegistry. 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 newMockChatRoomRegistry(t interface { + mock.TestingT + Cleanup(func()) +}) *mockChatRoomRegistry { + mock := &mockChatRoomRegistry{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/foodgroup/mock_chat_session_registry_test.go b/foodgroup/mock_chat_session_registry_test.go new file mode 100644 index 00000000..8be82e48 --- /dev/null +++ b/foodgroup/mock_chat_session_registry_test.go @@ -0,0 +1,117 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package foodgroup + +import ( + state "github.com/mk6i/retro-aim-server/state" + mock "github.com/stretchr/testify/mock" +) + +// mockChatSessionRegistry is an autogenerated mock type for the ChatSessionRegistry type +type mockChatSessionRegistry struct { + mock.Mock +} + +type mockChatSessionRegistry_Expecter struct { + mock *mock.Mock +} + +func (_m *mockChatSessionRegistry) EXPECT() *mockChatSessionRegistry_Expecter { + return &mockChatSessionRegistry_Expecter{mock: &_m.Mock} +} + +// AddSession provides a mock function with given fields: chatCookie, screenName +func (_m *mockChatSessionRegistry) AddSession(chatCookie string, screenName state.DisplayScreenName) *state.Session { + ret := _m.Called(chatCookie, screenName) + + if len(ret) == 0 { + panic("no return value specified for AddSession") + } + + var r0 *state.Session + if rf, ok := ret.Get(0).(func(string, state.DisplayScreenName) *state.Session); ok { + r0 = rf(chatCookie, screenName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*state.Session) + } + } + + return r0 +} + +// mockChatSessionRegistry_AddSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddSession' +type mockChatSessionRegistry_AddSession_Call struct { + *mock.Call +} + +// AddSession is a helper method to define mock.On call +// - chatCookie string +// - screenName state.DisplayScreenName +func (_e *mockChatSessionRegistry_Expecter) AddSession(chatCookie interface{}, screenName interface{}) *mockChatSessionRegistry_AddSession_Call { + return &mockChatSessionRegistry_AddSession_Call{Call: _e.mock.On("AddSession", chatCookie, screenName)} +} + +func (_c *mockChatSessionRegistry_AddSession_Call) Run(run func(chatCookie string, screenName state.DisplayScreenName)) *mockChatSessionRegistry_AddSession_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(state.DisplayScreenName)) + }) + return _c +} + +func (_c *mockChatSessionRegistry_AddSession_Call) Return(_a0 *state.Session) *mockChatSessionRegistry_AddSession_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockChatSessionRegistry_AddSession_Call) RunAndReturn(run func(string, state.DisplayScreenName) *state.Session) *mockChatSessionRegistry_AddSession_Call { + _c.Call.Return(run) + return _c +} + +// RemoveSession provides a mock function with given fields: sess +func (_m *mockChatSessionRegistry) RemoveSession(sess *state.Session) { + _m.Called(sess) +} + +// mockChatSessionRegistry_RemoveSession_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveSession' +type mockChatSessionRegistry_RemoveSession_Call struct { + *mock.Call +} + +// RemoveSession is a helper method to define mock.On call +// - sess *state.Session +func (_e *mockChatSessionRegistry_Expecter) RemoveSession(sess interface{}) *mockChatSessionRegistry_RemoveSession_Call { + return &mockChatSessionRegistry_RemoveSession_Call{Call: _e.mock.On("RemoveSession", sess)} +} + +func (_c *mockChatSessionRegistry_RemoveSession_Call) Run(run func(sess *state.Session)) *mockChatSessionRegistry_RemoveSession_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(*state.Session)) + }) + return _c +} + +func (_c *mockChatSessionRegistry_RemoveSession_Call) Return() *mockChatSessionRegistry_RemoveSession_Call { + _c.Call.Return() + return _c +} + +func (_c *mockChatSessionRegistry_RemoveSession_Call) RunAndReturn(run func(*state.Session)) *mockChatSessionRegistry_RemoveSession_Call { + _c.Call.Return(run) + return _c +} + +// newMockChatSessionRegistry creates a new instance of mockChatSessionRegistry. 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 newMockChatSessionRegistry(t interface { + mock.TestingT + Cleanup(func()) +}) *mockChatSessionRegistry { + mock := &mockChatSessionRegistry{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/foodgroup/oservice.go b/foodgroup/oservice.go index 9f3107c3..ae97bb2b 100644 --- a/foodgroup/oservice.go +++ b/foodgroup/oservice.go @@ -513,11 +513,11 @@ func NewOServiceServiceForBOS( legacyBuddyListManager LegacyBuddyListManager, logger *slog.Logger, cookieIssuer CookieBaker, - cr *state.ChatRegistry, feedbagManager FeedbagManager, + chatRoomManager ChatRoomRegistry, ) *OServiceServiceForBOS { return &OServiceServiceForBOS{ - chatRegistry: cr, + chatRoomManager: chatRoomManager, cookieIssuer: cookieIssuer, legacyBuddyListManager: legacyBuddyListManager, messageRelayer: messageRelayer, @@ -544,7 +544,7 @@ func NewOServiceServiceForBOS( // running on the BOS server. type OServiceServiceForBOS struct { OServiceService - chatRegistry *state.ChatRegistry + chatRoomManager ChatRoomRegistry cookieIssuer CookieBaker legacyBuddyListManager LegacyBuddyListManager messageRelayer MessageRelayer @@ -553,8 +553,8 @@ type OServiceServiceForBOS struct { // chatLoginCookie represents credentials used to authenticate a user chat // session. type chatLoginCookie struct { - ChatCookie string `len_prefix:"uint8"` - ScreenName string `len_prefix:"uint8"` + ChatCookie string `len_prefix:"uint8"` + ScreenName state.DisplayScreenName `len_prefix:"uint8"` } // ServiceRequest handles service discovery, providing a host name and metadata @@ -641,14 +641,14 @@ func (s OServiceServiceForBOS) ServiceRequest(ctx context.Context, sess *state.S return wire.SNACMessage{}, err } - room, _, err := s.chatRegistry.Retrieve(roomSNAC.Cookie) + room, err := s.chatRoomManager.ChatRoomByCookie(roomSNAC.Cookie) if err != nil { return wire.SNACMessage{}, fmt.Errorf("unable to retrieve room info: %w", err) } loginCookie := chatLoginCookie{ ChatCookie: room.Cookie, - ScreenName: sess.IdentScreenName().String(), + ScreenName: sess.DisplayScreenName(), } buf := &bytes.Buffer{} if err := wire.Marshal(loginCookie, buf); err != nil { @@ -720,13 +720,13 @@ func (s OServiceServiceForBOS) ClientOnline(ctx context.Context, _ wire.SNAC_0x0 func NewOServiceServiceForChat( cfg config.Config, logger *slog.Logger, - cr *state.ChatRegistry, messageRelayer MessageRelayer, legacyBuddyListManager LegacyBuddyListManager, feedbagManager FeedbagManager, + chatRoomManager ChatRoomRegistry, + chatMessageRelayer ChatMessageRelayer, ) *OServiceServiceForChat { return &OServiceServiceForChat{ - chatRegistry: cr, OServiceService: OServiceService{ buddyUpdateBroadcaster: NewBuddyService(messageRelayer, feedbagManager, legacyBuddyListManager), cfg: cfg, @@ -736,6 +736,8 @@ func NewOServiceServiceForChat( wire.Chat, }, }, + chatRoomManager: chatRoomManager, + chatMessageRelayer: chatMessageRelayer, } } @@ -743,7 +745,8 @@ func NewOServiceServiceForChat( // running on the Chat server. type OServiceServiceForChat struct { OServiceService - chatRegistry *state.ChatRegistry + chatRoomManager ChatRoomRegistry + chatMessageRelayer ChatMessageRelayer } // ClientOnline runs when the current user is ready to join the chat. @@ -752,13 +755,13 @@ type OServiceServiceForChat struct { // - Announce current user's arrival to other chat room participants // - Send current user the chat room participant list func (s OServiceServiceForChat) ClientOnline(ctx context.Context, _ wire.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error { - room, chatSessMgr, err := s.chatRegistry.Retrieve(sess.ChatRoomCookie()) + room, err := s.chatRoomManager.ChatRoomByCookie(sess.ChatRoomCookie()) if err != nil { - return err + return fmt.Errorf("error getting chat room: %w", err) } - sendChatRoomInfoUpdate(ctx, sess, chatSessMgr.(ChatMessageRelayer), room) - alertUserJoined(ctx, sess, chatSessMgr.(ChatMessageRelayer)) - setOnlineChatUsers(ctx, sess, chatSessMgr.(ChatMessageRelayer)) + sendChatRoomInfoUpdate(ctx, sess, s.chatMessageRelayer, room) + alertUserJoined(ctx, sess, s.chatMessageRelayer) + setOnlineChatUsers(ctx, sess, s.chatMessageRelayer) return nil } diff --git a/foodgroup/oservice_test.go b/foodgroup/oservice_test.go index 7078e3f9..b5f9397f 100644 --- a/foodgroup/oservice_test.go +++ b/foodgroup/oservice_test.go @@ -22,8 +22,6 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) { name string // config is the application config cfg config.Config - // chatRoom is the chat room the user connects to - chatRoom *state.ChatRoom // userSession is the session of the user requesting the chat service // info userSession *state.Session @@ -150,14 +148,6 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) { OSCARHost: "127.0.0.1", ChatPort: "1234", }, - chatRoom: &state.ChatRoom{ - CreateTime: time.UnixMilli(0), - DetailLevel: 4, - Exchange: 8, - Cookie: "the-chat-cookie", - InstanceNumber: 16, - Name: "my new chat", - }, userSession: newTestSession("user_screen_name"), inputSNAC: wire.SNACMessage{ Frame: wire.SNACFrame{ @@ -186,7 +176,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) { TLVRestBlock: wire.TLVRestBlock{ TLVList: wire.TLVList{ wire.NewTLV(wire.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"), - wire.NewTLV(wire.OServiceTLVTagsLoginCookie, []byte("the-cookie")), + wire.NewTLV(wire.OServiceTLVTagsLoginCookie, []byte("the-auth-cookie")), wire.NewTLV(wire.OServiceTLVTagsGroupID, wire.Chat), wire.NewTLV(wire.OServiceTLVTagsSSLCertName, ""), wire.NewTLV(wire.OServiceTLVTagsSSLState, uint8(0x00)), @@ -195,13 +185,28 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) { }, }, mockParams: mockParams{ + chatRoomRegistryParams: chatRoomRegistryParams{ + chatRoomByCookieParams: chatRoomByCookieParams{ + { + cookie: "the-chat-cookie", + room: state.ChatRoom{ + CreateTime: time.UnixMilli(0), + DetailLevel: 4, + Exchange: 8, + Cookie: "the-chat-cookie", + InstanceNumber: 16, + Name: "my new chat", + }, + }, + }, + }, cookieIssuerParams: cookieIssuerParams{ { data: []byte{ 0x0F, 't', 'h', 'e', '-', 'c', 'h', 'a', 't', '-', 'c', 'o', 'o', 'k', 'i', 'e', 0x10, 'u', 's', 'e', 'r', '_', 's', 'c', 'r', 'e', 'e', 'n', '_', 'n', 'a', 'm', 'e', }, - cookie: []byte("the-cookie"), + cookie: []byte("the-auth-cookie"), }, }, }, @@ -212,7 +217,6 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) { OSCARHost: "127.0.0.1", ChatPort: "1234", }, - chatRoom: nil, userSession: newTestSession("user_screen_name"), inputSNAC: wire.SNACMessage{ Frame: wire.SNACFrame{ @@ -231,6 +235,16 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) { }, }, }, + mockParams: mockParams{ + chatRoomRegistryParams: chatRoomRegistryParams{ + chatRoomByCookieParams: chatRoomByCookieParams{ + { + cookie: "the-chat-cookie", + err: state.ErrChatRoomNotFound, + }, + }, + }, + }, expectErr: state.ErrChatRoomNotFound, }, } @@ -240,15 +254,11 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) { // // initialize dependencies // - sessionManager := newMockSessionManager(t) - chatRegistry := state.NewChatRegistry() - chatSess := &state.Session{} - if tc.chatRoom != nil { - sessionManager.EXPECT(). - AddSession(tc.userSession.IdentScreenName()). - Return(chatSess). - Maybe() - chatRegistry.Register(*tc.chatRoom, sessionManager) + chatRoomManager := newMockChatRoomRegistry(t) + for _, params := range tc.mockParams.chatRoomByCookieParams { + chatRoomManager.EXPECT(). + ChatRoomByCookie(params.cookie). + Return(params.room, params.err) } cookieIssuer := newMockCookieBaker(t) for _, params := range tc.mockParams.cookieIssuerParams { @@ -259,7 +269,7 @@ func TestOServiceServiceForBOS_ServiceRequest(t *testing.T) { // // send input SNAC // - svc := NewOServiceServiceForBOS(tc.cfg, nil, nil, slog.Default(), cookieIssuer, chatRegistry, nil) + svc := NewOServiceServiceForBOS(tc.cfg, nil, nil, slog.Default(), cookieIssuer, nil, chatRoomManager) outputSNAC, err := svc.ServiceRequest(nil, tc.userSession, tc.inputSNAC.Frame, tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest)) @@ -1364,7 +1374,7 @@ func TestOServiceServiceForBOS_OServiceHostOnline(t *testing.T) { } func TestOServiceServiceForChat_OServiceHostOnline(t *testing.T) { - svc := NewOServiceServiceForChat(config.Config{}, slog.Default(), nil, nil, nil, nil) + svc := NewOServiceServiceForChat(config.Config{}, slog.Default(), nil, nil, nil, nil, nil) want := wire.SNACMessage{ Frame: wire.SNACFrame{ @@ -1660,16 +1670,6 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) { Name: "the-chat-room", } - type participantsParams []*state.Session - type broadcastExcept []struct { - sess *state.Session - message wire.SNACMessage - } - type sendToScreenNameParams []struct { - screenName state.IdentScreenName - message wire.SNACMessage - } - tests := []struct { // name is the name of the test name string @@ -1677,16 +1677,8 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) { joiningChatter *state.Session // bodyIn is the SNAC body sent from the arriving user's client to the // server - bodyIn wire.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 - // relayToScreenNameParams contains params for sending chat room - // metadata and chat participant list to joining user - sendToScreenNameParams sendToScreenNameParams - wantErr error + bodyIn wire.SNAC_0x01_0x02_OServiceClientOnline + wantErr error // mockParams is the list of params sent to mocks that satisfy this // method's dependencies mockParams mockParams @@ -1695,56 +1687,82 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) { name: "upon joining, send chat room metadata and participant list to joining user; alert arrival to existing participants", joiningChatter: chatter1, bodyIn: wire.SNAC_0x01_0x02_OServiceClientOnline{}, - broadcastExcept: broadcastExcept{ - { - sess: chatter1, - message: wire.SNACMessage{ - Frame: wire.SNACFrame{ - FoodGroup: wire.Chat, - SubGroup: wire.ChatUsersJoined, + mockParams: mockParams{ + chatMessageRelayerParams: chatMessageRelayerParams{ + chatRelayToAllExceptParams: chatRelayToAllExceptParams{ + { + screenName: state.NewIdentScreenName("chatter-1"), + cookie: "the-cookie", + message: wire.SNACMessage{ + Frame: wire.SNACFrame{ + FoodGroup: wire.Chat, + SubGroup: wire.ChatUsersJoined, + }, + Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{ + Users: []wire.TLVUserInfo{ + chatter1.TLVUserInfo(), + }, + }, + }, }, - Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{ - Users: []wire.TLVUserInfo{ - chatter1.TLVUserInfo(), + }, + chatAllSessionsParams: chatAllSessionsParams{ + { + cookie: "the-cookie", + sessions: []*state.Session{ + chatter1, + chatter2, }, }, }, - }, - }, - participantsParams: participantsParams{ - chatter1, - chatter2, - }, - sendToScreenNameParams: sendToScreenNameParams{ - { - screenName: chatter1.IdentScreenName(), - message: wire.SNACMessage{ - Frame: wire.SNACFrame{ - FoodGroup: wire.Chat, - SubGroup: wire.ChatRoomInfoUpdate, + chatRelayToScreenNameParams: chatRelayToScreenNameParams{ + { + cookie: "the-cookie", + screenName: chatter1.IdentScreenName(), + message: wire.SNACMessage{ + Frame: wire.SNACFrame{ + FoodGroup: wire.Chat, + SubGroup: wire.ChatRoomInfoUpdate, + }, + Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ + Exchange: chatRoom.Exchange, + Cookie: chatRoom.Cookie, + InstanceNumber: chatRoom.InstanceNumber, + DetailLevel: chatRoom.DetailLevel, + TLVBlock: wire.TLVBlock{ + TLVList: chatRoom.TLVList(), + }, + }, + }, }, - Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{ - Exchange: chatRoom.Exchange, - Cookie: chatRoom.Cookie, - InstanceNumber: chatRoom.InstanceNumber, - DetailLevel: chatRoom.DetailLevel, - TLVBlock: wire.TLVBlock{ - TLVList: chatRoom.TLVList(), + { + cookie: "the-cookie", + screenName: chatter1.IdentScreenName(), + message: wire.SNACMessage{ + Frame: wire.SNACFrame{ + FoodGroup: wire.Chat, + SubGroup: wire.ChatUsersJoined, + }, + Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{ + Users: []wire.TLVUserInfo{ + chatter1.TLVUserInfo(), + chatter2.TLVUserInfo(), + }, + }, }, }, }, }, - { - screenName: chatter1.IdentScreenName(), - message: wire.SNACMessage{ - Frame: wire.SNACFrame{ - FoodGroup: wire.Chat, - SubGroup: wire.ChatUsersJoined, - }, - Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{ - Users: []wire.TLVUserInfo{ - chatter1.TLVUserInfo(), - chatter2.TLVUserInfo(), + chatRoomRegistryParams: chatRoomRegistryParams{ + chatRoomByCookieParams: chatRoomByCookieParams{ + { + cookie: "the-cookie", + room: state.ChatRoom{ + Cookie: "the-cookie", + DetailLevel: 1, + Exchange: 2, + InstanceNumber: 3, + Name: "the-chat-room", }, }, }, @@ -1754,26 +1772,28 @@ func TestOServiceServiceForChat_ClientOnline(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + chatRoomManager := newMockChatRoomRegistry(t) + for _, params := range tt.mockParams.chatRoomByCookieParams { + chatRoomManager.EXPECT(). + ChatRoomByCookie(params.cookie). + Return(params.room, params.err) + } chatMessageRelayer := newMockChatMessageRelayer(t) - for _, params := range tt.broadcastExcept { + for _, params := range tt.mockParams.chatRelayToAllExceptParams { chatMessageRelayer.EXPECT(). - RelayToAllExcept(mock.Anything, params.sess, params.message). - Maybe() + RelayToAllExcept(mock.Anything, params.cookie, params.screenName, params.message) } - chatMessageRelayer.EXPECT(). - AllSessions(). - Return(tt.participantsParams). - Maybe() - for _, params := range tt.sendToScreenNameParams { + for _, params := range tt.mockParams.chatAllSessionsParams { chatMessageRelayer.EXPECT(). - RelayToScreenName(mock.Anything, params.screenName, params.message). - Maybe() + AllSessions(params.cookie). + Return(params.sessions) + } + for _, params := range tt.mockParams.chatRelayToScreenNameParams { + chatMessageRelayer.EXPECT(). + RelayToScreenName(mock.Anything, params.cookie, params.screenName, params.message) } - chatRegistry := state.NewChatRegistry() - chatRegistry.Register(chatRoom, chatMessageRelayer) - - svc := NewOServiceServiceForChat(config.Config{}, slog.Default(), chatRegistry, nil, nil, nil) + svc := NewOServiceServiceForChat(config.Config{}, slog.Default(), nil, nil, nil, chatRoomManager, chatMessageRelayer) haveErr := svc.ClientOnline(nil, wire.SNAC_0x01_0x02_OServiceClientOnline{}, tt.joiningChatter) assert.ErrorIs(t, tt.wantErr, haveErr) diff --git a/foodgroup/test_helpers.go b/foodgroup/test_helpers.go index dbec4768..29ef4bc8 100644 --- a/foodgroup/test_helpers.go +++ b/foodgroup/test_helpers.go @@ -3,6 +3,8 @@ package foodgroup import ( "time" + "github.com/stretchr/testify/mock" + "github.com/mk6i/retro-aim-server/state" "github.com/mk6i/retro-aim-server/wire" ) @@ -12,7 +14,6 @@ import ( type mockParams struct { bartManagerParams chatMessageRelayerParams - chatRegistryParams feedbagManagerParams legacyBuddyListManagerParams messageRelayerParams @@ -21,6 +22,7 @@ type mockParams struct { userManagerParams cookieIssuerParams buddyBroadcasterParams + chatRoomRegistryParams } // bartManagerParams is a helper struct that contains mock parameters for @@ -44,21 +46,6 @@ type bartManagerUpsertParams []struct { payload []byte } -// chatRegistryParams is a helper struct that contains mock parameters for -// ChatRegistry methods -type chatRegistryParams struct { - chatRegistryRetrieveParams -} - -// chatRegistryRetrieveParams is the list of parameters passed at the mock -// ChatRegistry.Retrieve call site -type chatRegistryRetrieveParams struct { - cookie string - retChatRoom state.ChatRoom - retChatSessMgr any - err error -} - // userManagerParams is a helper struct that contains mock parameters for // UserManager methods type userManagerParams struct { @@ -99,7 +86,7 @@ type addSessionParams []struct { // removeSessionParams is the list of parameters passed at the mock // SessionManager.RemoveSession call site type removeSessionParams []struct { - sess *state.Session + screenName state.IdentScreenName } // emptyParams is the list of parameters passed at the mock @@ -197,6 +184,7 @@ type relayToScreenNamesParams []struct { // relayToScreenNameParams is the list of parameters passed at the mock // MessageRelayer.RelayToScreenName call site type relayToScreenNameParams []struct { + cookie string screenName state.IdentScreenName message wire.SNACMessage } @@ -226,14 +214,35 @@ type setProfileParams []struct { // chatMessageRelayerParams is a helper struct that contains mock parameters // for ChatMessageRelayer methods type chatMessageRelayerParams struct { - broadcastExceptParams + chatAllSessionsParams + chatRelayToAllExceptParams + chatRelayToScreenNameParams +} + +// chatAllSessionsParams is the list of parameters passed at the mock +// ChatMessageRelayer.AllSessions call site +type chatAllSessionsParams []struct { + cookie string + sessions []*state.Session + err error } -// broadcastExceptParams is the list of parameters passed at the mock +// chatRelayToAllExceptParams is the list of parameters passed at the mock // ChatMessageRelayer.RelayToAllExcept call site -type broadcastExceptParams []struct { - except *state.Session - message wire.SNACMessage +type chatRelayToAllExceptParams []struct { + cookie string + screenName state.IdentScreenName + message wire.SNACMessage + err error +} + +// chatRelayToScreenNameParams is the list of parameters passed at the mock +// ChatMessageRelayer.RelayToScreenName call site +type chatRelayToScreenNameParams []struct { + cookie string + screenName state.IdentScreenName + message wire.SNACMessage + err error } // legacyBuddyListManagerParams is a helper struct that contains mock @@ -327,6 +336,40 @@ type unicastBuddyDepartedParams []struct { err error } +// chatRoomRegistryParams is a helper struct that contains mock parameters for +// ChatRoomRegistry methods +type chatRoomRegistryParams struct { + chatRoomByCookieParams + chatRoomByNameParams + createChatRoomParams +} + +// chatRoomByCookieParams is the list of parameters passed at the mock +// ChatRoomRegistry.ChatRoomByCookie call site +type chatRoomByCookieParams []struct { + cookie string + room state.ChatRoom + err error +} + +// chatRoomByCookieParams is the list of parameters passed at the mock +// ChatRoomRegistry.ChatRoomByName call site +type chatRoomByNameParams []struct { + exchange uint16 + name string + room state.ChatRoom + err error +} + +// createChatRoomParams is the list of parameters passed at the mock +// ChatRoomRegistry.CreateChatRoom call site +type createChatRoomParams []struct { + exchange uint16 + name string + room state.ChatRoom + err error +} + // sessOptWarning sets a warning level on the session object func sessOptWarning(level uint16) func(session *state.Session) { return func(session *state.Session) { @@ -402,3 +445,10 @@ func userInfoWithBARTIcon(sess *state.Session, bid wire.BARTID) wire.TLVUserInfo info.Append(wire.NewTLV(wire.OServiceUserInfoBARTInfo, bid)) return info } + +// matchSession matches a mock call based session ident screen name. +func matchSession(mustMatch state.IdentScreenName) interface{} { + return mock.MatchedBy(func(s *state.Session) bool { + return mustMatch == s.IdentScreenName() + }) +} diff --git a/foodgroup/types.go b/foodgroup/types.go index 0c4e0fae..1e8d94ff 100644 --- a/foodgroup/types.go +++ b/foodgroup/types.go @@ -96,16 +96,53 @@ type MessageRelayer interface { RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage) } +// ChatSessionRegistry defines the interface for adding and removing chat +// sessions. +type ChatSessionRegistry interface { + // AddSession adds a session to the chat session manager. The chatCookie + // param identifies the chat room to which screenName is added. It returns + // the newly created session instance registered in the chat session + // manager. + AddSession(chatCookie string, screenName state.DisplayScreenName) *state.Session + + // RemoveSession removes a session from the chat session manager. + RemoveSession(sess *state.Session) +} + +// ChatMessageRelayer defines the interface for sending messages to chat room +// participants. type ChatMessageRelayer interface { - MessageRelayer - RelayToAllExcept(ctx context.Context, except *state.Session, msg wire.SNACMessage) - AllSessions() []*state.Session + // AllSessions returns all chat room participants. Returns + // ErrChatRoomNotFound if the room does not exist. + AllSessions(chatCookie string) []*state.Session + + // RelayToAllExcept sends a message to all chat room participants except + // for the participant with a particular screen name. Returns + // ErrChatRoomNotFound if the room does not exist for cookie. + RelayToAllExcept(ctx context.Context, chatCookie string, except state.IdentScreenName, msg wire.SNACMessage) + + // RelayToScreenName sends a message to a chat room user. Returns + // ErrChatRoomNotFound if the room does not exist for cookie. + RelayToScreenName(ctx context.Context, chatCookie string, recipient state.IdentScreenName, msg wire.SNACMessage) } -type ChatRegistry interface { - Register(room state.ChatRoom, sessionManager any) - Retrieve(cookie string) (state.ChatRoom, any, error) - Remove(cookie string) +// ChatRoomRegistry defines the interface for storing and retrieving chat +// rooms in a persistent store. The persistent store has two purposes: +// - Remember user-created chat rooms (exchange 4) so that clients can +// reconnect to the rooms following server restarts. +// - Keep track of public chat room created by the server operator (exchange +// 5). User's can only join public chat rooms that exist in the room registry. +type ChatRoomRegistry interface { + // ChatRoomByCookie looks up a chat room by exchange. Returns + // ErrChatRoomNotFound if the room does not exist for cookie. + ChatRoomByCookie(chatCookie string) (state.ChatRoom, error) + + // ChatRoomByName looks up a chat room by exchange and name. Returns + // ErrChatRoomNotFound if the room does not exist for exchange and name. + ChatRoomByName(exchange uint16, name string) (state.ChatRoom, error) + + // CreateChatRoom creates a new chat room. + CreateChatRoom(chatRoom state.ChatRoom) error } type BARTManager interface { diff --git a/server/http/mgmt_api.go b/server/http/mgmt_api.go index 9c373c1b..0e9326fb 100644 --- a/server/http/mgmt_api.go +++ b/server/http/mgmt_api.go @@ -18,33 +18,16 @@ import ( "github.com/mk6i/retro-aim-server/wire" ) -type userWithPassword struct { - state.User - Password string `json:"password,omitempty"` -} - -type userSession struct { - ScreenName string `json:"screen_name"` -} - -type onlineUsers struct { - Count int `json:"count"` - Sessions []userSession `json:"sessions"` -} +func StartManagementAPI( + cfg config.Config, + userManager UserManager, + sessionRetriever SessionRetriever, + chatRoomRetriever ChatRoomRetriever, + chatRoomCreator ChatRoomCreator, + chatSessionRetriever ChatSessionRetriever, + logger *slog.Logger, +) { -type UserManager interface { - AllUsers() ([]state.User, error) - DeleteUser(screenName state.IdentScreenName) error - InsertUser(u state.User) error - SetUserPassword(u state.User) error - User(screenName state.IdentScreenName) (*state.User, error) -} - -type SessionRetriever interface { - AllSessions() []*state.Session -} - -func StartManagementAPI(cfg config.Config, userManager UserManager, sessionRetriever SessionRetriever, logger *slog.Logger) { mux := http.NewServeMux() mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { userHandler(w, r, userManager, uuid.New, logger) @@ -58,6 +41,12 @@ func StartManagementAPI(cfg config.Config, userManager UserManager, sessionRetri mux.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) { sessionHandler(w, r, sessionRetriever) }) + mux.HandleFunc("/chat/room/public", func(w http.ResponseWriter, r *http.Request) { + publicChatHandler(w, r, chatRoomRetriever, chatRoomCreator, chatSessionRetriever, state.NewChatRoom, logger) + }) + mux.HandleFunc("/chat/room/private", func(w http.ResponseWriter, r *http.Request) { + privateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger) + }) addr := net.JoinHostPort(cfg.ApiHost, cfg.ApiPort) logger.Info("starting management API server", "addr", addr) @@ -275,3 +264,127 @@ func loginHandler(w http.ResponseWriter, r *http.Request, userManager UserManage w.WriteHeader(http.StatusOK) w.Write([]byte("200 OK: Successfully Authenticated\n")) } + +func publicChatHandler(w http.ResponseWriter, r *http.Request, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatSessionRetriever ChatSessionRetriever, newChatRoom func() state.ChatRoom, logger *slog.Logger) { + switch r.Method { + case http.MethodGet: + getPublicChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger) + case http.MethodPost: + postPublicChatHandler(w, r, chatRoomCreator, newChatRoom, logger) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func privateChatHandler(w http.ResponseWriter, r *http.Request, chatRoomRetriever ChatRoomRetriever, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) { + switch r.Method { + case http.MethodGet: + getPrivateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// getPublicChatHandler handles the GET /chat/room/public endpoint. +func getPublicChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetriever ChatRoomRetriever, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) { + w.Header().Set("Content-Type", "application/json") + rooms, err := chatRoomRetriever.AllChatRooms(state.PublicExchange) + if err != nil { + logger.Error("error in GET /chat/rooms/public", "err", err.Error()) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + out := make([]chatRoom, len(rooms)) + for i, room := range rooms { + sessions := chatSessionRetriever.AllSessions(room.Cookie) + cr := chatRoom{ + CreateTime: room.CreateTime, + Name: room.Name, + Participants: make([]userHandle, 0, len(sessions)), + URL: room.URL().String(), + } + for _, sess := range sessions { + cr.Participants = append(cr.Participants, userHandle{ + ID: sess.IdentScreenName().String(), + ScreenName: sess.DisplayScreenName().String(), + }) + } + + out[i] = cr + } + + if err := json.NewEncoder(w).Encode(out); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// postPublicChatHandler handles the POST /chat/room/public endpoint. +func postPublicChatHandler(w http.ResponseWriter, r *http.Request, chatRoomCreator ChatRoomCreator, newChatRoom func() state.ChatRoom, logger *slog.Logger) { + input := chatRoomCreate{} + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + http.Error(w, "invalid input", http.StatusBadRequest) + return + } + + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 50 { + http.Error(w, "chat room name must be between 1 and 50 characters", http.StatusBadRequest) + return + } + + cr := newChatRoom() + cr.Name = input.Name + cr.Exchange = state.PublicExchange + + err := chatRoomCreator.CreateChatRoom(cr) + switch { + case errors.Is(err, state.ErrDupChatRoom): + http.Error(w, "Chat room already exists.", http.StatusConflict) + return + case err != nil: + logger.Error("error inserting chat room POST /chat/room/public", "err", err.Error()) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + fmt.Fprintln(w, "Chat room created successfully.") +} + +// getPrivateChatHandler handles the GET /chat/room/private endpoint. +func getPrivateChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetriever ChatRoomRetriever, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) { + w.Header().Set("Content-Type", "application/json") + rooms, err := chatRoomRetriever.AllChatRooms(state.PrivateExchange) + if err != nil { + logger.Error("error in GET /chat/rooms/private", "err", err.Error()) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + out := make([]chatRoom, len(rooms)) + for i, room := range rooms { + sessions := chatSessionRetriever.AllSessions(room.Cookie) + cr := chatRoom{ + CreateTime: room.CreateTime, + CreatorID: room.Creator.String(), + Name: room.Name, + Participants: make([]userHandle, 0, len(sessions)), + URL: room.URL().String(), + } + for _, sess := range sessions { + cr.Participants = append(cr.Participants, userHandle{ + ID: sess.IdentScreenName().String(), + ScreenName: sess.DisplayScreenName().String(), + }) + } + + out[i] = cr + } + + if err := json.NewEncoder(w).Encode(out); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/server/http/mgmt_api_test.go b/server/http/mgmt_api_test.go index 895518bd..b953d4ed 100644 --- a/server/http/mgmt_api_test.go +++ b/server/http/mgmt_api_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -424,3 +425,257 @@ func TestUserHandler_DisallowedMethod(t *testing.T) { t.Errorf("want '%s', got '%s'", wantBody, responseRecorder.Body) } } + +func TestPublicChatHandler_GET(t *testing.T) { + fnNewSess := func(screenName string) *state.Session { + sess := state.NewSession() + sess.SetIdentScreenName(state.NewIdentScreenName(screenName)) + sess.SetDisplayScreenName(state.DisplayScreenName(screenName)) + return sess + } + type allChatRoomsParams struct { + exchange uint16 + result []state.ChatRoom + err error + } + type allSessionsParams struct { + cookie string + result []*state.Session + } + + tt := []struct { + name string + allChatRoomsParams allChatRoomsParams + allSessionsParams []allSessionsParams + userHandlerErr error + want string + statusCode int + }{ + { + name: "multiple chat rooms with participants", + allChatRoomsParams: allChatRoomsParams{ + exchange: state.PublicExchange, + result: []state.ChatRoom{ + { + Cookie: "chat-room-1-cookie", + Creator: state.NewIdentScreenName("chat-room-1-creator"), + Name: "chat-room-1-name", + CreateTime: time.Date(2024, 06, 01, 1, 2, 3, 4, time.UTC), + }, + { + Cookie: "chat-room-2-cookie", + Creator: state.NewIdentScreenName("chat-room-2-creator"), + Name: "chat-room-2-name", + CreateTime: time.Date(2022, 01, 04, 6, 8, 1, 2, time.UTC), + }, + }, + }, + allSessionsParams: []allSessionsParams{ + { + cookie: "chat-room-1-cookie", + result: []*state.Session{ + fnNewSess("userA"), + fnNewSess("userB"), + }, + }, + { + cookie: "chat-room-2-cookie", + result: []*state.Session{ + fnNewSess("userC"), + fnNewSess("userD"), + }, + }, + }, + want: `[{"name":"chat-room-1-name","create_time":"2024-06-01T01:02:03.000000004Z","url":"aim:gochat?exchange=0\u0026roomname=chat-room-1-name","participants":[{"id":"usera","screen_name":"userA"},{"id":"userb","screen_name":"userB"}]},{"name":"chat-room-2-name","create_time":"2022-01-04T06:08:01.000000002Z","url":"aim:gochat?exchange=0\u0026roomname=chat-room-2-name","participants":[{"id":"userc","screen_name":"userC"},{"id":"userd","screen_name":"userD"}]}]`, + statusCode: http.StatusOK, + }, + { + name: "chat room without participants", + allChatRoomsParams: allChatRoomsParams{ + exchange: state.PublicExchange, + result: []state.ChatRoom{ + { + Cookie: "chat-room-1-cookie", + Creator: state.NewIdentScreenName("chat-room-1-creator"), + Name: "chat-room-1-name", + CreateTime: time.Date(2024, 06, 01, 1, 2, 3, 4, time.UTC), + }, + }, + }, + allSessionsParams: []allSessionsParams{ + { + cookie: "chat-room-1-cookie", + result: []*state.Session{}, + }, + }, + want: `[{"name":"chat-room-1-name","create_time":"2024-06-01T01:02:03.000000004Z","url":"aim:gochat?exchange=0\u0026roomname=chat-room-1-name","participants":[]}]`, + statusCode: http.StatusOK, + }, + { + name: "no chat rooms", + allChatRoomsParams: allChatRoomsParams{ + exchange: state.PublicExchange, + result: []state.ChatRoom{}, + }, + allSessionsParams: []allSessionsParams{}, + want: `[]`, + statusCode: http.StatusOK, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/chat/room/public", nil) + responseRecorder := httptest.NewRecorder() + + chatRoomRetriever := newMockChatRoomRetriever(t) + chatRoomRetriever.EXPECT(). + AllChatRooms(tc.allChatRoomsParams.exchange). + Return(tc.allChatRoomsParams.result, tc.allChatRoomsParams.err) + + chatSessionRetriever := newMockChatSessionRetriever(t) + for _, params := range tc.allSessionsParams { + chatSessionRetriever.EXPECT(). + AllSessions(params.cookie). + Return(params.result) + } + + getPublicChatHandler(responseRecorder, request, chatRoomRetriever, chatSessionRetriever, slog.Default()) + + if responseRecorder.Code != tc.statusCode { + t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code) + } + + if strings.TrimSpace(responseRecorder.Body.String()) != tc.want { + t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body) + } + }) + } +} + +func TestPrivateChatHandler_GET(t *testing.T) { + fnNewSess := func(screenName string) *state.Session { + sess := state.NewSession() + sess.SetIdentScreenName(state.NewIdentScreenName(screenName)) + sess.SetDisplayScreenName(state.DisplayScreenName(screenName)) + return sess + } + type allChatRoomsParams struct { + exchange uint16 + result []state.ChatRoom + err error + } + type allSessionsParams struct { + cookie string + result []*state.Session + } + + tt := []struct { + name string + allChatRoomsParams allChatRoomsParams + allSessionsParams []allSessionsParams + userHandlerErr error + want string + statusCode int + }{ + { + name: "multiple chat rooms with participants", + allChatRoomsParams: allChatRoomsParams{ + exchange: state.PrivateExchange, + result: []state.ChatRoom{ + { + Cookie: "chat-room-1-cookie", + Creator: state.NewIdentScreenName("chat-room-1-creator"), + Name: "chat-room-1-name", + CreateTime: time.Date(2024, 06, 01, 1, 2, 3, 4, time.UTC), + }, + { + Cookie: "chat-room-2-cookie", + Creator: state.NewIdentScreenName("chat-room-2-creator"), + Name: "chat-room-2-name", + CreateTime: time.Date(2022, 01, 04, 6, 8, 1, 2, time.UTC), + }, + }, + }, + allSessionsParams: []allSessionsParams{ + { + cookie: "chat-room-1-cookie", + result: []*state.Session{ + fnNewSess("userA"), + fnNewSess("userB"), + }, + }, + { + cookie: "chat-room-2-cookie", + result: []*state.Session{ + fnNewSess("userC"), + fnNewSess("userD"), + }, + }, + }, + want: `[{"name":"chat-room-1-name","create_time":"2024-06-01T01:02:03.000000004Z","creator_id":"chat-room-1-creator","url":"aim:gochat?exchange=0\u0026roomname=chat-room-1-name","participants":[{"id":"usera","screen_name":"userA"},{"id":"userb","screen_name":"userB"}]},{"name":"chat-room-2-name","create_time":"2022-01-04T06:08:01.000000002Z","creator_id":"chat-room-2-creator","url":"aim:gochat?exchange=0\u0026roomname=chat-room-2-name","participants":[{"id":"userc","screen_name":"userC"},{"id":"userd","screen_name":"userD"}]}]`, + statusCode: http.StatusOK, + }, + { + name: "chat room without participants", + allChatRoomsParams: allChatRoomsParams{ + exchange: state.PrivateExchange, + result: []state.ChatRoom{ + { + Cookie: "chat-room-1-cookie", + Creator: state.NewIdentScreenName("chat-room-1-creator"), + Name: "chat-room-1-name", + CreateTime: time.Date(2024, 06, 01, 1, 2, 3, 4, time.UTC), + }, + }, + }, + allSessionsParams: []allSessionsParams{ + { + cookie: "chat-room-1-cookie", + result: []*state.Session{}, + }, + }, + want: `[{"name":"chat-room-1-name","create_time":"2024-06-01T01:02:03.000000004Z","creator_id":"chat-room-1-creator","url":"aim:gochat?exchange=0\u0026roomname=chat-room-1-name","participants":[]}]`, + statusCode: http.StatusOK, + }, + { + name: "no chat rooms", + allChatRoomsParams: allChatRoomsParams{ + exchange: state.PrivateExchange, + result: []state.ChatRoom{}, + }, + allSessionsParams: []allSessionsParams{}, + want: `[]`, + statusCode: http.StatusOK, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/chat/room/private", nil) + responseRecorder := httptest.NewRecorder() + + chatRoomRetriever := newMockChatRoomRetriever(t) + chatRoomRetriever.EXPECT(). + AllChatRooms(tc.allChatRoomsParams.exchange). + Return(tc.allChatRoomsParams.result, tc.allChatRoomsParams.err) + + chatSessionRetriever := newMockChatSessionRetriever(t) + for _, params := range tc.allSessionsParams { + chatSessionRetriever.EXPECT(). + AllSessions(params.cookie). + Return(params.result) + } + + getPrivateChatHandler(responseRecorder, request, chatRoomRetriever, chatSessionRetriever, slog.Default()) + + if responseRecorder.Code != tc.statusCode { + t.Errorf("Want status '%d', got '%d'", tc.statusCode, responseRecorder.Code) + } + + if strings.TrimSpace(responseRecorder.Body.String()) != tc.want { + t.Errorf("Want '%s', got '%s'", tc.want, responseRecorder.Body) + } + }) + } +} diff --git a/server/http/mock_chat_room_creator_test.go b/server/http/mock_chat_room_creator_test.go new file mode 100644 index 00000000..86af104f --- /dev/null +++ b/server/http/mock_chat_room_creator_test.go @@ -0,0 +1,81 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package http + +import ( + state "github.com/mk6i/retro-aim-server/state" + mock "github.com/stretchr/testify/mock" +) + +// mockChatRoomCreator is an autogenerated mock type for the ChatRoomCreator type +type mockChatRoomCreator struct { + mock.Mock +} + +type mockChatRoomCreator_Expecter struct { + mock *mock.Mock +} + +func (_m *mockChatRoomCreator) EXPECT() *mockChatRoomCreator_Expecter { + return &mockChatRoomCreator_Expecter{mock: &_m.Mock} +} + +// CreateChatRoom provides a mock function with given fields: chatRoom +func (_m *mockChatRoomCreator) CreateChatRoom(chatRoom state.ChatRoom) error { + ret := _m.Called(chatRoom) + + if len(ret) == 0 { + panic("no return value specified for CreateChatRoom") + } + + var r0 error + if rf, ok := ret.Get(0).(func(state.ChatRoom) error); ok { + r0 = rf(chatRoom) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockChatRoomCreator_CreateChatRoom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateChatRoom' +type mockChatRoomCreator_CreateChatRoom_Call struct { + *mock.Call +} + +// CreateChatRoom is a helper method to define mock.On call +// - chatRoom state.ChatRoom +func (_e *mockChatRoomCreator_Expecter) CreateChatRoom(chatRoom interface{}) *mockChatRoomCreator_CreateChatRoom_Call { + return &mockChatRoomCreator_CreateChatRoom_Call{Call: _e.mock.On("CreateChatRoom", chatRoom)} +} + +func (_c *mockChatRoomCreator_CreateChatRoom_Call) Run(run func(chatRoom state.ChatRoom)) *mockChatRoomCreator_CreateChatRoom_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(state.ChatRoom)) + }) + return _c +} + +func (_c *mockChatRoomCreator_CreateChatRoom_Call) Return(_a0 error) *mockChatRoomCreator_CreateChatRoom_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockChatRoomCreator_CreateChatRoom_Call) RunAndReturn(run func(state.ChatRoom) error) *mockChatRoomCreator_CreateChatRoom_Call { + _c.Call.Return(run) + return _c +} + +// newMockChatRoomCreator creates a new instance of mockChatRoomCreator. 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 newMockChatRoomCreator(t interface { + mock.TestingT + Cleanup(func()) +}) *mockChatRoomCreator { + mock := &mockChatRoomCreator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/http/mock_chat_room_retriever_test.go b/server/http/mock_chat_room_retriever_test.go new file mode 100644 index 00000000..6ea33cdc --- /dev/null +++ b/server/http/mock_chat_room_retriever_test.go @@ -0,0 +1,93 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package http + +import ( + state "github.com/mk6i/retro-aim-server/state" + mock "github.com/stretchr/testify/mock" +) + +// mockChatRoomRetriever is an autogenerated mock type for the ChatRoomRetriever type +type mockChatRoomRetriever struct { + mock.Mock +} + +type mockChatRoomRetriever_Expecter struct { + mock *mock.Mock +} + +func (_m *mockChatRoomRetriever) EXPECT() *mockChatRoomRetriever_Expecter { + return &mockChatRoomRetriever_Expecter{mock: &_m.Mock} +} + +// AllChatRooms provides a mock function with given fields: exchange +func (_m *mockChatRoomRetriever) AllChatRooms(exchange uint16) ([]state.ChatRoom, error) { + ret := _m.Called(exchange) + + if len(ret) == 0 { + panic("no return value specified for AllChatRooms") + } + + var r0 []state.ChatRoom + var r1 error + if rf, ok := ret.Get(0).(func(uint16) ([]state.ChatRoom, error)); ok { + return rf(exchange) + } + if rf, ok := ret.Get(0).(func(uint16) []state.ChatRoom); ok { + r0 = rf(exchange) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]state.ChatRoom) + } + } + + if rf, ok := ret.Get(1).(func(uint16) error); ok { + r1 = rf(exchange) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockChatRoomRetriever_AllChatRooms_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllChatRooms' +type mockChatRoomRetriever_AllChatRooms_Call struct { + *mock.Call +} + +// AllChatRooms is a helper method to define mock.On call +// - exchange uint16 +func (_e *mockChatRoomRetriever_Expecter) AllChatRooms(exchange interface{}) *mockChatRoomRetriever_AllChatRooms_Call { + return &mockChatRoomRetriever_AllChatRooms_Call{Call: _e.mock.On("AllChatRooms", exchange)} +} + +func (_c *mockChatRoomRetriever_AllChatRooms_Call) Run(run func(exchange uint16)) *mockChatRoomRetriever_AllChatRooms_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(uint16)) + }) + return _c +} + +func (_c *mockChatRoomRetriever_AllChatRooms_Call) Return(_a0 []state.ChatRoom, _a1 error) *mockChatRoomRetriever_AllChatRooms_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockChatRoomRetriever_AllChatRooms_Call) RunAndReturn(run func(uint16) ([]state.ChatRoom, error)) *mockChatRoomRetriever_AllChatRooms_Call { + _c.Call.Return(run) + return _c +} + +// newMockChatRoomRetriever creates a new instance of mockChatRoomRetriever. 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 newMockChatRoomRetriever(t interface { + mock.TestingT + Cleanup(func()) +}) *mockChatRoomRetriever { + mock := &mockChatRoomRetriever{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/http/mock_chat_session_retriever_test.go b/server/http/mock_chat_session_retriever_test.go new file mode 100644 index 00000000..7e5d0653 --- /dev/null +++ b/server/http/mock_chat_session_retriever_test.go @@ -0,0 +1,83 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package http + +import ( + state "github.com/mk6i/retro-aim-server/state" + mock "github.com/stretchr/testify/mock" +) + +// mockChatSessionRetriever is an autogenerated mock type for the ChatSessionRetriever type +type mockChatSessionRetriever struct { + mock.Mock +} + +type mockChatSessionRetriever_Expecter struct { + mock *mock.Mock +} + +func (_m *mockChatSessionRetriever) EXPECT() *mockChatSessionRetriever_Expecter { + return &mockChatSessionRetriever_Expecter{mock: &_m.Mock} +} + +// AllSessions provides a mock function with given fields: cookie +func (_m *mockChatSessionRetriever) AllSessions(cookie string) []*state.Session { + ret := _m.Called(cookie) + + if len(ret) == 0 { + panic("no return value specified for AllSessions") + } + + var r0 []*state.Session + if rf, ok := ret.Get(0).(func(string) []*state.Session); ok { + r0 = rf(cookie) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*state.Session) + } + } + + return r0 +} + +// mockChatSessionRetriever_AllSessions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllSessions' +type mockChatSessionRetriever_AllSessions_Call struct { + *mock.Call +} + +// AllSessions is a helper method to define mock.On call +// - cookie string +func (_e *mockChatSessionRetriever_Expecter) AllSessions(cookie interface{}) *mockChatSessionRetriever_AllSessions_Call { + return &mockChatSessionRetriever_AllSessions_Call{Call: _e.mock.On("AllSessions", cookie)} +} + +func (_c *mockChatSessionRetriever_AllSessions_Call) Run(run func(cookie string)) *mockChatSessionRetriever_AllSessions_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *mockChatSessionRetriever_AllSessions_Call) Return(_a0 []*state.Session) *mockChatSessionRetriever_AllSessions_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockChatSessionRetriever_AllSessions_Call) RunAndReturn(run func(string) []*state.Session) *mockChatSessionRetriever_AllSessions_Call { + _c.Call.Return(run) + return _c +} + +// newMockChatSessionRetriever creates a new instance of mockChatSessionRetriever. 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 newMockChatSessionRetriever(t interface { + mock.TestingT + Cleanup(func()) +}) *mockChatSessionRetriever { + mock := &mockChatSessionRetriever{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/http/types.go b/server/http/types.go new file mode 100644 index 00000000..a1eb7b15 --- /dev/null +++ b/server/http/types.go @@ -0,0 +1,62 @@ +package http + +import ( + "time" + + "github.com/mk6i/retro-aim-server/state" +) + +type ChatRoomRetriever interface { + AllChatRooms(exchange uint16) ([]state.ChatRoom, error) +} + +type ChatRoomCreator interface { + CreateChatRoom(chatRoom state.ChatRoom) error +} + +type ChatSessionRetriever interface { + AllSessions(cookie string) []*state.Session +} + +type SessionRetriever interface { + AllSessions() []*state.Session +} + +type UserManager interface { + AllUsers() ([]state.User, error) + DeleteUser(screenName state.IdentScreenName) error + InsertUser(u state.User) error + SetUserPassword(u state.User) error + User(screenName state.IdentScreenName) (*state.User, error) +} + +type userWithPassword struct { + state.User + Password string `json:"password,omitempty"` +} + +type userSession struct { + ScreenName string `json:"screen_name"` +} + +type onlineUsers struct { + Count int `json:"count"` + Sessions []userSession `json:"sessions"` +} + +type userHandle struct { + ID string `json:"id"` + ScreenName string `json:"screen_name"` +} + +type chatRoomCreate struct { + Name string `json:"name"` +} + +type chatRoom struct { + Name string `json:"name"` + CreateTime time.Time `json:"create_time"` + CreatorID string `json:"creator_id,omitempty"` + URL string `json:"url"` + Participants []userHandle `json:"participants"` +} diff --git a/server/oscar/auth.go b/server/oscar/auth.go index 4127f686..6340fb4f 100644 --- a/server/oscar/auth.go +++ b/server/oscar/auth.go @@ -21,7 +21,7 @@ type AuthService interface { RegisterBOSSession(authCookie []byte) (*state.Session, error) RegisterChatSession(authCookie []byte) (*state.Session, error) Signout(ctx context.Context, sess *state.Session) error - SignoutChat(ctx context.Context, sess *state.Session) error + SignoutChat(ctx context.Context, sess *state.Session) } // AuthServer is an authentication server for both FLAP (AIM v1.0-3.0) and BUCP diff --git a/server/oscar/chat.go b/server/oscar/chat.go index d7f5be35..1ed59803 100644 --- a/server/oscar/chat.go +++ b/server/oscar/chat.go @@ -78,9 +78,7 @@ func (rt ChatServer) handleNewConnection(ctx context.Context, rwc io.ReadWriteCl defer func() { chatSess.Close() rwc.Close() - if err := rt.SignoutChat(ctx, chatSess); err != nil { - rt.Logger.ErrorContext(ctx, "unable to sign out user", "err", err.Error()) - } + rt.SignoutChat(ctx, chatSess) }() msg := rt.HostOnline() diff --git a/server/oscar/chat_test.go b/server/oscar/chat_test.go index eca89fe2..4a660add 100644 --- a/server/oscar/chat_test.go +++ b/server/oscar/chat_test.go @@ -70,8 +70,7 @@ func TestChatService_handleNewConnection(t *testing.T) { RegisterChatSession([]byte(`the-chat-login-cookie`)). Return(sess, nil) authService.EXPECT(). - SignoutChat(mock.Anything, sess). - Return(nil) + SignoutChat(mock.Anything, sess) onlineNotifier := newMockOnlineNotifier(t) onlineNotifier.EXPECT(). diff --git a/server/oscar/handler/chat_nav.go b/server/oscar/handler/chat_nav.go index 9289e68a..8ed44596 100644 --- a/server/oscar/handler/chat_nav.go +++ b/server/oscar/handler/chat_nav.go @@ -14,7 +14,7 @@ import ( type ChatNavService interface { CreateRoom(ctx context.Context, sess *state.Session, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (wire.SNACMessage, error) - ExchangeInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) wire.SNACMessage + ExchangeInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error) RequestChatRights(ctx context.Context, inFrame wire.SNACFrame) wire.SNACMessage RequestRoomInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (wire.SNACMessage, error) } @@ -44,7 +44,10 @@ func (rt ChatNavHandler) RequestExchangeInfo(ctx context.Context, _ *state.Sessi if err := wire.Unmarshal(&inBody, r); err != nil { return err } - outSNAC := rt.ChatNavService.ExchangeInfo(ctx, inFrame, inBody) + outSNAC, err := rt.ChatNavService.ExchangeInfo(ctx, inFrame, inBody) + if err != nil { + return err + } rt.LogRequestAndResponse(ctx, inFrame, inBody, outSNAC.Frame, outSNAC.Body) return rw.SendSNAC(outSNAC.Frame, outSNAC.Body) } diff --git a/server/oscar/handler/chat_nav_test.go b/server/oscar/handler/chat_nav_test.go index 8c9b275a..8910dc0d 100644 --- a/server/oscar/handler/chat_nav_test.go +++ b/server/oscar/handler/chat_nav_test.go @@ -5,6 +5,7 @@ import ( "log/slog" "testing" + "github.com/mk6i/retro-aim-server/state" "github.com/mk6i/retro-aim-server/wire" "github.com/stretchr/testify/assert" @@ -29,9 +30,11 @@ func TestChatNavHandler_CreateRoom(t *testing.T) { Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{}, } + sess := state.NewSession() + svc := newMockChatNavService(t) svc.EXPECT(). - CreateRoom(mock.Anything, mock.Anything, input.Frame, input.Body). + CreateRoom(mock.Anything, sess, input.Frame, input.Body). Return(output, nil) h := NewChatNavHandler(svc, slog.Default()) @@ -44,7 +47,7 @@ func TestChatNavHandler_CreateRoom(t *testing.T) { buf := &bytes.Buffer{} assert.NoError(t, wire.Marshal(input.Body, buf)) - assert.NoError(t, h.CreateRoom(nil, nil, input.Frame, buf, ss)) + assert.NoError(t, h.CreateRoom(nil, sess, input.Frame, buf, ss)) } func TestChatNavHandler_CreateRoom_ReadErr(t *testing.T) { @@ -65,9 +68,11 @@ func TestChatNavHandler_CreateRoom_ReadErr(t *testing.T) { Body: wire.SNAC_0x0D_0x09_ChatNavNavInfo{}, } + sess := state.NewSession() + svc := newMockChatNavService(t) svc.EXPECT(). - CreateRoom(mock.Anything, mock.Anything, input.Frame, input.Body). + CreateRoom(mock.Anything, sess, input.Frame, input.Body). Return(output, nil) h := NewChatNavHandler(svc, slog.Default()) @@ -80,7 +85,7 @@ func TestChatNavHandler_CreateRoom_ReadErr(t *testing.T) { buf := &bytes.Buffer{} assert.NoError(t, wire.Marshal(input.Body, buf)) - assert.NoError(t, h.CreateRoom(nil, nil, input.Frame, buf, ss)) + assert.NoError(t, h.CreateRoom(nil, sess, input.Frame, buf, ss)) } func TestChatNavHandler_RequestChatRights(t *testing.T) { @@ -174,7 +179,7 @@ func TestChatNavHandler_RequestExchangeInfo(t *testing.T) { svc := newMockChatNavService(t) svc.EXPECT(). ExchangeInfo(mock.Anything, input.Frame, input.Body). - Return(output) + Return(output, nil) h := NewChatNavHandler(svc, slog.Default()) diff --git a/server/oscar/handler/mock_chat_nav_test.go b/server/oscar/handler/mock_chat_nav_test.go index 29cf8aeb..2876ed39 100644 --- a/server/oscar/handler/mock_chat_nav_test.go +++ b/server/oscar/handler/mock_chat_nav_test.go @@ -84,7 +84,7 @@ func (_c *mockChatNavService_CreateRoom_Call) RunAndReturn(run func(context.Cont } // ExchangeInfo provides a mock function with given fields: ctx, inFrame, inBody -func (_m *mockChatNavService) ExchangeInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) wire.SNACMessage { +func (_m *mockChatNavService) ExchangeInfo(ctx context.Context, inFrame wire.SNACFrame, inBody wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error) { ret := _m.Called(ctx, inFrame, inBody) if len(ret) == 0 { @@ -92,13 +92,23 @@ func (_m *mockChatNavService) ExchangeInfo(ctx context.Context, inFrame wire.SNA } var r0 wire.SNACMessage + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error)); ok { + return rf(ctx, inFrame, inBody) + } if rf, ok := ret.Get(0).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) wire.SNACMessage); ok { r0 = rf(ctx, inFrame, inBody) } else { r0 = ret.Get(0).(wire.SNACMessage) } - return r0 + if rf, ok := ret.Get(1).(func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) error); ok { + r1 = rf(ctx, inFrame, inBody) + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // mockChatNavService_ExchangeInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExchangeInfo' @@ -121,12 +131,12 @@ func (_c *mockChatNavService_ExchangeInfo_Call) Run(run func(ctx context.Context return _c } -func (_c *mockChatNavService_ExchangeInfo_Call) Return(_a0 wire.SNACMessage) *mockChatNavService_ExchangeInfo_Call { - _c.Call.Return(_a0) +func (_c *mockChatNavService_ExchangeInfo_Call) Return(_a0 wire.SNACMessage, _a1 error) *mockChatNavService_ExchangeInfo_Call { + _c.Call.Return(_a0, _a1) return _c } -func (_c *mockChatNavService_ExchangeInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) wire.SNACMessage) *mockChatNavService_ExchangeInfo_Call { +func (_c *mockChatNavService_ExchangeInfo_Call) RunAndReturn(run func(context.Context, wire.SNACFrame, wire.SNAC_0x0D_0x03_ChatNavRequestExchangeInfo) (wire.SNACMessage, error)) *mockChatNavService_ExchangeInfo_Call { _c.Call.Return(run) return _c } diff --git a/server/oscar/handler/types.go b/server/oscar/handler/types.go deleted file mode 100644 index abeebd16..00000000 --- a/server/oscar/handler/types.go +++ /dev/null @@ -1 +0,0 @@ -package handler diff --git a/server/oscar/mock_auth_test.go b/server/oscar/mock_auth_test.go index d6c26c2f..b095e3de 100644 --- a/server/oscar/mock_auth_test.go +++ b/server/oscar/mock_auth_test.go @@ -361,21 +361,8 @@ func (_c *mockAuthService_Signout_Call) RunAndReturn(run func(context.Context, * } // SignoutChat provides a mock function with given fields: ctx, sess -func (_m *mockAuthService) SignoutChat(ctx context.Context, sess *state.Session) error { - ret := _m.Called(ctx, sess) - - if len(ret) == 0 { - panic("no return value specified for SignoutChat") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *state.Session) error); ok { - r0 = rf(ctx, sess) - } else { - r0 = ret.Error(0) - } - - return r0 +func (_m *mockAuthService) SignoutChat(ctx context.Context, sess *state.Session) { + _m.Called(ctx, sess) } // mockAuthService_SignoutChat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SignoutChat' @@ -397,12 +384,12 @@ func (_c *mockAuthService_SignoutChat_Call) Run(run func(ctx context.Context, se return _c } -func (_c *mockAuthService_SignoutChat_Call) Return(_a0 error) *mockAuthService_SignoutChat_Call { - _c.Call.Return(_a0) +func (_c *mockAuthService_SignoutChat_Call) Return() *mockAuthService_SignoutChat_Call { + _c.Call.Return() return _c } -func (_c *mockAuthService_SignoutChat_Call) RunAndReturn(run func(context.Context, *state.Session) error) *mockAuthService_SignoutChat_Call { +func (_c *mockAuthService_SignoutChat_Call) RunAndReturn(run func(context.Context, *state.Session)) *mockAuthService_SignoutChat_Call { _c.Call.Return(run) return _c } diff --git a/state/chat_registry.go b/state/chat.go similarity index 53% rename from state/chat_registry.go rename to state/chat.go index 406d4e88..a5b88a6e 100644 --- a/state/chat_registry.go +++ b/state/chat.go @@ -3,7 +3,7 @@ package state import ( "errors" "fmt" - "sync" + "net/url" "time" "github.com/mk6i/retro-aim-server/wire" @@ -11,57 +11,20 @@ import ( "github.com/google/uuid" ) -// ErrChatRoomNotFound indicates that a chat room lookup failed. -var ErrChatRoomNotFound = errors.New("chat room not found") - -// ChatRegistry keeps track of chat rooms. A ChatRegistry is safe for -// concurrent use by multiple goroutines. -type ChatRegistry struct { - roomStore map[string]ChatRoom // association of cookie identifier->chat room - valueStore map[string]any // association of cookie identifier->value - mutex sync.RWMutex // ensures thread-safe read-write access to stores -} - -// NewChatRegistry creates a new instance of ChatRegistry -func NewChatRegistry() *ChatRegistry { - return &ChatRegistry{ - roomStore: make(map[string]ChatRoom), - valueStore: make(map[string]any), - } -} - -// Register adds a chat room to the registry and associates an arbitrary value -// with the room. -func (c *ChatRegistry) Register(chatRoom ChatRoom, value any) { - c.mutex.Lock() - defer c.mutex.Unlock() - c.roomStore[chatRoom.Cookie] = chatRoom - c.valueStore[chatRoom.Cookie] = value -} - -// Retrieve retrieves a chat room and the arbitrary value associated with it. -// Returns ErrChatRoomNotFound if the room is not registered. -func (c *ChatRegistry) Retrieve(cookie string) (ChatRoom, any, error) { - c.mutex.RLock() - defer c.mutex.RUnlock() - chatRoom, found := c.roomStore[cookie] - if !found { - return ChatRoom{}, nil, fmt.Errorf("%w cookie: %s", ErrChatRoomNotFound, cookie) - } - value, found := c.valueStore[cookie] - if !found { - panic("unable to find value for chat room") - } - return chatRoom, value, nil -} +const ( + // PrivateExchange is the ID of the exchange that hosts non-public created + // by users. + PrivateExchange uint16 = 4 + // PublicExchange is the ID of the exchange that hosts public chat rooms + // created by the server operator exclusively. + PublicExchange uint16 = 5 +) -// Remove removes a chat room and the arbitrary value associated with it. -func (c *ChatRegistry) Remove(cookie string) { - c.mutex.Lock() - defer c.mutex.Unlock() - delete(c.roomStore, cookie) - delete(c.valueStore, cookie) -} +// ErrChatRoomNotFound indicates that a chat room lookup failed. +var ( + ErrChatRoomNotFound = errors.New("chat room not found") + ErrDupChatRoom = errors.New("chat room already exists") +) // ChatRoom is the representation of a chat room's metadata. type ChatRoom struct { @@ -69,6 +32,8 @@ type ChatRoom struct { Cookie string // CreateTime indicates when the chat room was created. CreateTime time.Time + // Creator is the screen name of the user who created the chat room. + Creator IdentScreenName // DetailLevel is the detail level of the chat room. Unclear what this value means. DetailLevel uint8 // Exchange indicates which exchange the chatroom belongs to. Typically, a canned value. @@ -79,6 +44,18 @@ type ChatRoom struct { Name string } +// URL creates a URL that can be used to join a chat room. +func (c ChatRoom) URL() *url.URL { + v := url.Values{} + v.Set("roomname", c.Name) + v.Set("exchange", fmt.Sprintf("%d", c.Exchange)) + + return &url.URL{ + Scheme: "aim", + Opaque: "gochat?" + v.Encode(), + } +} + // TLVList returns a TLV list of chat room metadata. func (c ChatRoom) TLVList() []wire.TLV { return []wire.TLV{ @@ -109,6 +86,6 @@ func (c ChatRoom) TLVList() []wire.TLV { func NewChatRoom() ChatRoom { return ChatRoom{ Cookie: uuid.New().String(), - CreateTime: time.Now(), + CreateTime: time.Now().UTC(), } } diff --git a/state/chat_registry_test.go b/state/chat_registry_test.go deleted file mode 100644 index 79926535..00000000 --- a/state/chat_registry_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package state - -import ( - "testing" - - "github.com/mk6i/retro-aim-server/wire" - - "github.com/stretchr/testify/assert" -) - -func TestChatRegistry_RegisterAndReceive(t *testing.T) { - type registration struct { - room ChatRoom - value any - } - tests := []struct { - name string - givenRegistered []registration - lookupCookie string - wantRegistered registration - wantErr error - }{ - { - name: "chat room and value found", - givenRegistered: []registration{ - { - room: ChatRoom{Cookie: "cookie1"}, - value: "value1", - }, - { - room: ChatRoom{Cookie: "cookie2"}, - value: "value2", - }, - }, - lookupCookie: "cookie2", - wantRegistered: registration{ - room: ChatRoom{Cookie: "cookie2"}, - value: "value2", - }, - }, - { - name: "chat room and value not found", - givenRegistered: []registration{ - { - room: ChatRoom{Cookie: "cookie1"}, - value: "value1", - }, - { - room: ChatRoom{Cookie: "cookie2"}, - value: "value2", - }, - }, - lookupCookie: "cookie3", - wantErr: ErrChatRoomNotFound, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - chatRegistry := NewChatRegistry() - for _, r := range tt.givenRegistered { - chatRegistry.Register(r.room, r.value) - } - - room, value, err := chatRegistry.Retrieve(tt.lookupCookie) - - assert.Equal(t, tt.wantRegistered.room, room) - assert.Equal(t, tt.wantRegistered.value, value) - assert.ErrorIs(t, err, tt.wantErr) - }) - } -} - -func TestChatRegistry_RegisterAndRemove(t *testing.T) { - type registration struct { - room ChatRoom - value any - } - tests := []struct { - name string - givenRegistered []registration - removeCookie string - wantRegistered []registration - wantErr error - }{ - { - name: "chat room and value removed", - givenRegistered: []registration{ - { - room: ChatRoom{Cookie: "cookie1"}, - value: "value1", - }, - { - room: ChatRoom{Cookie: "cookie2"}, - value: "value2", - }, - }, - removeCookie: "cookie2", - wantRegistered: []registration{ - { - room: ChatRoom{Cookie: "cookie1"}, - value: "value1", - }, - }, - }, - { - name: "no chat room and value removed", - givenRegistered: []registration{ - { - room: ChatRoom{Cookie: "cookie1"}, - value: "value1", - }, - { - room: ChatRoom{Cookie: "cookie2"}, - value: "value2", - }, - }, - removeCookie: "cookie3", - wantRegistered: []registration{ - { - room: ChatRoom{Cookie: "cookie1"}, - value: "value1", - }, - { - room: ChatRoom{Cookie: "cookie2"}, - value: "value2", - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - chatRegistry := NewChatRegistry() - for _, r := range tt.givenRegistered { - chatRegistry.Register(r.room, r.value) - } - - chatRegistry.Remove(tt.removeCookie) - - for _, r := range tt.wantRegistered { - room, value, err := chatRegistry.Retrieve(r.room.Cookie) - assert.Equal(t, r.room, room) - assert.Equal(t, r.value, value) - assert.NoError(t, err) - } - }) - } -} - -func TestChatRoom_TLVList(t *testing.T) { - room := NewChatRoom() - room.Name = "chat-room-name" - - have := room.TLVList() - want := []wire.TLV{ - wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)), - wire.NewTLV(wire.ChatRoomTLVCreateTime, uint32(room.CreateTime.Unix())), - wire.NewTLV(wire.ChatRoomTLVMaxMsgLen, uint16(1024)), - wire.NewTLV(wire.ChatRoomTLVMaxOccupancy, uint16(100)), - wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)), - wire.NewTLV(wire.ChatRoomTLVFullyQualifiedName, room.Name), - wire.NewTLV(wire.ChatRoomTLVRoomName, room.Name), - } - - assert.Equal(t, want, have) -} diff --git a/state/chat_test.go b/state/chat_test.go new file mode 100644 index 00000000..e3293a28 --- /dev/null +++ b/state/chat_test.go @@ -0,0 +1,27 @@ +package state + +import ( + "testing" + + "github.com/mk6i/retro-aim-server/wire" + + "github.com/stretchr/testify/assert" +) + +func TestChatRoom_TLVList(t *testing.T) { + room := NewChatRoom() + room.Name = "chat-room-name" + + have := room.TLVList() + want := []wire.TLV{ + wire.NewTLV(wire.ChatRoomTLVFlags, uint16(15)), + wire.NewTLV(wire.ChatRoomTLVCreateTime, uint32(room.CreateTime.Unix())), + wire.NewTLV(wire.ChatRoomTLVMaxMsgLen, uint16(1024)), + wire.NewTLV(wire.ChatRoomTLVMaxOccupancy, uint16(100)), + wire.NewTLV(wire.ChatRoomTLVNavCreatePerms, uint8(2)), + wire.NewTLV(wire.ChatRoomTLVFullyQualifiedName, room.Name), + wire.NewTLV(wire.ChatRoomTLVRoomName, room.Name), + } + + assert.Equal(t, want, have) +} diff --git a/state/migrations/0004_chat_rooms.down.sql b/state/migrations/0004_chat_rooms.down.sql new file mode 100644 index 00000000..e2ffba1a --- /dev/null +++ b/state/migrations/0004_chat_rooms.down.sql @@ -0,0 +1 @@ +DROP TABLE chatRoom; \ No newline at end of file diff --git a/state/migrations/0004_chat_rooms.up.sql b/state/migrations/0004_chat_rooms.up.sql new file mode 100644 index 00000000..72fb82fc --- /dev/null +++ b/state/migrations/0004_chat_rooms.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE chatRoom +( + cookie TEXT PRIMARY KEY, + exchange INTEGER, + name TEXT, + created TIMESTAMP, + creator TEXT, + UNIQUE (exchange, name) +); diff --git a/state/session_manager.go b/state/session_manager.go index e6e93014..6d28dbae 100644 --- a/state/session_manager.go +++ b/state/session_manager.go @@ -34,19 +34,6 @@ func (s *InMemorySessionManager) RelayToAll(ctx context.Context, msg wire.SNACMe } } -// RelayToAllExcept relays a message to all session in the pool except for one -// particular session. -func (s *InMemorySessionManager) RelayToAllExcept(ctx context.Context, except *Session, msg wire.SNACMessage) { - s.mapMutex.RLock() - defer s.mapMutex.RUnlock() - for _, sess := range s.store { - if sess == except { - continue - } - s.maybeRelayMessage(ctx, msg, sess) - } -} - // RelayToScreenName relays a message to a session with a matching screen name. func (s *InMemorySessionManager) RelayToScreenName(ctx context.Context, screenName IdentScreenName, msg wire.SNACMessage) { sess := s.RetrieveByScreenName(screenName) @@ -161,3 +148,100 @@ func (s *InMemorySessionManager) AllSessions() []*Session { } return sessions } + +// NewInMemoryChatSessionManager creates a new instance of +// InMemoryChatSessionManager. +func NewInMemoryChatSessionManager(logger *slog.Logger) *InMemoryChatSessionManager { + return &InMemoryChatSessionManager{ + store: make(map[string]*InMemorySessionManager), + logger: logger, + } +} + +// InMemoryChatSessionManager manages chat sessions for multiple chat rooms +// stored in memory. It provides thread-safe operations to add, remove, and +// manipulate sessions as well as relay messages to participants. +type InMemoryChatSessionManager struct { + logger *slog.Logger + mapMutex sync.RWMutex + store map[string]*InMemorySessionManager +} + +// AddSession adds a user to a chat room. +func (s *InMemoryChatSessionManager) AddSession(chatCookie string, screenName DisplayScreenName) *Session { + s.mapMutex.Lock() + defer s.mapMutex.Unlock() + + if _, ok := s.store[chatCookie]; !ok { + s.store[chatCookie] = NewInMemorySessionManager(s.logger) + } + + sessionManager := s.store[chatCookie] + sess := sessionManager.AddSession(screenName) + sess.SetChatRoomCookie(chatCookie) + return sess +} + +// RemoveSession removes a user session from a chat room. +func (s *InMemoryChatSessionManager) RemoveSession(sess *Session) { + s.mapMutex.Lock() + defer s.mapMutex.Unlock() + + sessionManager, ok := s.store[sess.ChatRoomCookie()] + if !ok { + panic("attempting to remove a session after its room has been deleted") + } + sessionManager.RemoveSession(sess) + if sessionManager.Empty() { + delete(s.store, sess.ChatRoomCookie()) + } +} + +// AllSessions returns all chat room participants. Returns +// ErrChatRoomNotFound if the room does not exist. +func (s *InMemoryChatSessionManager) AllSessions(cookie string) []*Session { + s.mapMutex.RLock() + defer s.mapMutex.RUnlock() + + sessionManager, ok := s.store[cookie] + if !ok { + s.logger.Debug("trying to get sessions for non-existent room", "cookie", cookie) + return nil + } + return sessionManager.AllSessions() +} + +// RelayToAllExcept sends a message to all chat room participants except for +// the participant with a particular screen name. Returns ErrChatRoomNotFound +// if the room does not exist for cookie. +func (s *InMemoryChatSessionManager) RelayToAllExcept(ctx context.Context, cookie string, except IdentScreenName, msg wire.SNACMessage) { + s.mapMutex.RLock() + defer s.mapMutex.RUnlock() + + sessionManager, ok := s.store[cookie] + if !ok { + s.logger.Error("trying to relay message to all for non-existent room", "cookie", cookie) + return + } + + for _, sess := range sessionManager.AllSessions() { + if sess.IdentScreenName() == except { + continue + } + sessionManager.maybeRelayMessage(ctx, msg, sess) + } +} + +// RelayToScreenName sends a message to a chat room user. Returns +// ErrChatRoomNotFound if the room does not exist for cookie. +func (s *InMemoryChatSessionManager) RelayToScreenName(ctx context.Context, cookie string, recipient IdentScreenName, msg wire.SNACMessage) { + s.mapMutex.RLock() + defer s.mapMutex.RUnlock() + + sessionManager, ok := s.store[cookie] + if !ok { + s.logger.Error("trying to relay message to screen name for non-existent room", "cookie", cookie) + return + } + sessionManager.RelayToScreenName(ctx, recipient, msg) +} diff --git a/state/session_manager_test.go b/state/session_manager_test.go index a782c8c5..ca921887 100644 --- a/state/session_manager_test.go +++ b/state/session_manager_test.go @@ -283,16 +283,17 @@ loop: assert.Equal(t, wantCount, haveCount) } -func TestInMemorySessionManager_RelayToAllExcept(t *testing.T) { - sm := NewInMemorySessionManager(slog.Default()) +func TestInMemoryChatSessionManager_RelayToAllExcept_HappyPath(t *testing.T) { + sm := NewInMemoryChatSessionManager(slog.Default()) - user1 := sm.AddSession("user-screen-name-1") - user2 := sm.AddSession("user-screen-name-2") - user3 := sm.AddSession("user-screen-name-3") + cookie := "the-cookie" + user1 := sm.AddSession(cookie, "user-screen-name-1") + user2 := sm.AddSession(cookie, "user-screen-name-2") + user3 := sm.AddSession(cookie, "user-screen-name-3") want := wire.SNACMessage{Frame: wire.SNACFrame{FoodGroup: wire.ICBM}} - sm.RelayToAllExcept(context.Background(), user2, want) + sm.RelayToAllExcept(context.Background(), cookie, user2.IdentScreenName(), want) select { case have := <-user1.ReceiveMessage(): @@ -310,3 +311,58 @@ func TestInMemorySessionManager_RelayToAllExcept(t *testing.T) { assert.Equal(t, want, have) } } + +func TestInMemoryChatSessionManager_AllSessions_RoomExists(t *testing.T) { + sm := NewInMemoryChatSessionManager(slog.Default()) + + user1 := sm.AddSession("the-cookie", "user-screen-name-1") + user2 := sm.AddSession("the-cookie", "user-screen-name-2") + + sessions := sm.AllSessions("the-cookie") + assert.Len(t, sessions, 2) + + lookup := make(map[*Session]bool) + for _, session := range sessions { + lookup[session] = true + } + + assert.True(t, lookup[user1]) + assert.True(t, lookup[user2]) +} + +func TestInMemoryChatSessionManager_RelayToScreenName_SessionAndChatRoomExist(t *testing.T) { + sm := NewInMemoryChatSessionManager(slog.Default()) + + user1 := sm.AddSession("chat-room-1", "user-screen-name-1") + user2 := sm.AddSession("chat-room-1", "user-screen-name-2") + + want := wire.SNACMessage{Frame: wire.SNACFrame{FoodGroup: wire.ICBM}} + + recip := NewIdentScreenName("user-screen-name-1") + sm.RelayToScreenName(context.Background(), "chat-room-1", recip, want) + + select { + case have := <-user1.ReceiveMessage(): + assert.Equal(t, want, have) + } + + select { + case <-user2.ReceiveMessage(): + assert.Fail(t, "user 2 should not receive a message") + default: + } +} + +func TestInMemoryChatSessionManager_RemoveSession(t *testing.T) { + sm := NewInMemoryChatSessionManager(slog.Default()) + + user1 := sm.AddSession("chat-room-1", "user-screen-name-1") + user2 := sm.AddSession("chat-room-1", "user-screen-name-2") + + assert.Len(t, sm.AllSessions("chat-room-1"), 2) + + sm.RemoveSession(user1) + sm.RemoveSession(user2) + + assert.Empty(t, sm.AllSessions("chat-room-1")) +} diff --git a/state/user_store.go b/state/user_store.go index 52caf33f..4cd4abd4 100644 --- a/state/user_store.go +++ b/state/user_store.go @@ -15,7 +15,7 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source/httpfs" - _ "github.com/mattn/go-sqlite3" + sqlite "github.com/mattn/go-sqlite3" ) //go:embed migrations/* @@ -467,5 +467,118 @@ func (f SQLiteUserStore) BARTRetrieve(hash []byte) ([]byte, error) { if errors.Is(err, sql.ErrNoRows) { err = nil } - return body, nil + return body, err +} + +// ChatRoomByCookie looks up a chat room by cookie. Returns +// ErrChatRoomNotFound if the room does not exist for cookie. +func (f SQLiteUserStore) ChatRoomByCookie(cookie string) (ChatRoom, error) { + chatRoom := ChatRoom{ + Cookie: cookie, + } + + q := ` + SELECT exchange, name, created, creator + FROM chatRoom + WHERE cookie = ? + ` + var creator string + err := f.db.QueryRow(q, cookie).Scan( + &chatRoom.Exchange, + &chatRoom.Name, + &chatRoom.CreateTime, + &creator, + ) + if errors.Is(err, sql.ErrNoRows) { + err = ErrChatRoomNotFound + } + chatRoom.Creator = NewIdentScreenName(creator) + + return chatRoom, err +} + +// ChatRoomByName looks up a chat room by exchange and name. Returns +// ErrChatRoomNotFound if the room does not exist for exchange and name. +func (f SQLiteUserStore) ChatRoomByName(exchange uint16, name string) (ChatRoom, error) { + chatRoom := ChatRoom{ + Exchange: exchange, + Name: name, + } + + q := ` + SELECT cookie, created, creator + FROM chatRoom + WHERE exchange = ? AND name = ? + ` + var creator string + err := f.db.QueryRow(q, exchange, name).Scan( + &chatRoom.Cookie, + &chatRoom.CreateTime, + &creator, + ) + if errors.Is(err, sql.ErrNoRows) { + err = ErrChatRoomNotFound + } + chatRoom.Creator = NewIdentScreenName(creator) + + return chatRoom, err +} + +// CreateChatRoom creates a new chat room. +func (f SQLiteUserStore) CreateChatRoom(chatRoom ChatRoom) error { + q := ` + INSERT INTO chatRoom (cookie, exchange, name, created, creator) + VALUES (?, ?, ?, ?, ?) + ` + _, err := f.db.Exec( + q, + chatRoom.Cookie, + chatRoom.Exchange, + chatRoom.Name, + chatRoom.CreateTime, + chatRoom.Creator.String(), + ) + + if err != nil { + if sqliteErr, ok := err.(sqlite.Error); ok { + if sqliteErr.ExtendedCode == sqlite.ErrConstraintUnique || sqliteErr.ExtendedCode == sqlite.ErrConstraintPrimaryKey { + err = ErrDupChatRoom + } + } + err = fmt.Errorf("CreateChatRoom: %w", err) + } + return err +} + +func (f SQLiteUserStore) AllChatRooms(exchange uint16) ([]ChatRoom, error) { + q := ` + SELECT cookie, created, creator, name + FROM chatRoom + WHERE exchange = ? + ORDER BY created ASC + ` + rows, err := f.db.Query(q, exchange) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []ChatRoom + for rows.Next() { + cr := ChatRoom{ + Exchange: exchange, + } + var creator string + if err := rows.Scan(&cr.Cookie, &cr.CreateTime, &creator, &cr.Name); err != nil { + return nil, err + } + cr.Creator = NewIdentScreenName(creator) + users = append(users, cr) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return users, nil } diff --git a/state/user_store_test.go b/state/user_store_test.go index 896c3d73..15d24523 100644 --- a/state/user_store_test.go +++ b/state/user_store_test.go @@ -682,3 +682,183 @@ func TestSQLiteUserStore_SetUserPassword_ErrNoUser(t *testing.T) { err = feedbagStore.SetUserPassword(u) assert.ErrorIs(t, err, ErrNoUser) } + +func TestSQLiteUserStore_ChatRoomByCookie_RoomFound(t *testing.T) { + defer func() { + assert.NoError(t, os.Remove(testFile)) + }() + + userStore, err := NewSQLiteUserStore(testFile) + assert.NoError(t, err) + + chatRoom := NewChatRoom() + chatRoom.Exchange = 4 + chatRoom.Name = "my new chat room!" + chatRoom.Creator = NewIdentScreenName("the-screen-name") + + err = userStore.CreateChatRoom(chatRoom) + assert.NoError(t, err) + + gotRoom, err := userStore.ChatRoomByCookie(chatRoom.Cookie) + assert.NoError(t, err) + assert.Equal(t, chatRoom, gotRoom) +} + +func TestSQLiteUserStore_ChatRoomByCookie_RoomNotFound(t *testing.T) { + defer func() { + assert.NoError(t, os.Remove(testFile)) + }() + + userStore, err := NewSQLiteUserStore(testFile) + assert.NoError(t, err) + + _, err = userStore.ChatRoomByCookie("the-chat-cookie") + assert.ErrorIs(t, err, ErrChatRoomNotFound) +} + +func TestSQLiteUserStore_ChatRoomByName_RoomFound(t *testing.T) { + defer func() { + assert.NoError(t, os.Remove(testFile)) + }() + + userStore, err := NewSQLiteUserStore(testFile) + assert.NoError(t, err) + + chatRoom := NewChatRoom() + chatRoom.Exchange = 4 + chatRoom.Name = "my new chat room!" + chatRoom.Creator = NewIdentScreenName("the-screen-name") + + err = userStore.CreateChatRoom(chatRoom) + assert.NoError(t, err) + + gotRoom, err := userStore.ChatRoomByName(chatRoom.Exchange, chatRoom.Name) + assert.NoError(t, err) + assert.Equal(t, chatRoom, gotRoom) +} + +func TestSQLiteUserStore_ChatRoomByName_RoomNotFound(t *testing.T) { + defer func() { + assert.NoError(t, os.Remove(testFile)) + }() + + userStore, err := NewSQLiteUserStore(testFile) + assert.NoError(t, err) + + _, err = userStore.ChatRoomByName(4, "the-chat-room") + assert.ErrorIs(t, err, ErrChatRoomNotFound) +} + +func TestSQLiteUserStore_AllChatRooms(t *testing.T) { + defer func() { + assert.NoError(t, os.Remove(testFile)) + }() + + userStore, err := NewSQLiteUserStore(testFile) + assert.NoError(t, err) + + chatRooms := []ChatRoom{ + { + Cookie: "chat-room-1", + Exchange: 4, + Name: "chat room 1", + }, + { + Cookie: "chat-room-2", + Exchange: 4, + Name: "chat room 2", + }, + { + Cookie: "chat-room-3", + Exchange: 5, + Name: "chat room 3", + }, + } + + for _, room := range chatRooms { + err = userStore.CreateChatRoom(room) + assert.NoError(t, err) + } + + // public exchange + gotRooms, err := userStore.AllChatRooms(5) + assert.NoError(t, err) + + assert.Equal(t, chatRooms[2:], gotRooms) + + // private exchange + gotRooms, err = userStore.AllChatRooms(4) + assert.NoError(t, err) + + assert.Equal(t, chatRooms[0:2], gotRooms) +} + +func TestSQLiteUserStore_CreateChatRoom_ErrChatRoomExists(t *testing.T) { + + tt := []struct { + name string + firstInsert ChatRoom + secondInsert ChatRoom + wantErr error + }{ + { + name: "create two rooms with same exchange/name, different cookie", + firstInsert: ChatRoom{ + Cookie: "chat-room-1", + Exchange: 4, + Name: "chat room 1", + }, + secondInsert: ChatRoom{ + Cookie: "chat-room-1-new", + Exchange: 4, + Name: "chat room 1", + }, + wantErr: ErrDupChatRoom, + }, + { + name: "create two rooms with different exchange/name, same cookie", + firstInsert: ChatRoom{ + Cookie: "chat-room", + Exchange: 5, + Name: "chat room 1", + }, + secondInsert: ChatRoom{ + Cookie: "chat-room", + Exchange: 4, + Name: "chat room 2", + }, + wantErr: ErrDupChatRoom, + }, + { + name: "create two rooms with different cookie/exchange, same name", + firstInsert: ChatRoom{ + Cookie: "chat-room-1", + Exchange: 5, + Name: "chat room", + }, + secondInsert: ChatRoom{ + Cookie: "chat-room-2", + Exchange: 4, + Name: "chat room", + }, + wantErr: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + defer func() { + assert.NoError(t, os.Remove(testFile)) + }() + + userStore, err := NewSQLiteUserStore(testFile) + assert.NoError(t, err) + + err = userStore.CreateChatRoom(tc.firstInsert) + assert.NoError(t, err) + + err = userStore.CreateChatRoom(tc.secondInsert) + assert.ErrorIs(t, err, tc.wantErr) + }) + } +}