feat(api): REST API foundation + Channels CRUD (#2a) #44

Merged
timothy merged 3 commits from worktree-rest-api-2a-foundation-channels into main 2026-06-27 16:14:15 +02:00
23 changed files with 1699 additions and 73 deletions
@@ -0,0 +1,60 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Validation rules shared by <see cref="CreateChannelHandler" /> and
/// <see cref="UpdateChannelHandler" />. These were previously enforced only by the Blazor page
/// (<c>ChannelEditViewModelValidator</c>); porting them into the handlers makes them apply to
/// the REST API as well. Failures map to HTTP 422.
/// </summary>
internal static class ChannelValidations
{
/// <summary>
/// A channel must belong to a non-empty group; the value is used as the M3U
/// <c>group-title</c>. Mirrors the Blazor rule <c>RuleFor(x =&gt; x.Group).NotEmpty()</c>.
/// </summary>
internal static Validation<BaseError, string> ValidateGroup(string group)
{
// Use explicit returns (not a ternary): BaseError has an implicit string conversion, so a
// ternary would collapse both branches to BaseError and always produce a Fail.
if (string.IsNullOrWhiteSpace(group))
{
return BaseError.New("Channel group is required");
}
return group;
}
/// <summary>A disabled channel may not be shown in the EPG.</summary>
internal static Validation<BaseError, bool> ValidateShowInEpg(bool isEnabled, bool showInEpg)
{
if (!isEnabled && showInEpg)
{
return BaseError.New("Disabled channels cannot be shown in EPG");
}
return showInEpg;
}
/// <summary>
/// A logo path that is an absolute URI must be a valid external (http/https) url.
/// Relative/local logo paths and empty values are allowed.
/// </summary>
internal static Validation<BaseError, string> ValidateLogo(string logoPath)
{
if (string.IsNullOrWhiteSpace(logoPath))
{
return string.Empty;
}
bool isAbsoluteUri = Uri.TryCreate(logoPath, UriKind.Absolute, out _);
if (isAbsoluteUri && !Artwork.IsExternalUrl(logoPath))
{
return BaseError.New("External logo url is invalid");
}
return logoPath;
}
}
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
@@ -36,18 +37,23 @@ public class CreateChannelHandler(
return new CreateChannelResult(channel.Id);
}
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) =>
(ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken)
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId,
_,
_,
_) =>
{
var artwork = new List<Artwork>();
@@ -125,6 +131,11 @@ public class CreateChannelHandler(
return channel;
});
// combine the page-only Group rule with the channel validation (keeps tuple arity within
// LanguageExt's supported applicative range while still accumulating all errors)
return (ValidateGroup(request.Group), channelValidation).Apply((_, channel) => channel);
}
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
@@ -149,16 +160,20 @@ public class CreateChannelHandler(
});
}
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
TvContext dbContext,
CreateChannel createChannel,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.CountAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => createChannel.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
CancellationToken cancellationToken)
{
bool exists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken);
if (exists)
{
return createChannel.FFmpegProfileId;
}
return BaseError.New($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
TvContext dbContext,
@@ -170,12 +185,14 @@ public class CreateChannelHandler(
return Option<int>.None;
}
return await dbContext.ChannelWatermarks
.CountAsync(w => w.Id == createChannel.WatermarkId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.WatermarkId))
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
bool exists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == createChannel.WatermarkId, cancellationToken);
if (exists)
{
return Optional(createChannel.WatermarkId);
}
return BaseError.New($"Watermark {createChannel.WatermarkId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
@@ -188,14 +205,15 @@ public class CreateChannelHandler(
return Option<int>.None;
}
return await dbContext.FillerPresets
bool exists = await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.CountAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.FallbackFillerId))
.Map(o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
.AnyAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken);
if (exists)
{
return Optional(createChannel.FallbackFillerId);
}
return BaseError.New($"Fallback filler {createChannel.FallbackFillerId} does not exist.");
}
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
@@ -1,6 +1,7 @@
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 ErsatzTV.Infrastructure.Extensions;
@@ -31,8 +32,17 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request, cancellationToken);
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
Option<Channel> maybeChannel = await dbContext.Channels
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
return await maybeChannel.Match(
Some: async channel =>
{
await DoDeletion(dbContext, channel, cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
},
None: () => Task.FromResult(
Left<BaseError, Unit>(new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
@@ -54,14 +64,4 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
return Unit.Default;
}
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext,
DeleteChannel deleteChannel,
CancellationToken cancellationToken)
{
Option<Channel> maybeChannel = await dbContext.Channels
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId, cancellationToken);
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
}
}
@@ -4,10 +4,13 @@ using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
using static ErsatzTV.Application.Channels.Mapper;
using Channel = ErsatzTV.Core.Domain.Channel;
@@ -24,8 +27,23 @@ public class UpdateChannelHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
Option<Channel> maybeChannel = await dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
return await maybeChannel.Match(
Some: async channel =>
{
Validation<BaseError, Channel> validation =
await Validate(dbContext, request, channel, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult(
Left<BaseError, ChannelViewModel>(
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
@@ -162,23 +180,82 @@ public class UpdateChannelHandler(
private static async Task<Validation<BaseError, Channel>> Validate(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken) =>
(await ChannelMustExist(dbContext, request, cancellationToken),
ValidateName(request),
Channel channel,
CancellationToken cancellationToken)
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((_, _, _, _, _) => channel);
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
// fallback filler) with the channel validation; splitting keeps tuple arity within
// LanguageExt's supported applicative range while still accumulating all errors
return (ValidateGroup(request.Group),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
channelValidation)
.Apply((_, _, _, _, c) => c);
}
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
TvContext dbContext,
UpdateChannel updateChannel,
CancellationToken cancellationToken) =>
dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
UpdateChannel request,
CancellationToken cancellationToken)
{
bool exists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
if (exists)
{
return request.FFmpegProfileId;
}
return BaseError.New($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.WatermarkId is null)
{
return Option<int>.None;
}
bool exists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == request.WatermarkId, cancellationToken);
if (exists)
{
return Optional(request.WatermarkId);
}
return BaseError.New($"Watermark {request.WatermarkId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.FallbackFillerId is null)
{
return Option<int>.None;
}
bool exists = await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.AnyAsync(w => w.Id == request.FallbackFillerId, cancellationToken);
if (exists)
{
return Optional(request.FallbackFillerId);
}
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
}
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
+12
View File
@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Errors;
/// <summary>
/// A <see cref="BaseError" /> that indicates the addressed resource does not exist.
/// REST endpoints map this to HTTP 404; other <see cref="BaseError" /> values map to 422.
/// </summary>
public class NotFoundError : BaseError
{
public NotFoundError(string value) : base(value)
{
}
}
@@ -0,0 +1,104 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using LanguageExt;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class CreateChannelHandlerTests : ChannelHandlerTestBase
{
private CreateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets);
[Test]
public async Task Should_Create_Channel_When_Valid()
{
await SeedFFmpegProfile();
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(MakeCreate(number: "7", name: "News"), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
bool exists = await context.Channels.AnyAsync(c => c.Number == "7" && c.Name == "News");
exists.ShouldBeTrue();
}
[Test]
public async Task Should_Reject_Duplicate_Number_With_422_Error()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(MakeCreate(number: "5"), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("unique");
}
[Test]
public async Task Should_Reject_ShowInEpg_When_Disabled()
{
await SeedFFmpegProfile();
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(
MakeCreate(number: "8", isEnabled: false, showInEpg: true),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("EPG");
}
[Test]
public async Task Should_Reject_Invalid_External_Logo_Url()
{
await SeedFFmpegProfile();
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(
MakeCreate(number: "9", logoPath: "ftp://example.com/logo.png"),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("logo");
}
[Test]
public async Task Should_Reject_Empty_Group()
{
await SeedFFmpegProfile();
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(MakeCreate(number: "11", group: " "), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("group");
}
[Test]
public async Task Should_Reject_Nonexistent_FFmpegProfile()
{
// intentionally do not seed an FFmpegProfile
Either<BaseError, CreateChannelResult> result =
await MakeHandler().Handle(MakeCreate(number: "12", ffmpegProfileId: 999), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("FFmpegProfile");
}
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,44 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using LanguageExt;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class DeleteChannelHandlerTests : ChannelHandlerTestBase
{
private DeleteChannelHandler MakeHandler() => new(Worker, Db.Factory, new MockFileSystem(), SearchTargets);
[Test]
public async Task Should_Return_NotFoundError_When_Channel_Missing()
{
Either<BaseError, Unit> result = await MakeHandler().Handle(new DeleteChannel(999), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Should_Delete_Existing_Channel()
{
await SeedChannel(1, "5");
Either<BaseError, Unit> result = await MakeHandler().Handle(new DeleteChannel(1), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
exists.ShouldBeFalse();
}
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,119 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using LanguageExt;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class UpdateChannelHandlerTests : ChannelHandlerTestBase
{
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets);
[Test]
public async Task Should_Return_NotFoundError_When_Channel_Missing()
{
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(999, number: "5"), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Should_Update_Existing_Channel()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5", "Old Name");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "New Name"), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
var channel = await context.Channels.SingleAsync(c => c.Id == 1);
channel.Name.ShouldBe("New Name");
}
[Test]
public async Task Should_Allow_Keeping_Own_Number()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5", "Old Name");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None);
result.IsRight.ShouldBeTrue();
}
[Test]
public async Task Should_Reject_Number_Used_By_Another_Channel()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(1, number: "6"), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("unique");
}
[Test]
public async Task Should_Reject_ShowInEpg_When_Disabled()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(
MakeUpdate(1, number: "5", isEnabled: false, showInEpg: true),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("EPG");
}
[Test]
public async Task Should_Reject_Empty_Group()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(1, number: "5", group: ""), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("group");
}
[Test]
public async Task Should_Reject_Nonexistent_FFmpegProfile()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(
MakeUpdate(1, number: "5", ffmpegProfileId: 999),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("FFmpegProfile");
}
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,62 @@
using System.Linq;
using System.Reflection;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Guards the API-key write-path contract for <see cref="ChannelController" />: every mutating
/// action (POST/PUT/PATCH/DELETE) must be covered by <see cref="ApiKeyAuthorizationFilter" />.
/// The filter is applied at the controller level, so this also protects any future write endpoint
/// added to the controller (regression net for the ResetPlayout bypass).
/// </summary>
[TestFixture]
public class ChannelControllerSecurityTests
{
[Test]
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
{
ServiceFilterAttribute? filter = typeof(ChannelController)
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
filter.ShouldNotBeNull("ChannelController must carry the ApiKeyAuthorizationFilter at the class level");
}
[Test]
public void Every_Mutating_Action_Should_Be_Protected()
{
MethodInfo[] actions = typeof(ChannelController)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
bool controllerHasFilter = typeof(ChannelController)
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
foreach (MethodInfo action in actions)
{
bool isMutating = action
.GetCustomAttributes<HttpMethodAttribute>(inherit: true)
.SelectMany(a => a.HttpMethods)
.Any(m =>
m is "POST" or "PUT" or "PATCH" or "DELETE");
if (!isMutating)
{
continue;
}
bool actionHasFilter = action
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
(controllerHasFilter || actionHasFilter)
.ShouldBeTrue($"Mutating action {action.Name} is not protected by ApiKeyAuthorizationFilter");
}
}
}
@@ -0,0 +1,241 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
using static LanguageExt.Prelude;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ChannelControllerTests
{
private IMediator _mediator = null!;
private ChannelController _controller = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
ChannelWriter<IBackgroundServiceRequest> writer =
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
_controller = new ChannelController(writer, _mediator);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
ChannelViewModel vm = MakeVm(5);
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(vm));
IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/channels/5");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(MakeVm(5)));
await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateChannel>(c => c.Number == "12" && c.Name == "Movies"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateChannelResult>(BaseError.New("bad")));
IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
ChannelViewModel vm = MakeVm(7);
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelViewModel>(vm));
IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateChannel>(c => c.ChannelId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, ChannelViewModel>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteChannel>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(3, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteChannel>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(99, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
ChannelViewModel vm = MakeVm(4);
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundResult>();
}
private static ChannelViewModel MakeVm(int id) =>
new(
id,
"5",
"Test",
"ErsatzTV",
string.Empty,
1,
null,
ArtworkContentTypeModel.None,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
0,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
private static CreateChannelRequest MakeCreateRequest(string number = "5", string name = "Test") =>
new(
name,
number,
"ErsatzTV",
string.Empty,
1,
null,
ArtworkContentTypeModel.None,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") =>
new(
name,
number,
"ErsatzTV",
string.Empty,
1,
null,
ArtworkContentTypeModel.None,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false);
}
+3
View File
@@ -11,13 +11,16 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="Testably.Abstractions.Testing" />
</ItemGroup>
</Project>
@@ -0,0 +1,115 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Extensions;
using LanguageExt;
using static LanguageExt.Prelude;
using Microsoft.AspNetCore.Mvc;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Extensions;
[TestFixture]
public class ApiResultsTests
{
[Test]
public void ToErrorResult_Should_Map_NotFoundError_To_404()
{
IActionResult result = new NotFoundError("missing").ToErrorResult();
result.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
}
[Test]
public void ToErrorResult_Should_Map_Other_Error_To_422()
{
IActionResult result = BaseError.New("bad").ToErrorResult();
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
}
[Test]
public void ToCreatedResult_Should_Return_201_With_Location_And_Body()
{
Either<BaseError, int> either = Right<BaseError, int>(5);
IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => $"body-{id}");
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/channels/5");
created.Value.ShouldBe("body-5");
}
[Test]
public void ToCreatedResult_Should_Map_NotFoundError_To_404()
{
Either<BaseError, int> either = Left<BaseError, int>(new NotFoundError("nope"));
IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => id);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public void ToUpdatedResult_Should_Return_200_On_Right()
{
Either<BaseError, string> either = Right<BaseError, string>("vm");
IActionResult result = either.ToUpdatedResult();
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBe("vm");
}
[Test]
public void ToUpdatedResult_Should_Return_404_For_NotFoundError()
{
Either<BaseError, string> either = Left<BaseError, string>(new NotFoundError("missing"));
IActionResult result = either.ToUpdatedResult();
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public void ToUpdatedResult_Should_Return_422_For_Other_Error()
{
Either<BaseError, string> either = Left<BaseError, string>(BaseError.New("bad"));
IActionResult result = either.ToUpdatedResult();
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public void ToDeletedResult_Should_Return_204_On_Right()
{
Either<BaseError, Unit> either = Right<BaseError, Unit>(Unit.Default);
IActionResult result = either.ToDeletedResult();
result.ShouldBeOfType<NoContentResult>().StatusCode.ShouldBe(204);
}
[Test]
public void ToDeletedResult_Should_Return_404_For_NotFoundError()
{
Either<BaseError, Unit> either = Left<BaseError, Unit>(new NotFoundError("missing"));
IActionResult result = either.ToDeletedResult();
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public void ToGetResult_Should_Return_200_For_Some()
{
Option<string> option = "value";
IActionResult result = option.ToGetResult();
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe("value");
}
[Test]
public void ToGetResult_Should_Return_404_For_None()
{
Option<string> option = Option<string>.None;
IActionResult result = option.ToGetResult();
result.ShouldBeOfType<NotFoundResult>();
}
}
@@ -0,0 +1,89 @@
using System.Collections.Generic;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Filters;
[TestFixture]
public class ApiKeyAuthorizationFilterTests
{
private static AuthorizationFilterContext MakeContext(string method, string? apiKeyHeader)
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = method;
if (apiKeyHeader is not null)
{
httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader;
}
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
}
private static ApiKeyAuthorizationFilter MakeFilter(string? configuredKey)
{
var settings = new Dictionary<string, string?>();
if (configuredKey is not null)
{
settings[ApiKeyAuthorizationFilter.ConfigurationKey] = configuredKey;
}
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
return new ApiKeyAuthorizationFilter(configuration);
}
[Test]
public void Should_Allow_When_Key_Not_Configured()
{
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
MakeFilter(configuredKey: null).OnAuthorization(context);
context.Result.ShouldBeNull();
}
[Test]
public void Should_Allow_When_Key_Configured_Empty()
{
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
MakeFilter(configuredKey: string.Empty).OnAuthorization(context);
context.Result.ShouldBeNull();
}
[Test]
public void Should_Reject_Mutating_Request_When_Key_Configured_And_Header_Missing()
{
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedResult>();
}
[Test]
public void Should_Reject_Mutating_Request_When_Key_Wrong()
{
AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong");
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeOfType<UnauthorizedResult>();
}
[Test]
public void Should_Allow_Mutating_Request_When_Key_Correct()
{
AuthorizationFilterContext context = MakeContext("PUT", apiKeyHeader: "secret");
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeNull();
}
[Test]
public void Should_Allow_Get_Request_Even_When_Key_Configured()
{
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null);
MakeFilter(configuredKey: "secret").OnAuthorization(context);
context.Result.ShouldBeNull();
}
}
@@ -0,0 +1,52 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using LanguageExt;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Tests.Integration;
/// <summary>
/// End-to-end create -> read (get-by-id) -> delete against the in-memory SQLite harness,
/// exercising the real EF Core handlers and repository.
/// </summary>
[TestFixture]
public class ChannelLifecycleIntegrationTests : ChannelHandlerTestBase
{
[Test]
public async Task Create_Then_Read_Then_Delete()
{
await SeedFFmpegProfile();
var createHandler = new CreateChannelHandler(Worker, Db.Factory, SearchTargets);
Either<BaseError, CreateChannelResult> created =
await createHandler.Handle(MakeCreate(number: "42", name: "Integration"), CancellationToken.None);
int channelId = created.Match(Left: _ => throw new AssertionException("create failed"), Right: r => r.ChannelId);
channelId.ShouldBeGreaterThan(0);
var getHandler = new GetChannelByIdHandler(new ChannelRepository(Db.Factory));
Option<ChannelViewModel> afterCreate =
await getHandler.Handle(new GetChannelById(channelId), CancellationToken.None);
afterCreate.IsSome.ShouldBeTrue();
afterCreate.Match(
Some: vm =>
{
vm.Number.ShouldBe("42");
vm.Name.ShouldBe("Integration");
},
None: () => throw new AssertionException("expected channel to exist"));
var deleteHandler = new DeleteChannelHandler(Worker, Db.Factory, new MockFileSystem(), SearchTargets);
Either<BaseError, Unit> deleted =
await deleteHandler.Handle(new DeleteChannel(channelId), CancellationToken.None);
deleted.IsRight.ShouldBeTrue();
Option<ChannelViewModel> afterDelete =
await getHandler.Handle(new GetChannelById(channelId), CancellationToken.None);
afterDelete.IsNone.ShouldBeTrue();
}
}
@@ -0,0 +1,137 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using NSubstitute;
using NUnit.Framework;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Tests.Support;
public abstract class ChannelHandlerTestBase
{
protected InMemoryTvContext Db = null!;
protected ChannelWriter<IBackgroundServiceRequest> Worker = null!;
protected ISearchTargets SearchTargets = null!;
[SetUp]
public async Task BaseSetUp()
{
Db = await InMemoryTvContext.CreateAsync();
Worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
SearchTargets = Substitute.For<ISearchTargets>();
}
[TearDown]
public async Task BaseTearDown() => await Db.DisposeAsync();
protected async Task SeedFFmpegProfile(int id = 1)
{
await using TvContext context = Db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile { Id = id, Name = $"profile-{id}" });
await context.SaveChangesAsync();
}
protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
{
await using TvContext context = Db.CreateContext();
context.Channels.Add(
new DomainChannel(Guid.NewGuid())
{
Id = id,
Number = number,
Name = name,
Group = "ErsatzTV",
Categories = string.Empty,
FFmpegProfileId = ffmpegProfileId,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
StreamingMode = StreamingMode.TransportStreamHybrid,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous
});
await context.SaveChangesAsync();
}
protected static CreateChannel MakeCreate(
string number = "5",
int ffmpegProfileId = 1,
bool isEnabled = true,
bool showInEpg = false,
string logoPath = "",
string name = "Test",
string group = "ErsatzTV") =>
new(
name,
number,
group,
string.Empty,
ffmpegProfileId,
null,
new ArtworkContentTypeModel(logoPath, string.Empty),
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
isEnabled,
showInEpg);
protected static UpdateChannel MakeUpdate(
int channelId,
string number = "5",
int ffmpegProfileId = 1,
bool isEnabled = true,
bool showInEpg = false,
string logoPath = "",
string name = "Test",
string group = "ErsatzTV") =>
new(
channelId,
name,
number,
group,
string.Empty,
ffmpegProfileId,
null,
new ArtworkContentTypeModel(logoPath, string.Empty),
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
null,
null,
StreamingMode.TransportStreamHybrid,
null,
null,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
isEnabled,
showInEpg);
}
@@ -0,0 +1,66 @@
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
namespace ErsatzTV.Tests.Support;
/// <summary>
/// In-memory SQLite harness for handler/integration tests. A single <see cref="SqliteConnection" />
/// is kept open for the lifetime of the harness so the schema (built via
/// <see cref="DatabaseFacade.EnsureCreatedAsync" />) and data persist across the multiple
/// <see cref="TvContext" /> instances created by an <see cref="IDbContextFactory{TvContext}" />.
/// Foreign keys are disabled so partial graphs can be seeded without satisfying every FK.
/// </summary>
public sealed class InMemoryTvContext : IAsyncDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<TvContext> _options;
private InMemoryTvContext(SqliteConnection connection, DbContextOptions<TvContext> options)
{
_connection = connection;
_options = options;
}
public IDbContextFactory<TvContext> Factory => new TestDbContextFactory(_options);
public static async Task<InMemoryTvContext> CreateAsync()
{
TvContext.IsSqlite = true;
var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
await connection.OpenAsync();
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
.UseSqlite(connection)
.Options;
await using (TvContext context = Create(options))
{
await context.Database.EnsureCreatedAsync();
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys=OFF");
}
return new InMemoryTvContext(connection, options);
}
public TvContext CreateContext() => Create(_options);
public async ValueTask DisposeAsync()
{
await _connection.DisposeAsync();
}
private static TvContext Create(DbContextOptions<TvContext> options) =>
new(
options,
NullLoggerFactory.Instance,
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
private sealed class TestDbContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() => Create(options);
}
}
+65 -21
View File
@@ -1,9 +1,13 @@
using System.Threading.Channels;
using System.ComponentModel.DataAnnotations;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -11,12 +15,72 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
// Apply the optional API-key control at the controller level so that EVERY mutating action
// (including future ones) is covered by default; the filter no-ops on read methods (GET) and
// when Api:WriteKey is unset, preserving the open LAN behavior. This is fail-safe: a developer
// adding a new write endpoint here cannot accidentally leave it unauthenticated.
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
{
[HttpGet("/api/channels")]
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
[EndpointGroupName("general")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/channels")]
[Tags("Channels")]
[EndpointSummary("Create a channel")]
[EndpointGroupName("general")]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateChannelRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateChannelResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async created =>
{
Option<ChannelViewModel> channel =
await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken);
return channel.Match(
Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm),
None: () => new NotFoundResult());
});
}
[HttpPut("/api/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Update a channel")]
[EndpointGroupName("general")]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateChannelRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ChannelViewModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
}
[HttpDelete("/api/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Delete a channel")]
[EndpointGroupName("general")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannel(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
@@ -32,24 +96,4 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
return new NotFoundResult();
}
// for debugging by fast-forwarding a playout
// [HttpPost("/api/channels/{channelNumber}/playout/continue")]
// public async Task<IActionResult> ContinuePlayout(string channelNumber, [FromQuery] int days = 1)
// {
// Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
// foreach (int playoutId in maybePlayoutId)
// {
// DateTimeOffset start = DateTimeOffset.Now;
// for (int i = 0; i < 24 * days; i++)
// {
// await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue, start));
// start += TimeSpan.FromHours(1);
// }
//
// return new OkResult();
// }
//
// return new NotFoundResult();
// }
}
@@ -0,0 +1,69 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// JSON request body for creating a channel. Mirrors <see cref="CreateChannel" />; decoupling the
/// API contract from the MediatR command keeps the wire format stable as the command evolves.
/// </summary>
public record CreateChannelRequest(
string Name,
string Number,
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg)
{
public CreateChannel ToCommand() =>
new(
Name,
Number,
Group,
Categories,
FFmpegProfileId,
SlugSeconds,
Logo,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
MirrorSourceChannelId,
PlayoutOffset,
StreamingMode,
WatermarkId,
FallbackFillerId,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
IsEnabled,
ShowInEpg);
}
@@ -0,0 +1,70 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// JSON request body for updating a channel. The channel id comes from the route, not the body;
/// all other fields mirror <see cref="UpdateChannel" />.
/// </summary>
public record UpdateChannelRequest(
string Name,
string Number,
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg)
{
public UpdateChannel ToCommand(int channelId) =>
new(
channelId,
Name,
Number,
Group,
Categories,
FFmpegProfileId,
SlugSeconds,
Logo,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
MirrorSourceChannelId,
PlayoutOffset,
StreamingMode,
WatermarkId,
FallbackFillerId,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
IsEnabled,
ShowInEpg);
}
+49
View File
@@ -0,0 +1,49 @@
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Extensions;
/// <summary>
/// REST status-code mapping helpers for the JSON write/read API (slice #2a).
/// These are additive and must not change the behavior of the existing
/// <see cref="EitherToActionResult" /> / <see cref="OptionToActionResult" /> helpers,
/// which IptvController and other read endpoints depend on.
/// </summary>
[SuppressMessage("ReSharper", "VSTHRD003")]
public static class ApiResults
{
/// <summary>Maps a failure to 404 when it is a <see cref="NotFoundError" />, otherwise 422.</summary>
public static IActionResult ToErrorResult(this BaseError error) =>
error is NotFoundError
? new NotFoundObjectResult(error.Value)
: new UnprocessableEntityObjectResult(error.Value);
/// <summary>Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToCreatedResult<TR>(
this Either<BaseError, TR> either,
Func<TR, string> location,
Func<TR, object> body) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: value => new CreatedResult(location(value), body(value)));
/// <summary>Right: 200 with body; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToUpdatedResult<TR>(this Either<BaseError, TR> either) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: value => (IActionResult)new OkObjectResult(value));
/// <summary>Right(Unit): 204 No Content; Left: 404 (NotFound) or 422.</summary>
public static IActionResult ToDeletedResult(this Either<BaseError, Unit> either) =>
either.Match(
Left: error => error.ToErrorResult(),
Right: _ => (IActionResult)new NoContentResult());
/// <summary>Some: 200 with body; None: 404.</summary>
public static IActionResult ToGetResult<T>(this Option<T> option) =>
option.Match(
Some: value => (IActionResult)new OkObjectResult(value),
None: () => new NotFoundResult());
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Primitives;
namespace ErsatzTV.Filters;
/// <summary>
/// Optional API-key authorization for mutating JSON API endpoints (slice #2a).
/// Reads the configured key from <c>Api:WriteKey</c>. When that key is empty the filter is
/// a no-op (preserving the current open LAN behavior); when it is set, mutating requests
/// (POST/PUT/PATCH/DELETE) must present a matching <c>X-Api-Key</c> header or receive 401.
/// This is fully independent of <see cref="JwtHelper" /> and only applies to the actions it
/// decorates — it never affects /iptv/* or any read endpoint.
/// </summary>
public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter
{
public const string HeaderName = "X-Api-Key";
public const string ConfigurationKey = "Api:WriteKey";
public void OnAuthorization(AuthorizationFilterContext context)
{
string configuredKey = configuration[ConfigurationKey];
// empty key => API-key auth disabled, endpoint is open
if (string.IsNullOrEmpty(configuredKey))
{
return;
}
string method = context.HttpContext.Request.Method;
bool isMutating = HttpMethods.IsPost(method)
|| HttpMethods.IsPut(method)
|| HttpMethods.IsPatch(method)
|| HttpMethods.IsDelete(method);
if (!isMutating)
{
return;
}
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided)
|| !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal))
{
context.Result = new UnauthorizedResult();
}
}
}
+3
View File
@@ -309,6 +309,9 @@ public class Startup
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
// optional API-key authorization for mutating JSON API endpoints (independent of JWT/OIDC)
services.AddScoped<ApiKeyAuthorizationFilter>();
services.AddFluentValidationAutoValidation();
services.AddValidatorsFromAssemblyContaining<Startup>();
+144
View File
@@ -0,0 +1,144 @@
# REST API Design — ersatztv#2
**Status:** Phase 1 signed off → Phase 2 in progress.
**Tracker:** ersatztv#2. **Sub-issues:** #34 (#2a), #35 (#2b), #36 (#2c), #37 (#2d), #38 (#2e). **Handoff:** `docs/handoffs/rest-api.md`.
## 1. Goals
1. **Programmatic CRUD** for channels / collections / schedules / playouts via REST — enabling **MCP** and direct API automation.
2. **Foundation for a new UI** built on top of the API (it replaces read paths too, not just writes).
3. **Retire fragile direct-SQLite writes.** All mutations route through EF Core + existing domain validation, returning proper HTTP codes — no more TPT/enum/NOT-NULL footguns, no container-stop-for-writes.
These goals push three things to the front: **OpenAPI is in-scope** (MCP tool-gen + UI codegen), **stable DTO contracts**, and a **programmatic auth** story.
## 2. What the investigation found (code-grounded)
### 2.1 No migration needed
Pure CRUD over existing tables. The full schema — including the four `ProgramScheduleItem` **TPT subtype tables** (`ProgramScheduleOneItem`, `ProgramScheduleMultipleItem`, `ProgramScheduleFloodItem`, `ProgramScheduleDurationItem`, via `.UseTptMappingStrategy()`) — already exists. The `migrations` CI job will stay green; no `scripts/add-migration.sh` run is required for any slice.
### 2.2 Every command already exists
All four resources already have MediatR Create/Update/Delete (+ item ops) handlers, **almost all returning `Either<BaseError, T>`** — which maps cleanly to HTTP. The API is mostly **thin controllers over existing handlers**, not new business logic.
| Resource | Create | Update | Delete | Item ops |
|---|---|---|---|---|
| **Channels** | `CreateChannel` → `Either<_, CreateChannelResult>` | `UpdateChannel` → `Either<_, ChannelViewModel>` | `DeleteChannel` → `Either<_, Unit>` | — |
| **Collections** | `CreateCollection` → `Either<_, MediaCollectionViewModel>` | `UpdateCollection` → `Either<_, Unit>` | `DeleteCollection` → `Either<_, Unit>` | `AddItemsToCollection`, `RemoveItemsFromCollection` |
| **Schedules** | `CreateProgramSchedule` → `Either<_, CreateProgramScheduleResult>` | `UpdateProgramSchedule` → `Either<_, UpdateProgramScheduleResult>` | `DeleteProgramSchedule` → `Either<_, Unit>` | `AddProgramScheduleItem`, `ReplaceProgramScheduleItems` (bulk) |
| **Playouts** | `CreateClassic/Block/Sequential/Scripted/ExternalJsonPlayout` → `Either<_, CreatePlayoutResponse>` | `UpdatePlayout` → `Either<_, PlayoutNameViewModel>` | `DeletePlayout` → `Either<_, Unit>` | reset (exists, via worker channel) |
Net-new command work is essentially **zero** for the happy paths. Effort is in controllers, DTOs, status-code mapping, auth, and tests.
### 2.3 Validation already lives in the handlers (mostly)
The substantive rules are in-handler, so the API inherits them:
- Channel **number uniqueness** (`CreateChannelHandler.cs:137`, `UpdateChannelHandler.cs:234` excludes self), name/FK-exists, mirror-source validity.
- Collection / schedule **name uniqueness** + length.
- Playout business rules: **"Channel already has one playout"** (`CreateClassicPlayoutHandler.cs:77`), **"Program schedule must have items"** (`:93`).
A few **format/UX checks are page-only** (FluentValidation in `.razor` / `Validators/`) and would NOT reach the API — port these into the handlers (with test-before/after):
- Channel: `ShowInEpg` must be false when `!IsEnabled`; external-logo-URL format. (`ChannelEditViewModelValidator.cs`)
- Playout: required-field shape per kind. (`PlayoutEditViewModelValidator.cs`) — mostly redundant with handler checks; confirm parity.
### 2.4 Existing controller conventions (match these)
- Controllers in `ErsatzTV/Controllers/Api/`, base `ControllerBase`, **routes declared per-action** (full path, no controller `[Route]` prefix).
- JSON: **Newtonsoft**, **camelCase** (`CustomNamingStrategy`, with `FFmpegProfileId` special-cased), **enums as strings** (`StringEnumConverter`), `NullValueHandling.Ignore` (`Startup.cs:294`).
- **OpenAPI already wired**: `AddOpenApi("v1")` + **Scalar UI at `/docs`** (`Startup.cs:136`, `:664`); endpoints opt in via `[EndpointGroupName("general")]`.
- `.ToActionResult()` extensions map: `Either` Left→**400**, Right→**200**; `Option` None→**404**, Some→**200**; `Validation` Failure→**400**. **No 201/422 today.**
### 2.5 Two existing CRUD controllers are non-idiomatic → standardize
`FFmpegProfileController` and `SmartCollectionController` use verb-in-path (`/new`, `/update`, `/delete/{id}`). Per the modernization decision, retrofit them to idiomatic REST as we work the related slices, gated by characterization tests.
### 2.6 Auth today
A **JWT bearer scheme** exists (`JwtHelper`, `JwtOnlyScheme` policy, `access_token` query-param support, 1-day tokens) but is wired **only to `IptvController`** via `ConditionalIptvAuthorizeFilter` (enforced only when `JWT:IssuerSigningKey` is set). `/api/*` currently has **no auth**; CORS is **AllowAll**.
## 3. Conventions (decided)
### 3.1 Routing — idiomatic REST
```
POST /api/channels GET /api/channels GET /api/channels/{id}
PUT /api/channels/{id} DELETE /api/channels/{id}
POST /api/collections GET /api/collections GET /api/collections/{id}
PUT /api/collections/{id} DELETE /api/collections/{id}
POST /api/collections/{id}/items
DELETE /api/collections/{id}/items/{mediaItemId}
POST /api/schedules GET /api/schedules GET /api/schedules/{id}
PUT /api/schedules/{id} DELETE /api/schedules/{id}
GET /api/schedules/{id}/items
POST /api/schedules/{id}/items
PUT /api/schedules/{id}/items # bulk replace (maps to ReplaceProgramScheduleItems)
DELETE /api/schedules/{id}/items/{itemId}
POST /api/playouts GET /api/playouts GET /api/playouts/{id}
DELETE /api/playouts/{id}
POST /api/channels/{number}/playout/reset # exists; keep
```
`{id}` = numeric entity id. Channel reset keeps its existing `{number}` route (already public).
### 3.2 Status codes
| Outcome | Code |
|---|---|
| Create success | **201 Created** + `Location` header + body |
| Update success | **200 OK** + body |
| Delete success | **204 No Content** |
| Read success | **200 OK** |
| Resource not found | **404** |
| Validation failure (bad field, uniqueness, business rule) | **422 Unprocessable Entity** + error body |
| Malformed request (unparseable JSON, missing required) | **400** |
The current `.ToActionResult()` only yields 200/400/404, so we add **richer mapping helpers** (a deliberate, stated deviation — proper HTTP semantics matter for MCP/UI consumers). **Foundation decision (#2a):** to distinguish 404 from 422, handlers fold "does not exist" into `BaseError` today (flat). Introduce a lightweight typed error (e.g. `NotFoundError : BaseError`) in `ErsatzTV.Core`; mapping helper returns 404 for it, 422 for other `BaseError`s. This is a Core/error-layer change (not an EF model change) — test-before/after applies where it touches existing handlers. If it proves to sprawl, fall back to per-controller existence checks and backlog the typed-error refactor.
### 3.3 DTOs
- **Requests:** dedicated request DTOs (bound from JSON) → mapped to existing Commands. Stable external contract; decouples the wire shape from internal command records.
- **Responses:** **reuse existing `*ViewModel`s** (already the camelCase read contract used by current GETs). One contract, already proven.
### 3.4 Validation
Reuse handler validation. Port the page-only checks in §2.3 into handlers so the API reaches parity with the Blazor UI. No validation logic in controllers.
### 3.5 Auth — dedicated API key for writes (decoupled from IPTV)
**Mechanism:** a dedicated **API key** for mutations — a new config key (e.g. `Api__WriteKey`) checked by a filter applied **only to POST/PUT/DELETE on `/api/*`**. Enforced only when the key is configured (LAN-open default preserved); reads and all `/iptv/*` stay open.
**Why not reuse the JWT scheme (important):** JWT is gated by a single global toggle, `JwtHelper.IsEnabled` ← `JWT:IssuerSigningKey` (`Startup.cs:166`). That **same toggle also gates the IPTV endpoints** (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, streams — `ConditionalIptvAuthorizeFilter:18`) which **Jellyfin and Dispatcharr consume**. Enabling JWT to protect writes would force token auth onto those media feeds — and JWT tokens **expire in 1 day** (`JwtHelper.cs:27`), unsuitable for a standing tuner URL. A dedicated API key **decouples write-auth from media-consumer auth**: turning it on changes **nothing** for Jellyfin/Dispatcharr.
**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one filter + one config key); a standard machine-API pattern.
**Backlog:** tighten CORS for mutation routes if writes are exposed beyond LAN.
### 3.6 OpenAPI / discoverability
New endpoints carry `[EndpointGroupName("general")]` → appear in the existing `v1` doc + Scalar `/docs`. This *is* the MCP/UI enabler (tool-gen + client codegen). Keep summaries/`[ProducesResponseType]` accurate per action.
### 3.7 Other conventions
- **Absolute URLs** in responses (logos/streams) use the **request-derived host**, same as M3U/XMLTV (`docs/m3u-xmltv.md`) — never bake a host. (Carries the #1 lesson forward.)
- **List endpoints:** add simple pagination where the underlying query supports it; defer rich filtering/sorting (backlog — the new UI will want it).
- **DELETE idempotency:** return 404 if the entity is already absent (clear over silently-204). EF cascade handles dependents (e.g. Channel→Playout cascade, ProgramSchedule→Items cascade) — integration tests prove this per slice.
## 4. Standardization scope
- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green).
- Opportunistic-fix policy: backlog/document unrelated issues found en route; fix-in-place only when limited-scope + useful-now, or when deferring would force rework of the new code.
## 5. Increment plan (sub-issues under #2 as tracker)
One slice = one branch = one PR. PR runs `test` + `migrations` (both required); merge to `main` adds `build` + smoke/E2E. Tests with **NUnit + Shouldly + NSubstitute**, run locally `TZ=UTC`.
- **#2a (#34) — API foundation + Channels (pattern-setter).** Shared conventions: idiomatic routing, status-code mapping helpers (incl. `NotFoundError` typed error + 201/422), request-DTO pattern, response = ViewModels, `[EndpointGroupName("general")]`, API-key mutation auth filter (decoupled from the IPTV JWT toggle). Then Channels CRUD (POST/PUT/DELETE, confirm/standardize GET list + add GET `/{id}`). Port page-only channel validations into handlers. Tests: handler unit (success + 404/422), controller status-mapping, create→read→delete EF integration.
- **#2b (#35) — Collections** CRUD + add/remove items; **retrofit `SmartCollectionController`** to idiomatic (characterization tests first).
- **#2c (#36) — Schedules** CRUD + schedule items (**TPT-heavy** — the one to budget for). Design the item DTO around the `PlayoutMode` discriminator (One/Multiple/Flood/Duration); reuse `AddProgramScheduleItem` / `ReplaceProgramScheduleItems`. Integration test proving the correct TPT subtype rows are written.
- **#2d (#37) — Playouts** create (Classic + 4 kinds via discriminated DTO) / delete; keep existing reset. Validation already strong.
- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d.
**Sequencing:** #2a first (sets every convention the others copy), then #2b–#2d in parallel-able order, #2e last. Each slice ships its own read endpoints so the new UI gains coverage incrementally.
## 6. Testing strategy (per slice)
- Handler unit tests: success + each failure (not-found→404, validation→422).
- Controller tests: status-code/`Location`/DTO mapping.
- EF integration test (real SQLite): create→read→delete proving TPT subtype creation + cascade correctness.
- Characterization tests before any existing-controller retrofit.
- Lean on existing nets: architecture tests (#12 — controllers in `ErsatzTV`, logic in `ErsatzTV.Application`), M3U goldens (#11) if a change touches `ToM3U`.
## 7. Open items / backlog seeds
- CORS tightening for mutation routes (if exposed beyond LAN).
- List-endpoint filtering/sorting/pagination depth (new-UI driven).
- API-key provisioning UX for MCP/UI write clients (how a caller obtains/sets `Api__WriteKey`).
- Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local.
```