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

webapi: route presence and profile through LocateService

Mike 2 недель назад
Родитель
Сommit
bdbd0a9809

+ 12 - 11
cmd/server/factory.go

@@ -516,10 +516,19 @@ func TOC(deps Container) *toc.Server {
 func WebAPI(deps Container) *webapi.Server {
 	logger := deps.logger.With("svc", "webapi")
 
+	locateService := foodgroup.NewLocateService(
+		deps.sqLiteUserStore,
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+		deps.sqLiteUserStore,
+		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
+	)
+
 	// Create WebAPI buddy list manager (local to WebAPI)
 	buddyListManager := handlers.NewBuddyListManager(
 		deps.feedbagSvc,
-		deps.inMemorySessionManager,
+		locateService,
 		logger,
 	)
 
@@ -552,15 +561,8 @@ func WebAPI(deps Container) *webapi.Server {
 		BuddyListRegistry: deps.sqLiteUserStore,
 		CookieBaker:       deps.hmacCookieBaker,
 		ICBMService:       deps.icbmSvc,
-		LocateService: foodgroup.NewLocateService(
-			deps.sqLiteUserStore,
-			deps.inMemorySessionManager,
-			deps.sqLiteUserStore,
-			deps.sqLiteUserStore,
-			deps.inMemorySessionManager,
-			deps.sqLiteUserStore,
-		),
-		Logger: logger,
+		LocateService:     locateService,
+		Logger:            logger,
 		OServiceService: foodgroup.NewOServiceService(
 			deps.cfg,
 			deps.inMemorySessionManager,
@@ -580,7 +582,6 @@ func WebAPI(deps Container) *webapi.Server {
 		SessionRetriever: deps.inMemorySessionManager,
 		// Phase 2 additions
 		BuddyBroadcaster: oscarBuddyBroadcaster,
-		ProfileManager:   deps.sqLiteUserStore,
 		// Phase 3 additions
 		PreferenceManager: deps.sqLiteUserStore.NewWebPreferenceManager(),
 		// Phase 4 additions for OSCAR Bridge

+ 0 - 1
server/webapi/handler.go

@@ -22,7 +22,6 @@ type Handler struct {
 	SessionRetriever SessionRetriever
 	// Phase 2 additions
 	BuddyBroadcaster BuddyBroadcaster
-	ProfileManager   ProfileManager
 	// Phase 3 additions
 	PreferenceManager PreferenceManager
 	// Phase 4 additions for OSCAR Bridge

+ 43 - 65
server/webapi/handlers/buddy_list_manager.go

@@ -8,7 +8,6 @@ import (
 	"math/rand"
 	"slices"
 	"strings"
-	"time"
 
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
@@ -17,17 +16,17 @@ import (
 // BuddyListManager handles the conversion of OSCAR feedbag data
 // to WebAPI buddy list format for web clients.
 type BuddyListManager struct {
-	feedbagService   FeedbagService
-	sessionRetriever SessionRetriever
-	logger           *slog.Logger
+	feedbagService FeedbagService
+	locateService  LocateService
+	logger         *slog.Logger
 }
 
 // NewBuddyListManager creates a new instance of the buddy list manager.
-func NewBuddyListManager(feedbagService FeedbagService, sessionRetriever SessionRetriever, logger *slog.Logger) *BuddyListManager {
+func NewBuddyListManager(feedbagService FeedbagService, locateService LocateService, logger *slog.Logger) *BuddyListManager {
 	return &BuddyListManager{
-		feedbagService:   feedbagService,
-		sessionRetriever: sessionRetriever,
-		logger:           logger,
+		feedbagService: feedbagService,
+		locateService:  locateService,
+		logger:         logger,
 	}
 }
 
@@ -154,7 +153,7 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.
 			if !ok {
 				continue
 			}
-			info := m.getBuddyInfo(b.name)
+			info := m.getBuddyInfo(ctx, sess.OSCARSession, b.name)
 			if b.alias != "" {
 				info.DisplayID = b.alias
 			}
@@ -166,8 +165,9 @@ func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.
 	return out, nil
 }
 
-// getBuddyInfo retrieves the current presence information for a buddy.
-func (m *BuddyListManager) getBuddyInfo(buddyName string) WebAPIBuddyInfo {
+// getBuddyInfo retrieves a buddy's current presence by issuing a locate
+// UserInfoQuery on behalf of the requesting session's OSCAR instance.
+func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.SessionInstance, buddyName string) WebAPIBuddyInfo {
 	// Default to offline
 	info := WebAPIBuddyInfo{
 		AimID:     buddyName,
@@ -178,71 +178,49 @@ func (m *BuddyListManager) getBuddyInfo(buddyName string) WebAPIBuddyInfo {
 		Service:   "AIM",
 	}
 
-	// Check if buddy is online
-	buddyScreenName := state.NewIdentScreenName(buddyName)
-	session := m.sessionRetriever.RetrieveSession(buddyScreenName)
-
-	if session != nil {
-		// Buddy is online
-		info.State = "online"
-		info.OnlineTime = session.SignonTime().Unix()
-
-		// Check away status
-		if session.Away() {
-			info.State = "away"
-			info.AwayMsg = session.AwayMessage()
-		}
-
-		// Check idle status
-		if session.Idle() {
-			idleDuration := time.Since(session.IdleTime())
-			info.IdleTime = int(idleDuration.Minutes())
-			if info.State == "online" {
-				info.State = "idle"
-			}
-		}
-
-		// Status messages not currently supported in SessionInstance
-
-		// Set capabilities
-		// Capabilities parsing not implemented
-		info.Capabilities = []string{}
+	// Web-only sessions have no OSCAR instance to query on behalf of.
+	if instance == nil {
+		return info
 	}
 
-	return info
-}
-
-// GetPresenceForBuddy retrieves presence information for a specific buddy.
-func (m *BuddyListManager) GetPresenceForBuddy(screenName string) WebAPIBuddyInfo {
-	return m.getBuddyInfo(screenName)
-}
-
-// GetOnlineBuddies returns a list of all online buddies for a user.
-func (m *BuddyListManager) GetOnlineBuddies(ctx context.Context, sess *state.WebAPISession) ([]WebAPIBuddyInfo, error) {
-	frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
-	snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
+	reply, err := m.locateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
+		wire.SNAC_0x02_0x05_LocateUserInfoQuery{
+			Type:       uint16(wire.LocateTypeUnavailable), // away message
+			ScreenName: buddyName,
+		})
 	if err != nil {
-		return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
+		m.logger.WarnContext(ctx, "failed to query buddy info", "screenName", buddyName, "error", err)
+		return info
 	}
-	reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
+
+	userInfo, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
 	if !ok {
-		return nil, fmt.Errorf("failed to retrieve feedbag: unexpected reply type")
+		// Locate error => buddy is blocked or offline.
+		return info
 	}
-	items := reply.Items
 
-	var onlineBuddies []WebAPIBuddyInfo
+	info.State = "online"
+	info.Capabilities = []string{}
 
-	// Check each buddy's presence
-	for _, item := range items {
-		if item.ClassID == wire.FeedbagClassIdBuddy {
-			buddyInfo := m.getBuddyInfo(item.Name)
-			if buddyInfo.State != "offline" {
-				onlineBuddies = append(onlineBuddies, buddyInfo)
-			}
+	if tod, ok := userInfo.Uint32BE(wire.OServiceUserInfoSignonTOD); ok {
+		info.OnlineTime = int64(tod)
+	}
+
+	if userInfo.IsAway() {
+		info.State = "away"
+		if msg, ok := userInfo.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData); ok {
+			info.AwayMsg = msg
 		}
 	}
 
-	return onlineBuddies, nil
+	if idle, ok := userInfo.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
+		info.IdleTime = int(idle)
+		if info.State == "online" {
+			info.State = "idle"
+		}
+	}
+
+	return info
 }
 
 // RemoveBuddyFromFeedbag removes a buddy from a group (or all groups if allGroups is true) using feedbag delete/update SNACs.

+ 5 - 16
server/webapi/handlers/buddy_list_manager_test.go

@@ -24,14 +24,6 @@ func offlineWebAPIBuddy(aimID, displayID string) WebAPIBuddyInfo {
 	}
 }
 
-func buddyCountInGroups(groups []WebAPIBuddyGroup) int {
-	n := 0
-	for _, g := range groups {
-		n += len(g.Buddies)
-	}
-	return n
-}
-
 func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 	ctx := context.Background()
 	owner := state.NewIdentScreenName("listowner")
@@ -240,19 +232,18 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			fs := &MockFeedbagService{}
-			sr := &MockSessionRetriever{}
+			// The test session has no OSCAR instance, so buddies resolve to
+			// offline without any locate query being issued.
+			ls := &MockLocateService{}
 			if tt.fbErr != nil {
 				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(wire.SNACMessage{}, tt.fbErr).Once()
 			} else {
 				fs.On("Query", mock.Anything, mock.Anything, mock.Anything).Return(
 					wire.SNACMessage{Body: wire.SNAC_0x13_0x06_FeedbagReply{Items: tt.fb}}, nil,
 				).Once()
-				if bc := buddyCountInGroups(tt.want); bc > 0 {
-					sr.On("RetrieveSession", mock.Anything).Return((*state.Session)(nil)).Times(bc)
-				}
 			}
 
-			m := NewBuddyListManager(fs, sr, slog.Default())
+			m := NewBuddyListManager(fs, ls, slog.Default())
 			sess := &state.WebAPISession{ScreenName: state.DisplayScreenName(owner.String())}
 			got, err := m.GetBuddyListForUser(ctx, sess)
 
@@ -265,9 +256,7 @@ func TestBuddyListManager_GetBuddyListForUser(t *testing.T) {
 			assert.NoError(t, err)
 			assert.Equal(t, tt.want, got)
 			fs.AssertExpectations(t)
-			if tt.fbErr == nil && buddyCountInGroups(tt.want) > 0 {
-				sr.AssertExpectations(t)
-			}
+			ls.AssertExpectations(t)
 		})
 	}
 }

+ 4 - 4
server/webapi/handlers/buddylist_test.go

@@ -376,7 +376,7 @@ func TestBuddyListHandler_AddBuddy(t *testing.T) {
 			sessionManager := &MockWebAPISessionManager{}
 			feedbagService := &MockFeedbagService{}
 			blmFeedbagService := &MockFeedbagService{}
-			blm := NewBuddyListManager(blmFeedbagService, &MockSessionRetriever{}, slog.Default())
+			blm := NewBuddyListManager(blmFeedbagService, &MockLocateService{}, slog.Default())
 			logger := slog.Default()
 
 			handler := &BuddyListHandler{
@@ -514,7 +514,7 @@ func TestBuddyListHandler_AddGroup(t *testing.T) {
 			sm := &MockWebAPISessionManager{}
 			fs := &MockFeedbagService{}
 			blmFs := &MockFeedbagService{}
-			blm := NewBuddyListManager(blmFs, &MockSessionRetriever{}, slog.Default())
+			blm := NewBuddyListManager(blmFs, &MockLocateService{}, slog.Default())
 
 			aimsid := ""
 			if v := tt.queryParams["aimsid"]; len(v) > 0 {
@@ -551,7 +551,7 @@ func TestBuddyListHandler_RemoveBuddy(t *testing.T) {
 	type setupFunc func(*MockWebAPISessionManager, *BuddyListManager, *MockFeedbagService, string) *state.WebAPISession
 
 	newBuddyListManager := func(fs *MockFeedbagService) *BuddyListManager {
-		return NewBuddyListManager(fs, &MockSessionRetriever{}, slog.Default())
+		return NewBuddyListManager(fs, &MockLocateService{}, slog.Default())
 	}
 
 	tests := []struct {
@@ -707,7 +707,7 @@ func TestBuddyListHandler_RemoveGroup(t *testing.T) {
 	type setupFunc func(*MockWebAPISessionManager, *BuddyListManager, *MockFeedbagService, string) *state.WebAPISession
 
 	newBuddyListManager := func(fs *MockFeedbagService) *BuddyListManager {
-		return NewBuddyListManager(fs, &MockSessionRetriever{}, slog.Default())
+		return NewBuddyListManager(fs, &MockLocateService{}, slog.Default())
 	}
 
 	tests := []struct {

+ 5 - 0
server/webapi/handlers/mocks_test.go

@@ -35,6 +35,11 @@ type MockLocateService struct {
 	mock.Mock
 }
 
+func (m *MockLocateService) SetInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error {
+	args := m.Called(ctx, instance, inBody)
+	return args.Error(0)
+}
+
 func (m *MockLocateService) UserInfoQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error) {
 	args := m.Called(ctx, instance, inFrame, inBody)
 	return args.Get(0).(wire.SNACMessage), args.Error(1)

+ 50 - 35
server/webapi/handlers/presence.go

@@ -16,10 +16,8 @@ import (
 // PresenceHandler handles Web AIM API presence-related endpoints.
 type PresenceHandler struct {
 	SessionManager   *state.WebAPISessionManager
-	SessionRetriever SessionRetriever
 	FeedbagService   FeedbagService
 	BuddyBroadcaster BuddyBroadcaster
-	ProfileManager   ProfileManager
 	LocateService    LocateService
 	Logger           *slog.Logger
 }
@@ -28,6 +26,7 @@ type PresenceHandler struct {
 // the blocking relationship check, the online/offline session lookup, and
 // returns the user's presence info plus optional profile and away-message data.
 type LocateService interface {
+	SetInfo(ctx context.Context, instance *state.SessionInstance, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error
 	UserInfoQuery(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x02_0x05_LocateUserInfoQuery) (wire.SNACMessage, error)
 }
 
@@ -41,12 +40,6 @@ type BuddyBroadcaster interface {
 // may query in target-list ("t=") mode.
 const maxPresenceTargets = 10
 
-// ProfileManager manages user profiles (uses types.ProfileManager)
-type ProfileManager interface {
-	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile state.UserProfile) error
-	Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)
-}
-
 // PresenceData contains presence information.
 type PresenceData struct {
 	Groups []BuddyGroupInfo    `json:"groups,omitempty" xml:"groups>group,omitempty"`
@@ -533,12 +526,23 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	// Save profile using ProfileManager
-	profile := state.UserProfile{
-		ProfileText: profileText,
-		UpdateTime:  time.Now().UTC(),
+	// Web-only sessions have no OSCAR instance to set info on behalf of.
+	instance := session.OSCARSession
+	if instance == nil {
+		h.Logger.WarnContext(ctx, "no OSCAR session for profile update", "aimsid", aimsid)
+		h.sendError(w, http.StatusBadRequest, "no OSCAR session")
+		return
 	}
-	if err := h.ProfileManager.SetProfile(ctx, session.ScreenName.IdentScreenName(), profile); err != nil {
+
+	// Save profile via OSCAR LocateService.
+	setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
+		TLVRestBlock: wire.TLVRestBlock{
+			TLVList: wire.TLVList{
+				wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, profileText),
+			},
+		},
+	}
+	if err := h.LocateService.SetInfo(ctx, instance, setInfo); err != nil {
 		h.Logger.ErrorContext(ctx, "failed to set profile", "err", err.Error())
 		h.sendError(w, http.StatusInternalServerError, "failed to save profile")
 		return
@@ -585,23 +589,26 @@ func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
 		targetSN = session.ScreenName.String()
 	}
 
-	// Retrieve profile using ProfileManager
-	profile, err := h.ProfileManager.Profile(ctx, state.NewIdentScreenName(targetSN))
-	if err != nil {
-		h.Logger.WarnContext(ctx, "failed to get profile", "err", err.Error())
-		// Return empty profile on error
-		profile = state.UserProfile{}
+	// Retrieve profile via OSCAR LocateService. Web-only sessions have no OSCAR
+	// instance to query on behalf of, so they resolve to an empty profile.
+	var profileText string
+	if instance := session.OSCARSession; instance != nil {
+		reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
+			wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: uint16(wire.LocateTypeSig), ScreenName: targetSN})
+		if err != nil {
+			h.Logger.WarnContext(ctx, "failed to get profile", "err", err.Error())
+		} else if info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply); ok {
+			if prof, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoSigData); ok {
+				profileText = prof
+			}
+		}
 	}
 
 	// Send response
-	lastUpdated := int64(0)
-	if !profile.UpdateTime.IsZero() {
-		lastUpdated = profile.UpdateTime.Unix()
-	}
 	responseData := map[string]interface{}{
 		"screenName":  targetSN,
-		"profile":     profile.ProfileText,
-		"lastUpdated": lastUpdated,
+		"profile":     profileText,
+		"lastUpdated": int64(0),
 	}
 
 	response := BaseResponse{}
@@ -643,18 +650,26 @@ func (h *PresenceHandler) Icon(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	// Check if user is online and get their state
-	screenName := state.NewIdentScreenName(name)
-	if session := h.SessionRetriever.RetrieveSession(screenName); session != nil {
-		if session.Away() {
-			iconURL = "/static/icons/away_" + iconType + "_" + size + ".png"
-		} else if session.Idle() {
-			iconURL = "/static/icons/idle_" + iconType + "_" + size + ".png"
-		} else {
-			iconURL = "/static/icons/online_" + iconType + "_" + size + ".png"
+	// Resolve the target's presence via OSCAR LocateService, querying on behalf
+	// of the caller's session. This endpoint is unauthenticated, so fall back to
+	// the offline icon when no valid session is supplied.
+	var instance *state.SessionInstance
+	if aimsid := r.URL.Query().Get("aimsid"); aimsid != "" {
+		if session, err := h.SessionManager.GetSession(r.Context(), aimsid); err == nil {
+			instance = session.OSCARSession
 		}
-	} else {
+	}
+
+	screenName := state.NewIdentScreenName(name)
+	switch h.getUserPresence(r.Context(), instance, screenName, false).State {
+	case "away":
+		iconURL = "/static/icons/away_" + iconType + "_" + size + ".png"
+	case "idle":
+		iconURL = "/static/icons/idle_" + iconType + "_" + size + ".png"
+	case "offline":
 		iconURL = "/static/icons/offline_" + iconType + "_" + size + ".png"
+	default:
+		iconURL = "/static/icons/online_" + iconType + "_" + size + ".png"
 	}
 
 	// Redirect to icon URL

+ 33 - 45
server/webapi/handlers/presence_test.go

@@ -85,21 +85,6 @@ func (m *MockBuddyBroadcaster) BroadcastBuddyDeparted(ctx context.Context, scree
 	return args.Error(0)
 }
 
-// MockProfileManager is a mock implementation of ProfileManager
-type MockProfileManager struct {
-	mock.Mock
-}
-
-func (m *MockProfileManager) SetProfile(ctx context.Context, screenName state.IdentScreenName, profile state.UserProfile) error {
-	args := m.Called(ctx, screenName, profile)
-	return args.Error(0)
-}
-
-func (m *MockProfileManager) Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error) {
-	args := m.Called(ctx, screenName)
-	return args.Get(0).(state.UserProfile), args.Error(1)
-}
-
 // onlineUserInfoReply builds a locate UserInfoReply for an online user,
 // optionally marking them idle by the given number of minutes (0 = not idle).
 func onlineUserInfoReply(screenName string, idleMinutes uint16) wire.SNACMessage {
@@ -360,16 +345,14 @@ func TestPresenceHandler_Icon(t *testing.T) {
 	tests := []struct {
 		name               string
 		queryParams        string
-		setupMocks         func(*MockSessionRetriever)
 		expectedStatusCode int
 		checkRedirect      func(*testing.T, *httptest.ResponseRecorder)
 	}{
 		{
-			name:        "Redirect_OfflineUser",
-			queryParams: "name=offlineuser",
-			setupMocks: func(sr *MockSessionRetriever) {
-				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).Return(nil)
-			},
+			name: "Redirect_OfflineUser",
+			// No aimsid, so there is no OSCAR session to query on behalf of and
+			// the target resolves to offline.
+			queryParams:        "name=offlineuser",
 			expectedStatusCode: http.StatusFound,
 			checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
 				location := rr.Header().Get("Location")
@@ -379,22 +362,18 @@ func TestPresenceHandler_Icon(t *testing.T) {
 		{
 			name:               "Error_MissingName",
 			queryParams:        "",
-			setupMocks:         func(sr *MockSessionRetriever) {},
 			expectedStatusCode: http.StatusBadRequest,
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			sessionRetriever := &MockSessionRetriever{}
-
 			handler := &PresenceHandler{
-				SessionRetriever: sessionRetriever,
-				Logger:           slog.Default(),
+				SessionManager: state.NewWebAPISessionManager(),
+				LocateService:  &MockLocateService{},
+				Logger:         slog.Default(),
 			}
 
-			tt.setupMocks(sessionRetriever)
-
 			reqURL := "/presence/icon"
 			if tt.queryParams != "" {
 				reqURL += "?" + tt.queryParams
@@ -411,25 +390,25 @@ func TestPresenceHandler_Icon(t *testing.T) {
 			if tt.checkRedirect != nil {
 				tt.checkRedirect(t, rr)
 			}
-
-			sessionRetriever.AssertExpectations(t)
 		})
 	}
 }
 
 func TestPresenceHandler_SetProfile(t *testing.T) {
+	oscarInstance := state.NewSession().AddInstance()
+
 	tests := []struct {
 		name               string
 		queryParams        string
-		setupMocks         func(*MockProfileManager)
+		setupMocks         func(*MockLocateService)
 		expectedStatusCode int
 		checkResponse      func(*testing.T, string)
 	}{
 		{
 			name:        "Success_SetProfile",
 			queryParams: "profile=Hello+World",
-			setupMocks: func(pm *MockProfileManager) {
-				pm.On("SetProfile", mock.Anything, state.NewIdentScreenName("testuser"), mock.AnythingOfType("state.UserProfile")).Return(nil)
+			setupMocks: func(ls *MockLocateService) {
+				ls.On("SetInfo", mock.Anything, oscarInstance, mock.AnythingOfType("wire.SNAC_0x02_0x04_LocateSetInfo")).Return(nil)
 			},
 			expectedStatusCode: http.StatusOK,
 			checkResponse: func(t *testing.T, body string) {
@@ -439,7 +418,7 @@ func TestPresenceHandler_SetProfile(t *testing.T) {
 		{
 			name:               "Error_ProfileTooLarge",
 			queryParams:        "profile=" + strings.Repeat("x", 4097),
-			setupMocks:         func(pm *MockProfileManager) {},
+			setupMocks:         func(ls *MockLocateService) {},
 			expectedStatusCode: http.StatusBadRequest,
 			checkResponse: func(t *testing.T, body string) {
 				assert.Contains(t, body, "profile too large")
@@ -449,17 +428,17 @@ func TestPresenceHandler_SetProfile(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			profileManager := &MockProfileManager{}
+			locateService := &MockLocateService{}
 
-			sessionMgr, aimsid := createTestSessionManager("testuser")
+			sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 			handler := &PresenceHandler{
 				SessionManager: sessionMgr,
-				ProfileManager: profileManager,
+				LocateService:  locateService,
 				Logger:         slog.Default(),
 			}
 
-			tt.setupMocks(profileManager)
+			tt.setupMocks(locateService)
 
 			reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
 			req, err := http.NewRequest("GET", reqURL, nil)
@@ -476,24 +455,33 @@ func TestPresenceHandler_SetProfile(t *testing.T) {
 				tt.checkResponse(t, responseBody)
 			}
 
-			profileManager.AssertExpectations(t)
+			locateService.AssertExpectations(t)
 		})
 	}
 }
 
 func TestPresenceHandler_GetProfile(t *testing.T) {
-	profileManager := &MockProfileManager{}
+	locateService := &MockLocateService{}
 
-	sessionMgr, aimsid := createTestSessionManager("testuser")
+	oscarInstance := state.NewSession().AddInstance()
+	sessionMgr, aimsid := createTestSessionManagerWithOSCAR("testuser", oscarInstance)
 
 	handler := &PresenceHandler{
 		SessionManager: sessionMgr,
-		ProfileManager: profileManager,
+		LocateService:  locateService,
 		Logger:         slog.Default(),
 	}
 
-	profileManager.On("Profile", mock.Anything, state.NewIdentScreenName("testuser")).
-		Return(state.UserProfile{ProfileText: "My profile"}, nil)
+	locateService.On("UserInfoQuery", mock.Anything, mock.Anything, mock.Anything, screenNameMatcher("testuser")).
+		Return(wire.SNACMessage{
+			Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
+				LocateInfo: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "My profile"),
+					},
+				},
+			},
+		}, nil)
 
 	req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
 	assert.NoError(t, err)
@@ -507,5 +495,5 @@ func TestPresenceHandler_GetProfile(t *testing.T) {
 	assert.Contains(t, body, `"My profile"`)
 	assert.Contains(t, body, `"testuser"`)
 
-	profileManager.AssertExpectations(t)
+	locateService.AssertExpectations(t)
 }

+ 0 - 2
server/webapi/server.go

@@ -49,10 +49,8 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 	presenceHandler := &handlers.PresenceHandler{
 		SessionManager:   sessionManager,
-		SessionRetriever: handler.SessionRetriever,
 		FeedbagService:   handler.FeedbagService,
 		BuddyBroadcaster: handler.BuddyBroadcaster,
-		ProfileManager:   handler.ProfileManager,
 		LocateService:    handler.LocateService,
 		Logger:           logger,
 	}

+ 0 - 6
server/webapi/types.go

@@ -87,12 +87,6 @@ type BuddyBroadcaster interface {
 	BroadcastBuddyDeparted(ctx context.Context, screenName state.IdentScreenName) error
 }
 
-// ProfileManager manages user profiles
-type ProfileManager interface {
-	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile state.UserProfile) error
-	Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)
-}
-
 // Phase 3: Preference interfaces
 
 // PreferenceManager provides methods to manage user preferences.