diff --git a/src/GmRelay.DiscordBot/Infrastructure/Health/DiscordHealthCheckHostedService.cs b/src/GmRelay.DiscordBot/Infrastructure/Health/DiscordHealthCheckHostedService.cs new file mode 100644 index 0000000..b40f204 --- /dev/null +++ b/src/GmRelay.DiscordBot/Infrastructure/Health/DiscordHealthCheckHostedService.cs @@ -0,0 +1,101 @@ +using System.Net; + +namespace GmRelay.DiscordBot.Infrastructure.Health; + +public sealed class DiscordHealthCheckHostedService : IHostedService +{ + private readonly ILogger _logger; + private readonly string _prefix; + private HttpListener? _listener; + private CancellationTokenSource? _cts; + private Task? _listenerTask; + + public DiscordHealthCheckHostedService( + ILogger logger, + IConfiguration configuration) + { + _logger = logger; + _prefix = configuration.GetValue("HealthCheck:Prefix", "http://+:8082/")!; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _cts = new CancellationTokenSource(); + _listener = new HttpListener(); + _listener.Prefixes.Add(_prefix); + _listener.Start(); + + _logger.LogInformation("Discord health check server started on {Prefix}", _prefix); + + _listenerTask = Task.Run(async () => await ListenAsync(_cts.Token), cancellationToken); + + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _cts?.Cancel(); + _listener?.Stop(); + + if (_listenerTask != null) + { + await Task.WhenAny(_listenerTask, Task.Delay(TimeSpan.FromSeconds(5), cancellationToken)); + } + + _listener?.Close(); + _logger.LogInformation("Discord health check server stopped"); + } + + private async Task ListenAsync(CancellationToken cancellationToken) + { + while (_listener?.IsListening == true && !cancellationToken.IsCancellationRequested) + { + try + { + var context = await _listener.GetContextAsync(); + _ = Task.Run(() => HandleRequestAsync(context), cancellationToken); + } + catch (HttpListenerException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in Discord health check listener"); + } + } + } + + private async Task HandleRequestAsync(HttpListenerContext context) + { + var response = context.Response; + try + { + var request = context.Request; + + if (request.Url?.AbsolutePath == "/health") + { + response.StatusCode = (int)HttpStatusCode.OK; + response.ContentType = "application/json"; + var body = "{\"status\":\"healthy\"}"u8.ToArray(); + await response.OutputStream.WriteAsync(body); + } + else + { + response.StatusCode = (int)HttpStatusCode.NotFound; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling Discord health check request"); + } + finally + { + response.Close(); + } + } +} diff --git a/tests/GmRelay.Bot.Tests/Infrastructure/Health/DiscordHealthCheckHostedServiceTests.cs b/tests/GmRelay.Bot.Tests/Infrastructure/Health/DiscordHealthCheckHostedServiceTests.cs new file mode 100644 index 0000000..98daa65 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Infrastructure/Health/DiscordHealthCheckHostedServiceTests.cs @@ -0,0 +1,53 @@ +using System.Net; +using System.Net.Sockets; +using GmRelay.DiscordBot.Infrastructure.Health; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GmRelay.Bot.Tests.Infrastructure.Health; + +public sealed class DiscordHealthCheckHostedServiceTests : IDisposable +{ + private readonly DiscordHealthCheckHostedService _service; + private readonly int _port; + + public DiscordHealthCheckHostedServiceTests() + { + _port = GetAvailablePort(); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["HealthCheck:Prefix"] = $"http://localhost:{_port}/" + }) + .Build(); + + _service = new DiscordHealthCheckHostedService( + NullLogger.Instance, + config); + } + + public void Dispose() + { + _service.StopAsync(CancellationToken.None).Wait(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task HealthEndpoint_ShouldReturn200_WhenServiceIsRunning() + { + await _service.StartAsync(CancellationToken.None); + + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var response = await client.GetAsync($"http://localhost:{_port}/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static int GetAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +}