Преглед на файлове

Add temporary buddy management to BuddyListHandler

- Introduced AddTempBuddy method to handle adding temporary buddies to a session without persisting them.
- Updated WebAPISession to include a TempBuddies map for managing temporary buddies.
- Enhanced session management by implementing session touch and error handling for missing or expired sessions.
- Added logging for temporary buddy additions to improve traceability.
ukozi преди 9 месеца
родител
ревизия
42e98c3226
променени са 4 файла, в които са добавени 917 реда и са изтрити 1 реда
  1. 97 1
      server/webapi/handlers/buddylist.go
  2. 559 0
      server/webapi/handlers/buddylist_test.go
  3. 1 0
      state/webapi_session.go
  4. 260 0
      state/webapi_session_test.go

+ 97 - 1
server/webapi/handlers/buddylist.go

@@ -11,9 +11,15 @@ import (
 	"github.com/mk6i/retro-aim-server/wire"
 )
 
+// WebAPISessionManager provides methods to manage WebAPI sessions.
+type WebAPISessionManager interface {
+	GetSession(ctx context.Context, aimsid string) (*state.WebAPISession, error)
+	TouchSession(ctx context.Context, aimsid string) error
+}
+
 // BuddyListHandler handles Web AIM API buddy list management endpoints.
 type BuddyListHandler struct {
-	SessionManager *state.WebAPISessionManager
+	SessionManager WebAPISessionManager
 	FeedbagManager FeedbagManager
 	Logger         *slog.Logger
 }
@@ -189,6 +195,96 @@ func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, screenName sta
 	return "success", buddyInfo
 }
 
+// AddTempBuddy handles GET /aim/addTempBuddy requests.
+// This adds temporary buddies to the session without persisting them to the feedbag.
+// The temporary buddies are only visible for the duration of the session.
+func (h *BuddyListHandler) AddTempBuddy(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+
+	// Get session ID from parameters
+	aimsid := r.URL.Query().Get("aimsid")
+	if aimsid == "" {
+		h.sendError(w, http.StatusBadRequest, "missing aimsid parameter")
+		return
+	}
+
+	// Get session
+	session, err := h.SessionManager.GetSession(r.Context(), aimsid)
+	if err != nil {
+		if err == state.ErrNoWebAPISession {
+			h.sendError(w, http.StatusNotFound, "session not found")
+		} else if err == state.ErrWebAPISessionExpired {
+			h.sendError(w, http.StatusGone, "session expired")
+		} else {
+			h.sendError(w, http.StatusInternalServerError, "internal server error")
+		}
+		return
+	}
+
+	// Touch the session
+	h.SessionManager.TouchSession(r.Context(), aimsid)
+
+	// Get buddy names from parameters
+	// The WebAPI accepts multiple buddy names via &t= parameters
+	buddyNames := r.URL.Query()["t"]
+	if len(buddyNames) == 0 {
+		h.sendError(w, http.StatusBadRequest, "missing buddy names (t parameter)")
+		return
+	}
+
+	// Store temporary buddies in the session
+	// Note: These are not persisted to the feedbag database
+	if session.TempBuddies == nil {
+		session.TempBuddies = make(map[string]bool)
+	}
+
+	for _, buddyName := range buddyNames {
+		buddyName = strings.TrimSpace(buddyName)
+		if buddyName != "" {
+			session.TempBuddies[buddyName] = true
+		}
+	}
+
+	// Prepare response
+	responseData := map[string]interface{}{
+		"resultCode": "success",
+		"buddyNames": buddyNames,
+	}
+
+	resp := BaseResponse{}
+	resp.Response.StatusCode = 200
+	resp.Response.StatusText = "OK"
+	resp.Response.Data = responseData
+	SendResponse(w, r, resp, h.Logger)
+
+	// Push temp buddy event to the session's event queue
+	if session.EventQueue != nil {
+		for _, buddyName := range buddyNames {
+			buddyName = strings.TrimSpace(buddyName)
+			if buddyName != "" {
+				// Create minimal buddy info for temp buddy
+				buddyInfo := &BuddyPresenceInfo{
+					AimID:    buddyName,
+					State:    "offline", // Default state
+					UserType: "aim",
+				}
+
+				event := types.BuddyListEvent{
+					Action: "addTemp",
+					Buddy:  buddyInfo,
+				}
+				session.EventQueue.Push(types.EventTypeBuddyList, event)
+			}
+		}
+	}
+
+	h.Logger.InfoContext(ctx, "temporary buddies added",
+		"aimsid", aimsid,
+		"buddies", buddyNames,
+		"count", len(buddyNames),
+	)
+}
+
 // sendError is a convenience method that wraps the common SendError function.
 func (h *BuddyListHandler) sendError(w http.ResponseWriter, statusCode int, message string) {
 	SendError(w, statusCode, message)

+ 559 - 0
server/webapi/handlers/buddylist_test.go

@@ -0,0 +1,559 @@
+package handlers
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+	"github.com/mk6i/retro-aim-server/state"
+	"github.com/mk6i/retro-aim-server/wire"
+)
+
+// MockWebAPISessionManager is a mock implementation of the WebAPISessionManager
+type MockWebAPISessionManager struct {
+	mock.Mock
+}
+
+func (m *MockWebAPISessionManager) GetSession(ctx context.Context, aimsid string) (*state.WebAPISession, error) {
+	args := m.Called(ctx, aimsid)
+	if session := args.Get(0); session != nil {
+		return session.(*state.WebAPISession), args.Error(1)
+	}
+	return nil, args.Error(1)
+}
+
+func (m *MockWebAPISessionManager) TouchSession(ctx context.Context, aimsid string) error {
+	args := m.Called(ctx, aimsid)
+	return args.Error(0)
+}
+
+// MockFeedbagManager is a mock implementation of FeedbagManager
+type MockFeedbagManager struct {
+	mock.Mock
+}
+
+func (m *MockFeedbagManager) RetrieveFeedbag(ctx context.Context, screenName state.IdentScreenName) ([]wire.FeedbagItem, error) {
+	args := m.Called(ctx, screenName)
+	if items := args.Get(0); items != nil {
+		return items.([]wire.FeedbagItem), args.Error(1)
+	}
+	return nil, args.Error(1)
+}
+
+func (m *MockFeedbagManager) InsertItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
+	args := m.Called(ctx, screenName, item)
+	return args.Error(0)
+}
+
+func (m *MockFeedbagManager) UpdateItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
+	args := m.Called(ctx, screenName, item)
+	return args.Error(0)
+}
+
+func (m *MockFeedbagManager) DeleteItem(ctx context.Context, screenName state.IdentScreenName, item wire.FeedbagItem) error {
+	args := m.Called(ctx, screenName, item)
+	return args.Error(0)
+}
+
+func TestBuddyListHandler_AddTempBuddy(t *testing.T) {
+	tests := []struct {
+		name               string
+		queryParams        map[string][]string
+		setupMocks         func(*MockWebAPISessionManager, *MockFeedbagManager, string)
+		expectedStatusCode int
+		expectedResponse   string
+		checkSession       func(*testing.T, *state.WebAPISession)
+	}{
+		{
+			name: "Success_SingleBuddy",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"buddy1"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					TempBuddies:  nil,
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1"],"resultCode":"success"}}}`,
+			checkSession: func(t *testing.T, session *state.WebAPISession) {
+				assert.NotNil(t, session.TempBuddies)
+				assert.True(t, session.TempBuddies["buddy1"])
+				assert.Equal(t, 1, len(session.TempBuddies))
+			},
+		},
+		{
+			name: "Success_MultipleBuddies",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"buddy1", "buddy2", "buddy3"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					TempBuddies:  nil,
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy1","buddy2","buddy3"],"resultCode":"success"}}}`,
+			checkSession: func(t *testing.T, session *state.WebAPISession) {
+				assert.NotNil(t, session.TempBuddies)
+				assert.True(t, session.TempBuddies["buddy1"])
+				assert.True(t, session.TempBuddies["buddy2"])
+				assert.True(t, session.TempBuddies["buddy3"])
+				assert.Equal(t, 3, len(session.TempBuddies))
+			},
+		},
+		{
+			name: "Success_AddToExistingTempBuddies",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"buddy2"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:     aimsid,
+					ScreenName: state.DisplayScreenName("testuser"),
+					EventQueue: types.NewEventQueue(100),
+					TempBuddies: map[string]bool{
+						"buddy1": true,
+					},
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["buddy2"],"resultCode":"success"}}}`,
+			checkSession: func(t *testing.T, session *state.WebAPISession) {
+				assert.NotNil(t, session.TempBuddies)
+				assert.True(t, session.TempBuddies["buddy1"])
+				assert.True(t, session.TempBuddies["buddy2"])
+				assert.Equal(t, 2, len(session.TempBuddies))
+			},
+		},
+		{
+			name: "Error_MissingAimSID",
+			queryParams: map[string][]string{
+				"t": {"buddy1"},
+			},
+			setupMocks:         func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {},
+			expectedStatusCode: http.StatusBadRequest,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing aimsid parameter"}}`,
+		},
+		{
+			name: "Error_MissingBuddyNames",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			},
+			expectedStatusCode: http.StatusBadRequest,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy names (t parameter)"}}`,
+		},
+		{
+			name: "Error_SessionNotFound",
+			queryParams: map[string][]string{
+				"aimsid": {"invalid-session"},
+				"t":      {"buddy1"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrNoWebAPISession)
+			},
+			expectedStatusCode: http.StatusNotFound,
+			expectedResponse:   `{"response":{"statusCode":404,"statusText":"session not found"}}`,
+		},
+		{
+			name: "Error_SessionExpired",
+			queryParams: map[string][]string{
+				"aimsid": {"expired-session"},
+				"t":      {"buddy1"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				sm.On("GetSession", mock.Anything, aimsid).Return(nil, state.ErrWebAPISessionExpired)
+			},
+			expectedStatusCode: http.StatusGone,
+			expectedResponse:   `{"response":{"statusCode":410,"statusText":"session expired"}}`,
+		},
+		{
+			name: "Error_InternalServerError",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"buddy1"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				sm.On("GetSession", mock.Anything, aimsid).Return(nil, errors.New("database error"))
+			},
+			expectedStatusCode: http.StatusInternalServerError,
+			expectedResponse:   `{"response":{"statusCode":500,"statusText":"internal server error"}}`,
+		},
+		{
+			name: "Success_WithWhitespace",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session-id"},
+				"t":      {"  buddy1  ", "buddy2 ", " buddy3"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					TempBuddies:  nil,
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyNames":["  buddy1  ","buddy2 "," buddy3"],"resultCode":"success"}}}`,
+			checkSession: func(t *testing.T, session *state.WebAPISession) {
+				assert.NotNil(t, session.TempBuddies)
+				assert.True(t, session.TempBuddies["buddy1"])
+				assert.True(t, session.TempBuddies["buddy2"])
+				assert.True(t, session.TempBuddies["buddy3"])
+				assert.Equal(t, 3, len(session.TempBuddies))
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Setup
+			sessionManager := &MockWebAPISessionManager{}
+			feedbagManager := &MockFeedbagManager{}
+			logger := slog.Default()
+
+			handler := &BuddyListHandler{
+				SessionManager: sessionManager,
+				FeedbagManager: feedbagManager,
+				Logger:         logger,
+			}
+
+			aimsid := ""
+			if aimsids, ok := tt.queryParams["aimsid"]; ok && len(aimsids) > 0 {
+				aimsid = aimsids[0]
+			}
+			tt.setupMocks(sessionManager, feedbagManager, aimsid)
+
+			// Create request with query parameters
+			reqURL := "/aim/addTempBuddy"
+			if len(tt.queryParams) > 0 {
+				values := url.Values{}
+				for key, vals := range tt.queryParams {
+					for _, val := range vals {
+						values.Add(key, val)
+					}
+				}
+				reqURL += "?" + values.Encode()
+			}
+
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			// Create response recorder
+			rr := httptest.NewRecorder()
+
+			// Execute
+			handler.AddTempBuddy(rr, req)
+
+			// Verify status code
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+
+			// Verify response body
+			responseBody := strings.TrimSpace(rr.Body.String())
+			assert.Equal(t, tt.expectedResponse, responseBody)
+
+			// Check session state if provided
+			if tt.checkSession != nil && aimsid != "" {
+				// Get the session from mock to verify state
+				for _, call := range sessionManager.Calls {
+					if call.Method == "GetSession" {
+						if session, ok := call.ReturnArguments[0].(*state.WebAPISession); ok {
+							tt.checkSession(t, session)
+							break
+						}
+					}
+				}
+			}
+
+			// Verify all mock expectations were met
+			sessionManager.AssertExpectations(t)
+			feedbagManager.AssertExpectations(t)
+		})
+	}
+}
+
+func TestBuddyListHandler_AddTempBuddy_EventQueueBehavior(t *testing.T) {
+	// Test that events are properly added to the event queue
+	sessionManager := &MockWebAPISessionManager{}
+	feedbagManager := &MockFeedbagManager{}
+	logger := slog.Default()
+
+	handler := &BuddyListHandler{
+		SessionManager: sessionManager,
+		FeedbagManager: feedbagManager,
+		Logger:         logger,
+	}
+
+	eventQueue := types.NewEventQueue(100)
+	session := &state.WebAPISession{
+		AimSID:       "test-session",
+		ScreenName:   state.DisplayScreenName("testuser"),
+		EventQueue:   eventQueue,
+		TempBuddies:  nil,
+		LastAccessed: time.Now(),
+	}
+
+	sessionManager.On("GetSession", mock.Anything, "test-session").Return(session, nil)
+	sessionManager.On("TouchSession", mock.Anything, "test-session").Return(nil)
+
+	req, err := http.NewRequest("GET", "/aim/addTempBuddy?aimsid=test-session&t=buddy1&t=buddy2", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.AddTempBuddy(rr, req)
+
+	// Verify that events were added to the queue
+	assert.Equal(t, http.StatusOK, rr.Code)
+
+	// Check that the event queue has the correct number of events
+	events := eventQueue.GetAllEvents()
+	assert.GreaterOrEqual(t, len(events), 2, "Should have at least 2 events for 2 buddies")
+
+	// Verify event content
+	for _, event := range events {
+		assert.Equal(t, types.EventTypeBuddyList, event.Type)
+		eventData, ok := event.Data.(types.BuddyListEvent)
+		assert.True(t, ok, "Event data should be BuddyListEvent")
+		assert.Equal(t, "addTemp", eventData.Action)
+		assert.NotNil(t, eventData.Buddy)
+		buddyInfo, ok := eventData.Buddy.(*BuddyPresenceInfo)
+		assert.True(t, ok, "Buddy should be *BuddyPresenceInfo")
+		if ok {
+			assert.Contains(t, []string{"buddy1", "buddy2"}, buddyInfo.AimID)
+			assert.Equal(t, "offline", buddyInfo.State)
+			assert.Equal(t, "aim", buddyInfo.UserType)
+		}
+	}
+
+	sessionManager.AssertExpectations(t)
+}
+
+func TestBuddyListHandler_AddBuddy(t *testing.T) {
+	tests := []struct {
+		name               string
+		queryParams        map[string][]string
+		setupMocks         func(*MockWebAPISessionManager, *MockFeedbagManager, string)
+		expectedStatusCode int
+		expectedResponse   string
+	}{
+		{
+			name: "Success_AddBuddyToExistingGroup",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session"},
+				"buddy":  {"newbuddy"},
+				"group":  {"Friends"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+
+				// Mock feedbag retrieval with existing group
+				existingItems := []wire.FeedbagItem{
+					{
+						ItemID:  1,
+						ClassID: wire.FeedbagClassIdGroup,
+						Name:    "Friends",
+						GroupID: 0,
+					},
+				}
+				fm.On("RetrieveFeedbag", mock.Anything, state.NewIdentScreenName("testuser")).
+					Return(existingItems, nil)
+
+				// Mock buddy insertion
+				fm.On("InsertItem", mock.Anything, state.NewIdentScreenName("testuser"), mock.MatchedBy(func(item wire.FeedbagItem) bool {
+					return item.ClassID == wire.FeedbagClassIdBuddy &&
+						item.Name == "newbuddy" &&
+						item.GroupID == 1
+				})).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyInfo":{"aimId":"newbuddy","state":"offline","userType":"aim"},"resultCode":"success"}}}`,
+		},
+		{
+			name: "Success_AddBuddyCreateNewGroup",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session"},
+				"buddy":  {"newbuddy"},
+				"group":  {"NewGroup"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+
+				// Mock feedbag retrieval with no existing groups
+				fm.On("RetrieveFeedbag", mock.Anything, state.NewIdentScreenName("testuser")).
+					Return([]wire.FeedbagItem{}, nil)
+
+				// Mock group creation
+				fm.On("InsertItem", mock.Anything, state.NewIdentScreenName("testuser"), mock.MatchedBy(func(item wire.FeedbagItem) bool {
+					return item.ClassID == wire.FeedbagClassIdGroup &&
+						item.Name == "NewGroup" &&
+						item.ItemID == 1
+				})).Return(nil)
+
+				// Mock buddy insertion
+				fm.On("InsertItem", mock.Anything, state.NewIdentScreenName("testuser"), mock.MatchedBy(func(item wire.FeedbagItem) bool {
+					return item.ClassID == wire.FeedbagClassIdBuddy &&
+						item.Name == "newbuddy" &&
+						item.GroupID == 1
+				})).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"buddyInfo":{"aimId":"newbuddy","state":"offline","userType":"aim"},"resultCode":"success"}}}`,
+		},
+		{
+			name: "Error_BuddyAlreadyExists",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session"},
+				"buddy":  {"existingbuddy"},
+				"group":  {"Friends"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+
+				// Mock feedbag retrieval with existing buddy
+				existingItems := []wire.FeedbagItem{
+					{
+						ItemID:  1,
+						ClassID: wire.FeedbagClassIdBuddy,
+						Name:    "existingbuddy",
+						GroupID: 1,
+					},
+				}
+				fm.On("RetrieveFeedbag", mock.Anything, state.NewIdentScreenName("testuser")).
+					Return(existingItems, nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			expectedResponse:   `{"response":{"statusCode":200,"statusText":"OK","data":{"resultCode":"alreadyExists"}}}`,
+		},
+		{
+			name: "Error_MissingBuddyParameter",
+			queryParams: map[string][]string{
+				"aimsid": {"test-session"},
+				"group":  {"Friends"},
+			},
+			setupMocks: func(sm *MockWebAPISessionManager, fm *MockFeedbagManager, aimsid string) {
+				session := &state.WebAPISession{
+					AimSID:       aimsid,
+					ScreenName:   state.DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					LastAccessed: time.Now(),
+				}
+				sm.On("GetSession", mock.Anything, aimsid).Return(session, nil)
+				sm.On("TouchSession", mock.Anything, aimsid).Return(nil)
+			},
+			expectedStatusCode: http.StatusBadRequest,
+			expectedResponse:   `{"response":{"statusCode":400,"statusText":"missing buddy parameter"}}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Setup
+			sessionManager := &MockWebAPISessionManager{}
+			feedbagManager := &MockFeedbagManager{}
+			logger := slog.Default()
+
+			handler := &BuddyListHandler{
+				SessionManager: sessionManager,
+				FeedbagManager: feedbagManager,
+				Logger:         logger,
+			}
+
+			aimsid := ""
+			if aimsids, ok := tt.queryParams["aimsid"]; ok && len(aimsids) > 0 {
+				aimsid = aimsids[0]
+			}
+			tt.setupMocks(sessionManager, feedbagManager, aimsid)
+
+			// Create request
+			reqURL := "/buddylist/addBuddy"
+			if len(tt.queryParams) > 0 {
+				values := url.Values{}
+				for key, vals := range tt.queryParams {
+					for _, val := range vals {
+						values.Add(key, val)
+					}
+				}
+				reqURL += "?" + values.Encode()
+			}
+
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			// Create response recorder
+			rr := httptest.NewRecorder()
+
+			// Execute
+			handler.AddBuddy(rr, req)
+
+			// Verify
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+			responseBody := strings.TrimSpace(rr.Body.String())
+			assert.Equal(t, tt.expectedResponse, responseBody)
+
+			sessionManager.AssertExpectations(t)
+			feedbagManager.AssertExpectations(t)
+		})
+	}
+}

+ 1 - 0
state/webapi_session.go

@@ -36,6 +36,7 @@ type WebAPISession struct {
 	FetchTimeout    int               // Long-polling timeout in milliseconds
 	TimeToNextFetch int               // Suggested delay before next fetch
 	RemoteAddr      string            // Client IP address
+	TempBuddies     map[string]bool   // Temporary buddies for this session only
 	logger          *slog.Logger      // Logger for debugging
 }
 

+ 260 - 0
state/webapi_session_test.go

@@ -0,0 +1,260 @@
+package state
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/mk6i/retro-aim-server/server/webapi/types"
+)
+
+func TestWebAPISession_TempBuddies(t *testing.T) {
+	tests := []struct {
+		name           string
+		setupSession   func() *WebAPISession
+		operations     func(*WebAPISession)
+		expectedChecks func(*testing.T, *WebAPISession)
+	}{
+		{
+			name: "Initialize_NilTempBuddies",
+			setupSession: func() *WebAPISession {
+				return &WebAPISession{
+					AimSID:       "test-session",
+					ScreenName:   DisplayScreenName("testuser"),
+					EventQueue:   types.NewEventQueue(100),
+					CreatedAt:    time.Now(),
+					LastAccessed: time.Now(),
+					ExpiresAt:    time.Now().Add(time.Hour),
+				}
+			},
+			operations: func(s *WebAPISession) {
+				// Initialize TempBuddies if nil
+				if s.TempBuddies == nil {
+					s.TempBuddies = make(map[string]bool)
+				}
+				s.TempBuddies["buddy1"] = true
+			},
+			expectedChecks: func(t *testing.T, s *WebAPISession) {
+				assert.NotNil(t, s.TempBuddies)
+				assert.True(t, s.TempBuddies["buddy1"])
+				assert.Equal(t, 1, len(s.TempBuddies))
+			},
+		},
+		{
+			name: "Add_MultipleTempBuddies",
+			setupSession: func() *WebAPISession {
+				return &WebAPISession{
+					AimSID:       "test-session",
+					ScreenName:   DisplayScreenName("testuser"),
+					TempBuddies:  make(map[string]bool),
+					EventQueue:   types.NewEventQueue(100),
+					CreatedAt:    time.Now(),
+					LastAccessed: time.Now(),
+					ExpiresAt:    time.Now().Add(time.Hour),
+				}
+			},
+			operations: func(s *WebAPISession) {
+				s.TempBuddies["buddy1"] = true
+				s.TempBuddies["buddy2"] = true
+				s.TempBuddies["buddy3"] = true
+			},
+			expectedChecks: func(t *testing.T, s *WebAPISession) {
+				assert.Equal(t, 3, len(s.TempBuddies))
+				assert.True(t, s.TempBuddies["buddy1"])
+				assert.True(t, s.TempBuddies["buddy2"])
+				assert.True(t, s.TempBuddies["buddy3"])
+			},
+		},
+		{
+			name: "Add_DuplicateTempBuddy",
+			setupSession: func() *WebAPISession {
+				return &WebAPISession{
+					AimSID:       "test-session",
+					ScreenName:   DisplayScreenName("testuser"),
+					TempBuddies:  map[string]bool{"buddy1": true},
+					EventQueue:   types.NewEventQueue(100),
+					CreatedAt:    time.Now(),
+					LastAccessed: time.Now(),
+					ExpiresAt:    time.Now().Add(time.Hour),
+				}
+			},
+			operations: func(s *WebAPISession) {
+				// Add the same buddy again
+				s.TempBuddies["buddy1"] = true
+			},
+			expectedChecks: func(t *testing.T, s *WebAPISession) {
+				// Should still only have one entry
+				assert.Equal(t, 1, len(s.TempBuddies))
+				assert.True(t, s.TempBuddies["buddy1"])
+			},
+		},
+		{
+			name: "Remove_TempBuddy",
+			setupSession: func() *WebAPISession {
+				return &WebAPISession{
+					AimSID:     "test-session",
+					ScreenName: DisplayScreenName("testuser"),
+					TempBuddies: map[string]bool{
+						"buddy1": true,
+						"buddy2": true,
+					},
+					EventQueue:   types.NewEventQueue(100),
+					CreatedAt:    time.Now(),
+					LastAccessed: time.Now(),
+					ExpiresAt:    time.Now().Add(time.Hour),
+				}
+			},
+			operations: func(s *WebAPISession) {
+				delete(s.TempBuddies, "buddy1")
+			},
+			expectedChecks: func(t *testing.T, s *WebAPISession) {
+				assert.Equal(t, 1, len(s.TempBuddies))
+				assert.False(t, s.TempBuddies["buddy1"])
+				assert.True(t, s.TempBuddies["buddy2"])
+			},
+		},
+		{
+			name: "Check_NonExistentBuddy",
+			setupSession: func() *WebAPISession {
+				return &WebAPISession{
+					AimSID:       "test-session",
+					ScreenName:   DisplayScreenName("testuser"),
+					TempBuddies:  map[string]bool{"buddy1": true},
+					EventQueue:   types.NewEventQueue(100),
+					CreatedAt:    time.Now(),
+					LastAccessed: time.Now(),
+					ExpiresAt:    time.Now().Add(time.Hour),
+				}
+			},
+			operations: func(s *WebAPISession) {
+				// No operations, just checking
+			},
+			expectedChecks: func(t *testing.T, s *WebAPISession) {
+				assert.False(t, s.TempBuddies["nonexistent"])
+				assert.True(t, s.TempBuddies["buddy1"])
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Setup
+			session := tt.setupSession()
+
+			// Perform operations
+			tt.operations(session)
+
+			// Verify
+			tt.expectedChecks(t, session)
+		})
+	}
+}
+
+func TestWebAPISession_IsExpired(t *testing.T) {
+	tests := []struct {
+		name      string
+		expiresAt time.Time
+		isExpired bool
+	}{
+		{
+			name:      "Not_Expired",
+			expiresAt: time.Now().Add(time.Hour),
+			isExpired: false,
+		},
+		{
+			name:      "Already_Expired",
+			expiresAt: time.Now().Add(-time.Hour),
+			isExpired: true,
+		},
+		{
+			name:      "Just_Expired",
+			expiresAt: time.Now().Add(-time.Second),
+			isExpired: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			session := &WebAPISession{
+				AimSID:     "test-session",
+				ScreenName: DisplayScreenName("testuser"),
+				ExpiresAt:  tt.expiresAt,
+			}
+
+			assert.Equal(t, tt.isExpired, session.IsExpired())
+		})
+	}
+}
+
+func TestWebAPISession_WithTempBuddiesIntegration(t *testing.T) {
+	// Test that temp buddies work correctly with a full session
+	session := &WebAPISession{
+		AimSID:       "integration-test",
+		ScreenName:   DisplayScreenName("testuser"),
+		EventQueue:   types.NewEventQueue(100),
+		TempBuddies:  nil,
+		CreatedAt:    time.Now(),
+		LastAccessed: time.Now(),
+		ExpiresAt:    time.Now().Add(time.Hour),
+		FetchTimeout: 30000,
+	}
+
+	// Initialize TempBuddies
+	session.TempBuddies = make(map[string]bool)
+
+	// Simulate adding temp buddies
+	buddies := []string{"alice", "bob", "charlie"}
+	for _, buddy := range buddies {
+		session.TempBuddies[buddy] = true
+	}
+
+	// Verify all buddies are present
+	assert.Equal(t, 3, len(session.TempBuddies))
+	for _, buddy := range buddies {
+		assert.True(t, session.TempBuddies[buddy], "Buddy %s should be in TempBuddies", buddy)
+	}
+
+	// Test that temp buddies persist with the session
+	assert.False(t, session.IsExpired())
+	assert.Equal(t, "testuser", string(session.ScreenName))
+	assert.NotNil(t, session.TempBuddies)
+
+	// Simulate buddy removal
+	delete(session.TempBuddies, "bob")
+	assert.Equal(t, 2, len(session.TempBuddies))
+	assert.False(t, session.TempBuddies["bob"])
+	assert.True(t, session.TempBuddies["alice"])
+	assert.True(t, session.TempBuddies["charlie"])
+}
+
+func TestWebAPISession_TempBuddiesIndependence(t *testing.T) {
+	// Test that temp buddies are independent across sessions
+	session1 := &WebAPISession{
+		AimSID:      "session1",
+		ScreenName:  DisplayScreenName("user1"),
+		TempBuddies: map[string]bool{"buddy1": true},
+		ExpiresAt:   time.Now().Add(time.Hour),
+	}
+
+	session2 := &WebAPISession{
+		AimSID:      "session2",
+		ScreenName:  DisplayScreenName("user2"),
+		TempBuddies: map[string]bool{"buddy2": true},
+		ExpiresAt:   time.Now().Add(time.Hour),
+	}
+
+	// Verify sessions have independent temp buddies
+	assert.True(t, session1.TempBuddies["buddy1"])
+	assert.False(t, session1.TempBuddies["buddy2"])
+
+	assert.False(t, session2.TempBuddies["buddy1"])
+	assert.True(t, session2.TempBuddies["buddy2"])
+
+	// Modify one session's temp buddies
+	session1.TempBuddies["buddy3"] = true
+
+	// Verify it doesn't affect the other session
+	assert.True(t, session1.TempBuddies["buddy3"])
+	assert.False(t, session2.TempBuddies["buddy3"])
+}