@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
|
|||||||
using ErsatzTV.Application.Search;
|
using ErsatzTV.Application.Search;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
@@ -42,8 +43,15 @@ public class AddItemsToCollectionHandler :
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, 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(
|
private async Task<Unit> ApplyAddItemsRequest(
|
||||||
@@ -89,22 +97,23 @@ public class AddItemsToCollectionHandler :
|
|||||||
private async Task<Validation<BaseError, Collection>> Validate(
|
private async Task<Validation<BaseError, Collection>> Validate(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
AddItemsToCollection request,
|
AddItemsToCollection request,
|
||||||
|
Collection collection,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
(await CollectionMustExist(dbContext, request, cancellationToken),
|
(await ValidateMovies(request),
|
||||||
await ValidateMovies(request),
|
|
||||||
await ValidateShows(request),
|
await ValidateShows(request),
|
||||||
await ValidateSeasons(request),
|
await ValidateSeasons(request),
|
||||||
await ValidateEpisodes(request))
|
await ValidateEpisodes(request),
|
||||||
.Apply((collection, _, _, _, _) => collection);
|
await ValidateMediaItems(dbContext, request, cancellationToken))
|
||||||
|
.Apply((_, _, _, _, _) => collection);
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
private static Task<Option<Collection>> CollectionMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
AddItemsToCollection request,
|
AddItemsToCollection request,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
dbContext.Collections
|
dbContext.Collections
|
||||||
.Include(c => c.MediaItems)
|
.Include(c => c.MediaItems)
|
||||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
|
.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) =>
|
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
|
||||||
_movieRepository.AllMoviesExist(request.MovieIds)
|
_movieRepository.AllMoviesExist(request.MovieIds)
|
||||||
@@ -133,4 +142,30 @@ public class AddItemsToCollectionHandler :
|
|||||||
.Filter(v => v == true)
|
.Filter(v => v == true)
|
||||||
.MapT(_ => Unit.Default)
|
.MapT(_ => Unit.Default)
|
||||||
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
|
.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;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
using ErsatzTV.Core.Interfaces.Search;
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
using ErsatzTV.Infrastructure.Extensions;
|
using ErsatzTV.Infrastructure.Extensions;
|
||||||
@@ -23,8 +24,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request, cancellationToken);
|
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||||
return await validation.Apply(c => DoDeletion(dbContext, c, 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)
|
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken)
|
||||||
@@ -35,11 +39,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
private static Task<Option<Collection>> CollectionMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
DeleteCollection request,
|
DeleteCollection request,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
dbContext.Collections
|
dbContext.Collections
|
||||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
|
.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;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
using ErsatzTV.Core.Interfaces.Search;
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Core.Search;
|
using ErsatzTV.Core.Search;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
@@ -29,11 +30,14 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(
|
Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
|
||||||
dbContext,
|
dbContext,
|
||||||
request,
|
request,
|
||||||
cancellationToken);
|
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(
|
private async Task<Unit> DoDeletion(
|
||||||
@@ -48,11 +52,11 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
DeleteSmartCollection request,
|
DeleteSmartCollection request,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
dbContext.SmartCollections
|
dbContext.SmartCollections
|
||||||
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId, cancellationToken)
|
.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.Application.Search;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
@@ -35,20 +36,29 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||||
return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c, 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,
|
TvContext dbContext,
|
||||||
RemoveItemsFromCollection request,
|
RemoveItemsFromCollection request,
|
||||||
Collection collection,
|
Collection collection,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
List<int> requestedIds = request.MediaItemIds.Distinct().ToList();
|
||||||
var itemsToRemove = collection.MediaItems
|
var itemsToRemove = collection.MediaItems
|
||||||
.Filter(m => request.MediaItemIds.Contains(m.Id))
|
.Filter(m => requestedIds.Contains(m.Id))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
if (itemsToRemove.Count != requestedIds.Count)
|
||||||
|
{
|
||||||
|
return new NotFoundError("Collection item does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
|
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
|
||||||
|
|
||||||
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||||
@@ -67,18 +77,12 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Collection>> Validate(
|
private static Task<Option<Collection>> CollectionMustExist(
|
||||||
TvContext dbContext,
|
|
||||||
RemoveItemsFromCollection request,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
CollectionMustExist(dbContext, request, cancellationToken);
|
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
RemoveItemsFromCollection request,
|
RemoveItemsFromCollection request,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
dbContext.Collections
|
dbContext.Collections
|
||||||
.Include(c => c.MediaItems)
|
.Include(c => c.MediaItems)
|
||||||
.SelectOneAsync(c => c.Id, c => c.Id == request.MediaCollectionId, cancellationToken)
|
.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.Application.Playouts;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Interfaces.Search;
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
@@ -35,8 +36,15 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, 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(
|
private async Task<Unit> ApplyUpdateRequest(
|
||||||
@@ -69,17 +77,16 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
|
|||||||
private static async Task<Validation<BaseError, Collection>> Validate(
|
private static async Task<Validation<BaseError, Collection>> Validate(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
UpdateCollection request,
|
UpdateCollection request,
|
||||||
CancellationToken cancellationToken) =>
|
Collection collection) =>
|
||||||
(await CollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
(await ValidateName(dbContext, request)).Map(_ => collection);
|
||||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
|
||||||
|
|
||||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
private static Task<Option<Collection>> CollectionMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
UpdateCollection updateCollection,
|
UpdateCollection updateCollection,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
dbContext.Collections
|
dbContext.Collections
|
||||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken)
|
.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(
|
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using ErsatzTV.Application.Playouts;
|
using ErsatzTV.Application.Playouts;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Errors;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Interfaces.Search;
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
@@ -41,8 +42,18 @@ public class
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, cancellationToken);
|
Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
|
||||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
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(
|
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
|
||||||
@@ -73,16 +84,16 @@ public class
|
|||||||
private static Task<Validation<BaseError, SmartCollection>> Validate(
|
private static Task<Validation<BaseError, SmartCollection>> Validate(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
UpdateSmartCollection request,
|
UpdateSmartCollection request,
|
||||||
CancellationToken cancellationToken) => ValidateName(dbContext, request)
|
SmartCollection smartCollection) => ValidateName(dbContext, request)
|
||||||
.BindT(_ => SmartCollectionMustExist(dbContext, request, cancellationToken));
|
.MapT(_ => smartCollection);
|
||||||
|
|
||||||
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
UpdateSmartCollection updateCollection,
|
UpdateSmartCollection updateCollection,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
dbContext.SmartCollections
|
dbContext.SmartCollections
|
||||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
|
.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(
|
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
|
|||||||
@@ -571,7 +571,8 @@ public class CustomStreamSelectorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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 =
|
const string YAML =
|
||||||
"""
|
"""
|
||||||
@@ -608,7 +609,8 @@ public class CustomStreamSelectorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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 =
|
const string YAML =
|
||||||
"""
|
"""
|
||||||
@@ -651,7 +653,8 @@ public class CustomStreamSelectorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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
|
// saturday from 9pm-11pm
|
||||||
const string YAML =
|
const string YAML =
|
||||||
@@ -689,7 +692,8 @@ public class CustomStreamSelectorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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
|
// saturday from 9pm-11pm
|
||||||
const string YAML =
|
const string YAML =
|
||||||
@@ -727,7 +731,8 @@ public class CustomStreamSelectorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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
|
// saturday from 9pm-11pm
|
||||||
const string YAML =
|
const string YAML =
|
||||||
@@ -765,7 +770,8 @@ public class CustomStreamSelectorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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
|
// saturday from 9pm-11pm
|
||||||
const string YAML =
|
const string YAML =
|
||||||
@@ -810,7 +816,7 @@ public class CustomStreamSelectorTests
|
|||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
[SetCulture("fr-FR")]
|
[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
|
// saturday from 9pm-11pm
|
||||||
const string YAML =
|
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]
|
[Test]
|
||||||
public async Task Should_Ignore_Blocked_Audio_Title()
|
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 System.ComponentModel.DataAnnotations;
|
||||||
using ErsatzTV.Application.MediaCollections;
|
using ErsatzTV.Application.MediaCollections;
|
||||||
|
using ErsatzTV.Controllers.Api.Requests;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Api.SmartCollections;
|
using ErsatzTV.Core.Api.SmartCollections;
|
||||||
|
using ErsatzTV.Extensions;
|
||||||
|
using ErsatzTV.Filters;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace ErsatzTV.Controllers.Api;
|
namespace ErsatzTV.Controllers.Api;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[EndpointGroupName("general")]
|
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||||
public class SmartCollectionController(IMediator mediator) : ControllerBase
|
public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("/api/collections/smart", Name="GetSmartCollections")]
|
[HttpGet("/api/smart-collections")]
|
||||||
public async Task<List<SmartCollectionResponseModel>> GetAll() =>
|
[Tags("Smart Collections")]
|
||||||
await mediator.Send(new GetAllSmartCollectionsForApi());
|
[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")]
|
[HttpGet("/api/smart-collections/{id:int}", Name = "GetSmartCollectionById")]
|
||||||
public async Task<IActionResult> AddOne(
|
[Tags("Smart Collections")]
|
||||||
[Required] [FromBody]
|
[EndpointSummary("Get a smart collection by id")]
|
||||||
CreateSmartCollection request)
|
[EndpointGroupName("general")]
|
||||||
|
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Either<BaseError, CreateSmartCollectionResult> result =
|
Option<SmartCollectionViewModel> result =
|
||||||
await mediator.Send(request).MapT(r => new CreateSmartCollectionResult(r.Id));
|
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
|
||||||
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
|
return result.ToGetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("/api/collections/smart/update", Name="UpdateSmartCollection")]
|
[HttpPost("/api/smart-collections")]
|
||||||
public async Task<IActionResult> UpdateOne(
|
[Tags("Smart Collections")]
|
||||||
[Required] [FromBody]
|
[EndpointSummary("Create a smart collection")]
|
||||||
UpdateSmartCollection request)
|
[EndpointGroupName("general")]
|
||||||
|
public async Task<IActionResult> Create(
|
||||||
|
[Required] [FromBody] CreateSmartCollectionRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Either<BaseError, UpdateSmartCollectionResult> result = await mediator.Send(request);
|
Either<BaseError, SmartCollectionViewModel> result =
|
||||||
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
|
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")]
|
[HttpPut("/api/smart-collections/{id:int}")]
|
||||||
public async Task<IActionResult> DeleteSmartCollection(int id)
|
[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));
|
Either<BaseError, UpdateSmartCollectionResult> result =
|
||||||
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
|
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