diff --git a/ErsatzTV.Application/Health/Mapper.cs b/ErsatzTV.Application/Health/Mapper.cs new file mode 100644 index 000000000..50c5fa61a --- /dev/null +++ b/ErsatzTV.Application/Health/Mapper.cs @@ -0,0 +1,25 @@ +using ErsatzTV.Core.Api.Health; +using ErsatzTV.Core.Health; + +namespace ErsatzTV.Application.Health; + +internal static class Mapper +{ + internal static HealthCheckResponseModel ProjectToResponseModel(HealthCheckResult result) => + new( + result.Title, + GetStatus(result.Status), + result.Message, + result.Link.Match(l => l.Link, () => null)); + + private static string GetStatus(HealthCheckStatus status) => + status switch + { + HealthCheckStatus.Pass => "pass", + HealthCheckStatus.Fail => "fail", + HealthCheckStatus.Warning => "warn", + HealthCheckStatus.Info => "info", + HealthCheckStatus.NotApplicable => "info", + _ => throw new ArgumentOutOfRangeException(nameof(status)) + }; +} diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs new file mode 100644 index 000000000..a3062a2f7 --- /dev/null +++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApi.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Api.Health; + +namespace ErsatzTV.Application.Health; + +public record GetAllHealthCheckResultsForApi : IRequest>; diff --git a/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs new file mode 100644 index 000000000..334d4cd28 --- /dev/null +++ b/ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsForApiHandler.cs @@ -0,0 +1,32 @@ +using ErsatzTV.Core.Api.Health; +using ErsatzTV.Core.Health; +using static ErsatzTV.Application.Health.Mapper; + +namespace ErsatzTV.Application.Health; + +public class GetAllHealthCheckResultsForApiHandler + : IRequestHandler> +{ + private readonly IHealthCheckService _healthCheckService; + + public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) => + _healthCheckService = healthCheckService; + + public async Task> Handle( + GetAllHealthCheckResultsForApi request, + CancellationToken cancellationToken) + { + try + { + List results = await _healthCheckService.PerformHealthChecks(cancellationToken); + return results + .Filter(r => r.Status != HealthCheckStatus.NotApplicable) + .Map(ProjectToResponseModel) + .ToList(); + } + catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) + { + return []; + } + } +} diff --git a/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs new file mode 100644 index 000000000..37be2a34c --- /dev/null +++ b/ErsatzTV.Core/Api/Health/HealthCheckResponseModel.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Core.Api.Health; + +public record HealthCheckResponseModel( + string Title, + string Status, + string Detail, + string Link); diff --git a/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs b/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs new file mode 100644 index 000000000..d0e9a0ff4 --- /dev/null +++ b/ErsatzTV.Tests/Application/Health/GetAllHealthCheckResultsForApiHandlerTests.cs @@ -0,0 +1,111 @@ +using ErsatzTV.Application.Health; +using ErsatzTV.Core.Api.Health; +using ErsatzTV.Core.Health; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Health; + +[TestFixture] +public class GetAllHealthCheckResultsForApiHandlerTests +{ + private IHealthCheckService _healthCheckService = null!; + private GetAllHealthCheckResultsForApiHandler _handler = null!; + + [SetUp] + public void SetUp() + { + _healthCheckService = Substitute.For(); + _handler = new GetAllHealthCheckResultsForApiHandler(_healthCheckService); + } + + [Test] + public async Task Should_Map_Status_Codes_To_Lowercase_Strings() + { + var results = new List + { + new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option.None), + new("Fail Check", HealthCheckStatus.Fail, "broken", "bad", Option.None), + new("Warn Check", HealthCheckStatus.Warning, "watch out", "warn", Option.None), + new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option.None) + }; + + _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response.Count.ShouldBe(4); + response.Select(r => r.Status).ShouldBe(["pass", "fail", "warn", "info"]); + } + + [Test] + public async Task Should_Filter_Out_NotApplicable_Results() + { + var results = new List + { + new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option.None), + new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option.None) + }; + + _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response.Count.ShouldBe(1); + response[0].Title.ShouldBe("Pass Check"); + } + + [Test] + public async Task Should_Include_Link_When_Present() + { + var results = new List + { + new( + "Linked Check", + HealthCheckStatus.Warning, + "detail message", + "brief", + Option.Some(new HealthCheckLink("https://example.com/docs"))) + }; + + _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response[0].Link.ShouldBe("https://example.com/docs"); + response[0].Detail.ShouldBe("detail message"); + } + + [Test] + public async Task Should_Have_Null_Link_When_Absent() + { + var results = new List + { + new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option.None) + }; + + _healthCheckService.PerformHealthChecks(Arg.Any()).Returns(results); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response[0].Link.ShouldBeNull(); + } + + [Test] + public async Task Should_Return_Empty_List_On_Cancellation() + { + _healthCheckService.PerformHealthChecks(Arg.Any()) + .Returns>>(_ => throw new TaskCanceledException()); + + List response = + await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None); + + response.ShouldBeEmpty(); + } +} diff --git a/ErsatzTV.Tests/Controllers/HealthControllerTests.cs b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs new file mode 100644 index 000000000..113732a89 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/HealthControllerTests.cs @@ -0,0 +1,40 @@ +using ErsatzTV.Application.Health; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Core.Api.Health; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class HealthControllerTests +{ + private IMediator _mediator = null!; + private HealthController _controller = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new HealthController(_mediator); + } + + [Test] + public async Task GetAll_Should_Return_Results_From_Mediator() + { + var expected = new List + { + new("Check One", "pass", "all good", null), + new("Check Two", "fail", "broken", "https://example.com") + }; + + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(expected); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(expected); + } +} diff --git a/ErsatzTV/Controllers/Api/HealthController.cs b/ErsatzTV/Controllers/Api/HealthController.cs new file mode 100644 index 000000000..3c865fd49 --- /dev/null +++ b/ErsatzTV/Controllers/Api/HealthController.cs @@ -0,0 +1,18 @@ +using ErsatzTV.Application.Health; +using ErsatzTV.Core.Api.Health; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +[EndpointGroupName("general")] +public class HealthController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/health")] + [Tags("Health")] + [EndpointSummary("Get health check results")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllHealthCheckResultsForApi(), cancellationToken); +}