@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkDeleteChannelsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IFileSystem fileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkDeleteChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
List<int> channelIds = request.ChannelIds.Distinct().ToList();
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count != channelIds.Count)
|
||||
{
|
||||
var found = channels.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
|
||||
}
|
||||
|
||||
dbContext.Channels.RemoveRange(channels);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkMoveChannelsToGroupHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkMoveChannelsToGroup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
|
||||
if (groupValidation.IsFail)
|
||||
{
|
||||
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
List<int> channelIds = request.ChannelIds.Distinct().ToList();
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count != channelIds.Count)
|
||||
{
|
||||
var found = channels.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
|
||||
}
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
channel.Group = request.Group;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
@@ -14,18 +16,41 @@ public class UpdateChannelNumbersHandler(
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BaseError> validationError = ValidateRequest(request);
|
||||
if (validationError.IsSome)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
|
||||
var channelIds = numberUpdates.Keys;
|
||||
List<int> channelIds = numberUpdates.Keys.ToList();
|
||||
|
||||
List<Channel> channelsToUpdate = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channelsToUpdate.Count != channelIds.Count)
|
||||
{
|
||||
var found = channelsToUpdate.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return new NotFoundError($"Channel {missingId} does not exist.");
|
||||
}
|
||||
|
||||
List<string> requestedNumbers = numberUpdates.Values.ToList();
|
||||
bool numberConflict = await dbContext.Channels
|
||||
.AnyAsync(
|
||||
c => requestedNumbers.Contains(c.Number) && !channelIds.Contains(c.Id),
|
||||
cancellationToken);
|
||||
if (numberConflict)
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
// give every channel a non-conflicting number
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
@@ -69,4 +94,32 @@ public class UpdateChannelNumbersHandler(
|
||||
return BaseError.New("Failed to update channel numbers: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<BaseError> ValidateRequest(UpdateChannelNumbers request)
|
||||
{
|
||||
if (request.Channels.Count == 0)
|
||||
{
|
||||
return BaseError.New("At least one channel is required");
|
||||
}
|
||||
|
||||
if (request.Channels.Select(c => c.Id).Distinct().Count() != request.Channels.Count)
|
||||
{
|
||||
return BaseError.New("Channel ids must be unique");
|
||||
}
|
||||
|
||||
if (request.Channels.Select(c => c.Number).Distinct(StringComparer.Ordinal).Count() != request.Channels.Count)
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
foreach (ChannelSortViewModel channel in request.Channels)
|
||||
{
|
||||
if (!Regex.IsMatch(channel.Number, Channel.NumberValidator))
|
||||
{
|
||||
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkDeleteChannelsHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private readonly MockFileSystem _fileSystem = new();
|
||||
|
||||
private BulkDeleteChannelsHandler MakeHandler() => new(Db.Factory, Worker, _fileSystem, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Delete_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 2]), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
int count = await context.Channels.CountAsync();
|
||||
count.ShouldBe(0);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Delete_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 99]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
|
||||
exists.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkMoveChannelsToGroupHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private BulkMoveChannelsToGroupHandler MakeHandler() => new(Db.Factory, Worker, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Move_All_Channels_To_Group()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 2], "Movies"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> groups = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => c.Group)
|
||||
.ToListAsync();
|
||||
groups.ShouldBe(["Movies", "Movies"]);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Move_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 99], "Movies"), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Empty_Group()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1], ""), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("group");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateChannelNumbersHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private UpdateChannelNumbersHandler MakeHandler() => new(Db.Factory, Worker);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Renumber_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 2, Number = "11.1" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channels = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new { c.Number, c.SortNumber })
|
||||
.ToListAsync();
|
||||
channels[0].Number.ShouldBe("10");
|
||||
channels[0].SortNumber.ShouldBe(10);
|
||||
channels[1].Number.ShouldBe("11.1");
|
||||
channels[1].SortNumber.ShouldBe(11.1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Renumber_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 99, Number = "11" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
|
||||
number.ShouldBe("5");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Duplicate_Numbers_And_Not_Renumber()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 2, Number = "10" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("unique");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> numbers = await context.Channels.OrderBy(c => c.Id).Select(c => c.Number).ToListAsync();
|
||||
numbers.ShouldBe(["5", "6"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Invalid_Number_And_Not_Renumber()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers([new ChannelSortViewModel { Id = 1, Number = "10.123" }]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Invalid channel number");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
|
||||
number.ShouldBe("5");
|
||||
}
|
||||
|
||||
private static BaseError ErrorOf(Option<BaseError> result) =>
|
||||
result.Match(
|
||||
Some: error => error,
|
||||
None: () => throw new AssertionException("Expected an error"));
|
||||
}
|
||||
@@ -17,6 +17,12 @@ public class ApiErrorResponseMetadataTests
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -125,6 +125,95 @@ public class ChannelControllerTests
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
|
||||
IActionResult result = await _controller.BulkRenumber(
|
||||
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "10")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateChannelNumbers>(
|
||||
c => c.Channels.Count == 1 && c.Channels[0].Id == 1 && c.Channels[0].Number == "10"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.BulkRenumber(
|
||||
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "bad")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkMoveToGroup_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.BulkMoveToGroup(
|
||||
new BulkMoveChannelsToGroupRequest([1, 2], "Movies"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<BulkMoveChannelsToGroup>(
|
||||
c => c.ChannelIds.SequenceEqual(new[] { 1, 2 }) && c.Group == "Movies"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.BulkMoveToGroup(
|
||||
new BulkMoveChannelsToGroupRequest([99], "Movies"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkDelete_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.BulkDelete(
|
||||
new BulkDeleteChannelsRequest([1, 2]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<BulkDeleteChannels>(c => c.ChannelIds.SequenceEqual(new[] { 1, 2 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task BulkDelete_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.BulkDelete(
|
||||
new BulkDeleteChannelsRequest([99]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
|
||||
@@ -48,6 +48,12 @@ public class OpenApiErrorResponseContractTests
|
||||
[TestCase("/api/channels/{id}", "put", "422")]
|
||||
[TestCase("/api/channels/{id}", "delete", "404")]
|
||||
[TestCase("/api/channels/{id}", "delete", "422")]
|
||||
[TestCase("/api/channels/bulk/renumber", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/renumber", "post", "422")]
|
||||
[TestCase("/api/channels/bulk/group", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/group", "post", "422")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "404")]
|
||||
[TestCase("/api/channels/bulk/delete", "post", "422")]
|
||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
|
||||
[TestCase("/api/collections/{id}", "get", "404")]
|
||||
[TestCase("/api/collections", "post", "404")]
|
||||
|
||||
@@ -35,7 +35,12 @@ public abstract class ChannelHandlerTestBase
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
|
||||
protected async Task SeedChannel(
|
||||
int id,
|
||||
string number,
|
||||
string name = "Test",
|
||||
int ffmpegProfileId = 1,
|
||||
string group = "ErsatzTV")
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Channels.Add(
|
||||
@@ -44,7 +49,7 @@ public abstract class ChannelHandlerTestBase
|
||||
Id = id,
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
Group = group,
|
||||
Categories = string.Empty,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
StreamSelector = string.Empty,
|
||||
|
||||
@@ -87,6 +87,53 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/renumber")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Renumber channels")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkRenumber(
|
||||
[Required] [FromBody] BulkRenumberChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.Match<IActionResult>(
|
||||
Some: error => error.ToErrorResult(),
|
||||
None: () => new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/group")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Move channels to a group")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkMoveToGroup(
|
||||
[Required] [FromBody] BulkMoveChannelsToGroupRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/bulk/delete")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Delete channels")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> BulkDelete(
|
||||
[Required] [FromBody] BulkDeleteChannelsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record BulkRenumberChannelRequest(int Id, string Number);
|
||||
|
||||
public record BulkRenumberChannelsRequest(List<BulkRenumberChannelRequest> Channels)
|
||||
{
|
||||
public UpdateChannelNumbers ToCommand() =>
|
||||
new(Channels.Select(c => new ChannelSortViewModel { Id = c.Id, Number = c.Number }).ToList());
|
||||
}
|
||||
|
||||
public record BulkMoveChannelsToGroupRequest(List<int> ChannelIds, string Group)
|
||||
{
|
||||
public BulkMoveChannelsToGroup ToCommand() => new(ChannelIds, Group);
|
||||
}
|
||||
|
||||
public record BulkDeleteChannelsRequest(List<int> ChannelIds)
|
||||
{
|
||||
public BulkDeleteChannels ToCommand() => new(ChannelIds);
|
||||
}
|
||||
@@ -364,6 +364,240 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/bulk/renumber": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Renumber channels",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/bulk/group": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Move channels to a group",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/bulk/delete": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Channels"
|
||||
],
|
||||
"summary": "Delete channels",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/channels/{channelNumber}/playout/reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -3060,6 +3294,85 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BulkDeleteChannelsRequest": {
|
||||
"required": [
|
||||
"channelIds"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channelIds": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BulkMoveChannelsToGroupRequest": {
|
||||
"required": [
|
||||
"channelIds",
|
||||
"group"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channelIds": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"group": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"BulkRenumberChannelRequest": {
|
||||
"required": [
|
||||
"id",
|
||||
"number"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"number": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"BulkRenumberChannelsRequest": {
|
||||
"required": [
|
||||
"channels"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channels": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BulkRenumberChannelRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ChannelIdleBehavior": {
|
||||
"enum": [
|
||||
"StopOnDisconnect",
|
||||
|
||||
Vendored
+14
@@ -21,6 +21,20 @@ export interface components {
|
||||
"isExternalUrl"?: boolean;
|
||||
"hasContentType"?: boolean;
|
||||
"urlWithContentType"?: null | string;
|
||||
};
|
||||
"BulkDeleteChannelsRequest": {
|
||||
"channelIds": null | Array<number>;
|
||||
};
|
||||
"BulkMoveChannelsToGroupRequest": {
|
||||
"channelIds": null | Array<number>;
|
||||
"group": null | string;
|
||||
};
|
||||
"BulkRenumberChannelRequest": {
|
||||
"id": number;
|
||||
"number": null | string;
|
||||
};
|
||||
"BulkRenumberChannelsRequest": {
|
||||
"channels": null | Array<components["schemas"]["BulkRenumberChannelRequest"]>;
|
||||
};
|
||||
"ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning";
|
||||
"ChannelMusicVideoCreditsMode": "None" | "GenerateSubtitles";
|
||||
|
||||
Reference in New Issue
Block a user