CollectionRepository: Adding tests

This commit is contained in:
Zoe Roux 2021-07-31 17:22:55 +02:00
parent 4ae28f2594
commit 0fa73b1d6a
8 changed files with 462 additions and 33 deletions

View File

@ -22,12 +22,13 @@ namespace Kyoo
/// <param name="second">The second enumerable to merge, if items from this list are equals to one from the first, they are not kept</param>
/// <param name="isEqual">Equality function to compare items. If this is null, duplicated elements are kept</param>
/// <returns>The two list merged as an array</returns>
public static T[] MergeLists<T>(IEnumerable<T> first,
IEnumerable<T> second,
Func<T, T, bool> isEqual = null)
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static T[] MergeLists<T>([CanBeNull] IEnumerable<T> first,
[CanBeNull] IEnumerable<T> second,
[CanBeNull] Func<T, T, bool> isEqual = null)
{
if (first == null)
return second.ToArray();
return second?.ToArray();
if (second == null)
return first.ToArray();
if (isEqual == null)
@ -36,6 +37,31 @@ namespace Kyoo
return list.Concat(second.Where(x => !list.Any(y => isEqual(x, y)))).ToArray();
}
/// <summary>
/// Merge two dictionary, if the same key is found on both dictionary, the values of the first one is kept.
/// </summary>
/// <param name="first">The first dictionary to merge</param>
/// <param name="second">The second dictionary to merge</param>
/// <typeparam name="T">The type of the keys in dictionaries</typeparam>
/// <typeparam name="T2">The type of values in the dictionaries</typeparam>
/// <returns>A dictionary containing the result of the merge.</returns>
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static IDictionary<T, T2> MergeDictionaries<T, T2>([CanBeNull] IDictionary<T, T2> first,
[CanBeNull] IDictionary<T, T2> second)
{
if (first == null)
return second;
if (second == null)
return first;
Dictionary<T, T2> merged = new();
merged.EnsureCapacity(first.Count + second.Count);
foreach ((T key, T2 value) in first)
merged.Add(key, value);
foreach ((T key, T2 value) in second)
merged.TryAdd(key, value);
return merged;
}
/// <summary>
/// Set every fields of first to those of second. Ignore fields marked with the <see cref="NotMergeableAttribute"/> attribute
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
@ -63,16 +89,32 @@ namespace Kyoo
}
/// <summary>
/// Set every default values of first to the value of second. ex: {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "test"}.
/// Set every non-default values of seconds to the corresponding property of second.
/// Dictionaries are handled like anonymous objects with a property per key/pair value
/// (see <see cref="MergeDictionaries{T,T2}"/> for more details).
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>
/// </summary>
/// <param name="first">The object to complete</param>
/// <param name="second">Missing fields of first will be completed by fields of this item. If second is null, the function no-op.</param>
/// <param name="where">Filter fields that will be merged</param>
/// <remarks>
/// This does the opposite of <see cref="Merge{T}"/>.
/// </remarks>
/// <example>
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "foo"}
/// </example>
/// <param name="first">
/// The object to complete
/// </param>
/// <param name="second">
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
/// </param>
/// <param name="where">
/// Filter fields that will be merged
/// </param>
/// <typeparam name="T">Fields of T will be completed</typeparam>
/// <returns><see cref="first"/></returns>
/// <exception cref="ArgumentNullException">If first is null</exception>
public static T Complete<T>([NotNull] T first, [CanBeNull] T second, Func<PropertyInfo, bool> where = null)
public static T Complete<T>([NotNull] T first,
[CanBeNull] T second,
[InstantHandle] Func<PropertyInfo, bool> where = null)
{
if (first == null)
throw new ArgumentNullException(nameof(first));
@ -93,7 +135,19 @@ namespace Kyoo
object defaultValue = property.GetCustomAttribute<DefaultValueAttribute>()?.Value
?? property.PropertyType.GetClrDefault();
if (value?.Equals(defaultValue) == false && value != property.GetValue(first))
if (value?.Equals(defaultValue) != false || value == property.GetValue(first))
continue;
if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
{
Type[] dictionaryTypes = Utility.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))
.GenericTypeArguments;
property.SetValue(first, Utility.RunGenericMethod<object>(
typeof(Merger),
nameof(MergeDictionaries),
dictionaryTypes,
value, property.GetValue(first)));
}
else
property.SetValue(first, value);
}
@ -103,17 +157,28 @@ namespace Kyoo
}
/// <summary>
/// An advanced <see cref="Complete{T}"/> function.
/// This will set missing values of <see cref="first"/> to the corresponding values of <see cref="second"/>.
/// Enumerable will be merged (concatenated).
/// Enumerable will be merged (concatenated) and Dictionaries too.
/// At the end, the OnMerge method of first will be called if first is a <see cref="IOnMerge"/>.
/// </summary>
/// <param name="first">The object to complete</param>
/// <param name="second">Missing fields of first will be completed by fields of this item. If second is null, the function no-op.</param>
/// <example>
/// {id: 0, slug: "test"}, {id: 4, slug: "foo"} -> {id: 4, slug: "test"}
/// </example>
/// <param name="first">
/// The object to complete
/// </param>
/// <param name="second">
/// Missing fields of first will be completed by fields of this item. If second is null, the function no-op.
/// </param>
/// <param name="where">
/// Filter fields that will be merged
/// </param>
/// <typeparam name="T">Fields of T will be merged</typeparam>
/// <returns><see cref="first"/></returns>
[ContractAnnotation("first:notnull => notnull; second:notnull => notnull", true)]
public static T Merge<T>([CanBeNull] T first, [CanBeNull] T second)
public static T Merge<T>([CanBeNull] T first,
[CanBeNull] T second,
[InstantHandle] Func<PropertyInfo, bool> where = null)
{
if (first == null)
return second;
@ -125,6 +190,9 @@ namespace Kyoo
.Where(x => x.CanRead && x.CanWrite
&& Attribute.GetCustomAttribute(x, typeof(NotMergeableAttribute)) == null);
if (where != null)
properties = properties.Where(where);
foreach (PropertyInfo property in properties)
{
object oldValue = property.GetValue(first);
@ -133,6 +201,16 @@ namespace Kyoo
if (oldValue?.Equals(defaultValue) != false)
property.SetValue(first, newValue);
else if (Utility.IsOfGenericType(property.PropertyType, typeof(IDictionary<,>)))
{
Type[] dictionaryTypes = Utility.GetGenericDefinition(property.PropertyType, typeof(IDictionary<,>))
.GenericTypeArguments;
property.SetValue(first, Utility.RunGenericMethod<object>(
typeof(Merger),
nameof(MergeDictionaries),
dictionaryTypes,
oldValue, newValue));
}
else if (typeof(IEnumerable).IsAssignableFrom(property.PropertyType)
&& property.PropertyType != typeof(string))
{

View File

@ -234,16 +234,23 @@ namespace Kyoo.Controllers
finally
{
Database.ChangeTracker.LazyLoadingEnabled = lazyLoading;
Database.ChangeTracker.Clear();
}
}
/// <summary>
/// An overridable method to edit relation of a resource.
/// </summary>
/// <param name="resource">The non edited resource</param>
/// <param name="changed">The new version of <see cref="resource"/>. This item will be saved on the databse and replace <see cref="resource"/></param>
/// <param name="resetOld">A boolean to indicate if all values of resource should be discarded or not.</param>
/// <returns></returns>
/// <param name="resource">
/// The non edited resource
/// </param>
/// <param name="changed">
/// The new version of <see cref="resource"/>.
/// This item will be saved on the database and replace <see cref="resource"/>
/// </param>
/// <param name="resetOld">
/// A boolean to indicate if all values of resource should be discarded or not.
/// </param>
protected virtual Task EditRelations(T resource, T changed, bool resetOld)
{
return Validate(resource);
@ -254,7 +261,9 @@ namespace Kyoo.Controllers
/// It is also called on the default implementation of <see cref="EditRelations"/>
/// </summary>
/// <param name="resource">The resource that will be saved</param>
/// <exception cref="ArgumentException">You can throw this if the resource is illegal and should not be saved.</exception>
/// <exception cref="ArgumentException">
/// You can throw this if the resource is illegal and should not be saved.
/// </exception>
protected virtual Task Validate(T resource)
{
if (typeof(T).GetProperty(nameof(resource.Slug))!.GetCustomAttribute<ComputedAttribute>() != null)

View File

@ -1,5 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Kyoo.Controllers;
using Kyoo.Models;
using Microsoft.EntityFrameworkCore;
using Xunit;
using Xunit.Abstractions;
@ -33,5 +38,161 @@ namespace Kyoo.Tests.Database
{
_repository = Repositories.LibraryManager.CollectionRepository;
}
[Fact]
public async Task CreateWithEmptySlugTest()
{
Collection collection = TestSample.GetNew<Collection>();
collection.Slug = "";
await Assert.ThrowsAsync<ArgumentException>(() => _repository.Create(collection));
}
[Fact]
public async Task CreateWithNumberSlugTest()
{
Collection collection = TestSample.GetNew<Collection>();
collection.Slug = "2";
Collection ret = await _repository.Create(collection);
Assert.Equal("2!", ret.Slug);
}
[Fact]
public async Task CreateWithoutNameTest()
{
Collection collection = TestSample.GetNew<Collection>();
collection.Name = null;
await Assert.ThrowsAsync<ArgumentException>(() => _repository.Create(collection));
}
[Fact]
public async Task CreateWithExternalIdTest()
{
Collection collection = TestSample.GetNew<Collection>();
collection.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "new-provider-link",
DataID = "new-id"
}
};
await _repository.Create(collection);
Collection retrieved = await _repository.Get(2);
await Repositories.LibraryManager.Load(retrieved, x => x.ExternalIDs);
Assert.Equal(2, retrieved.ExternalIDs.Count);
KAssert.DeepEqual(collection.ExternalIDs.First(), retrieved.ExternalIDs.First());
KAssert.DeepEqual(collection.ExternalIDs.Last(), retrieved.ExternalIDs.Last());
}
[Fact]
public async Task EditTest()
{
Collection value = await _repository.Get(TestSample.Get<Collection>().Slug);
value.Name = "New Title";
value.Images = new Dictionary<int, string>
{
[Images.Poster] = "poster"
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Collection retrieved = await database.Collections.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task EditMetadataTest()
{
Collection value = await _repository.Get(TestSample.Get<Collection>().Slug);
value.ExternalIDs = new[]
{
new MetadataID
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
await using DatabaseContext database = Repositories.Context.New();
Collection retrieved = await database.Collections
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
[Fact]
public async Task AddMetadataTest()
{
Collection value = await _repository.Get(TestSample.Get<Collection>().Slug);
value.ExternalIDs = new List<MetadataID>
{
new()
{
Provider = TestSample.Get<Provider>(),
Link = "link",
DataID = "id"
},
};
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
Collection retrieved = await database.Collections
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
value.ExternalIDs.Add(new MetadataID
{
Provider = TestSample.GetNew<Provider>(),
Link = "link",
DataID = "id"
});
await _repository.Edit(value, false);
{
await using DatabaseContext database = Repositories.Context.New();
Collection retrieved = await database.Collections
.Include(x => x.ExternalIDs)
.ThenInclude(x => x.Provider)
.FirstAsync();
KAssert.DeepEqual(value, retrieved);
}
}
[Theory]
[InlineData("test")]
[InlineData("super")]
[InlineData("title")]
[InlineData("TiTlE")]
[InlineData("SuPeR")]
public async Task SearchTest(string query)
{
Collection value = new()
{
Slug = "super-test",
Name = "This is a test title",
};
await _repository.Create(value);
ICollection<Collection> ret = await _repository.Search(query);
KAssert.DeepEqual(value, ret.First());
}
}
}

View File

@ -79,9 +79,10 @@ namespace Kyoo.Tests.Database
[Fact]
public async Task GetDuplicatedSlugTests()
{
await _repositories.LibraryManager.Create(new Collection()
await _repositories.LibraryManager.Create(new Collection
{
Slug = TestSample.Get<Show>().Slug
Slug = TestSample.Get<Show>().Slug,
Name = "name"
});
await Assert.ThrowsAsync<InvalidOperationException>(() => _repository.Get(TestSample.Get<Show>().Slug));
}

View File

@ -61,7 +61,7 @@ namespace Kyoo.Tests.Database
Library library = TestSample.GetNew<Library>();
library.Slug = "2";
Library ret = await _repository.Create(library);
Assert.Equal("2!", library.Slug);
Assert.Equal("2!", ret.Slug);
}
[Fact]

View File

@ -18,6 +18,20 @@ namespace Kyoo.Tests
Paths = new [] {"/a/random/path"}
}
},
{
typeof(Collection),
() => new Collection
{
ID = 2,
Slug = "new-collection",
Name = "New Collection",
Overview = "A collection created by new sample",
Images = new Dictionary<int, string>
{
[Images.Thumbnail] = "thumbnail"
}
}
},
{
typeof(Show),
() => new Show

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Kyoo.Models;
@ -135,6 +136,68 @@ namespace Kyoo.Tests.Utility
Assert.Equal(3, ret.Numbers[3]);
}
private class MergeDictionaryTest
{
public int ID { get; set; }
public Dictionary<int, string> Dictionary { get; set; }
}
[Fact]
public void GlobalMergeDictionariesTest()
{
MergeDictionaryTest test = new()
{
ID = 5,
Dictionary = new Dictionary<int, string>
{
[2] = "two"
}
};
MergeDictionaryTest test2 = new()
{
Dictionary = new Dictionary<int, string>
{
[3] = "third"
}
};
MergeDictionaryTest ret = Merger.Merge(test, test2);
Assert.True(ReferenceEquals(test, ret));
Assert.Equal(5, ret.ID);
Assert.Equal(2, ret.Dictionary.Count);
Assert.Equal("two", ret.Dictionary[2]);
Assert.Equal("third", ret.Dictionary[3]);
}
[Fact]
public void GlobalMergeDictionariesDuplicatesTest()
{
MergeDictionaryTest test = new()
{
ID = 5,
Dictionary = new Dictionary<int, string>
{
[2] = "two"
}
};
MergeDictionaryTest test2 = new()
{
Dictionary = new Dictionary<int, string>
{
[2] = "nope",
[3] = "third"
}
};
MergeDictionaryTest ret = Merger.Merge(test, test2);
Assert.True(ReferenceEquals(test, ret));
Assert.Equal(5, ret.ID);
Assert.Equal(2, ret.Dictionary.Count);
Assert.Equal("two", ret.Dictionary[2]);
Assert.Equal("third", ret.Dictionary[3]);
}
[Fact]
public void GlobalMergeListDuplicatesResourcesTest()
{
@ -208,5 +271,99 @@ namespace Kyoo.Tests.Utility
Assert.Equal(1, ret[0]);
Assert.Equal(2, ret[1]);
}
[Fact]
public void MergeDictionariesTest()
{
Dictionary<int, string> first = new()
{
[1] = "test",
[5] = "value"
};
Dictionary<int, string> second = new()
{
[3] = "third",
};
IDictionary<int, string> ret = Merger.MergeDictionaries(first, second);
Assert.Equal(3, ret.Count);
Assert.Equal("test", ret[1]);
Assert.Equal("value", ret[5]);
Assert.Equal("third", ret[3]);
}
[Fact]
public void MergeDictionariesDuplicateTest()
{
Dictionary<int, string> first = new()
{
[1] = "test",
[5] = "value"
};
Dictionary<int, string> second = new()
{
[3] = "third",
[5] = "new-value",
};
IDictionary<int, string> ret = Merger.MergeDictionaries(first, second);
Assert.Equal(3, ret.Count);
Assert.Equal("test", ret[1]);
Assert.Equal("value", ret[5]);
Assert.Equal("third", ret[3]);
}
[Fact]
public void CompleteTest()
{
Genre genre = new()
{
ID = 5,
Name = "merged"
};
Genre genre2 = new()
{
Name = "test"
};
Genre ret = Merger.Complete(genre, genre2);
Assert.True(ReferenceEquals(genre, ret));
Assert.Equal(5, ret.ID);
Assert.Equal("test", genre.Name);
Assert.Null(genre.Slug);
}
[Fact]
public void CompleteDictionaryTest()
{
Collection collection = new()
{
ID = 5,
Name = "merged",
Images = new Dictionary<int, string>
{
[Images.Logo] = "logo",
[Images.Poster] = "poster"
}
};
Collection collection2 = new()
{
Name = "test",
Images = new Dictionary<int, string>
{
[Images.Poster] = "new-poster",
[Images.Thumbnail] = "thumbnails"
}
};
Collection ret = Merger.Complete(collection, collection2);
Assert.True(ReferenceEquals(collection, ret));
Assert.Equal(5, ret.ID);
Assert.Equal("test", ret.Name);
Assert.Null(ret.Slug);
Assert.Equal(3, ret.Images.Count);
Assert.Equal("new-poster", ret.Images[Images.Poster]);
Assert.Equal("thumbnails", ret.Images[Images.Thumbnail]);
Assert.Equal("logo", ret.Images[Images.Logo]);
}
}
}

View File

@ -42,7 +42,7 @@ namespace Kyoo.Controllers
public override async Task<ICollection<Collection>> Search(string query)
{
return await _database.Collections
.Where(_database.Like<Collection>(x => x.Name, $"%{query}%"))
.Where(_database.Like<Collection>(x => x.Name + " " + x.Slug, $"%{query}%"))
.OrderBy(DefaultSort)
.Take(20)
.ToListAsync();
@ -53,7 +53,6 @@ namespace Kyoo.Controllers
{
await base.Create(obj);
_database.Entry(obj).State = EntityState.Added;
obj.ExternalIDs.ForEach(x => _database.MetadataIds<Collection>().Attach(x));
await _database.SaveChangesAsync($"Trying to insert a duplicated collection (slug {obj.Slug} already exists).");
return obj;
}
@ -62,24 +61,34 @@ namespace Kyoo.Controllers
protected override async Task Validate(Collection resource)
{
await base.Validate(resource);
await resource.ExternalIDs.ForEachAsync(async x =>
{
x.Provider = await _providers.CreateIfNotExists(x.Provider);
x.ProviderID = x.Provider.ID;
_database.Entry(x.Provider).State = EntityState.Detached;
});
if (string.IsNullOrEmpty(resource.Slug))
throw new ArgumentException("The collection's slug must be set and not empty");
if (string.IsNullOrEmpty(resource.Name))
throw new ArgumentException("The collection's name must be set and not empty");
if (resource.ExternalIDs != null)
{
foreach (MetadataID id in resource.ExternalIDs)
{
id.Provider = await _providers.CreateIfNotExists(id.Provider);
id.ProviderID = id.Provider.ID;
_database.Entry(id.Provider).State = EntityState.Detached;
}
_database.MetadataIds<Collection>().AttachRange(resource.ExternalIDs);
}
}
/// <inheritdoc />
protected override async Task EditRelations(Collection resource, Collection changed, bool resetOld)
{
await Validate(resource);
if (changed.ExternalIDs != null || resetOld)
{
await Database.Entry(resource).Collection(x => x.ExternalIDs).LoadAsync();
resource.ExternalIDs = changed.ExternalIDs;
}
await base.EditRelations(resource, changed, resetOld);
}
/// <inheritdoc />