using System.IO.Abstractions; using System.Reflection; using System.Text.Json; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Channels; using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Application.Troubleshooting.Queries; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Troubleshooting; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Troubleshooting; using ErsatzTV.Core.Notifications; using ErsatzTV.Core.Troubleshooting; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NSubstitute; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class TroubleshootControllerTests { private TroubleshootController _controller = null!; private IMediator _mediator = null!; private IEntityLocker _entityLocker = null!; private TroubleshootingPlaybackStatusStore _statusStore = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _entityLocker = Substitute.For(); _statusStore = new TroubleshootingPlaybackStatusStore(); _controller = new TroubleshootController( System.Threading.Channels.Channel.CreateUnbounded().Writer, Substitute.For(), Substitute.For(), Substitute.For(), _entityLocker, _statusStore, _mediator); } private static MediaItemInfo FakeMediaItemInfo() => new( 1, "Title", "Movie", "LocalLibrary", null, "Movies", MediaItemState.Normal, TimeSpan.FromMinutes(90), "1:1", "16:9", "24000/1001", VideoScanKind.Progressive, null, 1920, 1080, [], []); [Test] public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Info() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetInfo)) ?? throw new AssertionException("Missing action GetInfo"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/troubleshoot/info"); attribute.Name.ShouldBe("GetTroubleshootingInfo"); } [Test] public async Task GetInfo_Should_Serialize_General_Section_And_Carry_Platform_Capabilities() { var info = new TroubleshootingInfo( "1.2.3", new Dictionary { ["ETV_FOO"] = "bar" }, [], [], [], new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(), [], [], [], false, false, "nvidia output", "qsv output", "vaapi output", "videotoolbox output"); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(info); TroubleshootingInfoResponseModel result = await _controller.GetInfo(CancellationToken.None); result.NvidiaCapabilities.ShouldBe("nvidia output"); result.QsvCapabilities.ShouldBe("qsv output"); result.VaapiCapabilities.ShouldBe("vaapi output"); result.VideoToolboxCapabilities.ShouldBe("videotoolbox output"); using JsonDocument document = JsonDocument.Parse(result.GeneralJson); document.RootElement.GetProperty("Version").GetString().ShouldBe("1.2.3"); document.RootElement.GetProperty("Environment").GetProperty("ETV_FOO").GetString().ShouldBe("bar"); document.RootElement.GetProperty("AviSynth").GetProperty("Demuxer").GetBoolean().ShouldBeFalse(); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Route_For_ValidateSchedule() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.ValidateSchedule)) ?? throw new AssertionException("Missing action ValidateSchedule"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/troubleshoot/validate-schedule"); attribute.Name.ShouldBe("ValidateSequentialSchedule"); } [Test] public void ValidateSchedule_Should_Use_Stable_Request_Dto() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.ValidateSchedule)) ?? throw new AssertionException("Missing action ValidateSchedule"); action.GetParameters()[0].ParameterType.ShouldBe(typeof(ValidateSequentialScheduleRequest)); } [TestCase("")] [TestCase(" ")] public async Task ValidateSchedule_Should_Return_400_For_Empty_Yaml(string yaml) { IActionResult result = await _controller.ValidateSchedule( new ValidateSequentialScheduleRequest(yaml, false), CancellationToken.None); var badRequest = result.ShouldBeOfType(); badRequest.Value.ShouldBeOfType().Status.ShouldBe(400); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task ValidateSchedule_Should_Map_Result_To_Response() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new ValidateSequentialScheduleViewModel(false, ["boom"], "{}")); IActionResult result = await _controller.ValidateSchedule( new ValidateSequentialScheduleRequest("content: []", true), CancellationToken.None); var response = result.ShouldBeOfType().Value .ShouldBeOfType(); response.IsValid.ShouldBeFalse(); response.Messages.ShouldBe(["boom"]); response.Json.ShouldBe("{}"); await _mediator.Received(1).Send( Arg.Is(q => q.Yaml == "content: []" && q.IsImport), Arg.Any()); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Route_For_StreamSelectors() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetStreamSelectors)) ?? throw new AssertionException("Missing action GetStreamSelectors"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/stream-selectors"); attribute.Name.ShouldBe("GetTroubleshootingStreamSelectors"); } [Test] public async Task GetStreamSelectors_Should_Return_Selector_Names() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(["a.yml", "b.yml"]); List result = await _controller.GetStreamSelectors(CancellationToken.None); result.ShouldBe(["a.yml", "b.yml"]); await _mediator.Received(1).Send(Arg.Any(), Arg.Any()); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Subtitles() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetSubtitles)) ?? throw new AssertionException("Missing action GetSubtitles"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/subtitles/{mediaItemId:int}"); attribute.Name.ShouldBe("GetTroubleshootingSubtitles"); } [Test] public async Task GetSubtitles_Should_Return_404_For_Unknown_Media_Item() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Left(BaseError.New("nope"))); IActionResult result = await _controller.GetSubtitles(999, CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(404); await _mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public async Task GetSubtitles_Should_Map_ViewModels_To_Response() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Right(FakeMediaItemInfo())); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new List { new(7, "eng", "English", "subrip") }); IActionResult result = await _controller.GetSubtitles(1, CancellationToken.None); var subtitles = result.ShouldBeOfType().Value .ShouldBeOfType>(); subtitles.Count.ShouldBe(1); subtitles[0].Id.ShouldBe(7); subtitles[0].Language.ShouldBe("eng"); subtitles[0].Title.ShouldBe("English"); subtitles[0].Codec.ShouldBe("subrip"); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackStatus() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetPlaybackStatus)) ?? throw new AssertionException("Missing action GetPlaybackStatus"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/status"); attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus"); } private static StartTroubleshootingPlaybackRequest DefaultPlaybackRequest(int mediaItem = 1, int channel = 0) => new( mediaItem, channel, FfmpegProfile: 1, StreamingMode.HttpLiveStreamingSegmenter, Watermark: [], GraphicsElement: [], StreamSelector: string.Empty, SubtitleId: null, SeekSeconds: 0, Start: null); [Test] public void Controller_Should_Expose_Idiomatic_Rest_Route_For_TroubleshootPlayback() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.TroubleshootPlayback)) ?? throw new AssertionException("Missing action TroubleshootPlayback"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/start"); attribute.Name.ShouldBe("StartTroubleshootingPlayback"); action.GetParameters()[0].ParameterType.ShouldBe(typeof(StartTroubleshootingPlaybackRequest)); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackArchive() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.TroubleshootPlaybackArchive)) ?? throw new AssertionException("Missing action TroubleshootPlaybackArchive"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/archive"); attribute.Name.ShouldBe("DownloadTroubleshootingArchive"); action.GetCustomAttributes().ShouldBeEmpty(); action.GetCustomAttributes().ShouldBeEmpty(); } [Test] public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackSample() { MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.TroubleshootPlaybackSample)) ?? throw new AssertionException("Missing action TroubleshootPlaybackSample"); var attribute = action.GetCustomAttributes().Single(); attribute.Template.ShouldBe("/api/v1/troubleshoot/playback/sample/{mediaItemId:int}"); attribute.Name.ShouldBe("DownloadTroubleshootingMediaSample"); action.GetCustomAttributes().ShouldBeEmpty(); action.GetCustomAttributes().ShouldBeEmpty(); } [Test] public async Task TroubleshootPlayback_Should_Return_409_When_Already_Locked() { _entityLocker.IsTroubleshootingPlaybackLocked().Returns(true); IActionResult result = await _controller.TroubleshootPlayback( DefaultPlaybackRequest(), CancellationToken.None); ConflictObjectResult conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(409); // pre-check short-circuits before dispatching any work await _mediator.DidNotReceive() .Send(Arg.Any(), Arg.Any()); } [Test] public async Task TroubleshootPlayback_Should_Return_409_ProblemDetails_When_Prepare_Loses_Lock_Race() { // pre-check passes, but the handler's atomic acquire loses the race to another session // that grabbed the lock in between -> still a 409, not a 422 (ersatztv#316 review) _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Left( new ErsatzTV.Core.Errors.LockedError("Troubleshooting playback is locked"))); IActionResult result = await _controller.TroubleshootPlayback( DefaultPlaybackRequest(), CancellationToken.None); var conflict = result.ShouldBeOfType(); conflict.Value.ShouldBeOfType().Status.ShouldBe(409); } [Test] public async Task TroubleshootPlayback_Should_Return_404_ProblemDetails_When_Prepare_Not_Found() { _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Left( new ErsatzTV.Core.Errors.NotFoundError("no such media item"))); IActionResult result = await _controller.TroubleshootPlayback( DefaultPlaybackRequest(mediaItem: 999), CancellationToken.None); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(404); } [Test] public async Task TroubleshootPlayback_Should_Return_422_ProblemDetails_When_Prepare_Fails_Validation() { _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Left(BaseError.New("unable to prepare"))); IActionResult result = await _controller.TroubleshootPlayback( DefaultPlaybackRequest(), CancellationToken.None); var unprocessable = result.ShouldBeOfType(); unprocessable.Value.ShouldBeOfType().Status.ShouldBe(422); } [Test] public async Task TroubleshootPlayback_Should_Return_200_With_Rooted_Iptv_Url_On_Success() { // The segment-readiness poll shells out to the real filesystem (Directory.GetFiles), not the // injected IFileSystem abstraction, so this test seeds real files under the troubleshooting // transcode folder rather than mocking it. Directory.CreateDirectory(FileSystemLayout.TranscodeTroubleshootingFolder); string segmentPath = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "seg-test-0.ts"); string segmentPath2 = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "seg-test-1.ts"); await File.WriteAllTextAsync(segmentPath, string.Empty); await File.WriteAllTextAsync(segmentPath2, string.Empty); try { _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); var playoutItemResult = new PlayoutItemResult( new CliWrap.Command("ffmpeg"), Option.None, Option.Some(1)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Right(playoutItemResult)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Right(FakeMediaItemInfo())); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new TroubleshootingInfo( "1.2.3", new Dictionary(), [], [], [], new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(), [], [], [], false, false, null, null, null, null)); var fileSystem = Substitute.For(); fileSystem.File.Exists(Arg.Any()).Returns(true); var configElementRepository = Substitute.For(); configElementRepository .GetValue(ConfigElementKey.FFmpegInitialSegmentCount, Arg.Any()) .Returns(Option.Some(2)); var controller = new TroubleshootController( System.Threading.Channels.Channel.CreateUnbounded().Writer, fileSystem, configElementRepository, Substitute.For(), _entityLocker, _statusStore, _mediator) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { Request = { PathBase = "/etv" } } } }; IActionResult result = await controller.TroubleshootPlayback( DefaultPlaybackRequest(), CancellationToken.None); var ok = result.ShouldBeOfType(); var body = ok.Value.ShouldBeOfType(); body.Url.ShouldBe("/etv/iptv/session/.troubleshooting/live.m3u8"); } finally { File.Delete(segmentPath); File.Delete(segmentPath2); } } [Test] public async Task TroubleshootPlayback_Should_Return_404_And_Not_Spin_When_Ffmpeg_Fails_Before_Segments() { // ersatztv#320: if ffmpeg dies after writing the playlist but before any segments appear, the // segment-readiness poll must break on notifier.IsFailed instead of spinning until the client // cancels (which would tie up the request thread + hold the troubleshooting lock). // // The segment scan uses the real Directory.GetFiles on the shared troubleshooting folder, so // ensure the folder EXISTS — otherwise Directory.GetFiles throws DirectoryNotFoundException, // which the pre-fix loop would surface as a fast 404 (a false pass that hides the regression). // We deliberately do NOT delete stray *.ts here: this folder is machine-global and may hold a // live troubleshooting session's segments (don't reap files this test didn't create). It isn't // needed for determinism — NUnit runs serially and the sibling Should_Return_200 test cleans up // its own seg-test-*.ts in a finally, so no test leaves >= initialSegmentCount(2) files behind; // and the fixed code breaks on IsFailed before the scan runs regardless of folder contents. Directory.CreateDirectory(FileSystemLayout.TranscodeTroubleshootingFolder); _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); var playoutItemResult = new PlayoutItemResult( new CliWrap.Command("ffmpeg"), Option.None, Option.Some(1)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Right(playoutItemResult)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Either.Right(FakeMediaItemInfo())); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new TroubleshootingInfo( "1.2.3", new Dictionary(), [], [], [], new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(), [], [], [], false, false, null, null, null, null)); var fileSystem = Substitute.For(); // playlist "exists" so the first wait loop exits immediately and we reach the segment poll fileSystem.File.Exists(Arg.Any()).Returns(true); var configElementRepository = Substitute.For(); configElementRepository .GetValue(ConfigElementKey.FFmpegInitialSegmentCount, Arg.Any()) .Returns(Option.Some(2)); var notifier = Substitute.For(); notifier.IsFailed(Arg.Any()).Returns(true); var controller = new TroubleshootController( System.Threading.Channels.Channel.CreateUnbounded().Writer, fileSystem, configElementRepository, notifier, _entityLocker, _statusStore, _mediator) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { Request = { PathBase = "/etv" } } } }; // safety deadline so a regression (the loop spinning) fails the test in bounded time instead // of hanging CI; the fixed code exits via IsFailed long before this fires using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); IActionResult result = await controller.TroubleshootPlayback(DefaultPlaybackRequest(), cts.Token); var notFound = result.ShouldBeOfType(); notFound.Value.ShouldBeOfType().Status.ShouldBe(404); // the loop must have exited via IsFailed, NOT by hitting the cancellation deadline — this is // what makes the test non-vacuous (it fails on the pre-fix spinning loop) cts.IsCancellationRequested.ShouldBeFalse(); notifier.Received().RemoveSession(Arg.Any()); } [Test] public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked() { _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); TroubleshootingPlaybackStatusResponseModel result = await _controller.GetPlaybackStatus(CancellationToken.None); result.State.ShouldBe("idle"); result.ExitCode.ShouldBeNull(); result.Speed.ShouldBeNull(); } [Test] public async Task GetPlaybackStatus_Should_Report_Running_When_Locked() { _entityLocker.IsTroubleshootingPlaybackLocked().Returns(true); TroubleshootingPlaybackStatusResponseModel result = await _controller.GetPlaybackStatus(CancellationToken.None); result.State.ShouldBe("running"); } [Test] public async Task GetPlaybackStatus_Should_Report_Completed_With_ExitCode_And_Speed() { _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); _statusStore.RecordCompletion(0, 1.5); TroubleshootingPlaybackStatusResponseModel result = await _controller.GetPlaybackStatus(CancellationToken.None); result.State.ShouldBe("completed"); result.ExitCode.ShouldBe(0); result.Speed.ShouldBe(1.5); } [Test] public async Task GetPlaybackStatus_Should_Report_Failed_For_NonZero_ExitCode() { _entityLocker.IsTroubleshootingPlaybackLocked().Returns(false); _statusStore.RecordCompletion(1, Option.None); TroubleshootingPlaybackStatusResponseModel result = await _controller.GetPlaybackStatus(CancellationToken.None); result.State.ShouldBe("failed"); result.ExitCode.ShouldBe(1); result.Speed.ShouldBeNull(); } [Test] public async Task RecordTroubleshootingPlaybackStatusHandler_Should_Record_Result_In_Store() { var store = new TroubleshootingPlaybackStatusStore(); var handler = new RecordTroubleshootingPlaybackStatusHandler(store); store.CurrentResult.IsNone.ShouldBeTrue(); await handler.Handle( new PlaybackTroubleshootingCompletedNotification(0, Option.None, 2.0), CancellationToken.None); store.CurrentResult.IsSome.ShouldBeTrue(); TroubleshootingPlaybackResult recorded = store.CurrentResult.IfNone(() => throw new AssertionException("Expected a recorded result")); recorded.ExitCode.ShouldBe(0); recorded.Speed.IfNone(-1).ShouldBe(2.0); store.Reset(); store.CurrentResult.IsNone.ShouldBeTrue(); } }