using System.Net; using ErsatzTV.Infrastructure.Streaming; using Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Infrastructure.Tests.Streaming; [TestFixture] public class HttpRemoteStreamProberTests { private const string Url = "http://localhost:8409/media/jellyfin/abc123"; [Test] public async Task Should_Report_Unavailable_On_404() { HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.NotFound); bool result = await prober.IsAvailable(Url, CancellationToken.None); result.ShouldBeFalse(); } [TestCase(HttpStatusCode.OK)] [TestCase(HttpStatusCode.PartialContent)] [TestCase(HttpStatusCode.NoContent)] public async Task Should_Report_Available_On_Success(HttpStatusCode statusCode) { HttpRemoteStreamProber prober = ProberReturning(statusCode); bool result = await prober.IsAvailable(Url, CancellationToken.None); result.ShouldBeTrue(); } // the fail-open contract: a probe that cannot answer must never block a tune that would // otherwise have worked. these cases exist so a future refactor can't silently invert it. [TestCase(HttpStatusCode.InternalServerError)] [TestCase(HttpStatusCode.BadGateway)] [TestCase(HttpStatusCode.Unauthorized)] [TestCase(HttpStatusCode.Forbidden)] public async Task Should_Fail_Open_On_Other_Status_Codes(HttpStatusCode statusCode) { HttpRemoteStreamProber prober = ProberReturning(statusCode); bool result = await prober.IsAvailable(Url, CancellationToken.None); result.ShouldBeTrue(); } [Test] public async Task Should_Fail_Open_On_Transport_Failure() { var prober = new HttpRemoteStreamProber( new StubHttpClientFactory(new ThrowingHttpMessageHandler(new HttpRequestException("no route to host"))), Substitute.For>()); bool result = await prober.IsAvailable(Url, CancellationToken.None); result.ShouldBeTrue(); } [Test] public async Task Should_Fail_Open_On_Timeout() { var prober = new HttpRemoteStreamProber( new StubHttpClientFactory(new ThrowingHttpMessageHandler(new TaskCanceledException("timed out"))), Substitute.For>()); bool result = await prober.IsAvailable(Url, CancellationToken.None); result.ShouldBeTrue(); } [Test] public async Task Should_Fail_Open_When_Caller_Cancels() { HttpRemoteStreamProber prober = ProberReturning(HttpStatusCode.OK); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); bool result = await prober.IsAvailable(Url, cts.Token); result.ShouldBeTrue(); } private static HttpRemoteStreamProber ProberReturning(HttpStatusCode statusCode) => new( new StubHttpClientFactory(new StatusCodeHttpMessageHandler(statusCode)), Substitute.For>()); private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); } private sealed class StatusCodeHttpMessageHandler(HttpStatusCode statusCode) : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(new HttpResponseMessage(statusCode)); } } private sealed class ThrowingHttpMessageHandler(Exception exception) : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromException(exception); } }