diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs index 845ffe1ec..b21ce9f6f 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs @@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Search; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; @@ -42,8 +43,15 @@ public class AddItemsToCollectionHandler : CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken)); + Option maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken); + return await maybeCollection.Match( + Some: async collection => + { + Validation validation = await Validate(dbContext, request, collection, cancellationToken); + return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken)); + }, + None: () => Task.FromResult>( + new NotFoundError($"Collection {request.CollectionId} does not exist."))); } private async Task ApplyAddItemsRequest( @@ -89,22 +97,23 @@ public class AddItemsToCollectionHandler : private async Task> Validate( TvContext dbContext, AddItemsToCollection request, + Collection collection, CancellationToken cancellationToken) => - (await CollectionMustExist(dbContext, request, cancellationToken), - await ValidateMovies(request), + (await ValidateMovies(request), await ValidateShows(request), await ValidateSeasons(request), - await ValidateEpisodes(request)) - .Apply((collection, _, _, _, _) => collection); + await ValidateEpisodes(request), + await ValidateMediaItems(dbContext, request, cancellationToken)) + .Apply((_, _, _, _, _) => collection); - private static Task> CollectionMustExist( + private static Task> CollectionMustExist( TvContext dbContext, AddItemsToCollection request, CancellationToken cancellationToken) => dbContext.Collections .Include(c => c.MediaItems) .SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken) - .Map(o => o.ToValidation("Collection does not exist.")); + .Map(identity); private Task> ValidateMovies(AddItemsToCollection request) => _movieRepository.AllMoviesExist(request.MovieIds) @@ -133,4 +142,30 @@ public class AddItemsToCollectionHandler : .Filter(v => v == true) .MapT(_ => Unit.Default) .Map(v => v.ToValidation("Episode does not exist")); + + private static async Task> ValidateMediaItems( + TvContext dbContext, + AddItemsToCollection request, + CancellationToken cancellationToken) + { + List ids = GetRequestedMediaItemIds(request).Distinct().ToList(); + int existingCount = await dbContext.MediaItems + .CountAsync(mi => ids.Contains(mi.Id), cancellationToken); + + return existingCount == ids.Count + ? Unit.Default + : BaseError.New("Media item does not exist"); + } + + private static IEnumerable GetRequestedMediaItemIds(AddItemsToCollection request) => + request.MovieIds + .Append(request.ShowIds) + .Append(request.SeasonIds) + .Append(request.EpisodeIds) + .Append(request.ArtistIds) + .Append(request.MusicVideoIds) + .Append(request.OtherVideoIds) + .Append(request.SongIds) + .Append(request.ImageIds) + .Append(request.RemoteStreamIds); } diff --git a/ErsatzTV.Application/MediaCollections/Commands/DeleteCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/DeleteCollectionHandler.cs index c18a00c11..4d59b6eac 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/DeleteCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/DeleteCollectionHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -23,8 +24,11 @@ public class DeleteCollectionHandler : IRequestHandler validation = await CollectionMustExist(dbContext, request, cancellationToken); - return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken)); + Option maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken); + return await maybeCollection.Match( + Some: collection => DoDeletion(dbContext, collection, cancellationToken).Map(Right), + None: () => Task.FromResult>( + new NotFoundError($"Collection {request.CollectionId} does not exist."))); } private async Task DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken) @@ -35,11 +39,11 @@ public class DeleteCollectionHandler : IRequestHandler> CollectionMustExist( + private static Task> CollectionMustExist( TvContext dbContext, DeleteCollection request, CancellationToken cancellationToken) => dbContext.Collections .SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken) - .Map(o => o.ToValidation($"Collection {request.CollectionId} does not exist.")); + .Map(identity); } diff --git a/ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs index 00dd35336..7fdea22d6 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Data; @@ -29,11 +30,14 @@ public class DeleteSmartCollectionHandler : IRequestHandler validation = await SmartCollectionMustExist( + Option maybeSmartCollection = await SmartCollectionMustExist( dbContext, request, cancellationToken); - return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken)); + return await maybeSmartCollection.Match( + Some: smartCollection => DoDeletion(dbContext, smartCollection, cancellationToken).Map(Right), + None: () => Task.FromResult>( + new NotFoundError($"SmartCollection {request.SmartCollectionId} does not exist."))); } private async Task DoDeletion( @@ -48,11 +52,11 @@ public class DeleteSmartCollectionHandler : IRequestHandler> SmartCollectionMustExist( + private static Task> SmartCollectionMustExist( TvContext dbContext, DeleteSmartCollection request, CancellationToken cancellationToken) => dbContext.SmartCollections .SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId, cancellationToken) - .Map(o => o.ToValidation($"SmartCollection {request.SmartCollectionId} does not exist.")); + .Map(identity); } diff --git a/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs index 317fde28a..58e267ae6 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs @@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Search; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; @@ -35,20 +36,29 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c, cancellationToken)); + Option maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken); + return await maybeCollection.Match( + Some: collection => ApplyRemoveItemsRequest(dbContext, request, collection, cancellationToken), + None: () => Task.FromResult>( + new NotFoundError($"Collection {request.MediaCollectionId} does not exist."))); } - private async Task ApplyRemoveItemsRequest( + private async Task> ApplyRemoveItemsRequest( TvContext dbContext, RemoveItemsFromCollection request, Collection collection, CancellationToken cancellationToken) { + List requestedIds = request.MediaItemIds.Distinct().ToList(); var itemsToRemove = collection.MediaItems - .Filter(m => request.MediaItemIds.Contains(m.Id)) + .Filter(m => requestedIds.Contains(m.Id)) .ToList(); + if (itemsToRemove.Count != requestedIds.Count) + { + return new NotFoundError("Collection item does not exist."); + } + itemsToRemove.ForEach(m => collection.MediaItems.Remove(m)); if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0) @@ -67,18 +77,12 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler> Validate( - TvContext dbContext, - RemoveItemsFromCollection request, - CancellationToken cancellationToken) => - CollectionMustExist(dbContext, request, cancellationToken); - - private static Task> CollectionMustExist( + private static Task> CollectionMustExist( TvContext dbContext, RemoveItemsFromCollection request, CancellationToken cancellationToken) => dbContext.Collections .Include(c => c.MediaItems) .SelectOneAsync(c => c.Id, c => c.Id == request.MediaCollectionId, cancellationToken) - .Map(o => o.ToValidation("Collection does not exist.")); + .Map(identity); } diff --git a/ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionHandler.cs index bc5c53554..15010dbe6 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/UpdateCollectionHandler.cs @@ -2,6 +2,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Scheduling; @@ -35,8 +36,15 @@ public class UpdateCollectionHandler : IRequestHandler validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); + Option maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken); + return await maybeCollection.Match( + Some: async collection => + { + Validation validation = await Validate(dbContext, request, collection); + return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); + }, + None: () => Task.FromResult>( + new NotFoundError($"Collection {request.CollectionId} does not exist."))); } private async Task ApplyUpdateRequest( @@ -69,17 +77,16 @@ public class UpdateCollectionHandler : IRequestHandler> Validate( TvContext dbContext, UpdateCollection request, - CancellationToken cancellationToken) => - (await CollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request)) - .Apply((collectionToUpdate, _) => collectionToUpdate); + Collection collection) => + (await ValidateName(dbContext, request)).Map(_ => collection); - private static Task> CollectionMustExist( + private static Task> CollectionMustExist( TvContext dbContext, UpdateCollection updateCollection, CancellationToken cancellationToken) => dbContext.Collections .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken) - .Map(o => o.ToValidation("Collection does not exist.")); + .Map(identity); private static async Task> ValidateName( TvContext dbContext, diff --git a/ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs index a1e8e4609..e1cfbdc9d 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs @@ -2,6 +2,7 @@ using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Scheduling; @@ -41,8 +42,18 @@ public class CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); + Option maybeSmartCollection = await SmartCollectionMustExist( + dbContext, + request, + cancellationToken); + return await maybeSmartCollection.Match( + Some: async smartCollection => + { + Validation validation = await Validate(dbContext, request, smartCollection); + return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); + }, + None: () => Task.FromResult>( + new NotFoundError($"SmartCollection {request.Id} does not exist."))); } private async Task ApplyUpdateRequest( @@ -73,16 +84,16 @@ public class private static Task> Validate( TvContext dbContext, UpdateSmartCollection request, - CancellationToken cancellationToken) => ValidateName(dbContext, request) - .BindT(_ => SmartCollectionMustExist(dbContext, request, cancellationToken)); + SmartCollection smartCollection) => ValidateName(dbContext, request) + .MapT(_ => smartCollection); - private static Task> SmartCollectionMustExist( + private static Task> SmartCollectionMustExist( TvContext dbContext, UpdateSmartCollection updateCollection, CancellationToken cancellationToken) => dbContext.SmartCollections .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken) - .Map(o => o.ToValidation("SmartCollection does not exist.")); + .Map(identity); private static async Task> ValidateName( TvContext dbContext, diff --git a/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs b/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs index 4632a2f75..86153863c 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs @@ -571,7 +571,8 @@ public class CustomStreamSelectorTests } [Test] - public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail() + [SetCulture("en-US")] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail_SundayFirstCulture() { const string YAML = """ @@ -608,7 +609,8 @@ public class CustomStreamSelectorTests } [Test] - public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match() + [SetCulture("en-US")] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match_SundayFirstCulture() { const string YAML = """ @@ -651,7 +653,8 @@ public class CustomStreamSelectorTests } [Test] - public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before() + [SetCulture("en-US")] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before_SundayFirstCulture() { // saturday from 9pm-11pm const string YAML = @@ -689,7 +692,8 @@ public class CustomStreamSelectorTests } [Test] - public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After() + [SetCulture("en-US")] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After_SundayFirstCulture() { // saturday from 9pm-11pm const string YAML = @@ -727,7 +731,8 @@ public class CustomStreamSelectorTests } [Test] - public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day() + [SetCulture("en-US")] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_SundayFirstCulture() { // saturday from 9pm-11pm const string YAML = @@ -765,7 +770,8 @@ public class CustomStreamSelectorTests } [Test] - public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match() + [SetCulture("en-US")] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_SundayFirstCulture() { // saturday from 9pm-11pm const string YAML = @@ -810,7 +816,7 @@ public class CustomStreamSelectorTests [Test] [SetCulture("fr-FR")] - public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France() + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_MondayFirstCulture() { // saturday from 9pm-11pm const string YAML = @@ -853,6 +859,45 @@ public class CustomStreamSelectorTests } } + [Test] + [SetCulture("fr-FR")] + public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_MondayFirstCulture() + { + // In a Monday-first culture, saturday is day 5 and sunday is day 6. + const string YAML = + """ + --- + items: + - audio_language: ["ja"] + subtitle_language: ["eng"] + content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)" + + - audio_language: ["eng"] + disable_subtitles: true + """; + + var fileSystem = new MockFileSystem(); + fileSystem.Initialize() + .WithFile(TestFileName).Which(f => f.HasStringContent(YAML)); + var streamSelector = new CustomStreamSelector(fileSystem, _logger); + + var tz = TZConvert.GetTimeZoneInfo("America/Chicago"); + var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm + var dto = new DateTimeOffset(start, tz.GetUtcOffset(start)); + + StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles); + + result.AudioStream.IsSome.ShouldBeTrue(); + + foreach (MediaStream audioStream in result.AudioStream) + { + audioStream.Index.ShouldBe(1); + audioStream.Language.ShouldBe("eng"); + } + + result.Subtitle.IsSome.ShouldBeFalse(); + } + [Test] public async Task Should_Ignore_Blocked_Audio_Title() { diff --git a/ErsatzTV.Tests/Application/MediaCollections/CollectionHandlerTests.cs b/ErsatzTV.Tests/Application/MediaCollections/CollectionHandlerTests.cs new file mode 100644 index 000000000..5aa19cce8 --- /dev/null +++ b/ErsatzTV.Tests/Application/MediaCollections/CollectionHandlerTests.cs @@ -0,0 +1,149 @@ +using ErsatzTV.Application; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.Search; +using ErsatzTV.Core; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using ErsatzTV.Tests.Support; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.MediaCollections; + +[TestFixture] +public class CollectionHandlerTests : MediaCollectionHandlerTestBase +{ + [Test] + public async Task Update_Should_Return_NotFoundError_When_Collection_Missing() + { + var handler = new UpdateCollectionHandler( + Db.Factory, + Substitute.For(), + Worker, + SearchTargets); + + Either result = + await handler.Handle(new UpdateCollection(999, "Updated"), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_NotFoundError_When_Collection_Missing() + { + var handler = new DeleteCollectionHandler(Db.Factory, SearchTargets); + + Either result = + await handler.Handle(new DeleteCollection(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task AddItems_Should_Return_NotFoundError_When_Collection_Missing() + { + IMovieRepository movieRepository = Substitute.For(); + movieRepository.AllMoviesExist(Arg.Any>()).Returns(true); + ITelevisionRepository televisionRepository = Substitute.For(); + televisionRepository.AllShowsExist(Arg.Any>()).Returns(true); + televisionRepository.AllSeasonsExist(Arg.Any>()).Returns(true); + televisionRepository.AllEpisodesExist(Arg.Any>()).Returns(true); + + var handler = new AddItemsToCollectionHandler( + Db.Factory, + Substitute.For(), + movieRepository, + televisionRepository, + Worker, + System.Threading.Channels.Channel.CreateUnbounded().Writer); + + Either result = + await handler.Handle(MakeAddItems(collectionId: 999, movieIds: [1]), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task AddItems_Should_Return_ValidationError_When_Generic_MediaItem_Missing() + { + await SeedCollection(1); + IMovieRepository movieRepository = Substitute.For(); + movieRepository.AllMoviesExist(Arg.Any>()).Returns(true); + ITelevisionRepository televisionRepository = Substitute.For(); + televisionRepository.AllShowsExist(Arg.Any>()).Returns(true); + televisionRepository.AllSeasonsExist(Arg.Any>()).Returns(true); + televisionRepository.AllEpisodesExist(Arg.Any>()).Returns(true); + + var handler = new AddItemsToCollectionHandler( + Db.Factory, + Substitute.For(), + movieRepository, + televisionRepository, + Worker, + System.Threading.Channels.Channel.CreateUnbounded().Writer); + + Either result = + await handler.Handle( + new AddItemsToCollection(1, [], [], [], [], [], [], [], [], [], [999]), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("Media item does not exist"); + } + + [Test] + public async Task RemoveItems_Should_Return_NotFoundError_When_Collection_Missing() + { + var handler = new RemoveItemsFromCollectionHandler( + Db.Factory, + Substitute.For(), + Worker, + System.Threading.Channels.Channel.CreateUnbounded().Writer); + + Either result = + await handler.Handle( + new RemoveItemsFromCollection(999) { MediaItemIds = [1] }, + CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task RemoveItems_Should_Return_NotFoundError_When_Association_Missing() + { + await SeedCollection(1); + var handler = new RemoveItemsFromCollectionHandler( + Db.Factory, + Substitute.For(), + Worker, + System.Threading.Channels.Channel.CreateUnbounded().Writer); + + Either result = + await handler.Handle( + new RemoveItemsFromCollection(1) { MediaItemIds = [999] }, + CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + private static AddItemsToCollection MakeAddItems(int collectionId, List? movieIds = null) => + new( + collectionId, + movieIds ?? [], + [], + [], + [], + [], + [], + [], + [], + [], + []); + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); +} diff --git a/ErsatzTV.Tests/Application/MediaCollections/SmartCollectionHandlerTests.cs b/ErsatzTV.Tests/Application/MediaCollections/SmartCollectionHandlerTests.cs new file mode 100644 index 000000000..bac46e07d --- /dev/null +++ b/ErsatzTV.Tests/Application/MediaCollections/SmartCollectionHandlerTests.cs @@ -0,0 +1,50 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Core; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Search; +using LanguageExt; +using ErsatzTV.Tests.Support; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.MediaCollections; + +[TestFixture] +public class SmartCollectionHandlerTests : MediaCollectionHandlerTestBase +{ + [Test] + public async Task Update_Should_Return_NotFoundError_When_SmartCollection_Missing() + { + var handler = new UpdateSmartCollectionHandler( + Db.Factory, + Substitute.For(), + Worker, + SearchTargets, + Substitute.For()); + + Either result = + await handler.Handle(new UpdateSmartCollection(999, "Updated", "tag:updated"), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_NotFoundError_When_SmartCollection_Missing() + { + var handler = new DeleteSmartCollectionHandler( + Db.Factory, + SearchTargets, + Substitute.For()); + + Either result = + await handler.Handle(new DeleteSmartCollection(999), CancellationToken.None); + + LeftOf(result).ShouldBeOfType(); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); +} diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs new file mode 100644 index 000000000..3f4776b37 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/CollectionControllerSecurityTests.cs @@ -0,0 +1,56 @@ +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; + +[TestFixture] +public class CollectionControllerSecurityTests +{ + [TestCase(typeof(CollectionController))] + [TestCase(typeof(SmartCollectionController))] + public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType) + { + ServiceFilterAttribute? filter = controllerType + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + filter.ShouldNotBeNull($"{controllerType.Name} must carry ApiKeyAuthorizationFilter at the class level"); + } + + [TestCase(typeof(CollectionController))] + [TestCase(typeof(SmartCollectionController))] + public void Every_Mutating_Action_Should_Be_Protected(Type controllerType) + { + MethodInfo[] actions = controllerType + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + bool controllerHasFilter = controllerType + .GetCustomAttributes(inherit: true) + .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + foreach (MethodInfo action in actions) + { + bool isMutating = action + .GetCustomAttributes(inherit: true) + .SelectMany(a => a.HttpMethods) + .Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE"); + + if (!isMutating) + { + continue; + } + + bool actionHasFilter = action + .GetCustomAttributes(inherit: true) + .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + (controllerHasFilter || actionHasFilter) + .ShouldBeTrue($"Mutating action {controllerType.Name}.{action.Name} is not protected"); + } + } +} diff --git a/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs new file mode 100644 index 000000000..566818006 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/CollectionControllerTests.cs @@ -0,0 +1,223 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class CollectionControllerTests +{ + private CollectionController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new CollectionController(_mediator); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + MediaCollectionViewModel vm = MakeVm(5, "Movies"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(vm)); + + IActionResult result = await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/collections/5"); + created.Value.ShouldBe(vm); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create(new CreateCollectionRequest(string.Empty), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Create_Should_Map_Request_To_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(MakeVm(5, "Movies"))); + + await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => c.Name == "Movies"), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_200_And_Map_Route_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + MediaCollectionViewModel vm = MakeVm(7, "Updated"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Update( + 7, + new UpdateCollectionRequest("Updated", true), + CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + await _mediator.Received(1).Send( + Arg.Is(c => + c.CollectionId == 7 && + c.Name == "Updated" && + c.UseCustomPlaybackOrder.Match(Some: v => v, None: () => false)), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Update( + 99, + new UpdateCollectionRequest("Missing", false), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(3, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task AddItems_Should_Return_204_And_Map_Route_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.AddItems( + 3, + new AddItemsToCollectionRequest([10], null, null, null, null, null, null, null, null, null), + CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => c.CollectionId == 3 && c.MovieIds.SequenceEqual(new[] { 10 })), + Arg.Any()); + } + + [Test] + public async Task AddItems_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.AddItems( + 3, + new AddItemsToCollectionRequest(null, null, null, null, null, null, null, null, null, [999]), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task RemoveItem_Should_Return_204_And_Map_Route_Ids() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None); + + result.ShouldBeOfType(); + await _mediator.Received(1).Send( + Arg.Is(c => + c.MediaCollectionId == 3 && c.MediaItemIds.SequenceEqual(new[] { 10 })), + Arg.Any()); + } + + [Test] + public async Task RemoveItem_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetAll_Should_Return_Collections() + { + List vms = [MakeVm(1, "Movies"), MakeVm(2, "Shows")]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(vms); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(vms); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + MediaCollectionViewModel vm = MakeVm(4, "Movies"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static MediaCollectionViewModel MakeVm(int id, string name) => + new(CollectionType.Collection, id, name, false, MediaItemState.Normal); +} diff --git a/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs new file mode 100644 index 000000000..483337113 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/SmartCollectionControllerTests.cs @@ -0,0 +1,203 @@ +using System.Reflection; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Api.SmartCollections; +using ErsatzTV.Core.Errors; +using ErsatzTV.Filters; +using LanguageExt; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using static LanguageExt.Prelude; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class SmartCollectionControllerTests +{ + private SmartCollectionController _controller = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _controller = new SmartCollectionController(_mediator); + } + + [Test] + public void Controller_Should_Expose_Idiomatic_Rest_Routes() + { + ShouldHaveActionRoute(nameof(SmartCollectionController.GetAll), "GET", "/api/smart-collections"); + ShouldHaveActionRoute(nameof(SmartCollectionController.GetById), "GET", "/api/smart-collections/{id:int}"); + ShouldHaveActionRoute(nameof(SmartCollectionController.Create), "POST", "/api/smart-collections"); + ShouldHaveActionRoute(nameof(SmartCollectionController.Update), "PUT", "/api/smart-collections/{id:int}"); + ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/smart-collections/{id:int}"); + } + + [Test] + public void Controller_Should_Apply_ApiKeyAuthorizationFilter() + { + ServiceFilterAttribute? filter = typeof(SmartCollectionController) + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + filter.ShouldNotBeNull("SmartCollectionController must carry ApiKeyAuthorizationFilter at the class level"); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + var vm = new SmartCollectionViewModel(7, "Kids", "tag:family"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(vm)); + + IActionResult result = await _controller.Create( + new CreateSmartCollectionRequest("Kids", "tag:family"), + CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/smart-collections/7"); + created.Value.ShouldBe(vm); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create( + new CreateSmartCollectionRequest(string.Empty, string.Empty), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Create_Should_Map_Request_To_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right( + new SmartCollectionViewModel(7, "Kids", "tag:family"))); + + await _controller.Create(new CreateSmartCollectionRequest("Kids", "tag:family"), CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => c.Name == "Kids" && c.Query == "tag:family"), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_200_And_Map_Route_Id() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new UpdateSmartCollectionResult(8))); + var vm = new SmartCollectionViewModel(8, "Updated", "tag:updated"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Update( + 8, + new UpdateSmartCollectionRequest("Updated", "tag:updated"), + CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + await _mediator.Received(1).Send( + Arg.Is(c => + c.Id == 8 && c.Name == "Updated" && c.Query == "tag:updated"), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Update( + 99, + new UpdateSmartCollectionRequest("Missing", "tag:missing"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(9, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetAll_Should_Return_SmartCollections() + { + List vms = + [ + new SmartCollectionResponseModel(1, "Kids", "tag:kids"), + new SmartCollectionResponseModel(2, "News", "tag:news") + ]; + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(vms); + + List result = await _controller.GetAll(CancellationToken.None); + + result.ShouldBe(vms); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + var vm = new SmartCollectionViewModel(4, "Kids", "tag:kids"); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route) + { + MethodInfo action = typeof(SmartCollectionController).GetMethod(actionName) + ?? throw new AssertionException($"Missing action {actionName}"); + + HttpMethodAttribute attribute = action.GetCustomAttributes(inherit: true).Single(); + attribute.HttpMethods.ShouldContain(httpMethod); + attribute.Template.ShouldBe(route); + } +} diff --git a/ErsatzTV.Tests/Integration/CollectionLifecycleIntegrationTests.cs b/ErsatzTV.Tests/Integration/CollectionLifecycleIntegrationTests.cs new file mode 100644 index 000000000..2f8beb48b --- /dev/null +++ b/ErsatzTV.Tests/Integration/CollectionLifecycleIntegrationTests.cs @@ -0,0 +1,131 @@ +using ErsatzTV.Application; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.Search; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Integration; + +/// +/// End-to-end create -> read -> add item -> remove item -> delete against the in-memory SQLite harness, +/// exercising the real EF Core handlers and collection item persistence. +/// +[TestFixture] +public class CollectionLifecycleIntegrationTests : MediaCollectionHandlerTestBase +{ + [Test] + public async Task Create_Read_AddItem_RemoveItem_Delete() + { + int movieId = await SeedMovie(); + IMediaCollectionRepository mediaCollectionRepository = Substitute.For(); + mediaCollectionRepository.PlayoutIdsUsingCollection(Arg.Any()).Returns([]); + IMovieRepository movieRepository = Substitute.For(); + movieRepository.AllMoviesExist(Arg.Any>()).Returns(true); + ITelevisionRepository televisionRepository = Substitute.For(); + televisionRepository.AllShowsExist(Arg.Any>()).Returns(true); + televisionRepository.AllSeasonsExist(Arg.Any>()).Returns(true); + televisionRepository.AllEpisodesExist(Arg.Any>()).Returns(true); + + var createHandler = new CreateCollectionHandler(Db.Factory, SearchTargets); + Either created = + await createHandler.Handle(new CreateCollection("Integration"), CancellationToken.None); + + int collectionId = created.Match(Left: _ => throw new AssertionException("create failed"), Right: r => r.Id); + collectionId.ShouldBeGreaterThan(0); + + var getHandler = new GetCollectionByIdHandler(Db.Factory); + Option afterCreate = + await getHandler.Handle(new GetCollectionById(collectionId), CancellationToken.None); + afterCreate.IsSome.ShouldBeTrue(); + afterCreate.Match( + Some: vm => vm.Name.ShouldBe("Integration"), + None: () => throw new AssertionException("expected collection to exist")); + + var addHandler = new AddItemsToCollectionHandler( + Db.Factory, + mediaCollectionRepository, + movieRepository, + televisionRepository, + Worker, + System.Threading.Channels.Channel.CreateUnbounded().Writer); + Either added = + await addHandler.Handle(MakeAddItems(collectionId, movieId), CancellationToken.None); + added.IsRight.ShouldBeTrue(); + + await using (TvContext context = Db.CreateContext()) + { + bool itemExists = await context.CollectionItems + .AnyAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == movieId); + itemExists.ShouldBeTrue(); + } + + var removeHandler = new RemoveItemsFromCollectionHandler( + Db.Factory, + mediaCollectionRepository, + Worker, + System.Threading.Channels.Channel.CreateUnbounded().Writer); + Either removed = + await removeHandler.Handle( + new RemoveItemsFromCollection(collectionId) { MediaItemIds = [movieId] }, + CancellationToken.None); + removed.IsRight.ShouldBeTrue(); + + await using (TvContext context = Db.CreateContext()) + { + bool itemExists = await context.CollectionItems + .AnyAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == movieId); + itemExists.ShouldBeFalse(); + } + + var deleteHandler = new DeleteCollectionHandler(Db.Factory, SearchTargets); + Either deleted = + await deleteHandler.Handle(new DeleteCollection(collectionId), CancellationToken.None); + deleted.IsRight.ShouldBeTrue(); + + Option afterDelete = + await getHandler.Handle(new GetCollectionById(collectionId), CancellationToken.None); + afterDelete.IsNone.ShouldBeTrue(); + } + + private async Task SeedMovie() + { + await using TvContext context = Db.CreateContext(); + var mediaSource = new LocalMediaSource(); + var library = new LocalLibrary + { + Name = "Movies", + MediaKind = LibraryMediaKind.Movies, + MediaSource = mediaSource, + Paths = [] + }; + var libraryPath = new LibraryPath + { + Path = "/media/movies", + Library = library, + LibraryFolders = [], + MediaItems = [] + }; + var movie = new Movie + { + LibraryPath = libraryPath, + MovieMetadata = [], + MediaVersions = [], + Collections = [] + }; + context.Movies.Add(movie); + await context.SaveChangesAsync(); + return movie.Id; + } + + private static AddItemsToCollection MakeAddItems(int collectionId, int movieId) => + new(collectionId, [movieId], [], [], [], [], [], [], [], [], []); +} diff --git a/ErsatzTV.Tests/Support/MediaCollectionHandlerTestBase.cs b/ErsatzTV.Tests/Support/MediaCollectionHandlerTestBase.cs new file mode 100644 index 000000000..9fe4cc0ca --- /dev/null +++ b/ErsatzTV.Tests/Support/MediaCollectionHandlerTestBase.cs @@ -0,0 +1,51 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Data; +using NSubstitute; +using NUnit.Framework; + +namespace ErsatzTV.Tests.Support; + +public abstract class MediaCollectionHandlerTestBase +{ + protected InMemoryTvContext Db = null!; + protected ChannelWriter Worker = null!; + protected ISearchTargets SearchTargets = null!; + + [SetUp] + public async Task BaseSetUp() + { + Db = await InMemoryTvContext.CreateAsync(); + Worker = System.Threading.Channels.Channel.CreateUnbounded().Writer; + SearchTargets = Substitute.For(); + } + + [TearDown] + public async Task BaseTearDown() => await Db.DisposeAsync(); + + protected async Task SeedCollection(int id, string name = "Collection") + { + await using TvContext context = Db.CreateContext(); + context.Collections.Add(new Collection + { + Id = id, + Name = name, + MediaItems = [] + }); + await context.SaveChangesAsync(); + } + + protected async Task SeedSmartCollection(int id, string name = "Smart", string query = "tag:family") + { + await using TvContext context = Db.CreateContext(); + context.SmartCollections.Add(new SmartCollection + { + Id = id, + Name = name, + Query = query + }); + await context.SaveChangesAsync(); + } +} diff --git a/ErsatzTV/Controllers/Api/CollectionController.cs b/ErsatzTV/Controllers/Api/CollectionController.cs new file mode 100644 index 000000000..a426970fa --- /dev/null +++ b/ErsatzTV/Controllers/Api/CollectionController.cs @@ -0,0 +1,105 @@ +using System.ComponentModel.DataAnnotations; +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Extensions; +using ErsatzTV.Filters; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] +public class CollectionController(IMediator mediator) : ControllerBase +{ + [HttpGet("/api/collections")] + [Tags("Collections")] + [EndpointSummary("Get all collections")] + [EndpointGroupName("general")] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllCollections(), cancellationToken); + + [HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")] + [Tags("Collections")] + [EndpointSummary("Get a collection by id")] + [EndpointGroupName("general")] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetCollectionById(id), cancellationToken); + return result.ToGetResult(); + } + + [HttpPost("/api/collections")] + [Tags("Collections")] + [EndpointSummary("Create a collection")] + [EndpointGroupName("general")] + public async Task Create( + [Required] [FromBody] CreateCollectionRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", vm => vm); + } + + [HttpPut("/api/collections/{id:int}")] + [Tags("Collections")] + [EndpointSummary("Update a collection")] + [EndpointGroupName("general")] + public async Task Update( + int id, + [Required] [FromBody] UpdateCollectionRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(id), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option collection = + await mediator.Send(new GetCollectionById(id), cancellationToken); + return collection.Match( + Some: vm => (IActionResult)new OkObjectResult(vm), + None: () => new NotFoundResult()); + }); + } + + [HttpDelete("/api/collections/{id:int}")] + [Tags("Collections")] + [EndpointSummary("Delete a collection")] + [EndpointGroupName("general")] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteCollection(id), cancellationToken); + return result.ToDeletedResult(); + } + + [HttpPost("/api/collections/{id:int}/items")] + [Tags("Collections")] + [EndpointSummary("Add items to a collection")] + [EndpointGroupName("general")] + public async Task AddItems( + int id, + [Required] [FromBody] AddItemsToCollectionRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(id), cancellationToken); + return result.ToDeletedResult(); + } + + [HttpDelete("/api/collections/{id:int}/items/{mediaItemId:int}")] + [Tags("Collections")] + [EndpointSummary("Remove an item from a collection")] + [EndpointGroupName("general")] + public async Task RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken) + { + Either result = await mediator.Send( + new RemoveItemsFromCollection(id) + { + MediaItemIds = [mediaItemId] + }, + cancellationToken); + return result.ToDeletedResult(); + } +} diff --git a/ErsatzTV/Controllers/Api/Requests/AddItemsToCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/AddItemsToCollectionRequest.cs new file mode 100644 index 000000000..2bd966df7 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/AddItemsToCollectionRequest.cs @@ -0,0 +1,30 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record AddItemsToCollectionRequest( + List MovieIds, + List ShowIds, + List SeasonIds, + List EpisodeIds, + List ArtistIds, + List MusicVideoIds, + List OtherVideoIds, + List SongIds, + List ImageIds, + List RemoteStreamIds) +{ + public AddItemsToCollection ToCommand(int collectionId) => + new( + collectionId, + MovieIds ?? [], + ShowIds ?? [], + SeasonIds ?? [], + EpisodeIds ?? [], + ArtistIds ?? [], + MusicVideoIds ?? [], + OtherVideoIds ?? [], + SongIds ?? [], + ImageIds ?? [], + RemoteStreamIds ?? []); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateCollectionRequest.cs new file mode 100644 index 000000000..e2cb814ff --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateCollectionRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateCollectionRequest(string Name) +{ + public CreateCollection ToCommand() => new(Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/CreateSmartCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateSmartCollectionRequest.cs new file mode 100644 index 000000000..01bb6c90c --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateSmartCollectionRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateSmartCollectionRequest(string Name, string Query) +{ + public CreateSmartCollection ToCommand() => new(Query, Name); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateCollectionRequest.cs new file mode 100644 index 000000000..d3433a0ae --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateCollectionRequest.cs @@ -0,0 +1,12 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateCollectionRequest(string Name, bool? UseCustomPlaybackOrder) +{ + public UpdateCollection ToCommand(int id) => + new(id, Name) + { + UseCustomPlaybackOrder = Optional(UseCustomPlaybackOrder) + }; +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateSmartCollectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateSmartCollectionRequest.cs new file mode 100644 index 000000000..9e1c506f3 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateSmartCollectionRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record UpdateSmartCollectionRequest(string Name, string Query) +{ + public UpdateSmartCollection ToCommand(int id) => new(id, Name, Query); +} diff --git a/ErsatzTV/Controllers/Api/SmartCollectionController.cs b/ErsatzTV/Controllers/Api/SmartCollectionController.cs index 9c12269f6..13813624f 100644 --- a/ErsatzTV/Controllers/Api/SmartCollectionController.cs +++ b/ErsatzTV/Controllers/Api/SmartCollectionController.cs @@ -1,43 +1,80 @@ using System.ComponentModel.DataAnnotations; using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Controllers.Api.Requests; using ErsatzTV.Core; using ErsatzTV.Core.Api.SmartCollections; +using ErsatzTV.Extensions; +using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] -[EndpointGroupName("general")] +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class SmartCollectionController(IMediator mediator) : ControllerBase { - [HttpGet("/api/collections/smart", Name="GetSmartCollections")] - public async Task> GetAll() => - await mediator.Send(new GetAllSmartCollectionsForApi()); + [HttpGet("/api/smart-collections")] + [Tags("Smart Collections")] + [EndpointSummary("Get all smart collections")] + [EndpointGroupName("general")] + public async Task> GetAll(CancellationToken cancellationToken) => + await mediator.Send(new GetAllSmartCollectionsForApi(), cancellationToken); - [HttpPost("/api/collections/smart/new", Name = "CreateSmartCollection")] - public async Task AddOne( - [Required] [FromBody] - CreateSmartCollection request) + [HttpGet("/api/smart-collections/{id:int}", Name = "GetSmartCollectionById")] + [Tags("Smart Collections")] + [EndpointSummary("Get a smart collection by id")] + [EndpointGroupName("general")] + public async Task GetById(int id, CancellationToken cancellationToken) { - Either result = - await mediator.Send(request).MapT(r => new CreateSmartCollectionResult(r.Id)); - return result.Match(Ok, error => Problem(error.ToString())); + Option result = + await mediator.Send(new GetSmartCollectionById(id), cancellationToken); + return result.ToGetResult(); } - [HttpPut("/api/collections/smart/update", Name="UpdateSmartCollection")] - public async Task UpdateOne( - [Required] [FromBody] - UpdateSmartCollection request) + [HttpPost("/api/smart-collections")] + [Tags("Smart Collections")] + [EndpointSummary("Create a smart collection")] + [EndpointGroupName("general")] + public async Task Create( + [Required] [FromBody] CreateSmartCollectionRequest request, + CancellationToken cancellationToken) { - Either result = await mediator.Send(request); - return result.Match(Ok, error => Problem(error.ToString())); + Either result = + await mediator.Send(request.ToCommand(), cancellationToken); + return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", vm => vm); } - [HttpDelete("/api/collections/smart/delete/{id:int}", Name="DeleteSmartCollection")] - public async Task DeleteSmartCollection(int id) + [HttpPut("/api/smart-collections/{id:int}")] + [Tags("Smart Collections")] + [EndpointSummary("Update a smart collection")] + [EndpointGroupName("general")] + public async Task Update( + int id, + [Required] [FromBody] UpdateSmartCollectionRequest request, + CancellationToken cancellationToken) { - Either result = await mediator.Send(new DeleteSmartCollection(id)); - return result.Match(_ => Ok(), error => Problem(error.ToString())); + Either result = + await mediator.Send(request.ToCommand(id), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + Option smartCollection = + await mediator.Send(new GetSmartCollectionById(id), cancellationToken); + return smartCollection.Match( + Some: vm => (IActionResult)new OkObjectResult(vm), + None: () => new NotFoundResult()); + }); + } + + [HttpDelete("/api/smart-collections/{id:int}")] + [Tags("Smart Collections")] + [EndpointSummary("Delete a smart collection")] + [EndpointGroupName("general")] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken); + return result.ToDeletedResult(); } }