Explorar el Código

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.
Mike hace 2 años
padre
commit
ce6381850d
Se han modificado 40 ficheros con 2373 adiciones y 1099 borrados
  1. 14 2
      .mockery.yaml
  2. 93 1
      api.yml
  3. 13 15
      cmd/server/main.go
  4. 19 33
      foodgroup/auth.go
  5. 30 105
      foodgroup/auth_test.go
  6. 9 13
      foodgroup/chat.go
  7. 73 35
      foodgroup/chat_nav.go
  8. 206 89
      foodgroup/chat_nav_test.go
  9. 50 84
      foodgroup/chat_test.go
  10. 31 111
      foodgroup/mock_chat_message_relayer_test.go
  11. 0 167
      foodgroup/mock_chat_registry_test.go
  12. 194 0
      foodgroup/mock_chat_room_registry_test.go
  13. 117 0
      foodgroup/mock_chat_session_registry_test.go
  14. 18 15
      foodgroup/oservice.go
  15. 119 99
      foodgroup/oservice_test.go
  16. 72 22
      foodgroup/test_helpers.go
  17. 44 7
      foodgroup/types.go
  18. 139 26
      server/http/mgmt_api.go
  19. 255 0
      server/http/mgmt_api_test.go
  20. 81 0
      server/http/mock_chat_room_creator_test.go
  21. 93 0
      server/http/mock_chat_room_retriever_test.go
  22. 83 0
      server/http/mock_chat_session_retriever_test.go
  23. 62 0
      server/http/types.go
  24. 1 1
      server/oscar/auth.go
  25. 1 3
      server/oscar/chat.go
  26. 1 2
      server/oscar/chat_test.go
  27. 5 2
      server/oscar/handler/chat_nav.go
  28. 10 5
      server/oscar/handler/chat_nav_test.go
  29. 15 5
      server/oscar/handler/mock_chat_nav_test.go
  30. 0 1
      server/oscar/handler/types.go
  31. 5 18
      server/oscar/mock_auth_test.go
  32. 29 52
      state/chat.go
  33. 0 165
      state/chat_registry_test.go
  34. 27 0
      state/chat_test.go
  35. 1 0
      state/migrations/0004_chat_rooms.down.sql
  36. 9 0
      state/migrations/0004_chat_rooms.up.sql
  37. 97 13
      state/session_manager.go
  38. 62 6
      state/session_manager_test.go
  39. 115 2
      state/user_store.go
  40. 180 0
      state/user_store_test.go

+ 14 - 2
.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"

+ 93 - 1
api.yml

@@ -110,4 +110,96 @@ paths:
         '400':
           description: Bad request. Invalid input data.
         '404':
-          description: User not found.
+          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.

+ 13 - 15
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,

+ 19 - 33
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

+ 30 - 105
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)

+ 9 - 13
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,

+ 73 - 35
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
 }

+ 206 - 89
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"),

+ 50 - 84
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)

+ 31 - 111
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
 }

+ 0 - 167
foodgroup/mock_chat_registry_test.go

@@ -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
-}

+ 194 - 0
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
+}

+ 117 - 0
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
+}

+ 18 - 15
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
 }
 

+ 119 - 99
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)

+ 72 - 22
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()
+	})
+}

+ 44 - 7
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 {

+ 139 - 26
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
+	}
+}

+ 255 - 0
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)
+			}
+		})
+	}
+}

+ 81 - 0
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
+}

+ 93 - 0
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
+}

+ 83 - 0
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
+}

+ 62 - 0
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"`
+}

+ 1 - 1
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

+ 1 - 3
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()

+ 1 - 2
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().

+ 5 - 2
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)
 }

+ 10 - 5
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())
 

+ 15 - 5
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
 }

+ 0 - 1
server/oscar/handler/types.go

@@ -1 +0,0 @@
-package handler

+ 5 - 18
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
 }

+ 29 - 52
state/chat_registry.go → 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(),
 	}
 }

+ 0 - 165
state/chat_registry_test.go

@@ -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)
-}

+ 27 - 0
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)
+}

+ 1 - 0
state/migrations/0004_chat_rooms.down.sql

@@ -0,0 +1 @@
+DROP TABLE chatRoom;

+ 9 - 0
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)
+);

+ 97 - 13
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)
+}

+ 62 - 6
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"))
+}

+ 115 - 2
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
 }

+ 180 - 0
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)
+		})
+	}
+}