Compare commits

...
Author SHA1 Message Date
timothyandClaude Opus 4.8 48f7ceff62 fix(327): validate playlist name on rename (reject empty/whitespace/too-long)
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 5s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m0s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 15m43s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m11s
fixes #327

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:46:26 +02:00
2 changed files with 126 additions and 1 deletions
@@ -74,7 +74,12 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
CancellationToken cancellationToken) =>
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
.BindT(playlist => ValidateName(request).Map(_ => playlist));
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
ReplacePlaylistItems request,
@@ -0,0 +1,120 @@
using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Application.MediaCollections;
/// <summary>
/// Issue #327: ReplacePlaylistItemsHandler (playlist rename) did no name validation, so a playlist
/// could be renamed to an empty/whitespace or over-long name even though CreatePlaylistHandler
/// already refuses those on create. Mirrors RenamePlaylistGroupHandler's ValidateName combinator
/// (NotEmpty + NotLongerThan(50)) so create and rename enforce the same rule.
/// </summary>
[TestFixture]
public class ReplacePlaylistItemsHandlerNameValidationTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private async Task SeedPlaylistAsync()
{
await using TvContext ctx = _db.CreateContext();
ctx.Playlists.Add(
new Playlist
{
Id = 1,
PlaylistGroupId = 1,
Name = "Kids",
IsSystem = false,
Version = 1,
Items = new List<PlaylistItem>()
});
await ctx.SaveChangesAsync();
}
private static ReplacePlaylistItems Command(string name) =>
new(
1,
name,
new List<ReplacePlaylistItem>
{
new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true)
},
None);
private async Task<string> ReadNameAsync()
{
await using TvContext ctx = _db.CreateContext();
return await ctx.Playlists.Where(p => p.Id == 1).Select(p => p.Name).SingleAsync();
}
private static BaseError? LeftOrNull<T>(Either<BaseError, T> result) =>
result.Match<BaseError?>(Right: _ => null, Left: e => e);
[Test]
public async Task Empty_Name_Should_Be_Rejected_And_Not_Mutate()
{
await SeedPlaylistAsync();
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
Either<BaseError, List<PlaylistItemViewModel>> result =
await handler.Handle(Command(string.Empty), CancellationToken.None);
LeftOrNull(result).ShouldNotBeNull();
(await ReadNameAsync()).ShouldBe("Kids");
}
[Test]
public async Task Whitespace_Name_Should_Be_Rejected_And_Not_Mutate()
{
await SeedPlaylistAsync();
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
Either<BaseError, List<PlaylistItemViewModel>> result =
await handler.Handle(Command(" "), CancellationToken.None);
LeftOrNull(result).ShouldNotBeNull();
(await ReadNameAsync()).ShouldBe("Kids");
}
[Test]
public async Task Over_Long_Name_Should_Be_Rejected_And_Not_Mutate()
{
await SeedPlaylistAsync();
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
string tooLong = new string('a', 51);
Either<BaseError, List<PlaylistItemViewModel>> result =
await handler.Handle(Command(tooLong), CancellationToken.None);
LeftOrNull(result).ShouldNotBeNull();
(await ReadNameAsync()).ShouldBe("Kids");
}
[Test]
public async Task Valid_Rename_Should_Succeed()
{
await SeedPlaylistAsync();
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
string maxLength = new string('a', 50);
Either<BaseError, List<PlaylistItemViewModel>> result =
await handler.Handle(Command(maxLength), CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadNameAsync()).ShouldBe(maxLength);
}
}