Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 10m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m34s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m59s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 1m27s
The three recurrence arrays are read CONJUNCTIVELY by AlternateScheduleSelector.GetScheduleForDate, so an empty set matches no date. `?? []` on an omitted array therefore returned HTTP 200 while storing an alternate-schedule or template item that could never apply, silently -- while the read side (#823) already read a NULL column as the All*() sets. Absent and explicitly-empty are two different requests and get two answers: ABSENT (missing, or explicit null) normalizes to AlternateScheduleSelector.All*(), the same symbols the read side substitutes; EXPLICIT [] is rejected with a 422 naming the consequence, via RecurrenceSetBounds called from both replace handlers. The rejection lives in the handlers, not the controller, because api.ffmpeg-profile-numeric-bounds' "accept an UNCHANGED bad value" rule binds hardest here: both PUT paths are whole-list replaces, so rejecting a pre-existing empty set would make every OTHER item in the list uneditable. That comparison needs the stored row. The validated set is derived from `incoming`, so the highest-Index catch-all -- whose recurrence the handler discards -- is excluded by construction. Verified: full ErsatzTV.Tests suite green; three mutation proofs with disjoint reddened sets; live-E2E against a real instance confirmed an OMITTED property round-trips as unrestricted (the Newtonsoft missing-property chain unit tests cannot reach), an explicit [] returns the 422, and [] on the catch-all is accepted. Cross-family cold review BLOCKED the first implementation with 3 findings, all real and all fixed; re-review returned MERGEABLE. Follow-up #894 filed: the SPA can still build the empty state the server rejects. fixes #880 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1584 lines
70 KiB
C#
1584 lines
70 KiB
C#
using System.Reflection;
|
|
using ErsatzTV.Application.Playouts;
|
|
using ErsatzTV.Application.ProgramSchedules;
|
|
using ErsatzTV.Application.Scheduling;
|
|
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.Playouts;
|
|
using ErsatzTV.Core.Api.Scheduling;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
using ErsatzTV.Core.Domain.Scheduling;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Interfaces.Locking;
|
|
using ErsatzTV.Core.Scheduling;
|
|
using LanguageExt;
|
|
using MediatR;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.Routing;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using static LanguageExt.Prelude;
|
|
using Unit = LanguageExt.Unit;
|
|
|
|
namespace ErsatzTV.Tests.Controllers;
|
|
|
|
[TestFixture]
|
|
public class PlayoutControllerTests
|
|
{
|
|
private PlayoutController _controller = null!;
|
|
private IMediator _mediator = null!;
|
|
private IEntityLocker _entityLocker = null!;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
_mediator = Substitute.For<IMediator>();
|
|
_entityLocker = Substitute.For<IEntityLocker>();
|
|
_controller = new PlayoutController(_mediator, _entityLocker)
|
|
{
|
|
// Real HttpContext so the #253 ETag/If-Match concurrency headers can be read/written.
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
|
};
|
|
}
|
|
|
|
[Test]
|
|
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
|
{
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetAll), "GET", "/api/v1/playouts");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetById), "GET", "/api/v1/playouts/{id:int}");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetItems), "GET", "/api/v1/playouts/{id:int}/items");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetWarningsCount), "GET", "/api/v1/playouts/warnings/count");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.Create), "POST", "/api/v1/playouts");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.Update), "PUT", "/api/v1/playouts/{id:int}");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.ResetAll), "POST", "/api/v1/playouts/reset-all");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/v1/playouts/{id:int}");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetAlternateSchedules), "GET", "/api/v1/playouts/{id:int}/alternate-schedules");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.ReplaceAlternateSchedules), "PUT", "/api/v1/playouts/{id:int}/alternate-schedules");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetTemplates), "GET", "/api/v1/playouts/{id:int}/templates");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.ReplaceTemplates), "PUT", "/api/v1/playouts/{id:int}/templates");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/v1/playouts/{id:int}/blocks");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/v1/playouts/{id:int}/blocks/{blockId:int}/history");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/v1/playouts/history/{id:int}");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.EraseItems), "POST", "/api/v1/playouts/{id:int}/erase-items");
|
|
ShouldHaveActionRoute(
|
|
nameof(PlayoutController.EraseItemsAndHistory),
|
|
"POST",
|
|
"/api/v1/playouts/{id:int}/erase-items-and-history");
|
|
ShouldHaveActionRoute(nameof(PlayoutController.Reshuffle), "POST", "/api/v1/playouts/{id:int}/reshuffle");
|
|
ShouldHaveActionRoute(
|
|
nameof(PlayoutController.GetItemSchedulingContext),
|
|
"GET",
|
|
"/api/v1/playouts/items/{id:int}/scheduling-context");
|
|
}
|
|
|
|
// ----- Build-lock guard (#215): id-keyed mutations return 409 while the build lock is held -----
|
|
|
|
[Test]
|
|
public async Task Delete_Should_Return_409_When_Playout_Locked()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.Delete(9, CancellationToken.None);
|
|
|
|
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
|
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task EraseItems_Should_Return_409_When_Playout_Locked()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task EraseItemsAndHistory_Should_Return_409_When_Playout_Locked()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Return_409_When_Playout_Locked()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.Update(
|
|
9,
|
|
new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task UpdateDefaultDeco_Should_Return_409_When_Playout_Locked()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.UpdateDefaultDeco(
|
|
9,
|
|
new UpdateDefaultDecoRequest(null),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAll_Should_Stamp_IsLocked_From_Locker()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutsViewModel(1, [MakePlayout(9)]));
|
|
|
|
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
|
|
|
result.Page.Single().IsLocked.ShouldBeTrue();
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetById_Should_Surface_Seed()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { Seed = 4242 }));
|
|
|
|
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>()
|
|
.Value.ShouldBeOfType<PlayoutResponseModel>()
|
|
.Seed.ShouldBe(4242);
|
|
}
|
|
|
|
// #616: the playout DETAIL response carried channelName/channelNumber but no channelId, while
|
|
// reset_channel_playout takes a CHANNEL id. The two id spaces overlap numerically, so a caller
|
|
// that reached for the row's `id` reset a different channel and got a plausible 202 back. The
|
|
// list rows gained channelId in #297; this pins the same field on the detail response, and the
|
|
// distinct ids below prove it is the channel's, not the playout's.
|
|
[Test]
|
|
public async Task GetById_Should_Surface_ChannelId_Distinct_From_PlayoutId()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ChannelId = 400 }));
|
|
|
|
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
|
|
|
PlayoutResponseModel body = result.ShouldBeOfType<OkObjectResult>()
|
|
.Value.ShouldBeOfType<PlayoutResponseModel>();
|
|
body.Id.ShouldBe(9);
|
|
body.ChannelId.ShouldBe(400);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAll_Should_Surface_Seed()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutsViewModel(1, new List<PlayoutNameViewModel> { MakePlayout(9) with { Seed = 4242 } }));
|
|
|
|
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
|
|
|
result.Page[0].Seed.ShouldBe(4242);
|
|
}
|
|
|
|
// ----- Erase items / history -----
|
|
|
|
[Test]
|
|
public async Task EraseItems_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.EraseItems(404, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(PlayoutScheduleKind.Classic)]
|
|
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
|
public async Task EraseItems_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
|
|
|
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(PlayoutScheduleKind.Block)]
|
|
[TestCase(PlayoutScheduleKind.Sequential)]
|
|
[TestCase(PlayoutScheduleKind.Scripted)]
|
|
public async Task EraseItems_Should_Return_204_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
|
|
|
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NoContentResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ErasePlayoutItems>(c => c.PlayoutId == 9),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task EraseItemsAndHistory_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.EraseItemsAndHistory(404, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task EraseItemsAndHistory_Should_Return_422_For_Unsupported_Kind()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.ExternalJson }));
|
|
|
|
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(PlayoutScheduleKind.Classic)]
|
|
[TestCase(PlayoutScheduleKind.Block)]
|
|
[TestCase(PlayoutScheduleKind.Sequential)]
|
|
[TestCase(PlayoutScheduleKind.Scripted)]
|
|
public async Task EraseItemsAndHistory_Should_Return_204_And_Send_Command_For_Supported_Kind(
|
|
PlayoutScheduleKind kind)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
|
|
|
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NoContentResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ErasePlayoutHistory>(c => c.PlayoutId == 9),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ----- Reshuffle -----
|
|
|
|
[Test]
|
|
public async Task Reshuffle_Should_Return_409_When_Playout_Locked()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Reshuffle_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.Reshuffle(404, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
|
[TestCase(PlayoutScheduleKind.None)]
|
|
public async Task Reshuffle_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
|
|
|
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReshufflePlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(PlayoutScheduleKind.Classic)]
|
|
[TestCase(PlayoutScheduleKind.Block)]
|
|
[TestCase(PlayoutScheduleKind.Sequential)]
|
|
[TestCase(PlayoutScheduleKind.Scripted)]
|
|
public async Task Reshuffle_Should_Return_202_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
|
|
|
|
IActionResult result = await _controller.Reshuffle(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<AcceptedResult>().StatusCode.ShouldBe(202);
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ReshufflePlayout>(c => c.PlayoutId == 9),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ----- Playout item scheduling context -----
|
|
|
|
[Test]
|
|
public async Task GetItemSchedulingContext_Should_Return_200_With_Decoded_Context()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutItemSchedulingContext>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<string>.Some("{ \"decoded\": true }"));
|
|
|
|
IActionResult result = await _controller.GetItemSchedulingContext(42, CancellationToken.None);
|
|
|
|
var context = result.ShouldBeOfType<OkObjectResult>().Value
|
|
.ShouldBeOfType<PlayoutItemSchedulingContextResponseModel>();
|
|
context.Context.ShouldBe("{ \"decoded\": true }");
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<GetPlayoutItemSchedulingContext>(q => q.PlayoutItemId == 42),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetItemSchedulingContext_Should_Return_404_For_None()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutItemSchedulingContext>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<string>.None);
|
|
|
|
IActionResult result = await _controller.GetItemSchedulingContext(404, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
|
}
|
|
|
|
[Test]
|
|
public void Create_Should_Use_Stable_Request_Dto()
|
|
{
|
|
MethodInfo action = typeof(PlayoutController).GetMethod(nameof(PlayoutController.Create))
|
|
?? throw new AssertionException($"Missing action {nameof(PlayoutController.Create)}");
|
|
|
|
action.GetParameters()[0].ParameterType.ShouldBe(typeof(CreatePlayoutRequest));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Return_201_With_Location_And_Body()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, CreatePlayoutResponse>(new CreatePlayoutResponse(9)));
|
|
PlayoutNameViewModel vm = MakePlayout(9);
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(vm));
|
|
|
|
IActionResult result = await _controller.Create(
|
|
new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null),
|
|
CancellationToken.None);
|
|
|
|
var created = result.ShouldBeOfType<CreatedResult>();
|
|
created.StatusCode.ShouldBe(201);
|
|
created.Location.ShouldBe("/api/v1/playouts/9");
|
|
created.Value.ShouldBe(ToResponse(vm));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Map_Request_To_Classic_Playout_Command()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, CreatePlayoutResponse>(new CreatePlayoutResponse(9)));
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
await _controller.Create(new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null), CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<CreateClassicPlayout>(c => c.ChannelId == 3 && c.ProgramScheduleId == 4),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Return_404_For_NotFoundError_With_ProblemDetails()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, CreatePlayoutResponse>(new NotFoundError("missing")));
|
|
|
|
IActionResult result = await _controller.Create(
|
|
new CreatePlayoutRequest(404, PlayoutScheduleKind.Classic, 4, null),
|
|
CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(404);
|
|
problem.Title.ShouldBe("Resource not found");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Return_422_On_Validation_Error_With_ProblemDetails()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, CreatePlayoutResponse>(BaseError.New("bad")));
|
|
|
|
IActionResult result = await _controller.Create(
|
|
new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, 4, null),
|
|
CancellationToken.None);
|
|
|
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(422);
|
|
problem.Title.ShouldBe("Validation failed");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Return_422_When_Classic_Missing_ProgramScheduleId()
|
|
{
|
|
IActionResult result = await _controller.Create(
|
|
new CreatePlayoutRequest(3, PlayoutScheduleKind.Classic, null, null),
|
|
CancellationToken.None);
|
|
|
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(422);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<CreateClassicPlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Map_Block_Kind_With_No_Extra_Fields()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateBlockPlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, CreatePlayoutResponse>(new CreatePlayoutResponse(9)));
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
await _controller.Create(new CreatePlayoutRequest(3, PlayoutScheduleKind.Block, null, null), CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<CreateBlockPlayout>(c => c.ChannelId == 3),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(PlayoutScheduleKind.Sequential)]
|
|
[TestCase(PlayoutScheduleKind.Scripted)]
|
|
[TestCase(PlayoutScheduleKind.ExternalJson)]
|
|
public async Task Create_Should_Return_422_For_File_Backed_Kinds_Missing_ScheduleFile(PlayoutScheduleKind kind)
|
|
{
|
|
IActionResult result = await _controller.Create(
|
|
new CreatePlayoutRequest(3, kind, null, null),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
}
|
|
|
|
[Test]
|
|
public async Task Create_Should_Map_Sequential_Kind_With_ScheduleFile()
|
|
{
|
|
_mediator.Send(Arg.Any<CreateSequentialPlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, CreatePlayoutResponse>(new CreatePlayoutResponse(9)));
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
await _controller.Create(
|
|
new CreatePlayoutRequest(3, PlayoutScheduleKind.Sequential, null, "/config/schedule.yml"),
|
|
CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<CreateSequentialPlayout>(c => c.ChannelId == 3 && c.ScheduleFile == "/config/schedule.yml"),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.Update(
|
|
404,
|
|
new UpdatePlayoutDetailsRequest(null, null),
|
|
CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(404);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Apply_DailyRebuildTime_And_Return_200()
|
|
{
|
|
PlayoutNameViewModel vm = MakePlayout(9);
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(vm));
|
|
_mediator.Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, PlayoutNameViewModel>(vm with { DbDailyRebuildTime = TimeSpan.FromHours(4) }));
|
|
|
|
IActionResult result = await _controller.Update(
|
|
9,
|
|
new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<UpdatePlayout>(c => c.PlayoutId == 9 && c.DailyRebuildTime == Some(TimeSpan.FromHours(4))),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Clear_DailyRebuildTime_When_Null()
|
|
{
|
|
PlayoutNameViewModel vm = MakePlayout(9);
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(vm));
|
|
_mediator.Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, PlayoutNameViewModel>(vm));
|
|
|
|
await _controller.Update(9, new UpdatePlayoutDetailsRequest(null, null), CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<UpdatePlayout>(c => c.PlayoutId == 9 && c.DailyRebuildTime == Option<TimeSpan>.None),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Return_422_When_ScheduleFile_Set_For_Classic_Playout()
|
|
{
|
|
PlayoutNameViewModel vm = MakePlayout(9);
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(vm));
|
|
|
|
IActionResult result = await _controller.Update(
|
|
9,
|
|
new UpdatePlayoutDetailsRequest(null, "/config/schedule.yml"),
|
|
CancellationToken.None);
|
|
|
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(422);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Update_Should_Dispatch_UpdateSequentialPlayout_For_Sequential_Kind()
|
|
{
|
|
PlayoutNameViewModel vm = MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Sequential };
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(vm));
|
|
_mediator.Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, PlayoutNameViewModel>(vm));
|
|
_mediator.Send(Arg.Any<UpdateSequentialPlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, PlayoutNameViewModel>(vm with { ScheduleFile = "/config/new.yml" }));
|
|
|
|
IActionResult result = await _controller.Update(
|
|
9,
|
|
new UpdatePlayoutDetailsRequest(null, "/config/new.yml"),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<UpdateSequentialPlayout>(c => c.PlayoutId == 9 && c.ScheduleFile == "/config/new.yml"),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Delete_Should_Return_204_On_Success()
|
|
{
|
|
_mediator.Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, Unit>(Unit.Default));
|
|
|
|
IActionResult result = await _controller.Delete(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NoContentResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<DeletePlayout>(c => c.PlayoutId == 9),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Delete_Should_Return_404_For_NotFoundError_With_ProblemDetails()
|
|
{
|
|
_mediator.Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
|
|
|
IActionResult result = await _controller.Delete(404, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(404);
|
|
problem.Title.ShouldBe("Resource not found");
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetById_Should_Return_200_For_Some()
|
|
{
|
|
PlayoutNameViewModel vm = MakePlayout(9);
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(vm));
|
|
|
|
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(ToResponse(vm));
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetById_Should_Expose_IsLocked_From_EntityLocker()
|
|
{
|
|
PlayoutNameViewModel vm = MakePlayout(9);
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(vm));
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
|
|
|
var body = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlayoutResponseModel>();
|
|
body.IsLocked.ShouldBeTrue();
|
|
body.ShouldBe(ToResponse(vm, isLocked: true));
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetById_Should_Return_404_For_None_With_ProblemDetails()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.GetById(9, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(404);
|
|
problem.Title.ShouldBe("Resource not found");
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAll_Should_Project_Paged_List_With_BuildStatus()
|
|
{
|
|
var buildStatus = new PlayoutBuildStatus
|
|
{
|
|
LastBuild = new DateTimeOffset(2026, 7, 2, 10, 0, 0, TimeSpan.Zero),
|
|
Success = false,
|
|
Message = "boom"
|
|
};
|
|
PlayoutNameViewModel vm = MakePlayout(9) with
|
|
{
|
|
BuildStatus = buildStatus,
|
|
DbDailyRebuildTime = TimeSpan.FromHours(4)
|
|
};
|
|
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutsViewModel(1, [vm]));
|
|
|
|
PagedPlayoutsResponseModel result = await _controller.GetAll("q", 2, 25, CancellationToken.None);
|
|
|
|
result.TotalCount.ShouldBe(1);
|
|
PlayoutListItemResponseModel item = result.Page.Single();
|
|
item.Id.ShouldBe(9);
|
|
item.ChannelNumber.ShouldBe("101");
|
|
item.ChannelName.ShouldBe("Channel");
|
|
item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
|
|
item.ScheduleName.ShouldBe("Schedule");
|
|
item.PlayoutMode.ShouldBe(ChannelPlayoutMode.Continuous);
|
|
item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4));
|
|
item.BuildStatus.ShouldNotBeNull();
|
|
item.BuildStatus.Success.ShouldBeFalse();
|
|
item.BuildStatus.Message.ShouldBe("boom");
|
|
item.BuildStatus.LastBuild.ShouldBe(buildStatus.LastBuild);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<GetPagedPlayouts>(q => q.Query == "q" && q.PageNum == 2 && q.PageSize == 25),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAll_Should_Clamp_PageSize_And_PageNum_Before_Query()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutsViewModel(0, []));
|
|
|
|
await _controller.GetAll("q", -5, 100_000_000, CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<GetPagedPlayouts>(q => q.PageNum == 0 && q.PageSize == 100),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetItems_Should_Clamp_PageSize_And_PageNum_Before_Query()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutItemsViewModel(0, []));
|
|
|
|
await _controller.GetItems(9, false, -5, 100_000_000, CancellationToken.None);
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<GetFuturePlayoutItemsById>(q => q.PageNum == 0 && q.PageSize == 100),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAll_Should_Emit_Null_BuildStatus_When_Absent()
|
|
{
|
|
PlayoutNameViewModel vm = MakePlayout(9) with { BuildStatus = null };
|
|
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutsViewModel(1, [vm]));
|
|
|
|
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
|
|
|
|
result.Page.Single().BuildStatus.ShouldBeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetItems_Should_Project_Items_And_Null_FillerKind_For_Gaps()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
var item = new PlayoutItemViewModel(
|
|
77,
|
|
"Movie",
|
|
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
|
|
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
|
"1:00:00",
|
|
string.Empty,
|
|
Some(FillerKind.MidRoll));
|
|
var gap = new PlayoutItemViewModel(
|
|
null,
|
|
"UNSCHEDULED",
|
|
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
|
new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero),
|
|
"30:00",
|
|
string.Empty,
|
|
Option<FillerKind>.None);
|
|
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutItemsViewModel(2, [item, gap]));
|
|
|
|
IActionResult actionResult = await _controller.GetItems(9, showFiller: true, 1, 10, CancellationToken.None);
|
|
|
|
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
|
|
result.TotalCount.ShouldBe(2);
|
|
result.Page[0].Id.ShouldBe(77);
|
|
result.Page[0].Title.ShouldBe("Movie");
|
|
result.Page[0].Duration.ShouldBe("1:00:00");
|
|
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
|
|
result.Page[1].Id.ShouldBeNull();
|
|
result.Page[1].Title.ShouldBe("UNSCHEDULED");
|
|
result.Page[1].FillerKind.ShouldBeNull();
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<GetFuturePlayoutItemsById>(q =>
|
|
q.PlayoutId == 9 && q.ShowFiller && q.PageNum == 1 && q.PageSize == 10),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetItems_Should_Flag_HasSchedulingContext_Without_Exposing_Raw_Json()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
var withContext = new PlayoutItemViewModel(
|
|
42,
|
|
"Movie",
|
|
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
|
|
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
|
|
"1:00:00",
|
|
"{ \"ScheduleId\": 1 }",
|
|
Option<FillerKind>.None);
|
|
var withoutContext = withContext with { SchedulingContext = " " };
|
|
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutItemsViewModel(2, [withContext, withoutContext]));
|
|
|
|
IActionResult actionResult = await _controller.GetItems(9, showFiller: false, 0, 100, CancellationToken.None);
|
|
|
|
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value
|
|
.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
|
|
result.Page[0].HasSchedulingContext.ShouldBeTrue();
|
|
result.Page[1].HasSchedulingContext.ShouldBeFalse();
|
|
|
|
// The raw JSON blob must never appear on the list DTO.
|
|
typeof(PlayoutItemResponseModel).GetProperty("SchedulingContext").ShouldBeNull();
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.GetItems(404, showFiller: false, 0, 100, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
var problem = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(404);
|
|
problem.Title.ShouldBe("Resource not found");
|
|
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetWarningsCount_Should_Return_Count()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutWarningsCount>(), Arg.Any<CancellationToken>())
|
|
.Returns(7);
|
|
|
|
int result = await _controller.GetWarningsCount(CancellationToken.None);
|
|
|
|
result.ShouldBe(7);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResetAll_Should_Return_202_And_Send_Command()
|
|
{
|
|
_mediator.Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>())
|
|
.Returns(new ResetAllPlayoutsResult([1, 2], [3], [4]));
|
|
|
|
IActionResult result = await _controller.ResetAll(CancellationToken.None);
|
|
|
|
var accepted = result.ShouldBeOfType<AcceptedResult>();
|
|
accepted.StatusCode.ShouldBe(202);
|
|
var body = accepted.Value.ShouldBeOfType<ResetAllPlayoutsResponseModel>();
|
|
body.QueuedPlayoutIds.ShouldBe(new List<int> { 1, 2 });
|
|
body.SkippedLocked.ShouldBe(new List<int> { 3 });
|
|
body.SkippedUnsupported.ShouldBe(new List<int> { 4 });
|
|
await _mediator.Received(1).Send(Arg.Any<ResetAllPlayouts>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ----- Alternate schedules -----
|
|
|
|
[Test]
|
|
public async Task GetAlternateSchedules_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.GetAlternateSchedules(404, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAlternateSchedules_Should_Return_422_For_NonClassic_Playout()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
|
|
IActionResult result = await _controller.GetAlternateSchedules(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetAlternateSchedules_Should_Project_Ordered_List()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeAltVm(2, 1, 8), MakeAltVm(1, 0, 7)]);
|
|
|
|
IActionResult actionResult = await _controller.GetAlternateSchedules(9, CancellationToken.None);
|
|
|
|
var list = actionResult.ShouldBeOfType<OkObjectResult>().Value
|
|
.ShouldBeOfType<List<PlayoutAlternateScheduleResponseModel>>();
|
|
list.Count.ShouldBe(2);
|
|
list[0].Index.ShouldBe(0);
|
|
list[0].ProgramScheduleId.ShouldBe(7);
|
|
list[1].Index.ShouldBe(1);
|
|
list[1].ProgramScheduleId.ShouldBe(8);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Return_409_When_Playout_Locked()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
|
9,
|
|
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]),
|
|
CancellationToken.None);
|
|
|
|
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
|
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
await _mediator.DidNotReceive()
|
|
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
|
404,
|
|
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive()
|
|
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Return_422_For_NonClassic_Playout()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
|
9,
|
|
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive()
|
|
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Return_422_When_Items_Empty()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
|
9,
|
|
new ReplacePlayoutAlternateSchedulesRequest([]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive()
|
|
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(0, 1, 12, 31)]
|
|
[TestCase(13, 1, 12, 31)]
|
|
[TestCase(1, 1, 0, 31)]
|
|
[TestCase(1, 1, 13, 31)]
|
|
public async Task ReplaceAlternateSchedules_Should_Return_422_For_OutOfRange_Month_Without_Dispatch(
|
|
int startMonth,
|
|
int startDay,
|
|
int endMonth,
|
|
int endDay)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
|
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
|
|
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(422);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>());
|
|
await _mediator.DidNotReceive()
|
|
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(0, 1, 12, 31)]
|
|
[TestCase(1, 32, 12, 31)]
|
|
public async Task ReplaceAlternateSchedules_Should_Return_422_For_OutOfRange_Day_Without_Dispatch(
|
|
int startMonth,
|
|
int startDay,
|
|
int endMonth,
|
|
int endDay)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
|
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive()
|
|
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Ignore_DateRange_Fields_When_LimitToDateRange_Is_False()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeScheduleVm(7)]);
|
|
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, Unit>(Unit.Default));
|
|
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeAltVm(1, 0, 7)]);
|
|
|
|
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
|
|
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
|
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 0, 0, null, 13, 32, null)]);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1)
|
|
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Return_422_When_ProgramScheduleId_Unknown()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeScheduleVm(7)]);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
|
9,
|
|
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7), MakeAltRequest(999)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive()
|
|
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Assign_Index_From_Order_And_Return_200()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeScheduleVm(7), MakeScheduleVm(8)]);
|
|
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, Unit>(Unit.Default));
|
|
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeAltVm(1, 0, 7), MakeAltVm(2, 1, 8)]);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(
|
|
9,
|
|
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7), MakeAltRequest(8)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
|
|
c.PlayoutId == 9 &&
|
|
c.Items.Count == 2 &&
|
|
c.Items[0].Index == 0 && c.Items[0].ProgramScheduleId == 7 &&
|
|
c.Items[1].Index == 1 && c.Items[1].ProgramScheduleId == 8),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ----- #880: absent recurrence means UNRESTRICTED, an explicit [] is rejected -----
|
|
|
|
// Reddens if ToReplaceItem's `?? All*()` is reverted to `?? []`: the counts drop to 0. That is the
|
|
// point of the test -- the normalization is the fix, so it is what must be pinned.
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Normalize_Absent_Recurrence_To_Unrestricted()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeScheduleVm(7)]);
|
|
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, Unit>(Unit.Default));
|
|
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeAltVm(1, 0, 7)]);
|
|
|
|
// All three recurrence arrays omitted -- the shape an API client sends and the SPA never does.
|
|
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
|
[new PlayoutAlternateScheduleItemRequest(0, 7, null, null, null, false, 1, 1, null, 12, 31, null)]);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
|
|
c.Items.Count == 1 &&
|
|
c.Items[0].DaysOfWeek.Count == 7 &&
|
|
c.Items[0].DaysOfMonth.Count == 31 &&
|
|
c.Items[0].MonthsOfYear.Count == 12),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// The complement of the test above: normalization must fill in ONLY what was absent. Without this a
|
|
// fix that substituted All*() unconditionally would still pass the normalization test.
|
|
[Test]
|
|
public async Task ReplaceAlternateSchedules_Should_Preserve_An_Explicit_Recurrence_Selection()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
_mediator.Send(Arg.Any<GetAllProgramSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeScheduleVm(7)]);
|
|
_mediator.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, Unit>(Unit.Default));
|
|
_mediator.Send(Arg.Any<GetPlayoutAlternateSchedules>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeAltVm(1, 0, 7)]);
|
|
|
|
var request = new ReplacePlayoutAlternateSchedulesRequest(
|
|
[
|
|
new PlayoutAlternateScheduleItemRequest(
|
|
0,
|
|
7,
|
|
[DayOfWeek.Monday, DayOfWeek.Tuesday],
|
|
null,
|
|
[6],
|
|
false,
|
|
1,
|
|
1,
|
|
null,
|
|
12,
|
|
31,
|
|
null)
|
|
]);
|
|
|
|
IActionResult result = await _controller.ReplaceAlternateSchedules(9, request, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ReplacePlayoutAlternateScheduleItems>(c =>
|
|
c.Items[0].DaysOfWeek.Count == 2 &&
|
|
c.Items[0].DaysOfWeek.Contains(DayOfWeek.Monday) &&
|
|
c.Items[0].DaysOfMonth.Count == 31 &&
|
|
c.Items[0].MonthsOfYear.Count == 1 &&
|
|
c.Items[0].MonthsOfYear.Contains(6)),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceTemplates_Should_Normalize_Absent_Recurrence_To_Unrestricted()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(
|
|
Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateViewModel(7)]);
|
|
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<BaseError>.None);
|
|
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateVm(1, 0, 7, null)]);
|
|
|
|
var request = new ReplacePlayoutTemplatesRequest(
|
|
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 1, 1, null, 12, 31, null)]);
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ReplacePlayoutTemplateItems>(c =>
|
|
c.Items.Count == 1 &&
|
|
c.Items[0].DaysOfWeek.Count == 7 &&
|
|
c.Items[0].DaysOfMonth.Count == 31 &&
|
|
c.Items[0].MonthsOfYear.Count == 12),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ----- Playout templates -----
|
|
|
|
[Test]
|
|
public async Task GetTemplates_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.GetTemplates(404, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetTemplates_Should_Return_422_For_NonBlock_Playout()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
IActionResult result = await _controller.GetTemplates(9, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetTemplates_Should_Project_Ordered_List()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateVm(2, 1, 8, 5), MakeTemplateVm(1, 0, 7, null)]);
|
|
|
|
IActionResult actionResult = await _controller.GetTemplates(9, CancellationToken.None);
|
|
|
|
var list = actionResult.ShouldBeOfType<OkObjectResult>().Value
|
|
.ShouldBeOfType<List<PlayoutTemplateResponseModel>>();
|
|
list.Count.ShouldBe(2);
|
|
list[0].Index.ShouldBe(0);
|
|
list[0].TemplateId.ShouldBe(7);
|
|
list[0].DecoTemplateId.ShouldBeNull();
|
|
list[1].Index.ShouldBe(1);
|
|
list[1].TemplateId.ShouldBe(8);
|
|
list[1].DecoTemplateId.ShouldBe(5);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceTemplates_Should_Return_409_When_Playout_Locked()
|
|
{
|
|
_entityLocker.IsPlayoutLocked(9).Returns(true);
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(
|
|
9,
|
|
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]),
|
|
CancellationToken.None);
|
|
|
|
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
|
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceTemplates_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(
|
|
404,
|
|
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceTemplates_Should_Return_422_For_NonBlock_Playout()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(
|
|
9,
|
|
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[TestCase(0, 1, 12, 31)]
|
|
[TestCase(13, 1, 12, 31)]
|
|
[TestCase(1, 1, 0, 31)]
|
|
[TestCase(1, 1, 13, 31)]
|
|
[TestCase(1, 0, 12, 31)]
|
|
[TestCase(1, 32, 12, 31)]
|
|
public async Task ReplaceTemplates_Should_Return_422_For_OutOfRange_DateRange_Without_Dispatch(
|
|
int startMonth,
|
|
int startDay,
|
|
int endMonth,
|
|
int endDay)
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
|
|
var request = new ReplacePlayoutTemplatesRequest(
|
|
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, true, startMonth, startDay, null, endMonth, endDay, null)]);
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
|
|
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
var problem = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
|
problem.Status.ShouldBe(422);
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>());
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceTemplates_Should_Ignore_DateRange_Fields_When_LimitToDateRange_Is_False()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateViewModel(7)]);
|
|
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<BaseError>.None);
|
|
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateVm(1, 0, 7, null)]);
|
|
|
|
// LimitToDateRange is false, so the out-of-range month/day here must not block the save.
|
|
var request = new ReplacePlayoutTemplatesRequest(
|
|
[new PlayoutTemplateItemRequest(0, 7, null, null, null, null, false, 0, 0, null, 13, 32, null)]);
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(9, request, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1).Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceTemplates_Should_Return_422_When_TemplateId_Unknown()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateViewModel(7)]);
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(
|
|
9,
|
|
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(999, null)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceTemplates_Should_Return_422_When_DecoTemplateId_Unknown()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateViewModel(7)]);
|
|
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<DecoTemplateViewModel>.None);
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(
|
|
9,
|
|
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, 999)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ReplaceTemplates_Should_Assign_Index_From_Order_And_Return_200()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateViewModel(7), MakeTemplateViewModel(8)]);
|
|
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<DecoTemplateViewModel>.Some(new DecoTemplateViewModel(5, 1, "G", "DT", 0)));
|
|
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<BaseError>.None);
|
|
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
|
.Returns([MakeTemplateVm(1, 0, 7, null), MakeTemplateVm(2, 1, 8, 5)]);
|
|
|
|
IActionResult result = await _controller.ReplaceTemplates(
|
|
9,
|
|
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null), MakeTemplateRequest(8, 5)]),
|
|
CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<OkObjectResult>();
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<ReplacePlayoutTemplateItems>(c =>
|
|
c.PlayoutId == 9 &&
|
|
c.Items.Count == 2 &&
|
|
c.Items[0].Index == 0 && c.Items[0].TemplateId == 7 && c.Items[0].DecoTemplateId == null &&
|
|
c.Items[1].Index == 1 && c.Items[1].TemplateId == 8 && c.Items[1].DecoTemplateId == 5),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetBlocks_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.GetBlocks(404, CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetAllBlocksForPlayout>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetBlocks_Should_Map_Blocks_To_Response_And_Return_200()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
_mediator.Send(Arg.Any<GetAllBlocksForPlayout>(), Arg.Any<CancellationToken>())
|
|
.Returns([new BlockViewModel(10, 1, "Morning", "Toons", 60, BlockStopScheduling.AfterDurationEnd, 1)]);
|
|
|
|
IActionResult result = await _controller.GetBlocks(9, CancellationToken.None);
|
|
|
|
var list = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<BlockResponseModel>>();
|
|
list.Count.ShouldBe(1);
|
|
list[0].Id.ShouldBe(10);
|
|
list[0].Name.ShouldBe("Toons");
|
|
list[0].GroupName.ShouldBe("Morning");
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetBlockHistory_Should_Return_404_When_Playout_Missing()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.None);
|
|
|
|
IActionResult result = await _controller.GetBlockHistory(404, 10, cancellationToken: CancellationToken.None);
|
|
|
|
result.ShouldBeOfType<NotFoundObjectResult>();
|
|
await _mediator.DidNotReceive().Send(Arg.Any<GetBlockPlayoutHistory>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetBlockHistory_Should_Clamp_Paging_And_Map_Entries()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
|
|
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.Block }));
|
|
_mediator.Send(Arg.Any<GetBlockPlayoutHistory>(), Arg.Any<CancellationToken>())
|
|
.Returns(new PagedPlayoutHistoryViewModel(
|
|
1,
|
|
[new PlayoutHistoryViewModel(99, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddHours(1), "k", "d")]));
|
|
|
|
IActionResult result = await _controller.GetBlockHistory(9, 10, -5, 999, CancellationToken.None);
|
|
|
|
var paged = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutHistoryResponseModel>();
|
|
paged.TotalCount.ShouldBe(1);
|
|
paged.Page.Count.ShouldBe(1);
|
|
paged.Page[0].Id.ShouldBe(99);
|
|
paged.Page[0].Key.ShouldBe("k");
|
|
|
|
await _mediator.Received(1).Send(
|
|
Arg.Is<GetBlockPlayoutHistory>(q =>
|
|
q.PlayoutId == 9 && q.BlockId == 10 && q.PageNum == 0 && q.PageSize == 100),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetHistoryDetails_Should_Return_200_With_Decoded_Details()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutHistoryDetails>(), Arg.Any<CancellationToken>())
|
|
.Returns(Right<BaseError, PlayoutHistoryDetailsViewModel>(
|
|
new PlayoutHistoryDetailsViewModel(
|
|
PlaybackOrder.Shuffle,
|
|
CollectionType.Collection,
|
|
"Cartoons",
|
|
"Episode",
|
|
"S1E1")));
|
|
|
|
IActionResult result = await _controller.GetHistoryDetails(42, CancellationToken.None);
|
|
|
|
var details = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlayoutHistoryDetailsResponseModel>();
|
|
details.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle);
|
|
details.CollectionType.ShouldBe(CollectionType.Collection);
|
|
details.Name.ShouldBe("Cartoons");
|
|
details.MediaItemType.ShouldBe("Episode");
|
|
details.MediaItemTitle.ShouldBe("S1E1");
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetHistoryDetails_Should_Return_404_For_NotFoundError()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutHistoryDetails>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, PlayoutHistoryDetailsViewModel>(new NotFoundError("missing")));
|
|
|
|
IActionResult result = await _controller.GetHistoryDetails(404, CancellationToken.None);
|
|
|
|
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
|
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
|
|
}
|
|
|
|
[Test]
|
|
public async Task GetHistoryDetails_Should_Return_422_For_Decode_Error()
|
|
{
|
|
_mediator.Send(Arg.Any<GetPlayoutHistoryDetails>(), Arg.Any<CancellationToken>())
|
|
.Returns(Left<BaseError, PlayoutHistoryDetailsViewModel>(BaseError.New("bad json")));
|
|
|
|
IActionResult result = await _controller.GetHistoryDetails(42, CancellationToken.None);
|
|
|
|
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
|
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(422);
|
|
}
|
|
|
|
private static PlayoutAlternateScheduleViewModel MakeAltVm(int id, int index, int programScheduleId) =>
|
|
new(id, index, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
|
|
|
private static PlayoutAlternateScheduleItemRequest MakeAltRequest(int programScheduleId) =>
|
|
new(0, programScheduleId, null, null, null, false, 1, 1, null, 12, 31, null);
|
|
|
|
private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
|
|
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, null, 0);
|
|
|
|
private static TemplateViewModel MakeTemplateViewModel(int id) =>
|
|
new(id, 1, "Group", $"Template {id}", 0);
|
|
|
|
private static PlayoutTemplateViewModel MakeTemplateVm(int id, int index, int templateId, int? decoTemplateId) =>
|
|
new(
|
|
id,
|
|
new TemplateViewModel(templateId, 1, "Group", $"Template {templateId}", 0),
|
|
decoTemplateId is { } dtId ? new DecoTemplateViewModel(dtId, 1, "DGroup", $"Deco {dtId}", 0) : null,
|
|
index,
|
|
[],
|
|
[],
|
|
[],
|
|
false,
|
|
1,
|
|
1,
|
|
null,
|
|
12,
|
|
31,
|
|
null);
|
|
|
|
private static PlayoutTemplateItemRequest MakeTemplateRequest(int templateId, int? decoTemplateId) =>
|
|
new(0, templateId, decoTemplateId, null, null, null, false, 1, 1, null, 12, 31, null);
|
|
|
|
private static PlayoutNameViewModel MakePlayout(int id) =>
|
|
new(
|
|
id,
|
|
PlayoutScheduleKind.Classic,
|
|
"Channel",
|
|
"101",
|
|
1,
|
|
ChannelPlayoutMode.Continuous,
|
|
"Schedule",
|
|
string.Empty,
|
|
null,
|
|
new PlayoutBuildStatus(),
|
|
null,
|
|
null,
|
|
0,
|
|
0);
|
|
|
|
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm, bool isLocked = false) =>
|
|
PlayoutResponseModel.From(
|
|
vm.PlayoutId,
|
|
vm.ScheduleKind,
|
|
vm.ChannelName,
|
|
vm.ChannelNumber,
|
|
vm.ChannelId,
|
|
vm.PlayoutMode,
|
|
vm.ScheduleName,
|
|
vm.ScheduleFile,
|
|
vm.DbDailyRebuildTime,
|
|
vm.BuildStatus is null
|
|
? null
|
|
: new PlayoutBuildStatusResponseModel(
|
|
vm.BuildStatus.LastBuild,
|
|
vm.BuildStatus.Success,
|
|
vm.BuildStatus.Message),
|
|
vm.DecoId,
|
|
vm.DecoName,
|
|
isLocked,
|
|
vm.Seed);
|
|
|
|
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
|
{
|
|
MethodInfo action = typeof(PlayoutController).GetMethod(actionName)
|
|
?? throw new AssertionException($"Missing action {actionName}");
|
|
|
|
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
|
attribute.HttpMethods.ShouldContain(httpMethod);
|
|
attribute.Template.ShouldBe(route);
|
|
}
|
|
}
|