diff --git a/src/StevesBot.Worker.Tests/.editorconfig b/src/StevesBot.Worker.Tests/.editorconfig index 33e7631..c14fa32 100644 --- a/src/StevesBot.Worker.Tests/.editorconfig +++ b/src/StevesBot.Worker.Tests/.editorconfig @@ -3,3 +3,5 @@ dotnet_diagnostic.CA1515.severity = none dotnet_diagnostic.CA1707.severity = none dotnet_diagnostic.CA2201.severity = none dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA1303.severity = none +dotnet_diagnostic.CA1031.severity = none diff --git a/src/StevesBot.Worker.Tests/Integration/Infrastructure/TestWebSocketServer.cs b/src/StevesBot.Worker.Tests/Integration/Infrastructure/TestWebSocketServer.cs new file mode 100644 index 0000000..cf2ebc0 --- /dev/null +++ b/src/StevesBot.Worker.Tests/Integration/Infrastructure/TestWebSocketServer.cs @@ -0,0 +1,112 @@ +namespace StevesBot.Worker.Tests.Integration.Infrastructure; + +# pragma warning disable CA1001 + +public sealed class TestWebSocketServer : IAsyncLifetime +{ + private readonly IWebHost _host; + private readonly CancellationTokenSource _echoCts = new(); + private Uri? _webSocketUri; + + public Uri Uri + { + get + { + _webSocketUri ??= GetWebSocketUri(); + return _webSocketUri; + } + } + + public TestWebSocketServer() + { + _host = new WebHostBuilder() + .Configure(app => + { + app.UseWebSockets(); + + app.Run(async context => + { + if (context.Request.Path == "/ws" && context.WebSockets.IsWebSocketRequest) + { + var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await Echo(webSocket, _echoCts.Token); + } + else + { + context.Response.StatusCode = 400; + } + }); + }) + .UseKestrel() + .UseUrls("http://[::1]:0") + .Build(); + } + + public async Task InitializeAsync() + { + await _host.StartAsync(); + } + + public async Task DisposeAsync() + { + await _echoCts.CancelAsync(); + await _host.StopAsync(); + + _echoCts.Dispose(); + _host.Dispose(); + } + + private Uri GetWebSocketUri() + { + var address = _host.ServerFeatures.GetRequiredFeature().Addresses.First(); + var webSocketAddress = address.Replace("http://", "ws://", StringComparison.OrdinalIgnoreCase); + return new Uri(webSocketAddress + "/ws"); + } + + private static async Task Echo(WebSocket webSocket, CancellationToken cancellationToken) + { + var buffer = new byte[1024 * 4]; + + try + { + var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + + while (result.CloseStatus.HasValue is false && cancellationToken.IsCancellationRequested is false) + { + await webSocket.SendAsync(new ArraySegment(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, cancellationToken); + result = await webSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + } + + if (result.CloseStatus.HasValue && cancellationToken.IsCancellationRequested is false) + { + await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None); + } + } + catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) + { + Console.WriteLine($"{nameof(TestWebSocketServer)}: Client connection closed prematurely."); + } + catch (OperationCanceledException) + { + Console.WriteLine($"{nameof(TestWebSocketServer)}: Echo operation cancelled (server shutting down)."); + + if (webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await webSocket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, "Server shutting down", CancellationToken.None); + } + } + catch (Exception ex) + { + Console.WriteLine($"{nameof(TestWebSocketServer)}: Error during echo: {ex.Message}"); + + if (webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await webSocket.CloseAsync(WebSocketCloseStatus.InternalServerError, "Server error", CancellationToken.None); + } + } + finally + { + webSocket.Dispose(); + } + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker.Tests/Integration/WebSocketTests.cs b/src/StevesBot.Worker.Tests/Integration/WebSocketTests.cs new file mode 100644 index 0000000..f5a89b2 --- /dev/null +++ b/src/StevesBot.Worker.Tests/Integration/WebSocketTests.cs @@ -0,0 +1,43 @@ +using System.Text; + +using WebSocket = StevesBot.Worker.WebSockets.WebSocket; + +namespace StevesBot.Worker.Tests.Integration; + +public sealed class WebSocketTests : IClassFixture, IDisposable +{ + private readonly WebSocket _client = new(); + private readonly TestWebSocketServer _server; + + public WebSocketTests(TestWebSocketServer server) + { + _server = server; + } + + [Fact] + public async Task WebSocket_WhenUsed_ItShouldProperlyConnectSendMsgReceiveMsgAndClose() + { + await _client.ConnectAsync(_server.Uri, CancellationToken.None); + + var message = "Hello, WebSocket!"; + var buffer = new ArraySegment(Encoding.UTF8.GetBytes(message)); + await _client.SendAsync(buffer, WebSocketMessageType.Text, true, CancellationToken.None); + + var receivedBuffer = new ArraySegment(new byte[buffer.Count]); + var receivedMessage = await _client.ReceiveAsync(receivedBuffer, CancellationToken.None); + + receivedMessage.MessageType.Should().Be(WebSocketMessageType.Text); + receivedMessage.EndOfMessage.Should().BeTrue(); + receivedMessage.Count.Should().Be(buffer.Count); + + var receivedString = Encoding.UTF8.GetString([.. receivedBuffer], 0, receivedMessage.Count); + receivedString.Should().Be(message); + + await _client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Test complete", CancellationToken.None); + } + + public void Dispose() + { + _client.Dispose(); + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj b/src/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj index 3602d53..e696903 100644 --- a/src/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj +++ b/src/StevesBot.Worker.Tests/StevesBot.Worker.Tests.csproj @@ -14,6 +14,7 @@ all + diff --git a/src/StevesBot.Worker.Tests/Usings.cs b/src/StevesBot.Worker.Tests/Usings.cs index f34706e..22f266f 100644 --- a/src/StevesBot.Worker.Tests/Usings.cs +++ b/src/StevesBot.Worker.Tests/Usings.cs @@ -1,3 +1,13 @@ +global using System.Net.WebSockets; + +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Hosting.Server.Features; +global using Microsoft.AspNetCore.Http.Features; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; + global using StevesBot.Worker.Discord; global using StevesBot.Worker.Discord.Events; -global using StevesBot.Worker.Threading; \ No newline at end of file +global using StevesBot.Worker.Tests.Integration.Infrastructure; +global using StevesBot.Worker.Threading; diff --git a/src/StevesBot.Worker/Usings.cs b/src/StevesBot.Worker/Usings.cs index a344eb9..58cd73a 100644 --- a/src/StevesBot.Worker/Usings.cs +++ b/src/StevesBot.Worker/Usings.cs @@ -1,2 +1,3 @@ +global using System.Net.WebSockets; global using System.Text.Json; -global using System.Text.Json.Serialization; \ No newline at end of file +global using System.Text.Json.Serialization; diff --git a/src/StevesBot.Worker/WebSockets/IWebSocket.cs b/src/StevesBot.Worker/WebSockets/IWebSocket.cs new file mode 100644 index 0000000..ee89b79 --- /dev/null +++ b/src/StevesBot.Worker/WebSockets/IWebSocket.cs @@ -0,0 +1,11 @@ +namespace StevesBot.Worker.WebSockets; + +internal interface IWebSocket : IDisposable +{ + WebSocketState State { get; } + + Task ConnectAsync(Uri uri, CancellationToken cancellationToken); + Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken); + Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToken); + Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/StevesBot.Worker/WebSockets/IWebSocketFactory.cs b/src/StevesBot.Worker/WebSockets/IWebSocketFactory.cs new file mode 100644 index 0000000..e69de29 diff --git a/src/StevesBot.Worker/WebSockets/WebSocket.cs b/src/StevesBot.Worker/WebSockets/WebSocket.cs new file mode 100644 index 0000000..aec769c --- /dev/null +++ b/src/StevesBot.Worker/WebSockets/WebSocket.cs @@ -0,0 +1,38 @@ +namespace StevesBot.Worker.WebSockets; + +internal class WebSocket : IWebSocket +{ + private readonly ClientWebSocket _clientWebSocket; + + public WebSocketState State => _clientWebSocket.State; + + public WebSocket(ClientWebSocket? clientWebSocket = null) + { + _clientWebSocket = clientWebSocket ?? new ClientWebSocket(); + } + + public async Task ConnectAsync(Uri uri, CancellationToken cancellationToken) + { + await _clientWebSocket.ConnectAsync(uri, cancellationToken); + } + + public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken) + { + await _clientWebSocket.CloseAsync(closeStatus, statusDescription, cancellationToken); + } + + public async Task ReceiveAsync(ArraySegment buffer, CancellationToken cancellationToken) + { + return await _clientWebSocket.ReceiveAsync(buffer, cancellationToken); + } + + public async Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) + { + await _clientWebSocket.SendAsync(buffer, messageType, endOfMessage, cancellationToken); + } + + public void Dispose() + { + _clientWebSocket.Dispose(); + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker/WebSockets/WebSocketFactory.cs b/src/StevesBot.Worker/WebSockets/WebSocketFactory.cs new file mode 100644 index 0000000..e69de29