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

Merge pull request #61 from jgknight/mgmt-api-user-details

Mgmt API and unit test improvements
Mike 1 год назад
Родитель
Сommit
2e0cf4181a

+ 116 - 0
api.yml

@@ -72,6 +72,66 @@ paths:
         '404':
           description: User not found.
 
+  /user/{screenname}/account:
+    get:
+      summary: Get account details for a specific screen name.
+      description: Retrieve account details for a specific screen name.
+      parameters:
+      - name: screenname
+        in: path
+        description: User's AIM screen name or ICQ UIN.
+        required: true
+        type: string
+      responses:
+        '200':
+          description: Successful response containing account details
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                    id:
+                      type: string
+                      description: User's unique identifier.
+                    screen_name:
+                      type: string
+                      description: User's AIM screen name or ICQ UIN.
+                    profile:
+                      type: string
+                      description: User's AIM profile HTML.
+                    email_address:
+                      type: string
+                      description: User's email address
+                    confirmed:
+                      type: bool
+                      description: User's account confirmation status
+                    is_icq:
+                      type: boolean
+                      description: If true, indicates an ICQ user instead of an AIM user.
+        '404':
+          description: User not found.
+
+  /user/{screenname}/icon:
+    get:
+      summary: Get AIM buddy icon for a screen name
+      description: Retrieve account buddy icon for a specific screen name.
+      parameters:
+      - name: screenname
+        in: path
+        description: User's AIM screen name or ICQ UIN.
+        required: true
+        type: string
+      responses:
+        '200':
+          description: Successful response containing buddy icon bytes
+          content:
+            image/gif:
+              schema:
+                type: string
+                format: binary
+        '404':
+          description: User not found, or user has no buddy icon
+
   /session:
     get:
       summary: Get active sessions
@@ -98,10 +158,66 @@ paths:
                         screen_name:
                           type: string
                           description: User's AIM screen name or ICQ UIN.
+                        online_seconds:
+                          type: float
+                          description: Number of seconds this user session has been online.
+                        away_message:
+                          type: string
+                          description: User's AIM away message HTML. Empty if the user is not away.
+                        idle_seconds:
+                          type: float
+                          description: Number of seconds this user session has been idle. 0 if not idle.
                         is_icq:
                           type: boolean
                           description: If true, indicates an ICQ user instead of an AIM user.
 
+  /session/{screenname}:
+    get:
+      summary: Get active sessions for a given screen name or UIN.
+      description: Retrieve a list of active sessions of a specific logged in user.
+      parameters:
+      - name: screenname
+        in: path
+        description: User's AIM screen name or ICQ UIN.
+        required: true
+        type: string
+      responses:
+        '200':
+          description: Successful response containing a list of active sessions for the given screen name
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  count:
+                    type: integer
+                    description: The number of active sessions.
+                  sessions:
+                    type: array
+                    items:
+                      type: object
+                      properties:
+                        id:
+                          type: string
+                          description: User's unique identifier.
+                        screen_name:
+                          type: string
+                          description: User's AIM screen name or ICQ UIN.
+                        online_seconds:
+                          type: float
+                          description: Number of seconds this user session has been online.
+                        away_message:
+                          type: string
+                          description: User's AIM away message HTML. Empty if the user is not away.
+                        idle_seconds:
+                          type: float
+                          description: Number of seconds this user session has been idle. 0 if not idle.
+                        is_icq:
+                          type: boolean
+                          description: If true, indicates an ICQ user instead of an AIM user.
+        '404':
+          description: User not found.
+
   /user/password:
     put:
       summary: Set a user's password

+ 1 - 1
cmd/server/main.go

@@ -47,7 +47,7 @@ func main() {
 	wg.Add(7)
 
 	go func() {
-		http.StartManagementAPI(cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager, sessionManager, logger)
+		http.StartManagementAPI(cfg, feedbagStore, sessionManager, feedbagStore, feedbagStore, chatSessionManager, sessionManager, feedbagStore, feedbagStore, feedbagStore, feedbagStore, logger)
 		wg.Done()
 	}()
 	go func(logger *slog.Logger) {

+ 2 - 59
foodgroup/buddy.go

@@ -1,10 +1,7 @@
 package foodgroup
 
 import (
-	"bytes"
 	"context"
-	"errors"
-	"strconv"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -87,7 +84,7 @@ func (s BuddyService) DelBuddies(_ context.Context, sess *state.Session, inBody
 // buddy icons, warning levels, invisibility status, etc.
 func (s BuddyService) UnicastBuddyArrived(ctx context.Context, from *state.Session, to *state.Session) error {
 	userInfo := from.TLVUserInfo()
-	icon, err := getBuddyIconRefFromFeedbag(from, s.feedbagManager)
+	icon, err := s.feedbagManager.BuddyIconRefByName(from.IdentScreenName())
 	switch {
 	case err != nil:
 		return err
@@ -122,7 +119,7 @@ func (s BuddyService) BroadcastBuddyArrived(ctx context.Context, sess *state.Ses
 	recipients = append(recipients, legacyUsers...)
 
 	userInfo := sess.TLVUserInfo()
-	icon, err := getBuddyIconRefFromFeedbag(sess, s.feedbagManager)
+	icon, err := s.feedbagManager.BuddyIconRefByName(sess.IdentScreenName())
 	switch {
 	case err != nil:
 		return err
@@ -143,60 +140,6 @@ func (s BuddyService) BroadcastBuddyArrived(ctx context.Context, sess *state.Ses
 	return nil
 }
 
-// getBuddyIconRefFromFeedbag retrieves a reference to the user's buddy icon
-// from their feedbag. If it exists, the buddy icon is the feedbag item of
-// class wire.FeedbagClassIdBart with BART type wire.BARTTypesBuddyIcon.
-func getBuddyIconRefFromFeedbag(sess *state.Session, feedbagManager FeedbagManager) (*wire.BARTID, error) {
-	items, err := feedbagManager.Feedbag(sess.IdentScreenName())
-	if err != nil {
-		return nil, err
-	}
-
-	for _, item := range items {
-		if item.ClassID != wire.FeedbagClassIdBart {
-			continue
-		}
-		bartType, err := extractBARTItemType(item)
-		if err != nil {
-			return nil, err
-		}
-		if bartType != wire.BARTTypesBuddyIcon {
-			continue
-		}
-		b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo)
-		if !hasBuf {
-			return nil, errors.New("unable to extract icon payload")
-		}
-		bartInfo := wire.BARTInfo{}
-		if err := wire.UnmarshalBE(&bartInfo, bytes.NewBuffer(b)); err != nil {
-			return nil, err
-		}
-		return &wire.BARTID{
-			Type: bartType,
-			BARTInfo: wire.BARTInfo{
-				Flags: bartInfo.Flags,
-				Hash:  bartInfo.Hash,
-			},
-		}, nil
-	}
-
-	return nil, nil
-}
-
-// extractBARTItemType gets the BART type for item, which is stored in the
-// "name" field.
-func extractBARTItemType(item wire.FeedbagItem) (uint16, error) {
-	var bartType uint16
-	// Feedbag items of type wire.FeedbagClassIdBart store the BART type in the
-	// name field.
-	if bt, err := strconv.ParseUint(item.Name, 10, 16); err != nil {
-		return 0, err
-	} else {
-		bartType = uint16(bt)
-	}
-	return bartType, nil
-}
-
 func (s BuddyService) BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error {
 	recipients, err := s.feedbagManager.AdjacentUsers(sess.IdentScreenName())
 	if err != nil {

+ 39 - 30
foodgroup/buddy_test.go

@@ -106,9 +106,10 @@ func TestBuddyService_AddBuddies(t *testing.T) {
 					},
 				},
 				feedbagManagerParams: feedbagManagerParams{
-					feedbagParams: feedbagParams{
+					buddyIconRefByNameParams: buddyIconRefByNameParams{
 						{
 							screenName: state.NewIdentScreenName("buddy_1_online"),
+							result:     nil,
 						},
 					},
 				},
@@ -159,6 +160,11 @@ func TestBuddyService_AddBuddies(t *testing.T) {
 			}
 
 			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tt.mockParams.feedbagManagerParams.buddyIconRefByNameParams {
+				feedbagManager.EXPECT().
+					BuddyIconRefByName(params.screenName).
+					Return(params.result, params.err)
+			}
 			for _, params := range tt.mockParams.feedbagParams {
 				feedbagManager.EXPECT().
 					Feedbag(params.screenName).
@@ -266,6 +272,12 @@ func TestBuddyService_BroadcastBuddyArrived(t *testing.T) {
 							results:    []wire.FeedbagItem{},
 						},
 					},
+					buddyIconRefByNameParams: buddyIconRefByNameParams{
+						{
+							screenName: state.NewIdentScreenName("user_screen_name"),
+							result:     nil,
+						},
+					},
 				},
 				messageRelayerParams: messageRelayerParams{
 					relayToScreenNamesParams: relayToScreenNamesParams{
@@ -307,28 +319,14 @@ func TestBuddyService_BroadcastBuddyArrived(t *testing.T) {
 							users:      []state.IdentScreenName{state.NewIdentScreenName("friend1")},
 						},
 					},
-					feedbagParams: feedbagParams{
+					buddyIconRefByNameParams: buddyIconRefByNameParams{
 						{
 							screenName: state.NewIdentScreenName("user_screen_name"),
-							results: []wire.FeedbagItem{
-								{
-									ClassID: wire.FeedbagClassIdBuddy,
-									Name:    "friend10",
-								},
-								{
-									ClassID: wire.FeedbagClassIdBart,
-									Name:    strconv.Itoa(int(wire.BARTTypesBadgeUrl)),
-								},
-								{
-									ClassID: wire.FeedbagClassIdBart,
-									Name:    strconv.Itoa(int(wire.BARTTypesBuddyIcon)),
-									TLVLBlock: wire.TLVLBlock{
-										TLVList: wire.TLVList{
-											wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
-												Hash: []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
-											}),
-										},
-									},
+							result: &wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
 								},
 							},
 						},
@@ -379,10 +377,10 @@ func TestBuddyService_BroadcastBuddyArrived(t *testing.T) {
 					AdjacentUsers(params.screenName).
 					Return(params.users, params.err)
 			}
-			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+			for _, params := range tc.mockParams.feedbagManagerParams.buddyIconRefByNameParams {
 				feedbagManager.EXPECT().
-					Feedbag(params.screenName).
-					Return(params.results, nil)
+					BuddyIconRefByName(params.screenName).
+					Return(params.result, params.err)
 			}
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNamesParams {
@@ -566,10 +564,10 @@ func TestBuddyService_UnicastBuddyArrived(t *testing.T) {
 			destSession:   newTestSession("dest_screen_name"),
 			mockParams: mockParams{
 				feedbagManagerParams: feedbagManagerParams{
-					feedbagParams: feedbagParams{
+					buddyIconRefByNameParams: buddyIconRefByNameParams{
 						{
 							screenName: state.NewIdentScreenName("src_screen_name"),
-							results:    []wire.FeedbagItem{},
+							result:     nil,
 						},
 					},
 				},
@@ -623,6 +621,18 @@ func TestBuddyService_UnicastBuddyArrived(t *testing.T) {
 							},
 						},
 					},
+					buddyIconRefByNameParams: buddyIconRefByNameParams{
+						{
+							screenName: state.NewIdentScreenName("src_screen_name"),
+							result: &wire.BARTID{
+								Type: wire.BARTTypesBuddyIcon,
+								BARTInfo: wire.BARTInfo{
+									Flags: wire.BARTFlagsKnown,
+									Hash:  []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'},
+								},
+							},
+						},
+					},
 				},
 				messageRelayerParams: messageRelayerParams{
 					relayToScreenNameParams: relayToScreenNameParams{
@@ -656,17 +666,16 @@ func TestBuddyService_UnicastBuddyArrived(t *testing.T) {
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 			feedbagManager := newMockFeedbagManager(t)
-			for _, params := range tc.mockParams.feedbagManagerParams.feedbagParams {
+			for _, params := range tc.mockParams.feedbagManagerParams.buddyIconRefByNameParams {
 				feedbagManager.EXPECT().
-					Feedbag(params.screenName).
-					Return(params.results, nil)
+					BuddyIconRefByName(params.screenName).
+					Return(params.result, params.err)
 			}
 			messageRelayer := newMockMessageRelayer(t)
 			for _, params := range tc.mockParams.messageRelayerParams.relayToScreenNameParams {
 				messageRelayer.EXPECT().
 					RelayToScreenName(mock.Anything, params.screenName, params.message)
 			}
-
 			svc := NewBuddyService(messageRelayer, feedbagManager, nil)
 
 			err := svc.UnicastBuddyArrived(nil, tc.sourceSession, tc.destSession)

+ 58 - 0
foodgroup/mock_feedbag_manager_test.go

@@ -197,6 +197,64 @@ func (_c *mockFeedbagManager_Buddies_Call) RunAndReturn(run func(state.IdentScre
 	return _c
 }
 
+// BuddyIconRefByName provides a mock function with given fields: screenName
+func (_m *mockFeedbagManager) BuddyIconRefByName(screenName state.IdentScreenName) (*wire.BARTID, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BuddyIconRefByName")
+	}
+
+	var r0 *wire.BARTID
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (*wire.BARTID, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) *wire.BARTID); ok {
+		r0 = rf(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.BARTID)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockFeedbagManager_BuddyIconRefByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIconRefByName'
+type mockFeedbagManager_BuddyIconRefByName_Call struct {
+	*mock.Call
+}
+
+// BuddyIconRefByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockFeedbagManager_Expecter) BuddyIconRefByName(screenName interface{}) *mockFeedbagManager_BuddyIconRefByName_Call {
+	return &mockFeedbagManager_BuddyIconRefByName_Call{Call: _e.mock.On("BuddyIconRefByName", screenName)}
+}
+
+func (_c *mockFeedbagManager_BuddyIconRefByName_Call) Run(run func(screenName state.IdentScreenName)) *mockFeedbagManager_BuddyIconRefByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockFeedbagManager_BuddyIconRefByName_Call) Return(_a0 *wire.BARTID, _a1 error) *mockFeedbagManager_BuddyIconRefByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockFeedbagManager_BuddyIconRefByName_Call) RunAndReturn(run func(state.IdentScreenName) (*wire.BARTID, error)) *mockFeedbagManager_BuddyIconRefByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // Feedbag provides a mock function with given fields: screenName
 func (_m *mockFeedbagManager) Feedbag(screenName state.IdentScreenName) ([]wire.FeedbagItem, error) {
 	ret := _m.Called(screenName)

+ 12 - 3
foodgroup/test_helpers.go

@@ -67,8 +67,8 @@ type sessionRetrieverParams struct {
 	retrieveSessionParams
 }
 
-// retrieveSessionParams is a helper struct that contains mock parameters for
-// SessionRetriever methods
+// retrieveSessionParams is the list of parameters passed at the mock
+// SessionRetriever.RetrieveSession call site
 type retrieveSessionParams []struct {
 	screenName state.IdentScreenName
 	result     *state.Session
@@ -257,6 +257,7 @@ type feedbagManagerParams struct {
 	feedbagParams
 	feedbagLastModifiedParams
 	feedbagDeleteParams
+	buddyIconRefByNameParams
 }
 
 // blockedStateParams is the list of parameters passed at the mock
@@ -311,6 +312,14 @@ type feedbagDeleteParams []struct {
 	items      []wire.FeedbagItem
 }
 
+// buddyIconRefByNameParams is the list of parameters passed at the mock
+// FeedbagManager.BuddyIconRefByName call site
+type buddyIconRefByNameParams []struct {
+	screenName state.IdentScreenName
+	result     *wire.BARTID
+	err        error
+}
+
 // messageRelayerParams is a helper struct that contains mock parameters for
 // MessageRelayer methods
 type messageRelayerParams struct {
@@ -348,7 +357,7 @@ type profileManagerParams struct {
 	setProfileParams
 }
 
-// retrieveByScreenNameParams is the list of parameters passed at the mock
+// retrieveProfileParams is the list of parameters passed at the mock
 // ProfileManager.Profile call site
 type retrieveProfileParams []struct {
 	screenName state.IdentScreenName

+ 1 - 0
foodgroup/types.go

@@ -50,6 +50,7 @@ type FeedbagManager interface {
 	FeedbagLastModified(screenName state.IdentScreenName) (time.Time, error)
 	Feedbag(screenName state.IdentScreenName) ([]wire.FeedbagItem, error)
 	FeedbagUpsert(screenName state.IdentScreenName, items []wire.FeedbagItem) error
+	BuddyIconRefByName(screenName state.IdentScreenName) (*wire.BARTID, error)
 }
 
 // LegacyBuddyListManager defines operations for tracking user relationships

+ 183 - 81
server/http/mgmt_api.go

@@ -11,6 +11,7 @@ import (
 	"net/http"
 	"os"
 	"strings"
+	"time"
 
 	"github.com/google/uuid"
 
@@ -27,30 +28,72 @@ func StartManagementAPI(
 	chatRoomCreator ChatRoomCreator,
 	chatSessionRetriever ChatSessionRetriever,
 	messageRelayer MessageRelayer,
+	bartRetriever BARTRetriever,
+	feedbagRetriever FeedBagRetriever,
+	accountRetriever AccountRetriever,
+	profileRetriever ProfileRetriever,
 	logger *slog.Logger,
 ) {
 
 	mux := http.NewServeMux()
-	mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
-		userHandler(w, r, userManager, uuid.New, logger)
+
+	// Handlers for '/user' route
+	mux.HandleFunc("DELETE /user", func(w http.ResponseWriter, r *http.Request) {
+		deleteUserHandler(w, r, userManager, logger)
+	})
+	mux.HandleFunc("GET /user", func(w http.ResponseWriter, r *http.Request) {
+		getUserHandler(w, userManager, logger)
+	})
+	mux.HandleFunc("POST /user", func(w http.ResponseWriter, r *http.Request) {
+		postUserHandler(w, r, userManager, uuid.New, logger)
+	})
+
+	// Handlers for '/user/password' route
+	mux.HandleFunc("PUT /user/password", func(w http.ResponseWriter, r *http.Request) {
+		putUserPasswordHandler(w, r, userManager, logger)
+	})
+
+	// Handlers for '/user/login' route
+	mux.HandleFunc("GET /user/login", func(w http.ResponseWriter, r *http.Request) {
+		getUserLoginHandler(w, r, userManager, logger)
+	})
+
+	// Handlers for '/user/{screenname}/account' route
+	mux.HandleFunc("GET /user/{screenname}/account", func(w http.ResponseWriter, r *http.Request) {
+		getUserAccountHandler(w, r, userManager, accountRetriever, profileRetriever, logger)
+	})
+
+	// Handlers for '/user/{screenname}/icon' route
+	mux.HandleFunc("GET /user/{screenname}/icon", func(w http.ResponseWriter, r *http.Request) {
+		getUserBuddyIconHandler(w, r, userManager, feedbagRetriever, bartRetriever, logger)
 	})
-	mux.HandleFunc("/user/password", func(w http.ResponseWriter, r *http.Request) {
-		userPasswordHandler(w, r, userManager, logger)
+
+	// Handlers for '/session' route
+	mux.HandleFunc("GET /session", func(w http.ResponseWriter, r *http.Request) {
+		getSessionHandler(w, r, sessionRetriever, time.Since)
 	})
-	mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) {
-		loginHandler(w, r, userManager, logger)
+
+	// Handlers for '/session/{screenname}' route
+	mux.HandleFunc("GET /session/{screenname}", func(w http.ResponseWriter, r *http.Request) {
+		getSessionHandler(w, r, sessionRetriever, time.Since)
 	})
-	mux.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) {
-		sessionHandler(w, r, sessionRetriever)
+
+	// Handlers for '/chat/room/public' route
+	mux.HandleFunc("GET /chat/room/public", func(w http.ResponseWriter, r *http.Request) {
+		getPublicChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
 	})
-	mux.HandleFunc("/chat/room/public", func(w http.ResponseWriter, r *http.Request) {
-		publicChatHandler(w, r, chatRoomRetriever, chatRoomCreator, chatSessionRetriever, logger)
+	mux.HandleFunc("POST /chat/room/public", func(w http.ResponseWriter, r *http.Request) {
+		postPublicChatHandler(w, r, chatRoomCreator, logger)
 	})
-	mux.HandleFunc("/chat/room/private", func(w http.ResponseWriter, r *http.Request) {
-		privateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
+
+	// Handlers for '/chat/room/private' route
+	mux.HandleFunc("GET /chat/room/private", func(w http.ResponseWriter, r *http.Request) {
+		getPrivateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
 	})
-	mux.HandleFunc("/instant-message", func(w http.ResponseWriter, r *http.Request) {
-		instantMessageHandler(w, r, messageRelayer, logger)
+
+	// Handlers for '/instant-message' route
+	mux.HandleFunc("POST /instant-message", func(w http.ResponseWriter, r *http.Request) {
+		postInstantMessageHandler(w, r, messageRelayer, logger)
 	})
 
 	addr := net.JoinHostPort(cfg.ApiHost, cfg.ApiPort)
@@ -61,19 +104,7 @@ func StartManagementAPI(
 	}
 }
 
-func userHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, newUUID func() uuid.UUID, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodDelete:
-		deleteUserHandler(w, r, userManager, logger)
-	case http.MethodGet:
-		getUserHandler(w, r, userManager, logger)
-	case http.MethodPost:
-		postUserHandler(w, r, userManager, newUUID, logger)
-	default:
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-	}
-}
-
+// deleteUserHandler handles the DELETE /user endpoint.
 func deleteUserHandler(w http.ResponseWriter, r *http.Request, manager UserManager, logger *slog.Logger) {
 	user, err := userFromBody(r)
 	if err != nil {
@@ -96,15 +127,6 @@ func deleteUserHandler(w http.ResponseWriter, r *http.Request, manager UserManag
 	_, _ = fmt.Fprintln(w, "User account successfully deleted.")
 }
 
-func userPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodPut:
-		putUserPasswordHandler(w, r, userManager, logger)
-	default:
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-	}
-}
-
 // putUserPasswordHandler handles the PUT /user/password endpoint.
 func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
 	input, err := userFromBody(r)
@@ -123,7 +145,7 @@ func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager
 		case errors.Is(err, state.ErrPasswordInvalid):
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
-		case err != nil:
+		default:
 			logger.Error("error updating user password PUT /user/password", "err", err.Error())
 			http.Error(w, "internal server error", http.StatusInternalServerError)
 			return
@@ -134,26 +156,43 @@ func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager
 	_, _ = fmt.Fprintln(w, "Password successfully reset.")
 }
 
-// sessionHandler handles GET /session
-func sessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever SessionRetriever) {
+// getSessionHandler handles GET /session
+func getSessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever SessionRetriever, funcTimeSince func(t time.Time) time.Duration) {
 	w.Header().Set("Content-Type", "application/json")
-	if r.Method != http.MethodGet {
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-		return
-	}
 
-	allUsers := sessionRetriever.AllSessions()
+	var allUsers []*state.Session
+
+	if screenName := r.PathValue("screenname"); screenName != "" {
+		session := sessionRetriever.RetrieveByScreenName(state.NewIdentScreenName(screenName))
+		if session == nil {
+			http.Error(w, "session not found", http.StatusNotFound)
+			return
+		}
+		allUsers = append(allUsers, session)
+	} else {
+		allUsers = sessionRetriever.AllSessions()
+	}
 
 	ou := onlineUsers{
 		Count:    len(allUsers),
-		Sessions: make([]userHandle, len(allUsers)),
+		Sessions: make([]sessionHandle, len(allUsers)),
 	}
 
 	for i, s := range allUsers {
-		ou.Sessions[i] = userHandle{
-			ID:         s.IdentScreenName().String(),
-			ScreenName: s.DisplayScreenName().String(),
-			IsICQ:      s.UIN() > 0,
+		// report 0 if the user is not idle
+		idleSeconds := funcTimeSince(s.IdleTime()).Seconds()
+		if !s.Idle() {
+			idleSeconds = 0
+		}
+		onlineSeconds := funcTimeSince(s.SignonTime()).Seconds()
+
+		ou.Sessions[i] = sessionHandle{
+			ID:            s.IdentScreenName().String(),
+			ScreenName:    s.DisplayScreenName().String(),
+			OnlineSeconds: onlineSeconds,
+			AwayMessage:   s.AwayMessage(),
+			IdleSeconds:   idleSeconds,
+			IsICQ:         s.UIN() > 0,
 		}
 	}
 
@@ -164,7 +203,7 @@ func sessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever Ses
 }
 
 // getUserHandler handles the GET /user endpoint.
-func getUserHandler(w http.ResponseWriter, _ *http.Request, userManager UserManager, logger *slog.Logger) {
+func getUserHandler(w http.ResponseWriter, userManager UserManager, logger *slog.Logger) {
 	w.Header().Set("Content-Type", "application/json")
 
 	users, err := userManager.AllUsers()
@@ -246,9 +285,9 @@ func userFromBody(r *http.Request) (userWithPassword, error) {
 	return user, nil
 }
 
-// loginHandler is a temporary endpoint for validating user credentials for
+// getUserLoginHandler is a temporary endpoint for validating user credentials for
 // chivanet. do not rely on this endpoint, as it will be eventually removed.
-func loginHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
+func getUserLoginHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
 	authHeader := r.Header.Get("Authorization")
 	if authHeader == "" {
 		// No authentication header found
@@ -299,26 +338,6 @@ func loginHandler(w http.ResponseWriter, r *http.Request, userManager UserManage
 	_, _ = w.Write([]byte("200 OK: Successfully Authenticated\n"))
 }
 
-func publicChatHandler(w http.ResponseWriter, r *http.Request, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodGet:
-		getPublicChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
-	case http.MethodPost:
-		postPublicChatHandler(w, r, chatRoomCreator, 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")
@@ -421,15 +440,6 @@ func getPrivateChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetri
 	}
 }
 
-func instantMessageHandler(w http.ResponseWriter, r *http.Request, messageRelayer MessageRelayer, logger *slog.Logger) {
-	switch r.Method {
-	case http.MethodPost:
-		postInstantMessageHandler(w, r, messageRelayer, logger)
-	default:
-		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
-	}
-}
-
 // postIMHandler handles the POST /instant-message endpoint.
 func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, messageRelayer MessageRelayer, logger *slog.Logger) {
 	input := instantMessage{}
@@ -467,3 +477,95 @@ func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, messageRe
 	w.WriteHeader(http.StatusOK)
 	_, _ = fmt.Fprintln(w, "Message sent successfully.")
 }
+
+// getUserBuddyIconHandler handles the GET /user/{screenname}/icon endpoint.
+func getUserBuddyIconHandler(w http.ResponseWriter, r *http.Request, u UserManager, f FeedBagRetriever, b BARTRetriever, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "image/gif")
+
+	screenName := state.NewIdentScreenName(r.PathValue("screenname"))
+	user, err := u.User(screenName)
+	if err != nil {
+		logger.Error("error retrieving user", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if user == nil {
+		http.Error(w, "user not found", http.StatusNotFound)
+		return
+	}
+	iconRef, err := f.BuddyIconRefByName(screenName)
+	if err != nil {
+		logger.Error("error retrieving buddy icon ref", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if iconRef == nil || iconRef.HasClearIconHash() {
+		http.Error(w, "icon not found", http.StatusNotFound)
+		return
+	}
+	icon, err := b.BARTRetrieve(iconRef.Hash)
+	if err != nil {
+		logger.Error("error retrieving buddy icon bart item", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	w.Write(icon)
+}
+
+// getUserAccountHandler handles the GET /user/{screenname}/account endpoint.
+func getUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, a AccountRetriever, p ProfileRetriever, logger *slog.Logger) {
+	w.Header().Set("Content-Type", "application/json")
+
+	screenName := r.PathValue("screenname")
+	user, err := userManager.User(state.NewIdentScreenName(screenName))
+	if err != nil {
+		logger.Error("error in GET /user/{screenname}/account", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	if user == nil {
+		http.Error(w, "user not found", http.StatusNotFound)
+		return
+	}
+
+	emailAddress := ""
+	email, err := a.EmailAddressByName(user.IdentScreenName)
+	if err != nil {
+		emailAddress = ""
+	} else {
+		emailAddress = email.String()
+	}
+	regStatus, err := a.RegStatusByName(user.IdentScreenName)
+	if err != nil {
+		logger.Error("error in GET /user/*/account RegStatus", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	confirmStatus, err := a.ConfirmStatusByName(user.IdentScreenName)
+	if err != nil {
+		logger.Error("error in GET /user/*/account ConfirmStatus", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+	profile, err := p.Profile(user.IdentScreenName)
+	if err != nil {
+		logger.Error("error in GET /user/*/account Profile", "err", err.Error())
+		http.Error(w, "internal server error", http.StatusInternalServerError)
+		return
+	}
+
+	out := userAccountHandle{
+		ID:           user.IdentScreenName.String(),
+		ScreenName:   user.DisplayScreenName.String(),
+		EmailAddress: emailAddress,
+		RegStatus:    regStatus,
+		Confirmed:    confirmStatus,
+		Profile:      profile,
+		IsICQ:        user.IsICQ,
+	}
+
+	if err := json.NewEncoder(w).Encode(out); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}

Разница между файлами не показана из-за своего большого размера
+ 655 - 214
server/http/mgmt_api_test.go


+ 207 - 0
server/http/mock_AccountRetriever.go

@@ -0,0 +1,207 @@
+// Code generated by mockery v2.45.0. DO NOT EDIT.
+
+package http
+
+import (
+	mail "net/mail"
+
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockAccountRetriever is an autogenerated mock type for the AccountRetriever type
+type mockAccountRetriever struct {
+	mock.Mock
+}
+
+type mockAccountRetriever_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockAccountRetriever) EXPECT() *mockAccountRetriever_Expecter {
+	return &mockAccountRetriever_Expecter{mock: &_m.Mock}
+}
+
+// ConfirmStatusByName provides a mock function with given fields: screnName
+func (_m *mockAccountRetriever) ConfirmStatusByName(screnName state.IdentScreenName) (bool, error) {
+	ret := _m.Called(screnName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ConfirmStatusByName")
+	}
+
+	var r0 bool
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (bool, error)); ok {
+		return rf(screnName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) bool); ok {
+		r0 = rf(screnName)
+	} else {
+		r0 = ret.Get(0).(bool)
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screnName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountRetriever_ConfirmStatusByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConfirmStatusByName'
+type mockAccountRetriever_ConfirmStatusByName_Call struct {
+	*mock.Call
+}
+
+// ConfirmStatusByName is a helper method to define mock.On call
+//   - screnName state.IdentScreenName
+func (_e *mockAccountRetriever_Expecter) ConfirmStatusByName(screnName interface{}) *mockAccountRetriever_ConfirmStatusByName_Call {
+	return &mockAccountRetriever_ConfirmStatusByName_Call{Call: _e.mock.On("ConfirmStatusByName", screnName)}
+}
+
+func (_c *mockAccountRetriever_ConfirmStatusByName_Call) Run(run func(screnName state.IdentScreenName)) *mockAccountRetriever_ConfirmStatusByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountRetriever_ConfirmStatusByName_Call) Return(_a0 bool, _a1 error) *mockAccountRetriever_ConfirmStatusByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountRetriever_ConfirmStatusByName_Call) RunAndReturn(run func(state.IdentScreenName) (bool, error)) *mockAccountRetriever_ConfirmStatusByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// EmailAddressByName provides a mock function with given fields: screenName
+func (_m *mockAccountRetriever) EmailAddressByName(screenName state.IdentScreenName) (*mail.Address, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for EmailAddressByName")
+	}
+
+	var r0 *mail.Address
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (*mail.Address, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) *mail.Address); ok {
+		r0 = rf(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*mail.Address)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountRetriever_EmailAddressByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EmailAddressByName'
+type mockAccountRetriever_EmailAddressByName_Call struct {
+	*mock.Call
+}
+
+// EmailAddressByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockAccountRetriever_Expecter) EmailAddressByName(screenName interface{}) *mockAccountRetriever_EmailAddressByName_Call {
+	return &mockAccountRetriever_EmailAddressByName_Call{Call: _e.mock.On("EmailAddressByName", screenName)}
+}
+
+func (_c *mockAccountRetriever_EmailAddressByName_Call) Run(run func(screenName state.IdentScreenName)) *mockAccountRetriever_EmailAddressByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountRetriever_EmailAddressByName_Call) Return(_a0 *mail.Address, _a1 error) *mockAccountRetriever_EmailAddressByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountRetriever_EmailAddressByName_Call) RunAndReturn(run func(state.IdentScreenName) (*mail.Address, error)) *mockAccountRetriever_EmailAddressByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RegStatusByName provides a mock function with given fields: screenName
+func (_m *mockAccountRetriever) RegStatusByName(screenName state.IdentScreenName) (uint16, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for RegStatusByName")
+	}
+
+	var r0 uint16
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (uint16, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) uint16); ok {
+		r0 = rf(screenName)
+	} else {
+		r0 = ret.Get(0).(uint16)
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockAccountRetriever_RegStatusByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegStatusByName'
+type mockAccountRetriever_RegStatusByName_Call struct {
+	*mock.Call
+}
+
+// RegStatusByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockAccountRetriever_Expecter) RegStatusByName(screenName interface{}) *mockAccountRetriever_RegStatusByName_Call {
+	return &mockAccountRetriever_RegStatusByName_Call{Call: _e.mock.On("RegStatusByName", screenName)}
+}
+
+func (_c *mockAccountRetriever_RegStatusByName_Call) Run(run func(screenName state.IdentScreenName)) *mockAccountRetriever_RegStatusByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockAccountRetriever_RegStatusByName_Call) Return(_a0 uint16, _a1 error) *mockAccountRetriever_RegStatusByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockAccountRetriever_RegStatusByName_Call) RunAndReturn(run func(state.IdentScreenName) (uint16, error)) *mockAccountRetriever_RegStatusByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockAccountRetriever creates a new instance of mockAccountRetriever. 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 newMockAccountRetriever(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockAccountRetriever {
+	mock := &mockAccountRetriever{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 90 - 0
server/http/mock_BARTRetriever.go

@@ -0,0 +1,90 @@
+// Code generated by mockery v2.45.0. DO NOT EDIT.
+
+package http
+
+import mock "github.com/stretchr/testify/mock"
+
+// mockBARTRetriever is an autogenerated mock type for the BARTRetriever type
+type mockBARTRetriever struct {
+	mock.Mock
+}
+
+type mockBARTRetriever_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockBARTRetriever) EXPECT() *mockBARTRetriever_Expecter {
+	return &mockBARTRetriever_Expecter{mock: &_m.Mock}
+}
+
+// BARTRetrieve provides a mock function with given fields: itemHash
+func (_m *mockBARTRetriever) BARTRetrieve(itemHash []byte) ([]byte, error) {
+	ret := _m.Called(itemHash)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BARTRetrieve")
+	}
+
+	var r0 []byte
+	var r1 error
+	if rf, ok := ret.Get(0).(func([]byte) ([]byte, error)); ok {
+		return rf(itemHash)
+	}
+	if rf, ok := ret.Get(0).(func([]byte) []byte); ok {
+		r0 = rf(itemHash)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).([]byte)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func([]byte) error); ok {
+		r1 = rf(itemHash)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockBARTRetriever_BARTRetrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BARTRetrieve'
+type mockBARTRetriever_BARTRetrieve_Call struct {
+	*mock.Call
+}
+
+// BARTRetrieve is a helper method to define mock.On call
+//   - itemHash []byte
+func (_e *mockBARTRetriever_Expecter) BARTRetrieve(itemHash interface{}) *mockBARTRetriever_BARTRetrieve_Call {
+	return &mockBARTRetriever_BARTRetrieve_Call{Call: _e.mock.On("BARTRetrieve", itemHash)}
+}
+
+func (_c *mockBARTRetriever_BARTRetrieve_Call) Run(run func(itemHash []byte)) *mockBARTRetriever_BARTRetrieve_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].([]byte))
+	})
+	return _c
+}
+
+func (_c *mockBARTRetriever_BARTRetrieve_Call) Return(_a0 []byte, _a1 error) *mockBARTRetriever_BARTRetrieve_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockBARTRetriever_BARTRetrieve_Call) RunAndReturn(run func([]byte) ([]byte, error)) *mockBARTRetriever_BARTRetrieve_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockBARTRetriever creates a new instance of mockBARTRetriever. 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 newMockBARTRetriever(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockBARTRetriever {
+	mock := &mockBARTRetriever{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 95 - 0
server/http/mock_FeedBagRetriever.go

@@ -0,0 +1,95 @@
+// Code generated by mockery v2.45.0. DO NOT EDIT.
+
+package http
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+
+	wire "github.com/mk6i/retro-aim-server/wire"
+)
+
+// mockFeedBagRetriever is an autogenerated mock type for the FeedBagRetriever type
+type mockFeedBagRetriever struct {
+	mock.Mock
+}
+
+type mockFeedBagRetriever_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockFeedBagRetriever) EXPECT() *mockFeedBagRetriever_Expecter {
+	return &mockFeedBagRetriever_Expecter{mock: &_m.Mock}
+}
+
+// BuddyIconRefByName provides a mock function with given fields: screenName
+func (_m *mockFeedBagRetriever) BuddyIconRefByName(screenName state.IdentScreenName) (*wire.BARTID, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for BuddyIconRefByName")
+	}
+
+	var r0 *wire.BARTID
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (*wire.BARTID, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) *wire.BARTID); ok {
+		r0 = rf(screenName)
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(*wire.BARTID)
+		}
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockFeedBagRetriever_BuddyIconRefByName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuddyIconRefByName'
+type mockFeedBagRetriever_BuddyIconRefByName_Call struct {
+	*mock.Call
+}
+
+// BuddyIconRefByName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockFeedBagRetriever_Expecter) BuddyIconRefByName(screenName interface{}) *mockFeedBagRetriever_BuddyIconRefByName_Call {
+	return &mockFeedBagRetriever_BuddyIconRefByName_Call{Call: _e.mock.On("BuddyIconRefByName", screenName)}
+}
+
+func (_c *mockFeedBagRetriever_BuddyIconRefByName_Call) Run(run func(screenName state.IdentScreenName)) *mockFeedBagRetriever_BuddyIconRefByName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockFeedBagRetriever_BuddyIconRefByName_Call) Return(_a0 *wire.BARTID, _a1 error) *mockFeedBagRetriever_BuddyIconRefByName_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockFeedBagRetriever_BuddyIconRefByName_Call) RunAndReturn(run func(state.IdentScreenName) (*wire.BARTID, error)) *mockFeedBagRetriever_BuddyIconRefByName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockFeedBagRetriever creates a new instance of mockFeedBagRetriever. 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 newMockFeedBagRetriever(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockFeedBagRetriever {
+	mock := &mockFeedBagRetriever{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 91 - 0
server/http/mock_ProfileRetriever.go

@@ -0,0 +1,91 @@
+// Code generated by mockery v2.45.0. DO NOT EDIT.
+
+package http
+
+import (
+	state "github.com/mk6i/retro-aim-server/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockProfileRetriever is an autogenerated mock type for the ProfileRetriever type
+type mockProfileRetriever struct {
+	mock.Mock
+}
+
+type mockProfileRetriever_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockProfileRetriever) EXPECT() *mockProfileRetriever_Expecter {
+	return &mockProfileRetriever_Expecter{mock: &_m.Mock}
+}
+
+// Profile provides a mock function with given fields: screenName
+func (_m *mockProfileRetriever) Profile(screenName state.IdentScreenName) (string, error) {
+	ret := _m.Called(screenName)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Profile")
+	}
+
+	var r0 string
+	var r1 error
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) (string, error)); ok {
+		return rf(screenName)
+	}
+	if rf, ok := ret.Get(0).(func(state.IdentScreenName) string); ok {
+		r0 = rf(screenName)
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	if rf, ok := ret.Get(1).(func(state.IdentScreenName) error); ok {
+		r1 = rf(screenName)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// mockProfileRetriever_Profile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Profile'
+type mockProfileRetriever_Profile_Call struct {
+	*mock.Call
+}
+
+// Profile is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockProfileRetriever_Expecter) Profile(screenName interface{}) *mockProfileRetriever_Profile_Call {
+	return &mockProfileRetriever_Profile_Call{Call: _e.mock.On("Profile", screenName)}
+}
+
+func (_c *mockProfileRetriever_Profile_Call) Run(run func(screenName state.IdentScreenName)) *mockProfileRetriever_Profile_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockProfileRetriever_Profile_Call) Return(_a0 string, _a1 error) *mockProfileRetriever_Profile_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *mockProfileRetriever_Profile_Call) RunAndReturn(run func(state.IdentScreenName) (string, error)) *mockProfileRetriever_Profile_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockProfileRetriever creates a new instance of mockProfileRetriever. 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 newMockProfileRetriever(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockProfileRetriever {
+	mock := &mockProfileRetriever{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 48 - 0
server/http/mock_session_retriever_test.go

@@ -67,6 +67,54 @@ func (_c *mockSessionRetriever_AllSessions_Call) RunAndReturn(run func() []*stat
 	return _c
 }
 
+// RetrieveByScreenName provides a mock function with given fields: screenName
+func (_m *mockSessionRetriever) 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
+}
+
+// mockSessionRetriever_RetrieveByScreenName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RetrieveByScreenName'
+type mockSessionRetriever_RetrieveByScreenName_Call struct {
+	*mock.Call
+}
+
+// RetrieveByScreenName is a helper method to define mock.On call
+//   - screenName state.IdentScreenName
+func (_e *mockSessionRetriever_Expecter) RetrieveByScreenName(screenName interface{}) *mockSessionRetriever_RetrieveByScreenName_Call {
+	return &mockSessionRetriever_RetrieveByScreenName_Call{Call: _e.mock.On("RetrieveByScreenName", screenName)}
+}
+
+func (_c *mockSessionRetriever_RetrieveByScreenName_Call) Run(run func(screenName state.IdentScreenName)) *mockSessionRetriever_RetrieveByScreenName_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.IdentScreenName))
+	})
+	return _c
+}
+
+func (_c *mockSessionRetriever_RetrieveByScreenName_Call) Return(_a0 *state.Session) *mockSessionRetriever_RetrieveByScreenName_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockSessionRetriever_RetrieveByScreenName_Call) RunAndReturn(run func(state.IdentScreenName) *state.Session) *mockSessionRetriever_RetrieveByScreenName_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // newMockSessionRetriever creates a new instance of mockSessionRetriever. 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 newMockSessionRetriever(t interface {

+ 217 - 0
server/http/test_helpers.go

@@ -0,0 +1,217 @@
+package http
+
+import (
+	"context"
+	"net/mail"
+
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+type mockParams struct {
+	accountRetrieverParams
+	bartRetrieverParams
+	chatRoomCreatorParams
+	chatRoomRetrieverParams
+	chatSessionRetrieverParams
+	feedBagRetrieverParams
+	messageRelayerParams
+	profileRetrieverParams
+	sessionRetrieverParams
+	userManagerParams
+}
+
+// accountRetrieverParams is a helper struct that contains mock parameters for
+// accountRetriever methods
+type accountRetrieverParams struct {
+	emailAddressByNameParams
+	regStatusByNameParams
+	confirmStatusByNameParams
+}
+
+// emailAddressByNameParams is the list of parameters passed at the mock
+// accountRetriever.EmailAddressByName call site
+type emailAddressByNameParams []struct {
+	screenName state.IdentScreenName
+	result     *mail.Address
+	err        error
+}
+
+// regStatusByNameParams is the list of parameters passed at the mock
+// accountRetriever.RegStatusByName call site
+type regStatusByNameParams []struct {
+	screenName state.IdentScreenName
+	result     uint16
+	err        error
+}
+
+// confirmStatusByNameParams is the list of parameters passed at the mock
+// accountRetriever.ConfirmStatusByName call site
+type confirmStatusByNameParams []struct {
+	screenName state.IdentScreenName
+	result     bool
+	err        error
+}
+
+// bartRetrieverParams is a helper struct that contains mock parameters for
+// BARTRetriever methods
+type bartRetrieverParams struct {
+	bartRetrieveParams
+}
+
+// bartRetrieveParams is the list of parameters passed at the mock
+// BARTRetriever.BARTRetrieveParams call site
+type bartRetrieveParams []struct {
+	itemHash []byte
+	result   []byte
+	err      error
+}
+
+// chatRoomCreatorParams is a helper struct that contains mock parameters for
+// ChatRoomCreator methods
+type chatRoomCreatorParams struct {
+	createChatRoomParams
+}
+
+// createChatRoomParams is the list of parameters passed at the mock
+// ChatRoomCreator.CreateChatRoom call site
+type createChatRoomParams []struct {
+	chatRoom *state.ChatRoom
+	err      error
+}
+
+// chatRoomRetrieverParams is a helper struct that contains mock parameters for
+// ChatRoomRetriever methods
+type chatRoomRetrieverParams struct {
+	allChatRoomsParams
+}
+
+// allChatRoomsParams is the list of parameters passed at the mock
+// ChatRoomRetriever.AllChatRooms call site
+type allChatRoomsParams []struct {
+	exchange uint16
+	result   []state.ChatRoom
+	err      error
+}
+
+// chatRoomRetrieverParams is a helper struct that contains mock parameters for
+// ChatRoomRetriever methods
+type chatSessionRetrieverParams struct {
+	chatSessionRetrieverAllSessionsParams
+}
+
+// chatSessionRetrieverAllSessionsParams is the list of parameters passed at the mock
+// ChatSessionRetriever.AllSessions call site
+type chatSessionRetrieverAllSessionsParams []struct {
+	cookie string
+	result []*state.Session
+}
+
+// feedBagRetrieverParams is a helper struct that contains mock parameters for
+// FeedBagRetriever methods
+type feedBagRetrieverParams struct {
+	buddyIconRefByNameParams
+}
+
+// buddyIconRefByNameParams is the list of parameters passed at the mock
+// FeedBagRetriever.BuddyIconRefByNameParams call site
+type buddyIconRefByNameParams []struct {
+	screenName state.IdentScreenName
+	result     *wire.BARTID
+	err        error
+}
+
+// messageRelayerParams is a helper struct that contains mock parameters for
+// MessageRelayer methods
+type messageRelayerParams struct {
+	relayToScreenNameParams
+}
+
+// relayToScreenNameParams is the list of parameters passed at the mock
+// MessageRelayer.RelayToScreenNameParams call site
+type relayToScreenNameParams []struct {
+	ctx        context.Context
+	screenName state.IdentScreenName
+	msg        wire.SNACMessage
+}
+
+// profileRetrieverParams is a helper struct that contains mock parameters for
+// ProfileRetriever methods
+type profileRetrieverParams struct {
+	retrieveProfileParams
+}
+
+// retrieveProfileParams is the list of parameters passed at the mock
+// ProfileRetriever.Profile call site
+type retrieveProfileParams []struct {
+	screenName state.IdentScreenName
+	result     string
+	err        error
+}
+
+// sessionRetrieverParams is a helper struct that contains mock parameters for
+// SessionRetriever methods
+type sessionRetrieverParams struct {
+	sessionRetrieverAllSessionsParams
+	retrieveSessionByNameParams
+}
+
+// sessionRetrieverAllSessionsParams is the list of parameters passed at the mock
+// SessionRetriever.AllSessions call site
+type sessionRetrieverAllSessionsParams []struct {
+	result []*state.Session
+}
+
+// retrieveSessionParams is the list of parameters passed at the mock
+// SessionRetriever.RetrieveSessionByName call site
+type retrieveSessionByNameParams []struct {
+	screenName state.IdentScreenName
+	result     *state.Session
+}
+
+// userManagerParams is a helper struct that contains mock parameters for
+// UserManager methods
+type userManagerParams struct {
+	allUsersParams
+	deleteUserParams
+	getUserParams
+	insertUserParams
+	setUserPasswordParams
+}
+
+// allUsersParams is the list of parameters passed at the mock
+// UserManager.AllUsers call site
+type allUsersParams []struct {
+	result []state.User
+	err    error
+}
+
+// deleteUserParams is the list of parameters passed at the mock
+// UserManager.DeleteUser call site
+type deleteUserParams []struct {
+	screenName state.IdentScreenName
+	err        error
+}
+
+// getUserParams is the list of parameters passed at the mock
+// UserManager.User call site
+type getUserParams []struct {
+	screenName state.IdentScreenName
+	result     *state.User
+	err        error
+}
+
+// insertUserParams is the list of parameters passed at the mock
+// UserManager.InsertUser call site
+type insertUserParams []struct {
+	u   state.User
+	err error
+}
+
+// setUserPasswordParams is the list of parameters passed at the mock
+// UserManager.SetUserPassword call site
+type setUserPasswordParams []struct {
+	screenName  state.IdentScreenName
+	newPassword string
+	err         error
+}

+ 41 - 2
server/http/types.go

@@ -2,6 +2,7 @@ package http
 
 import (
 	"context"
+	"net/mail"
 	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
@@ -22,6 +23,7 @@ type ChatSessionRetriever interface {
 
 type SessionRetriever interface {
 	AllSessions() []*state.Session
+	RetrieveByScreenName(screenName state.IdentScreenName) *state.Session
 }
 
 type UserManager interface {
@@ -36,14 +38,28 @@ type MessageRelayer interface {
 	RelayToScreenName(ctx context.Context, screenName state.IdentScreenName, msg wire.SNACMessage)
 }
 
+type AccountRetriever interface {
+	EmailAddressByName(screenName state.IdentScreenName) (*mail.Address, error)
+	RegStatusByName(screenName state.IdentScreenName) (uint16, error)
+	ConfirmStatusByName(screnName state.IdentScreenName) (bool, error)
+}
+
+type BARTRetriever interface {
+	BARTRetrieve(itemHash []byte) ([]byte, error)
+}
+
+type FeedBagRetriever interface {
+	BuddyIconRefByName(screenName state.IdentScreenName) (*wire.BARTID, error)
+}
+
 type userWithPassword struct {
 	ScreenName string `json:"screen_name"`
 	Password   string `json:"password,omitempty"`
 }
 
 type onlineUsers struct {
-	Count    int          `json:"count"`
-	Sessions []userHandle `json:"sessions"`
+	Count    int             `json:"count"`
+	Sessions []sessionHandle `json:"sessions"`
 }
 
 type userHandle struct {
@@ -57,6 +73,29 @@ type aimChatUserHandle struct {
 	ScreenName string `json:"screen_name"`
 }
 
+type userAccountHandle struct {
+	ID           string `json:"id"`
+	ScreenName   string `json:"screen_name"`
+	Profile      string `json:"profile"`
+	EmailAddress string `json:"email_address"`
+	RegStatus    uint16 `json:"reg_status"`
+	Confirmed    bool   `json:"confirmed"`
+	IsICQ        bool   `json:"is_icq"`
+}
+
+type sessionHandle struct {
+	ID            string  `json:"id"`
+	ScreenName    string  `json:"screen_name"`
+	OnlineSeconds float64 `json:"online_seconds"`
+	AwayMessage   string  `json:"away_message"`
+	IdleSeconds   float64 `json:"idle_seconds"`
+	IsICQ         bool    `json:"is_icq"`
+}
+
+type ProfileRetriever interface {
+	Profile(screenName state.IdentScreenName) (string, error)
+}
+
 type chatRoomCreate struct {
 	Name string `json:"name"`
 }

+ 91 - 0
server/oscar/mock_HandlerFunc.go

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

+ 82 - 0
server/oscar/mock_ResponseWriter.go

@@ -0,0 +1,82 @@
+// Code generated by mockery v2.45.0. DO NOT EDIT.
+
+package oscar
+
+import (
+	wire "github.com/mk6i/retro-aim-server/wire"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockResponseWriter is an autogenerated mock type for the ResponseWriter type
+type mockResponseWriter struct {
+	mock.Mock
+}
+
+type mockResponseWriter_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockResponseWriter) EXPECT() *mockResponseWriter_Expecter {
+	return &mockResponseWriter_Expecter{mock: &_m.Mock}
+}
+
+// SendSNAC provides a mock function with given fields: frame, body
+func (_m *mockResponseWriter) SendSNAC(frame wire.SNACFrame, body interface{}) error {
+	ret := _m.Called(frame, body)
+
+	if len(ret) == 0 {
+		panic("no return value specified for SendSNAC")
+	}
+
+	var r0 error
+	if rf, ok := ret.Get(0).(func(wire.SNACFrame, interface{}) error); ok {
+		r0 = rf(frame, body)
+	} else {
+		r0 = ret.Error(0)
+	}
+
+	return r0
+}
+
+// mockResponseWriter_SendSNAC_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendSNAC'
+type mockResponseWriter_SendSNAC_Call struct {
+	*mock.Call
+}
+
+// SendSNAC is a helper method to define mock.On call
+//   - frame wire.SNACFrame
+//   - body interface{}
+func (_e *mockResponseWriter_Expecter) SendSNAC(frame interface{}, body interface{}) *mockResponseWriter_SendSNAC_Call {
+	return &mockResponseWriter_SendSNAC_Call{Call: _e.mock.On("SendSNAC", frame, body)}
+}
+
+func (_c *mockResponseWriter_SendSNAC_Call) Run(run func(frame wire.SNACFrame, body interface{})) *mockResponseWriter_SendSNAC_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(wire.SNACFrame), args[1].(interface{}))
+	})
+	return _c
+}
+
+func (_c *mockResponseWriter_SendSNAC_Call) Return(_a0 error) *mockResponseWriter_SendSNAC_Call {
+	_c.Call.Return(_a0)
+	return _c
+}
+
+func (_c *mockResponseWriter_SendSNAC_Call) RunAndReturn(run func(wire.SNACFrame, interface{}) error) *mockResponseWriter_SendSNAC_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockResponseWriter creates a new instance of mockResponseWriter. 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 newMockResponseWriter(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockResponseWriter {
+	mock := &mockResponseWriter{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 21 - 0
state/session.go

@@ -138,6 +138,27 @@ func (s *Session) SetSignonTime(t time.Time) {
 	s.signonTime = t
 }
 
+// SignonTime reports when the user signed on
+func (s *Session) SignonTime() time.Time {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.signonTime
+}
+
+// Idle reports the user's idle state.
+func (s *Session) Idle() bool {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.idle
+}
+
+// IdleTime reports when the user went idle
+func (s *Session) IdleTime() time.Time {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.idleTime
+}
+
 // SetIdle sets the user's idle state.
 func (s *Session) SetIdle(dur time.Duration) {
 	s.mutex.Lock()

+ 42 - 0
state/user_store.go

@@ -1281,3 +1281,45 @@ func (f SQLiteUserStore) DeleteMessages(recip IdentScreenName) error {
 	_, err := f.db.Exec(q, recip.String())
 	return err
 }
+
+// BuddyIconRefByName retrieves the buddy icon reference for a given user
+func (f SQLiteUserStore) BuddyIconRefByName(screenName IdentScreenName) (*wire.BARTID, error) {
+	q := `
+		SELECT
+			groupID,
+			itemID,
+			classID,
+			name,
+			attributes
+		FROM feedBag
+		WHERE screenname = ? AND name = ? AND classID = ?
+	`
+	var item wire.FeedbagItem
+	var attrs []byte
+	err := f.db.QueryRow(q, screenName.String(), wire.BARTTypesBuddyIcon, wire.FeedbagClassIdBart).Scan(&item.GroupID, &item.ItemID, &item.ClassID, &item.Name, &attrs)
+	if errors.Is(err, sql.ErrNoRows) {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	if err := wire.UnmarshalBE(&item.TLVLBlock, bytes.NewBuffer(attrs)); err != nil {
+		return nil, err
+	}
+	b, hasBuf := item.Slice(wire.FeedbagAttributesBartInfo)
+	if !hasBuf {
+		return nil, errors.New("unable to extract icon payload")
+	}
+	bartInfo := wire.BARTInfo{}
+	if err := wire.UnmarshalBE(&bartInfo, bytes.NewBuffer(b)); err != nil {
+		return nil, err
+	}
+	return &wire.BARTID{
+		Type: wire.BARTTypesBuddyIcon,
+		BARTInfo: wire.BARTInfo{
+			Flags: bartInfo.Flags,
+			Hash:  bartInfo.Hash,
+		},
+	}, nil
+
+}

+ 72 - 0
state/user_store_test.go

@@ -1796,3 +1796,75 @@ func TestSQLiteUserStore_DeleteMessages(t *testing.T) {
 		assert.Len(t, messages, 1)
 	})
 }
+
+func TestSQLiteUserStore_BuddyIconRefByNameExistingRef(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+	screenName := NewIdentScreenName("TalkingTyler")
+	testHash := []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'}
+
+	feedbagStore, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	itemsIn := []wire.FeedbagItem{
+		{
+			Name:    "1",
+			ClassID: wire.FeedbagClassIdBart,
+			TLVLBlock: wire.TLVLBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+						Hash: testHash,
+					}),
+				},
+			},
+		},
+	}
+	if err := feedbagStore.FeedbagUpsert(screenName, itemsIn); err != nil {
+		t.Fatalf("failed to upsert: %s", err.Error())
+	}
+
+	b, err := feedbagStore.BuddyIconRefByName(screenName)
+	assert.NoError(t, err)
+
+	if !reflect.DeepEqual(b.BARTInfo.Hash, testHash) {
+		t.Fatalf("expected hash did not match")
+	}
+}
+
+func TestSQLiteUserStore_BuddyIconRefByNameMissingRef(t *testing.T) {
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	existingScreenName := NewIdentScreenName("TalkingTyler")
+	queryScreenName := NewIdentScreenName("SingingSuzy")
+	testHash := []byte{'t', 'h', 'e', 'h', 'a', 's', 'h'}
+
+	feedbagStore, err := NewSQLiteUserStore(testFile)
+	assert.NoError(t, err)
+
+	itemsIn := []wire.FeedbagItem{
+		{
+			Name:    "1",
+			ClassID: wire.FeedbagClassIdBart,
+			TLVLBlock: wire.TLVLBlock{
+				TLVList: wire.TLVList{
+					wire.NewTLV(wire.FeedbagAttributesBartInfo, wire.BARTInfo{
+						Hash: testHash,
+					}),
+				},
+			},
+		},
+	}
+	if err := feedbagStore.FeedbagUpsert(existingScreenName, itemsIn); err != nil {
+		t.Fatalf("failed to upsert: %s", err.Error())
+	}
+
+	b, err := feedbagStore.BuddyIconRefByName(queryScreenName)
+	assert.NoError(t, err)
+
+	if b != nil {
+		t.Fatalf("empty BARTID expected")
+	}
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов