@@ -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<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: async collection =>
|
||||
{
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddItemsRequest(
|
||||
@@ -89,22 +97,23 @@ public class AddItemsToCollectionHandler :
|
||||
private async Task<Validation<BaseError, Collection>> 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<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
private static Task<Option<Collection>> 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<BaseError>("Collection does not exist."));
|
||||
.Map(identity);
|
||||
|
||||
private Task<Validation<BaseError, Unit>> 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<BaseError>("Episode does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
|
||||
TvContext dbContext,
|
||||
AddItemsToCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> 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<int> 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);
|
||||
}
|
||||
|
||||
@@ -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<DeleteCollection, Either<
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: collection => DoDeletion(dbContext, collection, cancellationToken).Map(Right<BaseError, Unit>),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken)
|
||||
@@ -35,11 +39,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Collections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"Collection {request.CollectionId} does not exist."));
|
||||
.Map(identity);
|
||||
}
|
||||
|
||||
@@ -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<DeleteSmartCollectio
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(
|
||||
Option<SmartCollection> 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<BaseError, Unit>),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"SmartCollection {request.SmartCollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(
|
||||
@@ -48,11 +52,11 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
||||
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteSmartCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.SmartCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
|
||||
.Map(identity);
|
||||
}
|
||||
|
||||
+16
-12
@@ -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<RemoveItemsFromC
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c, cancellationToken));
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: collection => ApplyRemoveItemsRequest(dbContext, request, collection, cancellationToken),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.MediaCollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyRemoveItemsRequest(
|
||||
private async Task<Either<BaseError, Unit>> ApplyRemoveItemsRequest(
|
||||
TvContext dbContext,
|
||||
RemoveItemsFromCollection request,
|
||||
Collection collection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> 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<RemoveItemsFromC
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
RemoveItemsFromCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
CollectionMustExist(dbContext, request, cancellationToken);
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
private static Task<Option<Collection>> 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<BaseError>("Collection does not exist."));
|
||||
.Map(identity);
|
||||
}
|
||||
|
||||
@@ -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<UpdateCollection, Either<
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: async collection =>
|
||||
{
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
@@ -69,17 +77,16 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
|
||||
private static async Task<Validation<BaseError, Collection>> 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<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateCollection updateCollection,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Collections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
.Map(identity);
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -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<BaseError, SmartCollection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
return await maybeSmartCollection.Match(
|
||||
Some: async smartCollection =>
|
||||
{
|
||||
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, smartCollection);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, UpdateSmartCollectionResult>>(
|
||||
new NotFoundError($"SmartCollection {request.Id} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
|
||||
@@ -73,16 +84,16 @@ public class
|
||||
private static Task<Validation<BaseError, SmartCollection>> 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<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
||||
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateSmartCollection updateCollection,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.SmartCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
|
||||
.Map(identity);
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<IMediaCollectionRepository>(),
|
||||
Worker,
|
||||
SearchTargets);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new UpdateCollection(999, "Updated"), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_NotFoundError_When_Collection_Missing()
|
||||
{
|
||||
var handler = new DeleteCollectionHandler(Db.Factory, SearchTargets);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new DeleteCollection(999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_NotFoundError_When_Collection_Missing()
|
||||
{
|
||||
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
||||
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
|
||||
var handler = new AddItemsToCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
movieRepository,
|
||||
televisionRepository,
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(MakeAddItems(collectionId: 999, movieIds: [1]), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_ValidationError_When_Generic_MediaItem_Missing()
|
||||
{
|
||||
await SeedCollection(1);
|
||||
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
||||
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
|
||||
var handler = new AddItemsToCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
movieRepository,
|
||||
televisionRepository,
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(
|
||||
new AddItemsToCollection(1, [], [], [], [], [], [], [], [], [], [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
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<IMediaCollectionRepository>(),
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(
|
||||
new RemoveItemsFromCollection(999) { MediaItemIds = [1] },
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveItems_Should_Return_NotFoundError_When_Association_Missing()
|
||||
{
|
||||
await SeedCollection(1);
|
||||
var handler = new RemoveItemsFromCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(
|
||||
new RemoveItemsFromCollection(1) { MediaItemIds = [999] },
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
private static AddItemsToCollection MakeAddItems(int collectionId, List<int>? movieIds = null) =>
|
||||
new(
|
||||
collectionId,
|
||||
movieIds ?? [],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]);
|
||||
|
||||
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,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<IMediaCollectionRepository>(),
|
||||
Worker,
|
||||
SearchTargets,
|
||||
Substitute.For<ISmartCollectionCache>());
|
||||
|
||||
Either<BaseError, UpdateSmartCollectionResult> result =
|
||||
await handler.Handle(new UpdateSmartCollection(999, "Updated", "tag:updated"), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_NotFoundError_When_SmartCollection_Missing()
|
||||
{
|
||||
var handler = new DeleteSmartCollectionHandler(
|
||||
Db.Factory,
|
||||
SearchTargets,
|
||||
Substitute.For<ISmartCollectionCache>());
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new DeleteSmartCollection(999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
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,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<ServiceFilterAttribute>(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<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 {controllerType.Name}.{action.Name} is not protected");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IMediator>();
|
||||
_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<CreateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MediaCollectionViewModel>(vm));
|
||||
|
||||
IActionResult result = await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
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<CreateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MediaCollectionViewModel>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Create(new CreateCollectionRequest(string.Empty), CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Map_Request_To_Command()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MediaCollectionViewModel>(MakeVm(5, "Movies")));
|
||||
|
||||
await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateCollection>(c => c.Name == "Movies"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_And_Map_Route_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
MediaCollectionViewModel vm = MakeVm(7, "Updated");
|
||||
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.Some(vm));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
7,
|
||||
new UpdateCollectionRequest("Updated", true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateCollection>(c =>
|
||||
c.CollectionId == 7 &&
|
||||
c.Name == "Updated" &&
|
||||
c.UseCustomPlaybackOrder.Match(Some: v => v, None: () => false)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
99,
|
||||
new UpdateCollectionRequest("Missing", false),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteCollection>(), 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<DeleteCollection>(), 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 AddItems_Should_Return_204_And_Map_Route_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<AddItemsToCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.AddItems(
|
||||
3,
|
||||
new AddItemsToCollectionRequest([10], null, null, null, null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<AddItemsToCollection>(c => c.CollectionId == 3 && c.MovieIds.SequenceEqual(new[] { 10 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<AddItemsToCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(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<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveItem_Should_Return_204_And_Map_Route_Ids()
|
||||
{
|
||||
_mediator.Send(Arg.Any<RemoveItemsFromCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<RemoveItemsFromCollection>(c =>
|
||||
c.MediaCollectionId == 3 && c.MediaItemIds.SequenceEqual(new[] { 10 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveItem_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<RemoveItemsFromCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Collections()
|
||||
{
|
||||
List<MediaCollectionViewModel> vms = [MakeVm(1, "Movies"), MakeVm(2, "Shows")];
|
||||
_mediator.Send(Arg.Any<GetAllCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(vms);
|
||||
|
||||
List<MediaCollectionViewModel> 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<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.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<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundResult>();
|
||||
}
|
||||
|
||||
private static MediaCollectionViewModel MakeVm(int id, string name) =>
|
||||
new(CollectionType.Collection, id, name, false, MediaItemState.Normal);
|
||||
}
|
||||
@@ -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<IMediator>();
|
||||
_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<ServiceFilterAttribute>(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<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, SmartCollectionViewModel>(vm));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateSmartCollectionRequest("Kids", "tag:family"),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
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<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, SmartCollectionViewModel>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateSmartCollectionRequest(string.Empty, string.Empty),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Map_Request_To_Command()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, SmartCollectionViewModel>(
|
||||
new SmartCollectionViewModel(7, "Kids", "tag:family")));
|
||||
|
||||
await _controller.Create(new CreateSmartCollectionRequest("Kids", "tag:family"), CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateSmartCollection>(c => c.Name == "Kids" && c.Query == "tag:family"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_And_Map_Route_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, UpdateSmartCollectionResult>(new UpdateSmartCollectionResult(8)));
|
||||
var vm = new SmartCollectionViewModel(8, "Updated", "tag:updated");
|
||||
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<SmartCollectionViewModel>.Some(vm));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
8,
|
||||
new UpdateSmartCollectionRequest("Updated", "tag:updated"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateSmartCollection>(c =>
|
||||
c.Id == 8 && c.Name == "Updated" && c.Query == "tag:updated"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, UpdateSmartCollectionResult>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
99,
|
||||
new UpdateSmartCollectionRequest("Missing", "tag:missing"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_SmartCollections()
|
||||
{
|
||||
List<SmartCollectionResponseModel> vms =
|
||||
[
|
||||
new SmartCollectionResponseModel(1, "Kids", "tag:kids"),
|
||||
new SmartCollectionResponseModel(2, "News", "tag:news")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllSmartCollectionsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(vms);
|
||||
|
||||
List<SmartCollectionResponseModel> 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<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<SmartCollectionViewModel>.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<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<SmartCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundResult>();
|
||||
}
|
||||
|
||||
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<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class CollectionLifecycleIntegrationTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task Create_Read_AddItem_RemoveItem_Delete()
|
||||
{
|
||||
int movieId = await SeedMovie();
|
||||
IMediaCollectionRepository mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
|
||||
mediaCollectionRepository.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([]);
|
||||
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
||||
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
|
||||
var createHandler = new CreateCollectionHandler(Db.Factory, SearchTargets);
|
||||
Either<BaseError, MediaCollectionViewModel> 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<MediaCollectionViewModel> 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<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
Either<BaseError, Unit> 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<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
Either<BaseError, Unit> 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<BaseError, Unit> deleted =
|
||||
await deleteHandler.Handle(new DeleteCollection(collectionId), CancellationToken.None);
|
||||
deleted.IsRight.ShouldBeTrue();
|
||||
|
||||
Option<MediaCollectionViewModel> afterDelete =
|
||||
await getHandler.Handle(new GetCollectionById(collectionId), CancellationToken.None);
|
||||
afterDelete.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private async Task<int> 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], [], [], [], [], [], [], [], [], []);
|
||||
}
|
||||
@@ -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<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 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();
|
||||
}
|
||||
}
|
||||
@@ -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<List<MediaCollectionViewModel>> 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<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MediaCollectionViewModel> 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<IActionResult> Create(
|
||||
[Required] [FromBody] CreateCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, MediaCollectionViewModel> 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<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<MediaCollectionViewModel> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> 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<IActionResult> AddItems(
|
||||
int id,
|
||||
[Required] [FromBody] AddItemsToCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> 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<IActionResult> RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(
|
||||
new RemoveItemsFromCollection(id)
|
||||
{
|
||||
MediaItemIds = [mediaItemId]
|
||||
},
|
||||
cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record AddItemsToCollectionRequest(
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds,
|
||||
List<int> RemoteStreamIds)
|
||||
{
|
||||
public AddItemsToCollection ToCommand(int collectionId) =>
|
||||
new(
|
||||
collectionId,
|
||||
MovieIds ?? [],
|
||||
ShowIds ?? [],
|
||||
SeasonIds ?? [],
|
||||
EpisodeIds ?? [],
|
||||
ArtistIds ?? [],
|
||||
MusicVideoIds ?? [],
|
||||
OtherVideoIds ?? [],
|
||||
SongIds ?? [],
|
||||
ImageIds ?? [],
|
||||
RemoteStreamIds ?? []);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateCollectionRequest(string Name)
|
||||
{
|
||||
public CreateCollection ToCommand() => new(Name);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<List<SmartCollectionResponseModel>> GetAll() =>
|
||||
await mediator.Send(new GetAllSmartCollectionsForApi());
|
||||
[HttpGet("/api/smart-collections")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Get all smart collections")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<SmartCollectionResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllSmartCollectionsForApi(), cancellationToken);
|
||||
|
||||
[HttpPost("/api/collections/smart/new", Name = "CreateSmartCollection")]
|
||||
public async Task<IActionResult> 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<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateSmartCollectionResult> result =
|
||||
await mediator.Send(request).MapT(r => new CreateSmartCollectionResult(r.Id));
|
||||
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
|
||||
Option<SmartCollectionViewModel> result =
|
||||
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPut("/api/collections/smart/update", Name="UpdateSmartCollection")]
|
||||
public async Task<IActionResult> UpdateOne(
|
||||
[Required] [FromBody]
|
||||
UpdateSmartCollection request)
|
||||
[HttpPost("/api/smart-collections")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Create a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateSmartCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, UpdateSmartCollectionResult> result = await mediator.Send(request);
|
||||
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
|
||||
Either<BaseError, SmartCollectionViewModel> 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<IActionResult> DeleteSmartCollection(int id)
|
||||
[HttpPut("/api/smart-collections/{id:int}")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Update a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateSmartCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id));
|
||||
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
|
||||
Either<BaseError, UpdateSmartCollectionResult> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<SmartCollectionViewModel> 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<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user