Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Consume the EntityLocker ownership contract (#231/#241: Lock* returns true iff this caller won the slot) at three lock-leak sites surfaced by adversarial-reviewer#20. #233 (F3) — troubleshooting playback: - PrepareTroubleshootingPlaybackHandler: both lock sites now acquire via `if (!LockTroubleshootingPlayback())` (kills the check-then-set TOCTOU) and the empty-media-path Left return releases the lock it acquired — previously it leaked, wedging the status endpoint at "running" forever for a file gone from disk. - TroubleshootController.TroubleshootPlayback: lock conflict is now 409 ProblemDetails (was a bare 404, indistinguishable from a bad id); the Prepare-success -> enqueue window releases the lock if we never hand off to StartTroubleshootingPlayback. #234 (F4 + F5.2) — playout builds: - ExtractEmbeddedSubtitlesHandler: try/finally releases exactly the playouts it locked, on every terminal path (cancellation early-return, swallowed cancellation, any exception) — no more permanent leaks after cancelled mid-extraction, and no cross-release of playouts held by someone else. - BuildPlayoutHandler: skips (logs, returns Right) when LockPlayout returns false instead of building unlocked and cross-releasing the other owner's lock in finally. Tests: handler-level release-discipline tests (Prepare empty-path, Extract cancellation + no-cross-release, BuildPlayout skip + finally-release) via the InMemoryTvContext harness, a TroubleshootController 409 test, and OpenApi contract cases for the m3u8 endpoint's 409. OpenAPI regenerated. All non-vacuous (F3 verified against a negative control). Fixes #233, #234 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
351 lines
13 KiB
C#
351 lines
13 KiB
C#
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.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.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<IMediator>();
|
|
_entityLocker = Substitute.For<IEntityLocker>();
|
|
_statusStore = new TroubleshootingPlaybackStatusStore();
|
|
_controller = new TroubleshootController(
|
|
System.Threading.Channels.Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
|
|
Substitute.For<IFileSystem>(),
|
|
Substitute.For<IConfigElementRepository>(),
|
|
Substitute.For<ITroubleshootingNotifier>(),
|
|
_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<HttpGetAttribute>().Single();
|
|
attribute.Template.ShouldBe("api/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<string, string> { ["ETV_FOO"] = "bar" },
|
|
[],
|
|
[],
|
|
[],
|
|
new ErsatzTV.Application.FFmpegProfiles.FFmpegSettingsViewModel(),
|
|
[],
|
|
[],
|
|
[],
|
|
false,
|
|
false,
|
|
"nvidia output",
|
|
"qsv output",
|
|
"vaapi output",
|
|
"videotoolbox output");
|
|
|
|
_mediator.Send(Arg.Any<GetTroubleshootingInfo>(), Arg.Any<CancellationToken>())
|
|
.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<HttpPostAttribute>().Single();
|
|
attribute.Template.ShouldBe("api/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<BadRequestObjectResult>();
|
|
badRequest.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(400);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ValidateSequentialSchedule>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ValidateSchedule_Should_Map_Result_To_Response()
|
|
{
|
|
_mediator.Send(Arg.Any<ValidateSequentialSchedule>(), Arg.Any<CancellationToken>())
|
|
.Returns(new ValidateSequentialScheduleViewModel(false, ["boom"], "{}"));
|
|
|
|
IActionResult result = await _controller.ValidateSchedule(
|
|
new ValidateSequentialScheduleRequest("content: []", true),
|
|
CancellationToken.None);
|
|
|
|
var response = result.ShouldBeOfType<OkObjectResult>().Value
|
|
.ShouldBeOfType<ValidateSequentialScheduleResponseModel>();
|
|
response.IsValid.ShouldBeFalse();
|
|
response.Messages.ShouldBe(["boom"]);
|
|
response.Json.ShouldBe("{}");
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ValidateSequentialSchedule>(q => q.Yaml == "content: []" && q.IsImport),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<HttpGetAttribute>().Single();
|
|
attribute.Template.ShouldBe("api/troubleshoot/playback/stream-selectors");
|
|
attribute.Name.ShouldBe("GetTroubleshootingStreamSelectors");
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetStreamSelectors_Should_Return_Selector_Names()
|
|
{
|
|
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
|
|
.Returns(["a.yml", "b.yml"]);
|
|
|
|
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
|
|
|
|
result.ShouldBe(["a.yml", "b.yml"]);
|
|
await _mediator.Received(1).Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<HttpGetAttribute>().Single();
|
|
attribute.Template.ShouldBe("api/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<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
|
|
.Returns(Either<BaseError, MediaItemInfo>.Left(BaseError.New("nope")));
|
|
|
|
IActionResult result = await _controller.GetSubtitles(999, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetSubtitles_Should_Map_ViewModels_To_Response()
|
|
{
|
|
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
|
|
.Returns(Either<BaseError, MediaItemInfo>.Right(FakeMediaItemInfo()));
|
|
_mediator.Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>())
|
|
.Returns(new List<SubtitleViewModel> { new(7, "eng", "English", "subrip") });
|
|
|
|
IActionResult result = await _controller.GetSubtitles(1, CancellationToken.None);
|
|
|
|
var subtitles = result.ShouldBeOfType<OkObjectResult>().Value
|
|
.ShouldBeOfType<List<TroubleshootingSubtitleResponseModel>>();
|
|
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<HttpGetAttribute>().Single();
|
|
attribute.Template.ShouldBe("api/troubleshoot/playback/status");
|
|
attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus");
|
|
}
|
|
|
|
[Test]
|
|
public async Task TroubleshootPlayback_Should_Return_409_When_Already_Locked()
|
|
{
|
|
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(true);
|
|
|
|
IActionResult result = await _controller.TroubleshootPlayback(
|
|
mediaItem: 1,
|
|
channel: 0,
|
|
ffmpegProfile: 1,
|
|
StreamingMode.HttpLiveStreamingSegmenter,
|
|
watermark: [],
|
|
graphicsElement: [],
|
|
streamSelector: string.Empty,
|
|
subtitleId: null,
|
|
seekSeconds: 0,
|
|
start: null,
|
|
CancellationToken.None);
|
|
|
|
ConflictObjectResult conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
|
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
|
|
// pre-check short-circuits before dispatching any work
|
|
await _mediator.DidNotReceive()
|
|
.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[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<double>.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<Exception>.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();
|
|
}
|
|
}
|