feat(api): REST foundation + Channels CRUD (#34)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m41s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m41s
First slice of the REST API (ersatztv#2). Idiomatic REST over existing MediatR handlers; design in docs/rest-api.md.
Foundation: NotFoundError : BaseError (Core) to split 404 from 422; ApiResults mapping helpers (Either/Option -> 201+Location / 200 / 204 / 404 / 422), additive (existing *ToActionResult untouched); ApiKeyAuthorizationFilter (optional X-Api-Key on mutating actions, gated by Api:WriteKey, no-op when unset) — independent of the IPTV JWT toggle so Jellyfin/Dispatcharr reads are unaffected, applied at controller-class level so every mutating action incl. ResetPlayout is covered fail-safe.
Channels: POST /api/channels (201+Location), PUT/{id} (200/404), DELETE/{id} (204/404), GET/{id} (200/404). Request DTOs -> existing commands; responses reuse ChannelViewModel. Ported page-only validations into handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + FFmpegProfile/Watermark/Filler existence on update for create-parity. Fixed a latent 500: the .Filter(c>0) existence pattern threw TaskCanceledException on not-found; rewritten to AnyAsync.
Tests (NUnit, TZ=UTC): handler success/404/422, ApiResults mapping, API-key filter, controller status/Location/mapping, a controller-security regression net, and a create->read->delete EF integration test on a new in-memory SQLite harness. ErsatzTV.Tests 46/46, Architecture 5/5.
Refs #34
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #44.
This commit is contained in:
@@ -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 => 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user