Files
ersatztv/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
T
timothyandClaude Fable 5 20ca71b388
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): playout read endpoints + reset-all (#100 #101 #107 #110)
- GET /api/playouts — paged list over GetPagedPlayouts (query/pageNum/pageSize), list DTO with buildStatus (#100, #107)
- GET /api/playouts/{id}/items — paged future items + UNSCHEDULED gaps over GetFuturePlayoutItemsById; 404 ProblemDetails for unknown playout (matches /api/schedules/{id}/items precedent) (#101)
- buildStatus {lastBuild, success, message} on GET /api/playouts/{id} (BuildStatus now included by GetPlayoutByIdHandler) (#107)
- GET /api/playouts/warnings/count — failed-build count for the warnings badge (#107)
- POST /api/playouts/reset-all — 202 Accepted, wraps ResetAllPlayouts (#110)
- POST /api/channels/{channelNumber}/playout/reset — optional ?mode= override; default branches by ScheduleKind (Classic→Refresh, others→Reset) to match Blazor semantics (#110)
- Option<FillerKind>→FillerKind? projection uses MatchUnsafe (Match throws on null-returning branch; idiom per Health/Mapper.cs)
- Tests: controller unit tests (routes, projections, 404s, reset-mode defaults), OpenAPI ProblemDetails contract entry for /api/playouts/{id}/items get 404
- Regenerated wwwroot/openapi/v1.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:28:09 +02:00

308 lines
11 KiB
C#

using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ChannelControllerTests
{
private IMediator _mediator = null!;
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
private ChannelController _controller = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_controller = new ChannelController(_workerChannel.Writer, _mediator);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
ChannelViewModel vm = MakeVm(5);
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(vm));
IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/channels/5");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(MakeVm(5)));
await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateChannel>(c => c.Number == "12" && c.Name == "Movies"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateChannelResult>(BaseError.New("bad")));
IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
ChannelViewModel vm = MakeVm(7);
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelViewModel>(vm));
IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateChannel>(c => c.ChannelId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, ChannelViewModel>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(3, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteChannel>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(99, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
ChannelViewModel vm = MakeVm(4);
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
problemDetails.Title.ShouldBe("Resource not found");
}
[Test]
public async Task ResetPlayout_Should_Return_ProblemDetails_404_For_Missing_Channel()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.None);
IActionResult result = await _controller.ResetPlayout("404", mode: null, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Status.ShouldBe(404);
problemDetails.Title.ShouldBe("Resource not found");
}
[TestCase(PlayoutScheduleKind.Classic, PlayoutBuildMode.Refresh)]
[TestCase(PlayoutScheduleKind.Block, PlayoutBuildMode.Reset)]
[TestCase(PlayoutScheduleKind.Sequential, PlayoutBuildMode.Reset)]
public async Task ResetPlayout_Should_Default_Mode_By_ScheduleKind(
PlayoutScheduleKind scheduleKind,
PlayoutBuildMode expectedMode)
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9, scheduleKind)));
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
var buildPlayout = request.ShouldBeOfType<BuildPlayout>();
buildPlayout.PlayoutId.ShouldBe(9);
buildPlayout.Mode.ShouldBe(expectedMode);
}
[Test]
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
IActionResult result = await _controller.ResetPlayout("5", PlayoutBuildMode.Continue, CancellationToken.None);
result.ShouldBeOfType<OkResult>();
_workerChannel.Reader.TryRead(out IBackgroundServiceRequest? request).ShouldBeTrue();
request.ShouldBeOfType<BuildPlayout>().Mode.ShouldBe(PlayoutBuildMode.Continue);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
}
private static PlayoutNameViewModel MakePlayout(int id, PlayoutScheduleKind scheduleKind) =>
new(
id,
scheduleKind,
"Channel",
"5",
ChannelPlayoutMode.Continuous,
"Schedule",
string.Empty,
null,
null);
private static ChannelViewModel MakeVm(int id) =>
new(
id,
"5",
"Test",
"ErsatzTV",
string.Empty,
1,
null,
ArtworkContentTypeModel.None,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
0,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
private static CreateChannelRequest MakeCreateRequest(string number = "5", string name = "Test") =>
new(
name,
number,
"ErsatzTV",
string.Empty,
1,
null,
ArtworkContentTypeModel.None,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") =>
new(
name,
number,
"ErsatzTV",
string.Empty,
1,
null,
ArtworkContentTypeModel.None,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
}