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

test: add basic tests for aim's webapi

siohaza 4 месяцев назад
Родитель
Сommit
40ca485a73

+ 226 - 0
server/webapi/handlers/auth_test.go

@@ -0,0 +1,226 @@
+package handlers
+
+import (
+	"context"
+	"errors"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/open-oscar-server/state"
+)
+
+// MockUserManager is a mock implementation of UserManager
+type MockUserManager struct {
+	mock.Mock
+}
+
+func (m *MockUserManager) AuthenticateUser(ctx context.Context, username, password string) (*state.User, error) {
+	args := m.Called(ctx, username, password)
+	if user := args.Get(0); user != nil {
+		return user.(*state.User), args.Error(1)
+	}
+	return nil, args.Error(1)
+}
+
+func (m *MockUserManager) FindUserByScreenName(ctx context.Context, screenName state.IdentScreenName) (*state.User, error) {
+	args := m.Called(ctx, screenName)
+	if user := args.Get(0); user != nil {
+		return user.(*state.User), args.Error(1)
+	}
+	return nil, args.Error(1)
+}
+
+func (m *MockUserManager) InsertUser(ctx context.Context, u state.User) error {
+	args := m.Called(ctx, u)
+	return args.Error(0)
+}
+
+// MockTokenStore is a mock implementation of TokenStore
+type MockTokenStore struct {
+	mock.Mock
+}
+
+func (m *MockTokenStore) StoreToken(ctx context.Context, token string, screenName state.IdentScreenName, expiresAt time.Time) error {
+	args := m.Called(ctx, token, screenName, expiresAt)
+	return args.Error(0)
+}
+
+func (m *MockTokenStore) ValidateToken(ctx context.Context, token string) (state.IdentScreenName, error) {
+	args := m.Called(ctx, token)
+	return args.Get(0).(state.IdentScreenName), args.Error(1)
+}
+
+func (m *MockTokenStore) DeleteToken(ctx context.Context, token string) error {
+	args := m.Called(ctx, token)
+	return args.Error(0)
+}
+
+func TestAuthHandler_ClientLogin(t *testing.T) {
+	tests := []struct {
+		name               string
+		method             string
+		contentType        string
+		body               string
+		setupMocks         func(*MockUserManager, *MockTokenStore)
+		disableAuth        bool
+		expectedStatusCode int
+		checkResponse      func(*testing.T, string)
+	}{
+		{
+			name:        "Success_JSONBody",
+			method:      "POST",
+			contentType: "application/json",
+			body:        `{"username":"testuser","password":"testpass","devId":"dev123"}`,
+			setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
+				user := &state.User{
+					IdentScreenName:   state.NewIdentScreenName("testuser"),
+					DisplayScreenName: state.DisplayScreenName("testuser"),
+				}
+				um.On("AuthenticateUser", mock.Anything, "testuser", "testpass").Return(user, nil)
+				ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("testuser"), mock.AnythingOfType("time.Time")).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"loginId":"testuser"`)
+				assert.Contains(t, body, `"screenName":"testuser"`)
+				assert.Contains(t, body, `"token"`)
+				assert.Contains(t, body, `"sessionSecret"`)
+			},
+		},
+		{
+			name:        "Success_FormEncoded",
+			method:      "POST",
+			contentType: "application/x-www-form-urlencoded",
+			body:        "s=testuser&pwd=testpass&devId=dev123",
+			setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
+				user := &state.User{
+					IdentScreenName:   state.NewIdentScreenName("testuser"),
+					DisplayScreenName: state.DisplayScreenName("testuser"),
+				}
+				um.On("AuthenticateUser", mock.Anything, "testuser", "testpass").Return(user, nil)
+				ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("testuser"), mock.AnythingOfType("time.Time")).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"loginId":"testuser"`)
+			},
+		},
+		{
+			name:        "Success_DisableAuth_NewUser",
+			method:      "POST",
+			contentType: "application/json",
+			body:        `{"username":"newuser","password":"pass123"}`,
+			disableAuth: true,
+			setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
+				// First auth attempt fails - user not found
+				um.On("AuthenticateUser", mock.Anything, "newuser", "pass123").Return(nil, errors.New("user not found")).Once()
+				// User is created
+				um.On("InsertUser", mock.Anything, mock.MatchedBy(func(u state.User) bool {
+					return u.IdentScreenName == state.NewIdentScreenName("newuser")
+				})).Return(nil)
+				// Second auth attempt succeeds
+				user := &state.User{
+					IdentScreenName:   state.NewIdentScreenName("newuser"),
+					DisplayScreenName: state.DisplayScreenName("newuser"),
+				}
+				um.On("AuthenticateUser", mock.Anything, "newuser", "pass123").Return(user, nil)
+				ts.On("StoreToken", mock.Anything, mock.AnythingOfType("string"), state.NewIdentScreenName("newuser"), mock.AnythingOfType("time.Time")).Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"loginId":"newuser"`)
+			},
+		},
+		{
+			name:               "Error_MissingUsername",
+			method:             "POST",
+			contentType:        "application/json",
+			body:               `{"username":"","password":"testpass"}`,
+			setupMocks:         func(um *MockUserManager, ts *MockTokenStore) {},
+			expectedStatusCode: http.StatusBadRequest,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "username and password required")
+			},
+		},
+		{
+			name:               "Error_MissingPassword",
+			method:             "POST",
+			contentType:        "application/json",
+			body:               `{"username":"testuser","password":""}`,
+			setupMocks:         func(um *MockUserManager, ts *MockTokenStore) {},
+			expectedStatusCode: http.StatusBadRequest,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "username and password required")
+			},
+		},
+		{
+			name:        "Error_AuthFailed",
+			method:      "POST",
+			contentType: "application/json",
+			body:        `{"username":"testuser","password":"wrongpass"}`,
+			setupMocks: func(um *MockUserManager, ts *MockTokenStore) {
+				um.On("AuthenticateUser", mock.Anything, "testuser", "wrongpass").Return(nil, errors.New("invalid credentials"))
+			},
+			expectedStatusCode: http.StatusUnauthorized,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "authentication failed")
+			},
+		},
+		{
+			name:               "Error_InvalidJSON",
+			method:             "POST",
+			contentType:        "application/json",
+			body:               `{invalid json`,
+			setupMocks:         func(um *MockUserManager, ts *MockTokenStore) {},
+			expectedStatusCode: http.StatusBadRequest,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "invalid JSON format")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			userManager := &MockUserManager{}
+			tokenStore := &MockTokenStore{}
+			logger := slog.Default()
+
+			handler := &AuthHandler{
+				UserManager: userManager,
+				TokenStore:  tokenStore,
+				Logger:      logger,
+				DisableAuth: tt.disableAuth,
+			}
+
+			tt.setupMocks(userManager, tokenStore)
+
+			req, err := http.NewRequest(tt.method, "/auth/clientLogin", strings.NewReader(tt.body))
+			assert.NoError(t, err)
+			req.Header.Set("Content-Type", tt.contentType)
+
+			rr := httptest.NewRecorder()
+
+			handler.ClientLogin(rr, req)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+
+			responseBody := strings.TrimSpace(rr.Body.String())
+			if tt.checkResponse != nil {
+				tt.checkResponse(t, responseBody)
+			}
+
+			userManager.AssertExpectations(t)
+			tokenStore.AssertExpectations(t)
+		})
+	}
+}

+ 406 - 0
server/webapi/handlers/messaging_test.go

@@ -0,0 +1,406 @@
+package handlers
+
+import (
+	"context"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// MockMessageRelayer is a mock implementation of MessageRelayer
+type MockMessageRelayer struct {
+	mock.Mock
+}
+
+func (m *MockMessageRelayer) RelayToScreenName(ctx context.Context, recipient state.IdentScreenName, msg wire.SNACMessage) {
+	m.Called(ctx, recipient, msg)
+}
+
+// MockOfflineMessageManager is a mock implementation of OfflineMessageManager
+type MockOfflineMessageManager struct {
+	mock.Mock
+}
+
+func (m *MockOfflineMessageManager) SaveMessage(ctx context.Context, msg state.OfflineMessage) (int, error) {
+	args := m.Called(ctx, msg)
+	return args.Int(0), args.Error(1)
+}
+
+// MockSessionRetriever is a mock implementation of SessionRetriever
+type MockSessionRetriever struct {
+	mock.Mock
+}
+
+func (m *MockSessionRetriever) AllSessions() []*state.Session {
+	args := m.Called()
+	if sessions := args.Get(0); sessions != nil {
+		return sessions.([]*state.Session)
+	}
+	return nil
+}
+
+func (m *MockSessionRetriever) RetrieveSession(screenName state.IdentScreenName) *state.Session {
+	args := m.Called(screenName)
+	if session := args.Get(0); session != nil {
+		return session.(*state.Session)
+	}
+	return nil
+}
+
+// MockRelationshipFetcher is a mock implementation of RelationshipFetcher
+type MockRelationshipFetcher struct {
+	mock.Mock
+}
+
+func (m *MockRelationshipFetcher) Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error) {
+	args := m.Called(ctx, me, them)
+	return args.Get(0).(state.Relationship), args.Error(1)
+}
+
+// createTestSessionManager creates a WebAPISessionManager with a pre-populated session.
+func createTestSessionManager(screenName string) (*state.WebAPISessionManager, string) {
+	mgr := state.NewWebAPISessionManager()
+	session, _ := mgr.CreateSession(
+		context.Background(),
+		state.DisplayScreenName(screenName),
+		"test-dev",
+		[]string{"im", "presence", "buddylist", "sentIM"},
+		nil,
+		slog.Default(),
+	)
+	return mgr, session.AimSID
+}
+
+func TestMessagingHandler_SendIM(t *testing.T) {
+	tests := []struct {
+		name               string
+		queryParams        string
+		setupMocks         func(*MockMessageRelayer, *MockOfflineMessageManager, *MockSessionRetriever, *MockRelationshipFetcher)
+		expectedStatusCode int
+		checkResponse      func(*testing.T, string)
+	}{
+		{
+			name:        "Success_OnlineRecipient",
+			queryParams: "t=recipient&message=hello+world",
+			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("recipient")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("recipient")).
+					Return(&state.Session{})
+				mr.On("RelayToScreenName", mock.Anything, state.NewIdentScreenName("recipient"), mock.AnythingOfType("wire.SNACMessage")).
+					Return()
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"msgId"`)
+				assert.Contains(t, body, `"state":"delivered"`)
+			},
+		},
+		{
+			name:        "Success_OfflineRecipient_OfflineIM",
+			queryParams: "t=offlineuser&message=hello&offlineIM=1",
+			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("offlineuser")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).
+					Return(nil)
+				om.On("SaveMessage", mock.Anything, mock.AnythingOfType("state.OfflineMessage")).
+					Return(1, nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"msgId"`)
+			},
+		},
+		{
+			name:        "Error_MissingRecipient",
+			queryParams: "message=hello",
+			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+			},
+			expectedStatusCode: http.StatusBadRequest,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "missing required parameter: t")
+			},
+		},
+		{
+			name:        "Error_MissingMessage",
+			queryParams: "t=recipient",
+			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+			},
+			expectedStatusCode: http.StatusBadRequest,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "missing required parameter: message")
+			},
+		},
+		{
+			name:        "Error_BlockedBySender",
+			queryParams: "t=blockeduser&message=hello",
+			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("blockeduser")).
+					Return(state.Relationship{YouBlock: true}, nil)
+			},
+			expectedStatusCode: http.StatusForbidden,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "cannot send message to blocked user")
+			},
+		},
+		{
+			name:        "Error_BlockedByRecipient",
+			queryParams: "t=blocker&message=hello",
+			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("blocker")).
+					Return(state.Relationship{BlocksYou: true}, nil)
+			},
+			expectedStatusCode: http.StatusNotFound,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "recipient is not online")
+			},
+		},
+		{
+			name:        "Error_OfflineInboxFull",
+			queryParams: "t=offlineuser&message=hello&offlineIM=1",
+			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("offlineuser")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).
+					Return(nil)
+				om.On("SaveMessage", mock.Anything, mock.AnythingOfType("state.OfflineMessage")).
+					Return(0, state.ErrOfflineInboxFull)
+			},
+			expectedStatusCode: http.StatusConflict,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "recipient inbox full")
+			},
+		},
+		{
+			name:        "Error_OfflineRecipient_NoOfflineIM",
+			queryParams: "t=offlineuser&message=hello&offlineIM=0",
+			setupMocks: func(mr *MockMessageRelayer, om *MockOfflineMessageManager, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("offlineuser")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).
+					Return(nil)
+			},
+			expectedStatusCode: http.StatusNotFound,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "recipient is not online")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			messageRelayer := &MockMessageRelayer{}
+			offlineMsgMgr := &MockOfflineMessageManager{}
+			sessionRetriever := &MockSessionRetriever{}
+			relFetcher := &MockRelationshipFetcher{}
+
+			sessionMgr, aimsid := createTestSessionManager("testuser")
+
+			handler := &MessagingHandler{
+				SessionManager:        sessionMgr,
+				MessageRelayer:        messageRelayer,
+				OfflineMessageManager: offlineMsgMgr,
+				SessionRetriever:      sessionRetriever,
+				RelationshipFetcher:   relFetcher,
+				Logger:                slog.Default(),
+			}
+
+			tt.setupMocks(messageRelayer, offlineMsgMgr, sessionRetriever, relFetcher)
+
+			reqURL := "/im/sendIM?aimsid=" + aimsid + "&" + tt.queryParams
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			rr := httptest.NewRecorder()
+
+			handler.SendIM(rr, req)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+
+			responseBody := strings.TrimSpace(rr.Body.String())
+			if tt.checkResponse != nil {
+				tt.checkResponse(t, responseBody)
+			}
+
+			messageRelayer.AssertExpectations(t)
+			offlineMsgMgr.AssertExpectations(t)
+			sessionRetriever.AssertExpectations(t)
+			relFetcher.AssertExpectations(t)
+		})
+	}
+}
+
+func TestMessagingHandler_SendIM_MissingAimsid(t *testing.T) {
+	handler := &MessagingHandler{
+		SessionManager: state.NewWebAPISessionManager(),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/im/sendIM", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.SendIM(rr, req)
+
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "missing required parameter: aimsid")
+}
+
+func TestMessagingHandler_SendIM_InvalidSession(t *testing.T) {
+	handler := &MessagingHandler{
+		SessionManager: state.NewWebAPISessionManager(),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/im/sendIM?aimsid=nonexistent&t=someone&message=hi", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.SendIM(rr, req)
+
+	assert.Equal(t, http.StatusUnauthorized, rr.Code)
+	assert.Contains(t, rr.Body.String(), "invalid or expired session")
+}
+
+func TestMessagingHandler_SetTyping(t *testing.T) {
+	tests := []struct {
+		name               string
+		queryParams        string
+		setupMocks         func(*MockMessageRelayer, *MockSessionRetriever, *MockRelationshipFetcher)
+		expectedStatusCode int
+		checkResponse      func(*testing.T, string)
+	}{
+		{
+			name:        "Success_TypingStarted",
+			queryParams: "t=recipient&typing=true",
+			setupMocks: func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("recipient")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("recipient")).
+					Return(&state.Session{})
+				mr.On("RelayToScreenName", mock.Anything, state.NewIdentScreenName("recipient"), mock.AnythingOfType("wire.SNACMessage")).
+					Return()
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+			},
+		},
+		{
+			name:        "Success_TypingStopped",
+			queryParams: "t=recipient&typing=false",
+			setupMocks: func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("recipient")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("recipient")).
+					Return(&state.Session{})
+				mr.On("RelayToScreenName", mock.Anything, state.NewIdentScreenName("recipient"), mock.AnythingOfType("wire.SNACMessage")).
+					Return()
+			},
+			expectedStatusCode: http.StatusOK,
+		},
+		{
+			name:        "Success_BlockedSilent",
+			queryParams: "t=blockeduser&typing=true",
+			setupMocks: func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("blockeduser")).
+					Return(state.Relationship{YouBlock: true}, nil)
+				// No relay should happen
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+			},
+		},
+		{
+			name:        "Success_OfflineRecipient",
+			queryParams: "t=offlineuser&typing=true",
+			setupMocks: func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, mock.Anything, state.NewIdentScreenName("offlineuser")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("offlineuser")).
+					Return(nil)
+				// No relay should happen for offline users
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+			},
+		},
+		{
+			name:               "Error_MissingRecipient",
+			queryParams:        "typing=true",
+			setupMocks:         func(mr *MockMessageRelayer, sr *MockSessionRetriever, rf *MockRelationshipFetcher) {},
+			expectedStatusCode: http.StatusBadRequest,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "missing required parameter: t")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			messageRelayer := &MockMessageRelayer{}
+			sessionRetriever := &MockSessionRetriever{}
+			relFetcher := &MockRelationshipFetcher{}
+
+			sessionMgr, aimsid := createTestSessionManager("testuser")
+
+			handler := &MessagingHandler{
+				SessionManager:      sessionMgr,
+				MessageRelayer:      messageRelayer,
+				SessionRetriever:    sessionRetriever,
+				RelationshipFetcher: relFetcher,
+				Logger:              slog.Default(),
+			}
+
+			tt.setupMocks(messageRelayer, sessionRetriever, relFetcher)
+
+			reqURL := "/im/setTyping?aimsid=" + aimsid + "&" + tt.queryParams
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			rr := httptest.NewRecorder()
+
+			handler.SetTyping(rr, req)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+
+			if tt.checkResponse != nil {
+				responseBody := strings.TrimSpace(rr.Body.String())
+				tt.checkResponse(t, responseBody)
+			}
+
+			messageRelayer.AssertExpectations(t)
+			sessionRetriever.AssertExpectations(t)
+			relFetcher.AssertExpectations(t)
+		})
+	}
+}
+
+func TestMessagingHandler_SetTyping_MissingAimsid(t *testing.T) {
+	handler := &MessagingHandler{
+		SessionManager: state.NewWebAPISessionManager(),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/im/setTyping", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.SetTyping(rr, req)
+
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "missing required parameter: aimsid")
+}

+ 450 - 0
server/webapi/handlers/presence_test.go

@@ -0,0 +1,450 @@
+package handlers
+
+import (
+	"context"
+	"log/slog"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
+
+	"github.com/mk6i/open-oscar-server/state"
+	"github.com/mk6i/open-oscar-server/wire"
+)
+
+// MockFeedbagRetriever is a mock implementation of FeedbagRetriever
+type MockFeedbagRetriever struct {
+	mock.Mock
+}
+
+func (m *MockFeedbagRetriever) 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 *MockFeedbagRetriever) RelationshipsByUser(ctx context.Context, screenName state.IdentScreenName) ([]state.IdentScreenName, error) {
+	args := m.Called(ctx, screenName)
+	if names := args.Get(0); names != nil {
+		return names.([]state.IdentScreenName), args.Error(1)
+	}
+	return nil, args.Error(1)
+}
+
+// MockBuddyBroadcaster is a mock implementation of BuddyBroadcaster
+type MockBuddyBroadcaster struct {
+	mock.Mock
+}
+
+func (m *MockBuddyBroadcaster) BroadcastBuddyArrived(ctx context.Context, screenName state.IdentScreenName, userInfo wire.TLVUserInfo) error {
+	args := m.Called(ctx, screenName, userInfo)
+	return args.Error(0)
+}
+
+func (m *MockBuddyBroadcaster) BroadcastBuddyDeparted(ctx context.Context, instance *state.SessionInstance) error {
+	args := m.Called(ctx, instance)
+	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)
+}
+
+func TestPresenceHandler_GetPresence(t *testing.T) {
+	tests := []struct {
+		name               string
+		queryParams        string
+		setupMocks         func(*MockSessionRetriever, *MockFeedbagRetriever, *MockRelationshipFetcher)
+		expectedStatusCode int
+		checkResponse      func(*testing.T, string)
+	}{
+		{
+			name:        "Success_BuddyList",
+			queryParams: "bl=1",
+			setupMocks: func(sr *MockSessionRetriever, fr *MockFeedbagRetriever, rf *MockRelationshipFetcher) {
+				// Return feedbag with a group and buddy
+				fr.On("RetrieveFeedbag", mock.Anything, state.NewIdentScreenName("testuser")).
+					Return([]wire.FeedbagItem{
+						{ItemID: 1, ClassID: wire.FeedbagClassIdGroup, Name: "Friends", GroupID: 0},
+						{ItemID: 2, ClassID: wire.FeedbagClassIdBuddy, Name: "buddy1", GroupID: 1},
+					}, nil)
+				rf.On("Relationship", mock.Anything, state.NewIdentScreenName("testuser"), state.NewIdentScreenName("buddy1")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("buddy1")).
+					Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"groups"`)
+				assert.Contains(t, body, `"Friends"`)
+				assert.Contains(t, body, `"buddy1"`)
+				assert.Contains(t, body, `"offline"`)
+			},
+		},
+		{
+			name:        "Success_TargetUsers",
+			queryParams: "t=user1,user2",
+			setupMocks: func(sr *MockSessionRetriever, fr *MockFeedbagRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, state.NewIdentScreenName("testuser"), state.NewIdentScreenName("user1")).
+					Return(state.Relationship{}, nil)
+				rf.On("Relationship", mock.Anything, state.NewIdentScreenName("testuser"), state.NewIdentScreenName("user2")).
+					Return(state.Relationship{}, nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("user1")).
+					Return(nil)
+				sr.On("RetrieveSession", state.NewIdentScreenName("user2")).
+					Return(nil)
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"users"`)
+				assert.Contains(t, body, `"user1"`)
+				assert.Contains(t, body, `"user2"`)
+			},
+		},
+		{
+			name:        "Success_BlockedUserOffline",
+			queryParams: "t=blockeduser",
+			setupMocks: func(sr *MockSessionRetriever, fr *MockFeedbagRetriever, rf *MockRelationshipFetcher) {
+				rf.On("Relationship", mock.Anything, state.NewIdentScreenName("testuser"), state.NewIdentScreenName("blockeduser")).
+					Return(state.Relationship{YouBlock: true}, nil)
+				// RetrieveSession should NOT be called for blocked users
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+				assert.Contains(t, body, `"blockeduser"`)
+				assert.Contains(t, body, `"offline"`)
+			},
+		},
+		{
+			name:               "Success_EmptyRequest",
+			queryParams:        "",
+			setupMocks:         func(sr *MockSessionRetriever, fr *MockFeedbagRetriever, rf *MockRelationshipFetcher) {},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			sessionRetriever := &MockSessionRetriever{}
+			feedbagRetriever := &MockFeedbagRetriever{}
+			relFetcher := &MockRelationshipFetcher{}
+
+			sessionMgr, aimsid := createTestSessionManager("testuser")
+
+			handler := &PresenceHandler{
+				SessionManager:      sessionMgr,
+				SessionRetriever:    sessionRetriever,
+				FeedbagRetriever:    feedbagRetriever,
+				RelationshipFetcher: relFetcher,
+				Logger:              slog.Default(),
+			}
+
+			tt.setupMocks(sessionRetriever, feedbagRetriever, relFetcher)
+
+			reqURL := "/presence/get?aimsid=" + aimsid
+			if tt.queryParams != "" {
+				reqURL += "&" + tt.queryParams
+			}
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			rr := httptest.NewRecorder()
+
+			handler.GetPresence(rr, req)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+
+			if tt.checkResponse != nil {
+				responseBody := strings.TrimSpace(rr.Body.String())
+				tt.checkResponse(t, responseBody)
+			}
+
+			sessionRetriever.AssertExpectations(t)
+			feedbagRetriever.AssertExpectations(t)
+			relFetcher.AssertExpectations(t)
+		})
+	}
+}
+
+func TestPresenceHandler_GetPresence_MissingAimsid(t *testing.T) {
+	handler := &PresenceHandler{
+		SessionManager: state.NewWebAPISessionManager(),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/get", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.GetPresence(rr, req)
+
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
+}
+
+func TestPresenceHandler_GetPresence_SessionNotFound(t *testing.T) {
+	handler := &PresenceHandler{
+		SessionManager: state.NewWebAPISessionManager(),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/get?aimsid=nonexistent", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.GetPresence(rr, req)
+
+	assert.Equal(t, http.StatusNotFound, rr.Code)
+	assert.Contains(t, rr.Body.String(), "session not found")
+}
+
+func TestPresenceHandler_SetState_MissingAimsid(t *testing.T) {
+	handler := &PresenceHandler{
+		SessionManager: state.NewWebAPISessionManager(),
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/setState", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.SetState(rr, req)
+
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "missing aimsid parameter")
+}
+
+func TestPresenceHandler_SetState_InvalidState(t *testing.T) {
+	sessionMgr, aimsid := createTestSessionManager("testuser")
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=bogus", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.SetState(rr, req)
+
+	// Web-only sessions (no OSCAR session) return 200 before checking state param
+	// because the handler returns early with success for web-only sessions
+	assert.Equal(t, http.StatusOK, rr.Code)
+}
+
+func TestPresenceHandler_SetState_WebOnlySession(t *testing.T) {
+	// Web-only sessions (no OSCAR session) should return success
+	sessionMgr, aimsid := createTestSessionManager("testuser")
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		Logger:         slog.Default(),
+	}
+
+	req, err := http.NewRequest("GET", "/presence/setState?aimsid="+aimsid+"&state=online", nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.SetState(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	assert.Contains(t, rr.Body.String(), `"statusCode":200`)
+}
+
+func TestIsICQScreenName(t *testing.T) {
+	tests := []struct {
+		name       string
+		screenName string
+		expected   bool
+	}{
+		{"ICQ_Number", "123456789", true},
+		{"AIM_Name", "cooluser", false},
+		{"AIM_WithNumbers", "cool123", false},
+		{"Empty", "", false},
+		{"Single_Digit", "5", true},
+		{"Mixed_Chars", "12abc34", false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.expected, isICQScreenName(tt.screenName))
+		})
+	}
+}
+
+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)
+			},
+			expectedStatusCode: http.StatusFound,
+			checkRedirect: func(t *testing.T, rr *httptest.ResponseRecorder) {
+				location := rr.Header().Get("Location")
+				assert.Contains(t, location, "offline")
+			},
+		},
+		{
+			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(),
+			}
+
+			tt.setupMocks(sessionRetriever)
+
+			reqURL := "/presence/icon"
+			if tt.queryParams != "" {
+				reqURL += "?" + tt.queryParams
+			}
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			rr := httptest.NewRecorder()
+
+			handler.Icon(rr, req)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+
+			if tt.checkRedirect != nil {
+				tt.checkRedirect(t, rr)
+			}
+
+			sessionRetriever.AssertExpectations(t)
+		})
+	}
+}
+
+func TestPresenceHandler_SetProfile(t *testing.T) {
+	tests := []struct {
+		name               string
+		queryParams        string
+		setupMocks         func(*MockProfileManager)
+		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)
+			},
+			expectedStatusCode: http.StatusOK,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, `"statusCode":200`)
+			},
+		},
+		{
+			name:               "Error_ProfileTooLarge",
+			queryParams:        "profile=" + strings.Repeat("x", 4097),
+			setupMocks:         func(pm *MockProfileManager) {},
+			expectedStatusCode: http.StatusBadRequest,
+			checkResponse: func(t *testing.T, body string) {
+				assert.Contains(t, body, "profile too large")
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			profileManager := &MockProfileManager{}
+
+			sessionMgr, aimsid := createTestSessionManager("testuser")
+
+			handler := &PresenceHandler{
+				SessionManager: sessionMgr,
+				ProfileManager: profileManager,
+				Logger:         slog.Default(),
+			}
+
+			tt.setupMocks(profileManager)
+
+			reqURL := "/presence/setProfile?aimsid=" + aimsid + "&" + tt.queryParams
+			req, err := http.NewRequest("GET", reqURL, nil)
+			assert.NoError(t, err)
+
+			rr := httptest.NewRecorder()
+
+			handler.SetProfile(rr, req)
+
+			assert.Equal(t, tt.expectedStatusCode, rr.Code)
+
+			if tt.checkResponse != nil {
+				responseBody := strings.TrimSpace(rr.Body.String())
+				tt.checkResponse(t, responseBody)
+			}
+
+			profileManager.AssertExpectations(t)
+		})
+	}
+}
+
+func TestPresenceHandler_GetProfile(t *testing.T) {
+	profileManager := &MockProfileManager{}
+
+	sessionMgr, aimsid := createTestSessionManager("testuser")
+
+	handler := &PresenceHandler{
+		SessionManager: sessionMgr,
+		ProfileManager: profileManager,
+		Logger:         slog.Default(),
+	}
+
+	profileManager.On("Profile", mock.Anything, state.NewIdentScreenName("testuser")).
+		Return(state.UserProfile{ProfileText: "My profile"}, nil)
+
+	req, err := http.NewRequest("GET", "/presence/getProfile?aimsid="+aimsid, nil)
+	assert.NoError(t, err)
+
+	rr := httptest.NewRecorder()
+	handler.GetProfile(rr, req)
+
+	assert.Equal(t, http.StatusOK, rr.Code)
+	body := rr.Body.String()
+	assert.Contains(t, body, `"statusCode":200`)
+	assert.Contains(t, body, `"My profile"`)
+	assert.Contains(t, body, `"testuser"`)
+
+	profileManager.AssertExpectations(t)
+}