using ErsatzTV.Application.Configuration; using ErsatzTV.Application.FFmpegProfiles; using ErsatzTV.Application.HDHR; using ErsatzTV.Controllers.Api; using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.Settings; using ErsatzTV.FFmpeg.OutputFormat; using LanguageExt; using MediatR; using Microsoft.AspNetCore.Mvc; using NSubstitute; using NUnit.Framework; using Serilog.Events; using Shouldly; using static LanguageExt.Prelude; using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior; using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone; using Unit = LanguageExt.Unit; using VmXmltvBlockBehavior = ErsatzTV.Application.Configuration.XmltvBlockBehavior; using VmXmltvTimeZone = ErsatzTV.Application.Configuration.XmltvTimeZone; namespace ErsatzTV.Tests.Controllers; [TestFixture] public class SettingsControllerTests { private SettingsController _controller = null!; private IMediator _mediator = null!; [SetUp] public void SetUp() { _mediator = Substitute.For(); _controller = new SettingsController(_mediator); } [Test] public async Task GetFfmpeg_Should_Map_Vm_To_Response_Model() { var vm = new FFmpegSettingsViewModel { FFmpegPath = "/usr/bin/ffmpeg", FFprobePath = "/usr/bin/ffprobe", DefaultFFmpegProfileId = 1, PreferredAudioLanguageCode = "eng", UseEmbeddedSubtitles = true, ExtractEmbeddedSubtitles = false, ProbeForInterlacedFrames = false, SaveReports = false, GlobalWatermarkId = 5, GlobalFallbackFillerId = null, HlsSegmenterIdleTimeout = 60, WorkAheadSegmenterLimit = 1, InitialSegmentCount = 1, HlsDirectOutputFormat = OutputFormatKind.MpegTs, DefaultMpegTsScript = "Default" }; _mediator.Send(Arg.Any(), Arg.Any()).Returns(vm); FFmpegSettingsResponseModel result = await _controller.GetFfmpeg(CancellationToken.None); result.FFmpegPath.ShouldBe("/usr/bin/ffmpeg"); result.GlobalWatermarkId.ShouldBe(5); result.GlobalFallbackFillerId.ShouldBeNull(); result.HlsDirectOutputFormat.ShouldBe(OutputFormatKind.MpegTs); } [Test] public async Task UpdateFfmpeg_Should_Map_Request_To_Command_And_Return_Refreshed_Settings() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); var refreshed = new FFmpegSettingsViewModel { FFmpegPath = "/usr/bin/ffmpeg", FFprobePath = "/usr/bin/ffprobe", PreferredAudioLanguageCode = "eng", HlsDirectOutputFormat = OutputFormatKind.Hls, DefaultMpegTsScript = "Default" }; _mediator.Send(Arg.Any(), Arg.Any()).Returns(refreshed); IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None); var ok = result.ShouldBeOfType(); ok.Value.ShouldBeOfType().HlsDirectOutputFormat.ShouldBe(OutputFormatKind.Hls); await _mediator.Received(1).Send( Arg.Is( c => c.Settings.FFmpegPath == "/usr/bin/ffmpeg" && c.Settings.FFprobePath == "/usr/bin/ffprobe" && c.Settings.DefaultFFmpegProfileId == 1 && c.Settings.PreferredAudioLanguageCode == "eng" && c.Settings.UseEmbeddedSubtitles && !c.Settings.ExtractEmbeddedSubtitles && !c.Settings.ProbeForInterlacedFrames && !c.Settings.SaveReports && c.Settings.GlobalWatermarkId == null && c.Settings.GlobalFallbackFillerId == null && c.Settings.HlsSegmenterIdleTimeout == 60 && c.Settings.WorkAheadSegmenterLimit == 1 && c.Settings.InitialSegmentCount == 1 && c.Settings.HlsDirectOutputFormat == OutputFormatKind.MpegTs && c.Settings.DefaultMpegTsScript == "Default"), Arg.Any()); } [Test] public async Task UpdateFfmpeg_Should_Flow_NonNull_Watermark_And_Filler_Ids_Through() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new FFmpegSettingsViewModel { FFmpegPath = "/usr/bin/ffmpeg", FFprobePath = "/usr/bin/ffprobe" }); UpdateFFmpegSettingsRequest request = MakeFfmpegRequest() with { GlobalWatermarkId = 7, GlobalFallbackFillerId = 9 }; IActionResult result = await _controller.UpdateFfmpeg(request, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is( c => c.Settings.GlobalWatermarkId == 7 && c.Settings.GlobalFallbackFillerId == 9), Arg.Any()); } [Test] public async Task UpdateFfmpeg_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("ffmpeg path does not exist"))); IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetPlayout_Should_Map_Vm_To_Response_Model() { var vm = new PlayoutSettingsViewModel { DaysToBuild = 3, SkipMissingItems = true, ScriptedScheduleTimeoutSeconds = 45 }; _mediator.Send(Arg.Any(), Arg.Any()).Returns(vm); PlayoutSettingsResponseModel result = await _controller.GetPlayout(CancellationToken.None); result.ShouldBe(new PlayoutSettingsResponseModel(3, true, 45)); } [Test] public async Task UpdatePlayout_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.UpdatePlayout( new UpdatePlayoutSettingsRequest(0, false, 30), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task UpdatePlayout_Should_Return_Refreshed_Settings_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new PlayoutSettingsViewModel { DaysToBuild = 5, SkipMissingItems = false, ScriptedScheduleTimeoutSeconds = 30 }); IActionResult result = await _controller.UpdatePlayout( new UpdatePlayoutSettingsRequest(5, false, 30), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(new PlayoutSettingsResponseModel(5, false, 30)); await _mediator.Received(1).Send( Arg.Is( c => c.PlayoutSettings.DaysToBuild == 5 && c.PlayoutSettings.SkipMissingItems == false && c.PlayoutSettings.ScriptedScheduleTimeoutSeconds == 30), Arg.Any()); } [Test] public async Task GetXmltv_Should_Map_Vm_Enums_To_Api_Enums() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns( new XmltvSettingsViewModel { DaysToBuild = 2, TimeZone = VmXmltvTimeZone.Utc, BlockBehavior = VmXmltvBlockBehavior.UseActualTimes }); XmltvSettingsResponseModel result = await _controller.GetXmltv(CancellationToken.None); result.ShouldBe( new XmltvSettingsResponseModel( 2, ApiXmltvTimeZone.Utc, ApiXmltvBlockBehavior.UseActualTimes)); } [Test] public async Task UpdateXmltv_Should_Map_Api_Enums_To_Vm_Enums() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new XmltvSettingsViewModel { DaysToBuild = 2, TimeZone = VmXmltvTimeZone.Local, BlockBehavior = VmXmltvBlockBehavior.SplitTimeEvenly }); IActionResult result = await _controller.UpdateXmltv( new UpdateXmltvSettingsRequest(2, ApiXmltvTimeZone.Local, ApiXmltvBlockBehavior.SplitTimeEvenly), CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is( c => c.XmltvSettings.TimeZone == VmXmltvTimeZone.Local && c.XmltvSettings.BlockBehavior == VmXmltvBlockBehavior.SplitTimeEvenly), Arg.Any()); } [Test] public async Task UpdateXmltv_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.UpdateXmltv( new UpdateXmltvSettingsRequest( 2, ErsatzTV.Core.Api.Settings.XmltvTimeZone.Local, ErsatzTV.Core.Api.Settings.XmltvBlockBehavior.SplitTimeEvenly), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetIptv_Should_Map_Vm_To_Response_Model() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new IptvSettingsViewModel { BaseUrl = "https://tv.example.com/etv" }); IptvSettingsResponseModel result = await _controller.GetIptv(CancellationToken.None); result.ShouldBe(new IptvSettingsResponseModel("https://tv.example.com/etv")); } [Test] public async Task UpdateIptv_Should_Map_Request_To_Command_And_Return_Refreshed_Settings() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new IptvSettingsViewModel { BaseUrl = "https://public.example.com" }); IActionResult result = await _controller.UpdateIptv( new UpdateIptvSettingsRequest("https://public.example.com"), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(new IptvSettingsResponseModel("https://public.example.com")); await _mediator.Received(1).Send( Arg.Is(c => c.IptvSettings.BaseUrl == "https://public.example.com"), Arg.Any()); } [Test] public async Task UpdateIptv_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.UpdateIptv( new UpdateIptvSettingsRequest("not a url"), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetScanner_Should_Return_Library_Refresh_Interval() { _mediator.Send(Arg.Any(), Arg.Any()).Returns(6); ScannerSettingsResponseModel result = await _controller.GetScanner(CancellationToken.None); result.ShouldBe(new ScannerSettingsResponseModel(6)); } [Test] public async Task UpdateScanner_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.UpdateScanner(new UpdateScannerSettingsRequest(-1), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task UpdateScanner_Should_Return_Refreshed_Settings_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()).Returns(12); IActionResult result = await _controller.UpdateScanner(new UpdateScannerSettingsRequest(12), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(new ScannerSettingsResponseModel(12)); await _mediator.Received(1).Send( Arg.Is(c => c.LibraryRefreshInterval == 12), Arg.Any()); } [Test] public async Task GetLogging_Should_Map_Vm_To_Response_Model() { var vm = new LoggingSettingsViewModel { DefaultMinimumLogLevel = LogEventLevel.Information, ScanningMinimumLogLevel = LogEventLevel.Debug, SchedulingMinimumLogLevel = LogEventLevel.Warning, SearchingMinimumLogLevel = LogEventLevel.Error, StreamingMinimumLogLevel = LogEventLevel.Verbose, HttpMinimumLogLevel = LogEventLevel.Fatal }; _mediator.Send(Arg.Any(), Arg.Any()).Returns(vm); LoggingSettingsResponseModel result = await _controller.GetLogging(CancellationToken.None); result.DefaultMinimumLogLevel.ShouldBe(LogEventLevel.Information); result.ScanningMinimumLogLevel.ShouldBe(LogEventLevel.Debug); result.SchedulingMinimumLogLevel.ShouldBe(LogEventLevel.Warning); result.SearchingMinimumLogLevel.ShouldBe(LogEventLevel.Error); result.StreamingMinimumLogLevel.ShouldBe(LogEventLevel.Verbose); result.HttpMinimumLogLevel.ShouldBe(LogEventLevel.Fatal); } [Test] public async Task UpdateLogging_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.UpdateLogging(MakeLoggingRequest(), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task UpdateLogging_Should_Map_Request_To_Command_And_Return_Refreshed_Settings() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); var refreshed = new LoggingSettingsViewModel { DefaultMinimumLogLevel = LogEventLevel.Debug, ScanningMinimumLogLevel = LogEventLevel.Debug, SchedulingMinimumLogLevel = LogEventLevel.Debug, SearchingMinimumLogLevel = LogEventLevel.Debug, StreamingMinimumLogLevel = LogEventLevel.Debug, HttpMinimumLogLevel = LogEventLevel.Debug }; _mediator.Send(Arg.Any(), Arg.Any()).Returns(refreshed); var request = new UpdateLoggingSettingsRequest( LogEventLevel.Warning, LogEventLevel.Error, LogEventLevel.Fatal, LogEventLevel.Verbose, LogEventLevel.Debug, LogEventLevel.Information); IActionResult result = await _controller.UpdateLogging(request, CancellationToken.None); result.ShouldBeOfType(); await _mediator.Received(1).Send( Arg.Is( c => c.LoggingSettings.DefaultMinimumLogLevel == LogEventLevel.Warning && c.LoggingSettings.ScanningMinimumLogLevel == LogEventLevel.Error && c.LoggingSettings.SchedulingMinimumLogLevel == LogEventLevel.Fatal && c.LoggingSettings.SearchingMinimumLogLevel == LogEventLevel.Verbose && c.LoggingSettings.StreamingMinimumLogLevel == LogEventLevel.Debug && c.LoggingSettings.HttpMinimumLogLevel == LogEventLevel.Information), Arg.Any()); } [Test] public async Task GetUi_Should_Map_Vm_To_Response_Model() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new UiSettingsViewModel { IsDarkMode = true, Language = "en" }); UiSettingsResponseModel result = await _controller.GetUi(CancellationToken.None); result.ShouldBe(new UiSettingsResponseModel(true, "en")); } [Test] public async Task UpdateUi_Should_Return_Refreshed_Settings_On_Success() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(new UiSettingsViewModel { IsDarkMode = false, Language = "fr" }); IActionResult result = await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(new UiSettingsResponseModel(false, "fr")); await _mediator.Received(1).Send( Arg.Is(c => c.UiSettings.IsDarkMode == false && c.UiSettings.Language == "fr"), Arg.Any()); } [Test] public async Task UpdateUi_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("bad"))); IActionResult result = await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None); result.ShouldBeOfType(); } [Test] public async Task GetHdhr_Should_Combine_Tuner_Count_And_Uuid() { var uuid = Guid.NewGuid(); _mediator.Send(Arg.Any(), Arg.Any()).Returns(3); _mediator.Send(Arg.Any(), Arg.Any()).Returns(uuid); HdhrSettingsResponseModel result = await _controller.GetHdhr(CancellationToken.None); result.ShouldBe(new HdhrSettingsResponseModel(3, uuid)); } [Test] public async Task UpdateHdhr_Should_Return_Refreshed_Settings_On_Success() { var uuid = Guid.NewGuid(); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Right(Unit.Default)); _mediator.Send(Arg.Any(), Arg.Any()).Returns(4); _mediator.Send(Arg.Any(), Arg.Any()).Returns(uuid); IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(4), CancellationToken.None); result.ShouldBeOfType().Value.ShouldBe(new HdhrSettingsResponseModel(4, uuid)); await _mediator.Received(1).Send( Arg.Is(c => c.TunerCount == 4), Arg.Any()); } [Test] public async Task UpdateHdhr_Should_Return_422_On_Validation_Error() { _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Left(BaseError.New("Tuner count must be greater than zero"))); IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(0), CancellationToken.None); result.ShouldBeOfType(); } private static UpdateFFmpegSettingsRequest MakeFfmpegRequest() => new( "/usr/bin/ffmpeg", "/usr/bin/ffprobe", 1, "eng", true, false, false, false, null, null, 60, 1, 1, OutputFormatKind.MpegTs, "Default"); private static UpdateLoggingSettingsRequest MakeLoggingRequest() => new( LogEventLevel.Information, LogEventLevel.Information, LogEventLevel.Information, LogEventLevel.Information, LogEventLevel.Information, LogEventLevel.Information); }