#616 filed three MCP/API paging traps. Two were real; one was not, and one was already half-fixed on main. Verified each against the code before changing it. REAL — pageNum documented as 1-based. `ToolCatalog.Page()` described pageNum as "1-based page number" while every paged controller defaults it to 0, floors it with `Math.Max(0, pageNum)`, and skips `PageNum * PageSize`. A caller that trusted the description started at page 1 and silently lost the first page: no error, just a short set that reads as data loss rather than an off-by-one (it cost #487 a verification pass). Fixed in the description rather than by making the MCP layer 1-based: /api/v1 is additive-only post-freeze, 0-based is load-bearing in a dozen controllers and the SPA, and a 1-based wrapper over a 0-based API would make the same parameter name mean two different things on two surfaces a reader reads together. NOT REAL — "pageSize caps the page but the offset honors the requested value". Not reproducible on any endpoint. Every controller clamps before passing, every handler skips by the clamped size, and GetCollectionItemsHandler re-clamps defensively. The reported observation (pageSize=500&pageNum=2 on a 204-item collection returning 4 items) is exactly correct 0-based behaviour at the clamped width of 100 — page 2 is items 201-204. The issue's own trap-1 table states this. Pinned by test rather than "fixed". ALREADY FIXED — playout LIST rows gained channelId in #297 (2026-07-22), three days before #616 was filed; the report was measured against prod, which runs an older :prod image. The DETAIL response (PlayoutResponseModel) genuinely still lacked it, so channelId is added there (additive) and the reset_channel_playout argument now names the trap: the id spaces overlap numerically, so passing a playout id silently resets a different channel and returns a plausible 202. Tests, both mutation-verified (each fails when its fix is reverted): - ToolCatalogTests pins "0-based" on EVERY paged tool's pageNum description, with a non-empty guard so it can't pass vacuously over an empty tool set. - GetCollectionItemsHandlerTests pins 0-based page boundaries and proves the offset derives from the clamped pageSize (page 1 at pageSize=500 returns items 101-150; the mutation that honors 500 returns an empty page). Docs: new decision record api.paging-zero-based (catalog regenerated), the api-conventions paging bullet, and a Paging section in docs/mcp.md. OpenAPI v1.json + web/src/api/generated/v1.d.ts regenerated for the added field. fixes #616 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1480 lines
65 KiB
C#
1480 lines
65 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, [], [], [], 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, [], [], [], 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, [], [], [], 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>());
|
|
}
|
|
|
|
// ----- 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, [], [], [], 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, [], [], [], 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, [], [], [], 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, [], [], [], 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);
|
|
}
|
|
}
|