fix(235-A): normalize async-op error contracts on Maintenance + Troubleshoot controllers (#235)
Slice A of the async-op API contract normalization.
MaintenanceController:
- EmptyTrash error path: was 500 text/plain (error.ToString()); now maps the
BaseError Left through ApiResults.ToErrorResult() -> 404 (NotFoundError) / 422
ProblemDetails. Success stays 200 OkResult. Added ProducesResponseType 200 + 422.
- CleanArtwork: fire-and-forget enqueue of DeleteOrphanedArtwork was a silent 200;
now returns 202 Accepted (AcceptedResult) since it queues background work.
Added ProducesResponseType 202. (Controller does not derive from ControllerBase,
so results are built directly as before.)
TroubleshootController.TroubleshootPlayback (GET|HEAD /api/troubleshoot/playback.m3u8):
- Two bare body-less NotFound() call sites conflated "not found" with "prepare/
playback failure". Both now return a ProblemDetails body:
* prepare-failure (result.IsLeft): mapped through error.ToErrorResult() -> 404 for
NotFoundError (unknown media item/channel) else 422 for a validation BaseError.
* terminal fall-through (prepare ok but no playable output): kept 404 with a
distinguishing ApiResults.NotFoundProblem(...) detail.
- Added ProducesResponseType 404 + 422 (409 already present).
Consumer check: the SPA (PlaybackTroubleshootingScreen) feeds the playback.m3u8 URL
straight to hls.js via HlsPlayer, which never inspects the HTTP status code — playback
state is surfaced via the separate /api/troubleshoot/playback/status poll. So the
404->422 split for the validation subcase is safe; no player code branches on the
status code.
Tests: MaintenanceControllerTests (200/422/202 + enqueue assertion),
TroubleshootControllerTests (prepare 404 NotFoundError, 422 validation). All green;
Api error-metadata/contract/security scans still pass.
Note: OpenAPI artifacts (v1.json / v1.d.ts) intentionally NOT regenerated here — the
orchestrator regenerates once after all #235 slices merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using ErsatzTV.Application;
|
||||||
|
using ErsatzTV.Application.Maintenance;
|
||||||
|
using ErsatzTV.Controllers.Api;
|
||||||
|
using ErsatzTV.Core;
|
||||||
|
using LanguageExt;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using NSubstitute;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using Shouldly;
|
||||||
|
using static LanguageExt.Prelude;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Tests.Controllers;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class MaintenanceControllerTests
|
||||||
|
{
|
||||||
|
private MaintenanceController _controller = null!;
|
||||||
|
private IMediator _mediator = null!;
|
||||||
|
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void SetUp()
|
||||||
|
{
|
||||||
|
_mediator = Substitute.For<IMediator>();
|
||||||
|
_workerChannel = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||||
|
_controller = new MaintenanceController(_mediator, _workerChannel.Writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task EmptyTrash_Should_Return_200_On_Success()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<EmptyTrash>())
|
||||||
|
.Returns(Right<BaseError, LanguageExt.Unit>(unit));
|
||||||
|
|
||||||
|
IActionResult result = await _controller.EmptyTrash();
|
||||||
|
|
||||||
|
result.ShouldBeOfType<OkResult>();
|
||||||
|
await _mediator.Received(1).Send(Arg.Any<EmptyTrash>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task EmptyTrash_Should_Return_422_ProblemDetails_On_Error()
|
||||||
|
{
|
||||||
|
_mediator.Send(Arg.Any<EmptyTrash>())
|
||||||
|
.Returns(Left<BaseError, LanguageExt.Unit>(BaseError.New("Failed to empty trash")));
|
||||||
|
|
||||||
|
IActionResult result = await _controller.EmptyTrash();
|
||||||
|
|
||||||
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||||
|
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||||
|
problem.Status.ShouldBe(422);
|
||||||
|
problem.Detail.ShouldBe("Failed to empty trash");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task CleanArtwork_Should_Return_202_And_Enqueue_DeleteOrphanedArtwork()
|
||||||
|
{
|
||||||
|
IActionResult result = await _controller.CleanArtwork(CancellationToken.None);
|
||||||
|
|
||||||
|
result.ShouldBeOfType<AcceptedResult>();
|
||||||
|
|
||||||
|
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? enqueued).ShouldBeTrue();
|
||||||
|
enqueued.ShouldBeOfType<DeleteOrphanedArtwork>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ using ErsatzTV.Controllers.Api.Requests;
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Api.Troubleshooting;
|
using ErsatzTV.Core.Api.Troubleshooting;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||||
using ErsatzTV.Core.Interfaces.Locking;
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||||
@@ -274,6 +275,55 @@ public class TroubleshootControllerTests
|
|||||||
.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>());
|
.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TroubleshootPlayback_Should_Return_404_ProblemDetails_When_Prepare_Not_Found()
|
||||||
|
{
|
||||||
|
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||||
|
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Either<BaseError, PlayoutItemResult>.Left(
|
||||||
|
new ErsatzTV.Core.Errors.NotFoundError("no such media item")));
|
||||||
|
|
||||||
|
IActionResult result = await _controller.TroubleshootPlayback(
|
||||||
|
mediaItem: 999,
|
||||||
|
channel: 0,
|
||||||
|
ffmpegProfile: 1,
|
||||||
|
StreamingMode.HttpLiveStreamingSegmenter,
|
||||||
|
watermark: [],
|
||||||
|
graphicsElement: [],
|
||||||
|
streamSelector: string.Empty,
|
||||||
|
subtitleId: null,
|
||||||
|
seekSeconds: 0,
|
||||||
|
start: null,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||||
|
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task TroubleshootPlayback_Should_Return_422_ProblemDetails_When_Prepare_Fails_Validation()
|
||||||
|
{
|
||||||
|
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
|
||||||
|
_mediator.Send(Arg.Any<PrepareTroubleshootingPlayback>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Either<BaseError, PlayoutItemResult>.Left(BaseError.New("unable to prepare")));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||||
|
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(422);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
|
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Threading.Channels;
|
|||||||
using ErsatzTV.Application;
|
using ErsatzTV.Application;
|
||||||
using ErsatzTV.Application.Maintenance;
|
using ErsatzTV.Application.Maintenance;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
|
using ErsatzTV.Extensions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -23,17 +24,14 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
|
|||||||
[HttpPost("/api/maintenance/empty_trash")]
|
[HttpPost("/api/maintenance/empty_trash")]
|
||||||
[Tags("Maintenance")]
|
[Tags("Maintenance")]
|
||||||
[EndpointSummary("Empty trash")]
|
[EndpointSummary("Empty trash")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> EmptyTrash()
|
public async Task<IActionResult> EmptyTrash()
|
||||||
{
|
{
|
||||||
Either<BaseError, Unit> result = await mediator.Send(new EmptyTrash());
|
Either<BaseError, Unit> result = await mediator.Send(new EmptyTrash());
|
||||||
foreach (BaseError error in result.LeftToSeq())
|
foreach (BaseError error in result.LeftToSeq())
|
||||||
{
|
{
|
||||||
return new ContentResult
|
return error.ToErrorResult();
|
||||||
{
|
|
||||||
StatusCode = StatusCodes.Status500InternalServerError,
|
|
||||||
Content = error.ToString(),
|
|
||||||
ContentType = "text/plain"
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OkResult();
|
return new OkResult();
|
||||||
@@ -42,9 +40,10 @@ public class MaintenanceController(IMediator mediator, ChannelWriter<IBackground
|
|||||||
[HttpPost("/api/maintenance/clean_artwork")]
|
[HttpPost("/api/maintenance/clean_artwork")]
|
||||||
[Tags("Maintenance")]
|
[Tags("Maintenance")]
|
||||||
[EndpointSummary("Clean artwork cache")]
|
[EndpointSummary("Clean artwork cache")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
public async Task<IActionResult> CleanArtwork(CancellationToken cancellationToken)
|
public async Task<IActionResult> CleanArtwork(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
|
await workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
|
||||||
return new OkResult();
|
return new AcceptedResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ public class TroubleshootController(
|
|||||||
[Tags("Troubleshooting")]
|
[Tags("Troubleshooting")]
|
||||||
[EndpointSummary("Start a troubleshooting playback session")]
|
[EndpointSummary("Start a troubleshooting playback session")]
|
||||||
[EndpointGroupName("general")]
|
[EndpointGroupName("general")]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||||
public async Task<IActionResult> TroubleshootPlayback(
|
public async Task<IActionResult> TroubleshootPlayback(
|
||||||
[FromQuery]
|
[FromQuery]
|
||||||
int mediaItem,
|
int mediaItem,
|
||||||
@@ -174,9 +176,15 @@ public class TroubleshootController(
|
|||||||
Optional(start)),
|
Optional(start)),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
if (result.IsLeft)
|
// Distinguish "prepare failed" from the later "no playable output" fall-through: map the
|
||||||
|
// handler's BaseError through the standard helper (404 for NotFoundError — e.g. an unknown
|
||||||
|
// media item/channel — else 422 for a validation failure) with a ProblemDetails body,
|
||||||
|
// instead of a bare body-less 404. The SPA feeds this URL straight to hls.js (HlsPlayer)
|
||||||
|
// and never inspects the status code — failures surface via the /status poll — so the
|
||||||
|
// 404→422 split for validation errors is safe.
|
||||||
|
foreach (BaseError error in result.LeftToSeq())
|
||||||
{
|
{
|
||||||
return NotFound();
|
return error.ToErrorResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare returned a process, so the handler holds the troubleshooting lock now
|
// Prepare returned a process, so the handler holds the troubleshooting lock now
|
||||||
@@ -273,7 +281,12 @@ public class TroubleshootController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NotFound();
|
// Terminal fall-through: Prepare succeeded but no playable output was produced (playback
|
||||||
|
// failed to start, was cancelled, or the segmenter never wrote segments). Keep the 404 status
|
||||||
|
// the SPA player already tolerates, but attach a distinguishing ProblemDetails body rather
|
||||||
|
// than a bare NotFound() so the response is self-describing.
|
||||||
|
return ApiResults.NotFoundProblem(
|
||||||
|
"Troubleshooting playback did not produce any output. It may have failed to start or been cancelled.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpHead("api/troubleshoot/playback/archive")]
|
[HttpHead("api/troubleshoot/playback/archive")]
|
||||||
|
|||||||
Reference in New Issue
Block a user