using System.Net; using System.Text; using Moq; using Moq.Protected; using Xunit; using OpenHarbor.MCP.Client.Core.Exceptions; using OpenHarbor.MCP.Client.Core.Models; using OpenHarbor.MCP.Client.Infrastructure; namespace OpenHarbor.MCP.Client.Core.Tests; /// /// Unit tests for HttpServerConnection following TDD approach. /// Tests HTTP transport implementation for MCP client connections. /// public class HttpServerConnectionTests { private readonly McpServerConfig _httpConfig; private readonly Mock _mockHttpHandler; private readonly HttpClient _mockHttpClient; public HttpServerConnectionTests() { _httpConfig = new McpServerConfig { Name = "test-server", Transport = new HttpTransportConfig { Type = "Http", BaseUrl = "http://localhost:5050" }, Timeout = TimeSpan.FromSeconds(30) }; _mockHttpHandler = new Mock(); _mockHttpClient = new HttpClient(_mockHttpHandler.Object) { BaseAddress = new Uri("http://localhost:5050") }; } [Fact] public void Constructor_WithValidConfig_CreatesConnection() { // Arrange & Act var connection = new HttpServerConnection(_httpConfig); // Assert Assert.Equal("test-server", connection.ServerName); Assert.False(connection.IsConnected); } [Fact] public void Constructor_WithNullConfig_ThrowsArgumentNullException() { // Arrange & Act & Assert Assert.Throws(() => new HttpServerConnection(null!)); } [Fact] public async Task ConnectAsync_WithValidServer_EstablishesConnection() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); // Act await connection.ConnectAsync(); // Assert Assert.True(connection.IsConnected); } [Fact] public async Task ConnectAsync_WhenAlreadyConnected_DoesNothing() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act await connection.ConnectAsync(); // Second call // Assert Assert.True(connection.IsConnected); _mockHttpHandler.Protected().Verify( "SendAsync", Times.Once(), // Only called once ItExpr.IsAny(), ItExpr.IsAny() ); } [Fact] public async Task ConnectAsync_WithNonHttpConfig_ThrowsMcpConnectionException() { // Arrange var stdioConfig = new McpServerConfig { Name = "stdio-server", Transport = new StdioTransportConfig { Type = "Stdio", Command = "dotnet" } }; var connection = new HttpServerConnection(stdioConfig, _mockHttpClient); // Act & Assert var exception = await Assert.ThrowsAsync( () => connection.ConnectAsync()); Assert.Contains("not configured for HTTP transport", exception.Message); } [Fact] public async Task ConnectAsync_WithFailedHealthCheck_ThrowsMcpConnectionException() { // Arrange SetupMockHealthCheck(HttpStatusCode.ServiceUnavailable, ""); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); // Act & Assert var exception = await Assert.ThrowsAsync( () => connection.ConnectAsync()); Assert.Contains("health check failed", exception.Message); } [Fact] public async Task ConnectAsync_WithApiKey_AddsAuthorizationHeader() { // Arrange var configWithApiKey = new McpServerConfig { Name = "secure-server", Transport = new HttpTransportConfig { Type = "Http", BaseUrl = "http://localhost:5050", ApiKey = "test-api-key-123" } }; SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var connection = new HttpServerConnection(configWithApiKey, _mockHttpClient); // Act await connection.ConnectAsync(); // Assert Assert.NotNull(_mockHttpClient.DefaultRequestHeaders.Authorization); Assert.Equal("Bearer", _mockHttpClient.DefaultRequestHeaders.Authorization.Scheme); Assert.Equal("test-api-key-123", _mockHttpClient.DefaultRequestHeaders.Authorization.Parameter); } [Fact] public async Task DisconnectAsync_WhenConnected_SetsIsConnectedToFalse() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act await connection.DisconnectAsync(); // Assert Assert.False(connection.IsConnected); } [Fact] public async Task DisconnectAsync_WhenNotConnected_DoesNothing() { // Arrange var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); // Act & Assert (should not throw) await connection.DisconnectAsync(); Assert.False(connection.IsConnected); } [Fact] public async Task ListToolsAsync_WhenNotConnected_ThrowsMcpConnectionException() { // Arrange var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); // Act & Assert var exception = await Assert.ThrowsAsync( () => connection.ListToolsAsync()); Assert.Contains("not connected", exception.Message); } [Fact] public async Task ListToolsAsync_WithValidResponse_ReturnsTools() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var responseJson = @"{ ""jsonrpc"": ""2.0"", ""id"": ""1"", ""result"": { ""tools"": [ { ""name"": ""search_codex"", ""description"": ""Search the CODEX knowledge base"" }, { ""name"": ""get_document"", ""description"": ""Get a document by ID"" } ] } }"; SetupMockJsonRpcResponse(HttpStatusCode.OK, responseJson); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act var tools = (await connection.ListToolsAsync()).ToList(); // Assert Assert.Equal(2, tools.Count); Assert.Equal("search_codex", tools[0].Name); Assert.Equal("Search the CODEX knowledge base", tools[0].Description); Assert.Equal("get_document", tools[1].Name); } [Fact] public async Task ListToolsAsync_WithErrorResponse_ThrowsMcpConnectionException() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var errorJson = @"{ ""jsonrpc"": ""2.0"", ""id"": ""1"", ""error"": { ""code"": -32600, ""message"": ""Invalid Request"" } }"; SetupMockJsonRpcResponse(HttpStatusCode.OK, errorJson); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act & Assert var exception = await Assert.ThrowsAsync( () => connection.ListToolsAsync()); Assert.Contains("returned error", exception.Message); } [Fact] public async Task CallToolAsync_WhenNotConnected_ThrowsMcpConnectionException() { // Arrange var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); // Act & Assert var exception = await Assert.ThrowsAsync( () => connection.CallToolAsync("search_codex")); Assert.Contains("not connected", exception.Message); } [Fact] public async Task CallToolAsync_WithEmptyToolName_ThrowsArgumentException() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act & Assert await Assert.ThrowsAsync( () => connection.CallToolAsync(string.Empty)); } [Fact] public async Task CallToolAsync_WithValidResponse_ReturnsSuccess() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var responseJson = @"{ ""jsonrpc"": ""2.0"", ""id"": ""2"", ""result"": { ""content"": ""Search results: Found 5 documents"" } }"; SetupMockJsonRpcResponse(HttpStatusCode.OK, responseJson); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act var result = await connection.CallToolAsync( "search_codex", new Dictionary { ["query"] = "test" } ); // Assert Assert.True(result.IsSuccess); Assert.Equal("Search results: Found 5 documents", result.Content); } [Fact] public async Task CallToolAsync_WithErrorResponse_ReturnsFailure() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var errorJson = @"{ ""jsonrpc"": ""2.0"", ""id"": ""2"", ""error"": { ""code"": -32602, ""message"": ""Invalid parameters"" } }"; SetupMockJsonRpcResponse(HttpStatusCode.OK, errorJson); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act var result = await connection.CallToolAsync("search_codex"); // Assert Assert.False(result.IsSuccess); Assert.Contains("Invalid parameters", result.Error); } [Fact] public async Task CallToolAsync_WithoutArguments_SendsValidRequest() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var responseJson = @"{ ""jsonrpc"": ""2.0"", ""id"": ""2"", ""result"": { ""content"": ""Tool executed successfully"" } }"; SetupMockJsonRpcResponse(HttpStatusCode.OK, responseJson); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act var result = await connection.CallToolAsync("list_tools"); // Assert Assert.True(result.IsSuccess); } [Fact] public async Task PingAsync_WhenNotConnected_ThrowsMcpConnectionException() { // Arrange var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); // Act & Assert var exception = await Assert.ThrowsAsync( () => connection.PingAsync()); Assert.Contains("not connected", exception.Message); } [Fact] public async Task PingAsync_WithHealthyServer_Succeeds() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act & Assert (should not throw) await connection.PingAsync(); } [Fact] public async Task PingAsync_WithUnhealthyServer_ThrowsMcpConnectionException() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Setup ping to fail _mockHttpHandler.Protected() .Setup>( "SendAsync", ItExpr.Is(req => req.RequestUri!.PathAndQuery.Contains("/health")), ItExpr.IsAny() ) .ReturnsAsync(new HttpResponseMessage { StatusCode = HttpStatusCode.ServiceUnavailable }); // Act & Assert var exception = await Assert.ThrowsAsync( () => connection.PingAsync()); Assert.Contains("Ping", exception.Message); } [Fact] public async Task DisposeAsync_WhenConnected_DisconnectsAndDisposesHttpClient() { // Arrange SetupMockHealthCheck(HttpStatusCode.OK, "{\"status\":\"Healthy\"}"); var connection = new HttpServerConnection(_httpConfig, _mockHttpClient); await connection.ConnectAsync(); // Act await connection.DisposeAsync(); // Assert Assert.False(connection.IsConnected); } // Helper methods private void SetupMockHealthCheck(HttpStatusCode statusCode, string content) { _mockHttpHandler.Protected() .Setup>( "SendAsync", ItExpr.Is(req => req.RequestUri!.PathAndQuery.Contains("/health")), ItExpr.IsAny() ) .ReturnsAsync(new HttpResponseMessage { StatusCode = statusCode, Content = new StringContent(content, Encoding.UTF8, "application/json") }); } private void SetupMockJsonRpcResponse(HttpStatusCode statusCode, string json) { _mockHttpHandler.Protected() .Setup>( "SendAsync", ItExpr.Is(req => req.RequestUri!.PathAndQuery.Contains("/mcp/invoke")), ItExpr.IsAny() ) .ReturnsAsync(new HttpResponseMessage { StatusCode = statusCode, Content = new StringContent(json, Encoding.UTF8, "application/json") }); } }