Compare commits

..

5 commits

641 changed files with 7782 additions and 46618 deletions

View file

@ -28,7 +28,7 @@ body:
label: Kavita Version Number - If you don't see your version number listed, please update Kavita and see if your issue still persists.
multiple: false
options:
- 0.8.7 - Stable
- 0.8.6.2 - Stable
- Nightly Testing Branch
validations:
required: true

View file

@ -10,8 +10,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.1" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.15.1" />
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.14.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
</ItemGroup>
@ -26,10 +26,5 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<None Update="Data\AesopsFables.epub">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

Binary file not shown.

View file

@ -1,41 +0,0 @@
using API.Helpers.Builders;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using System;
using API.Entities.Enums;
namespace API.Benchmark
{
[StopOnFirstError]
[MemoryDiagnoser]
[RankColumn]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[SimpleJob(launchCount: 1, warmupCount: 5, invocationCount: 20)]
public class KoreaderHashBenchmark
{
private const string sourceEpub = "./Data/AesopsFables.epub";
[Benchmark(Baseline = true)]
public void TestBuildManga_baseline()
{
var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
.Build();
if (file == null)
{
throw new Exception("Failed to build manga file");
}
}
[Benchmark]
public void TestBuildManga_withHash()
{
var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
.WithHash()
.Build();
if (file == null)
{
throw new Exception("Failed to build manga file");
}
}
}
}

View file

@ -6,13 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.14" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.13" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.1">
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@ -36,10 +36,4 @@
<None Remove="Extensions\Test Data\modified on run.txt" />
</ItemGroup>
<ItemGroup>
<None Update="Data\AesopsFables.epub">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

Binary file not shown.

View file

@ -67,7 +67,7 @@ public class QueryableExtensionsTests
[Theory]
[InlineData(true, 2)]
[InlineData(false, 2)]
[InlineData(false, 1)]
public void RestrictAgainstAgeRestriction_Genre_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
{
var items = new List<Genre>()
@ -94,7 +94,7 @@ public class QueryableExtensionsTests
[Theory]
[InlineData(true, 2)]
[InlineData(false, 2)]
[InlineData(false, 1)]
public void RestrictAgainstAgeRestriction_Tag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
{
var items = new List<Tag>()

View file

@ -1,178 +0,0 @@
using API.Helpers;
using Xunit;
namespace API.Tests.Helpers;
public class BookSortTitlePrefixHelperTests
{
[Theory]
[InlineData("The Avengers", "Avengers")]
[InlineData("A Game of Thrones", "Game of Thrones")]
[InlineData("An American Tragedy", "American Tragedy")]
public void TestEnglishPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("El Quijote", "Quijote")]
[InlineData("La Casa de Papel", "Casa de Papel")]
[InlineData("Los Miserables", "Miserables")]
[InlineData("Las Vegas", "Vegas")]
[InlineData("Un Mundo Feliz", "Mundo Feliz")]
[InlineData("Una Historia", "Historia")]
public void TestSpanishPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("Le Petit Prince", "Petit Prince")]
[InlineData("La Belle et la Bête", "Belle et la Bête")]
[InlineData("Les Misérables", "Misérables")]
[InlineData("Un Amour de Swann", "Amour de Swann")]
[InlineData("Une Vie", "Vie")]
[InlineData("Des Souris et des Hommes", "Souris et des Hommes")]
public void TestFrenchPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("Der Herr der Ringe", "Herr der Ringe")]
[InlineData("Die Verwandlung", "Verwandlung")]
[InlineData("Das Kapital", "Kapital")]
[InlineData("Ein Sommernachtstraum", "Sommernachtstraum")]
[InlineData("Eine Geschichte", "Geschichte")]
public void TestGermanPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("Il Nome della Rosa", "Nome della Rosa")]
[InlineData("La Divina Commedia", "Divina Commedia")]
[InlineData("Lo Hobbit", "Hobbit")]
[InlineData("Gli Ultimi", "Ultimi")]
[InlineData("Le Città Invisibili", "Città Invisibili")]
[InlineData("Un Giorno", "Giorno")]
[InlineData("Una Notte", "Notte")]
public void TestItalianPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("O Alquimista", "Alquimista")]
[InlineData("A Moreninha", "Moreninha")]
[InlineData("Os Lusíadas", "Lusíadas")]
[InlineData("As Meninas", "Meninas")]
[InlineData("Um Defeito de Cor", "Defeito de Cor")]
[InlineData("Uma História", "História")]
public void TestPortuguesePrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("", "")] // Empty string returns empty
[InlineData("Book", "Book")] // Single word, no change
[InlineData("Avengers", "Avengers")] // No prefix, no change
public void TestNoPrefixCases(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("The", "The")] // Just a prefix word alone
[InlineData("A", "A")] // Just single letter prefix alone
[InlineData("Le", "Le")] // French prefix alone
public void TestPrefixWordAlone(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("THE AVENGERS", "AVENGERS")] // All caps
[InlineData("the avengers", "avengers")] // All lowercase
[InlineData("The AVENGERS", "AVENGERS")] // Mixed case
[InlineData("tHe AvEnGeRs", "AvEnGeRs")] // Random case
public void TestCaseInsensitivity(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("Then Came You", "Then Came You")] // "The" + "n" = not a prefix
[InlineData("And Then There Were None", "And Then There Were None")] // "An" + "d" = not a prefix
[InlineData("Elsewhere", "Elsewhere")] // "El" + "sewhere" = not a prefix (no space)
[InlineData("Lesson Plans", "Lesson Plans")] // "Les" + "son" = not a prefix (no space)
[InlineData("Theory of Everything", "Theory of Everything")] // "The" + "ory" = not a prefix
public void TestFalsePositivePrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("The ", "The ")] // Prefix with only space after - returns original
[InlineData("La ", "La ")] // Same for other languages
[InlineData("El ", "El ")] // Same for Spanish
public void TestPrefixWithOnlySpaceAfter(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("The Multiple Spaces", " Multiple Spaces")] // Doesn't trim extra spaces from remainder
[InlineData("Le Petit Prince", " Petit Prince")] // Leading space preserved in remainder
public void TestSpaceHandling(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("The The Matrix", "The Matrix")] // Removes first "The", leaves second
[InlineData("A A Clockwork Orange", "A Clockwork Orange")] // Removes first "A", leaves second
[InlineData("El El Cid", "El Cid")] // Spanish version
public void TestRepeatedPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("L'Étranger", "L'Étranger")] // French contraction - no space, no change
[InlineData("D'Artagnan", "D'Artagnan")] // Contraction - no space, no change
[InlineData("The-Matrix", "The-Matrix")] // Hyphen instead of space - no change
[InlineData("The.Avengers", "The.Avengers")] // Period instead of space - no change
public void TestNonSpaceSeparators(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("三国演义", "三国演义")] // Chinese - no processing due to CJK detection
[InlineData("한국어", "한국어")] // Korean - not in CJK range, would be processed normally
public void TestCjkLanguages(string inputString, string expected)
{
// NOTE: These don't do anything, I am waiting for user input on if these are needed
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("नमस्ते दुनिया", "नमस्ते दुनिया")] // Hindi - not CJK, processed normally
[InlineData("مرحبا بالعالم", "مرحبا بالعالم")] // Arabic - not CJK, processed normally
[InlineData("שלום עולם", "שלום עולם")] // Hebrew - not CJK, processed normally
public void TestNonLatinNonCjkScripts(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
[Theory]
[InlineData("в мире", "мире")] // Russian "в" (in) - should be removed
[InlineData("на столе", "столе")] // Russian "на" (on) - should be removed
[InlineData("с друзьями", "друзьями")] // Russian "с" (with) - should be removed
public void TestRussianPrefixes(string inputString, string expected)
{
Assert.Equal(expected, BookSortTitlePrefixHelper.GetSortTitle(inputString));
}
}

View file

@ -1,60 +0,0 @@
using API.DTOs.Koreader;
using API.DTOs.Progress;
using API.Helpers;
using System.Runtime.CompilerServices;
using Xunit;
namespace API.Tests.Helpers;
public class KoreaderHelperTests
{
[Theory]
[InlineData("/body/DocFragment[11]/body/div/a", 10, null)]
[InlineData("/body/DocFragment[1]/body/div/p[40]", 0, 40)]
[InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)]
public void GetEpubPositionDto(string koreaderPosition, int page, int? pNumber)
{
var expected = EmptyProgressDto();
expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[{pNumber}]" : null;
expected.PageNum = page;
var actual = EmptyProgressDto();
KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
Assert.Equal(expected.BookScrollId, actual.BookScrollId);
Assert.Equal(expected.PageNum, actual.PageNum);
}
[Theory]
[InlineData("//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[20]", 5, "/body/DocFragment[6]/body/div/p[20]")]
[InlineData(null, 10, "/body/DocFragment[11]/body/div/a")]
public void GetKoreaderPosition(string scrollId, int page, string koreaderPosition)
{
var given = EmptyProgressDto();
given.BookScrollId = scrollId;
given.PageNum = page;
Assert.Equal(koreaderPosition, KoreaderHelper.GetKoreaderPosition(given));
}
[Theory]
[InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")]
public void GetKoreaderHash(string filePath, string hash)
{
Assert.Equal(KoreaderHelper.HashContents(filePath), hash);
}
private ProgressDto EmptyProgressDto()
{
return new ProgressDto
{
ChapterId = 0,
PageNum = 0,
VolumeId = 0,
SeriesId = 0,
LibraryId = 0
};
}
}

View file

@ -1,10 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using API.Entities.Enums;
using API.Helpers;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Helpers;
@ -12,215 +7,127 @@ public class PersonHelperTests : AbstractDbTest
{
protected override async Task ResetDb()
{
Context.Series.RemoveRange(Context.Series.ToList());
Context.Person.RemoveRange(Context.Person.ToList());
Context.Library.RemoveRange(Context.Library.ToList());
Context.Series.RemoveRange(Context.Series.ToList());
await Context.SaveChangesAsync();
}
// 1. Test adding new people and keeping existing ones
[Fact]
public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var existingPerson = new PersonBuilder("Joe Shmo").Build();
var chapter = new ChapterBuilder("1").Build();
// Create an existing person and assign them to the series with a role
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithFormat(MangaFormat.Archive)
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(existingPerson, PersonRole.Editor)
.Build())
.WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Call UpdateChapterPeopleAsync with one existing and one new person
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo", "New Person" }, PersonRole.Editor, UnitOfWork);
// Assert existing person retained and new person added
var people = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Contains(people, p => p.Name == "Joe Shmo");
Assert.Contains(people, p => p.Name == "New Person");
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
Assert.Contains("Joe Shmo", chapterPeople);
Assert.Contains("New Person", chapterPeople);
}
// 2. Test removing a person no longer in the list
[Fact]
public async Task UpdateChapterPeopleAsync_RemovePeople()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var existingPerson1 = new PersonBuilder("Joe Shmo").Build();
var existingPerson2 = new PersonBuilder("Jane Doe").Build();
var chapter = new ChapterBuilder("1")
.WithPerson(existingPerson1, PersonRole.Editor)
.WithPerson(existingPerson2, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Call UpdateChapterPeopleAsync with only one person
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
// PersonHelper does not remove the Person from the global DbSet itself
await UnitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated();
var people = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.DoesNotContain(people, p => p.Name == "Jane Doe");
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
Assert.Contains("Joe Shmo", chapterPeople);
Assert.DoesNotContain("Jane Doe", chapterPeople);
}
// 3. Test no changes when the list of people is the same
[Fact]
public async Task UpdateChapterPeopleAsync_NoChanges()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var existingPerson = new PersonBuilder("Joe Shmo").Build();
var chapter = new ChapterBuilder("1").WithPerson(existingPerson, PersonRole.Editor).Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Call UpdateChapterPeopleAsync with the same list
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
var people = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Contains(people, p => p.Name == "Joe Shmo");
var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
Assert.Contains("Joe Shmo", chapterPeople);
Assert.Single(chapter.People); // No duplicate entries
}
// 4. Test multiple roles for a person
[Fact]
public async Task UpdateChapterPeopleAsync_MultipleRoles()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var person = new PersonBuilder("Joe Shmo").Build();
var chapter = new ChapterBuilder("1").WithPerson(person, PersonRole.Writer).Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Add same person as Editor
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, UnitOfWork);
// Ensure that the same person is assigned with two roles
var chapterPeople = chapter
.People
.Where(cp =>
cp.Person.Name == "Joe Shmo")
.ToList();
Assert.Equal(2, chapterPeople.Count); // One for each role
Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer);
Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor);
}
[Fact]
public async Task UpdateChapterPeopleAsync_MatchOnAlias_NoChanges()
{
await ResetDb();
var library = new LibraryBuilder("My Library")
.Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var person = new PersonBuilder("Joe Doe")
.WithAlias("Jonny Doe")
.Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
await UnitOfWork.CommitAsync();
// Add on Name
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Doe" }, PersonRole.Editor, UnitOfWork);
await UnitOfWork.CommitAsync();
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
// Add on alias
await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Jonny Doe" }, PersonRole.Editor, UnitOfWork);
await UnitOfWork.CommitAsync();
allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
}
// TODO: Unit tests for series
//
// // 1. Test adding new people and keeping existing ones
// [Fact]
// public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained()
// {
// var existingPerson = new PersonBuilder("Joe Shmo").Build();
// var chapter = new ChapterBuilder("1").Build();
//
// // Create an existing person and assign them to the series with a role
// var series = new SeriesBuilder("Test 1")
// .WithFormat(MangaFormat.Archive)
// .WithMetadata(new SeriesMetadataBuilder()
// .WithPerson(existingPerson, PersonRole.Editor)
// .Build())
// .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build())
// .Build();
//
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync();
//
// // Call UpdateChapterPeopleAsync with one existing and one new person
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork);
//
// // Assert existing person retained and new person added
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
// Assert.Contains(people, p => p.Name == "Joe Shmo");
// Assert.Contains(people, p => p.Name == "New Person");
//
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains("Joe Shmo", chapterPeople);
// Assert.Contains("New Person", chapterPeople);
// }
//
// // 2. Test removing a person no longer in the list
// [Fact]
// public async Task UpdateChapterPeopleAsync_RemovePeople()
// {
// var existingPerson1 = new PersonBuilder("Joe Shmo").Build();
// var existingPerson2 = new PersonBuilder("Jane Doe").Build();
// var chapter = new ChapterBuilder("1").Build();
//
// var series = new SeriesBuilder("Test 1")
// .WithVolume(new VolumeBuilder("1")
// .WithChapter(new ChapterBuilder("1")
// .WithPerson(existingPerson1, PersonRole.Editor)
// .WithPerson(existingPerson2, PersonRole.Editor)
// .Build())
// .Build())
// .Build();
//
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync();
//
// // Call UpdateChapterPeopleAsync with only one person
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
//
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
// Assert.DoesNotContain(people, p => p.Name == "Jane Doe");
//
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains("Joe Shmo", chapterPeople);
// Assert.DoesNotContain("Jane Doe", chapterPeople);
// }
//
// // 3. Test no changes when the list of people is the same
// [Fact]
// public async Task UpdateChapterPeopleAsync_NoChanges()
// {
// var existingPerson = new PersonBuilder("Joe Shmo").Build();
// var chapter = new ChapterBuilder("1").Build();
//
// var series = new SeriesBuilder("Test 1")
// .WithVolume(new VolumeBuilder("1")
// .WithChapter(new ChapterBuilder("1")
// .WithPerson(existingPerson, PersonRole.Editor)
// .Build())
// .Build())
// .Build();
//
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync();
//
// // Call UpdateChapterPeopleAsync with the same list
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
//
// var people = await _unitOfWork.PersonRepository.GetAllPeople();
// Assert.Contains(people, p => p.Name == "Joe Shmo");
//
// var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList();
// Assert.Contains("Joe Shmo", chapterPeople);
// Assert.Single(chapter.People); // No duplicate entries
// }
//
// // 4. Test multiple roles for a person
// [Fact]
// public async Task UpdateChapterPeopleAsync_MultipleRoles()
// {
// var person = new PersonBuilder("Joe Shmo").Build();
// var chapter = new ChapterBuilder("1").Build();
//
// var series = new SeriesBuilder("Test 1")
// .WithVolume(new VolumeBuilder("1")
// .WithChapter(new ChapterBuilder("1")
// .WithPerson(person, PersonRole.Writer) // Assign person as Writer
// .Build())
// .Build())
// .Build();
//
// _unitOfWork.SeriesRepository.Add(series);
// await _unitOfWork.CommitAsync();
//
// // Add same person as Editor
// await PersonHelper.UpdateChapterPeopleAsync(chapter, new List<string> { "Joe Shmo" }, PersonRole.Editor, _unitOfWork);
//
// // Ensure that the same person is assigned with two roles
// var chapterPeople = chapter.People.Where(cp => cp.Person.Name == "Joe Shmo").ToList();
// Assert.Equal(2, chapterPeople.Count); // One for each role
// Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer);
// Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor);
// }
}

View file

@ -1,124 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace API.Tests.Helpers;
public class RandfHelper
{
private static readonly Random Random = new ();
/// <summary>
/// Returns true if all simple fields are equal
/// </summary>
/// <param name="obj1"></param>
/// <param name="obj2"></param>
/// <param name="ignoreFields">fields to ignore, note that the names are very weird sometimes</param>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
/// <exception cref="ArgumentException"></exception>
public static bool AreSimpleFieldsEqual(object obj1, object obj2, IList<string> ignoreFields)
{
if (obj1 == null || obj2 == null)
throw new ArgumentNullException("Neither object can be null.");
Type type1 = obj1.GetType();
Type type2 = obj2.GetType();
if (type1 != type2)
throw new ArgumentException("Objects must be of the same type.");
FieldInfo[] fields = type1.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic);
foreach (var field in fields)
{
if (field.IsInitOnly) continue;
if (ignoreFields.Contains(field.Name)) continue;
Type fieldType = field.FieldType;
if (IsRelevantType(fieldType))
{
object value1 = field.GetValue(obj1);
object value2 = field.GetValue(obj2);
if (!Equals(value1, value2))
{
throw new ArgumentException("Fields must be of the same type: " + field.Name + " was " + value1 + " and " + value2);
}
}
}
return true;
}
private static bool IsRelevantType(Type type)
{
return type.IsPrimitive
|| type == typeof(string)
|| type.IsEnum;
}
/// <summary>
/// Sets all simple fields of the given object to a random value
/// </summary>
/// <param name="obj"></param>
/// <remarks>Simple is, primitive, string, or enum</remarks>
/// <exception cref="ArgumentNullException"></exception>
public static void SetRandomValues(object obj)
{
if (obj == null) throw new ArgumentNullException(nameof(obj));
Type type = obj.GetType();
FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
foreach (var field in fields)
{
if (field.IsInitOnly) continue; // Skip readonly fields
object value = GenerateRandomValue(field.FieldType);
if (value != null)
{
field.SetValue(obj, value);
}
}
}
private static object GenerateRandomValue(Type type)
{
if (type == typeof(int))
return Random.Next();
if (type == typeof(float))
return (float)Random.NextDouble() * 100;
if (type == typeof(double))
return Random.NextDouble() * 100;
if (type == typeof(bool))
return Random.Next(2) == 1;
if (type == typeof(char))
return (char)Random.Next('A', 'Z' + 1);
if (type == typeof(byte))
return (byte)Random.Next(0, 256);
if (type == typeof(short))
return (short)Random.Next(short.MinValue, short.MaxValue);
if (type == typeof(long))
return (long)(Random.NextDouble() * long.MaxValue);
if (type == typeof(string))
return GenerateRandomString(10);
if (type.IsEnum)
{
var values = Enum.GetValues(type);
return values.GetValue(Random.Next(values.Length));
}
// Unsupported type
return null;
}
private static string GenerateRandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[Random.Next(s.Length)]).ToArray());
}
}

View file

@ -35,7 +35,7 @@ public class ScannerHelper
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases");
private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png");
private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" };
private static readonly string[] ComicInfoExtensions = [".cbz", ".cbr", ".zip", ".rar"];
public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper)
{
@ -43,7 +43,7 @@ public class ScannerHelper
_testOutputHelper = testOutputHelper;
}
public async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo> comicInfos = null)
public async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo>? comicInfos = null)
{
var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos);
@ -64,7 +64,7 @@ public class ScannerHelper
return library;
}
public ScannerService CreateServices(DirectoryService ds = null, IFileSystem fs = null)
public ScannerService CreateServices(DirectoryService? ds = null, IFileSystem? fs = null)
{
fs ??= new FileSystem();
ds ??= new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
@ -113,7 +113,7 @@ public class ScannerHelper
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo> comicInfos = null)
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo>? comicInfos = null)
{
// Read the map file
var mapContent = await File.ReadAllTextAsync(mapPath);
@ -130,7 +130,7 @@ public class ScannerHelper
Directory.CreateDirectory(testDirectory);
// Generate the files and folders
await Scaffold(testDirectory, filePaths, comicInfos);
await Scaffold(testDirectory, filePaths ?? [], comicInfos);
_testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}");
@ -138,18 +138,20 @@ public class ScannerHelper
}
public async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo> comicInfos = null)
public async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo>? comicInfos = null)
{
foreach (var relativePath in filePaths)
{
var fullPath = Path.Combine(testDirectory, relativePath);
var fileDir = Path.GetDirectoryName(fullPath);
if (string.IsNullOrEmpty(fileDir)) continue;
// Create the directory if it doesn't exist
if (!Directory.Exists(fileDir))
{
Directory.CreateDirectory(fileDir);
Console.WriteLine($"Created directory: {fileDir}");
_testOutputHelper.WriteLine($"Created directory: {fileDir}");
}
var ext = Path.GetExtension(fullPath).ToLower();
@ -161,7 +163,7 @@ public class ScannerHelper
{
// Create an empty file
await File.Create(fullPath).DisposeAsync();
Console.WriteLine($"Created empty file: {fullPath}");
_testOutputHelper.WriteLine($"Created empty file: {fullPath}");
}
}
}
@ -188,7 +190,7 @@ public class ScannerHelper
}
}
Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata.");
_testOutputHelper.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata.");
}

View file

@ -36,7 +36,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithComicInfo()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/",
RootDirectory, LibraryType.ComicVine, true, new ComicInfo()
RootDirectory, LibraryType.ComicVine, new ComicInfo()
{
Series = "Birds of Prey",
Volume = "2002"
@ -54,7 +54,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithDirectoryNameAsSeriesYear()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/",
RootDirectory, LibraryType.ComicVine, true, null);
RootDirectory, LibraryType.ComicVine, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (2002)", actual.Series);
@ -69,7 +69,7 @@ public class ComicVineParserTests
public void Parse_SeriesWithADirectoryNameAsSeriesYear()
{
var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/",
RootDirectory, LibraryType.ComicVine, true, null);
RootDirectory, LibraryType.ComicVine, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (1999)", actual.Series);
@ -84,7 +84,7 @@ public class ComicVineParserTests
public void Parse_FallbackToDirectoryNameOnly()
{
var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/",
RootDirectory, LibraryType.ComicVine, true, null);
RootDirectory, LibraryType.ComicVine, null);
Assert.NotNull(actual);
Assert.Equal("Blood Syndicate", actual.Series);

View file

@ -33,7 +33,7 @@ public class DefaultParserTests
[InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")]
public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries)
{
var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, true, null);
var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null);
if (actual == null)
{
Assert.NotNull(actual);
@ -74,7 +74,7 @@ public class DefaultParserTests
fs.AddFile(inputFile, new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var parser = new BasicParser(ds, new ImageParser(ds));
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null);
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null);
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo, actual.Series);
}
@ -90,7 +90,7 @@ public class DefaultParserTests
fs.AddFile(inputFile, new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var parser = new BasicParser(ds, new ImageParser(ds));
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null);
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null);
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo, actual.Series);
}
@ -251,7 +251,7 @@ public class DefaultParserTests
foreach (var file in expected.Keys)
{
var expectedInfo = expected[file];
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, true, null);
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null);
if (expectedInfo == null)
{
Assert.Null(actual);
@ -289,7 +289,7 @@ public class DefaultParserTests
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, true, null);
var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -315,7 +315,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, true, null);
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -341,7 +341,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, true, null);
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -383,7 +383,7 @@ public class DefaultParserTests
FullFilePath = filepath
};
var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null);
var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null);
Assert.NotNull(actual);
_testOutputHelper.WriteLine($"Validating {filepath}");
@ -412,7 +412,7 @@ public class DefaultParserTests
FullFilePath = filepath
};
actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null);
actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null);
Assert.NotNull(actual);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expected.Format, actual.Format);
@ -475,7 +475,7 @@ public class DefaultParserTests
foreach (var file in expected.Keys)
{
var expectedInfo = expected[file];
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, true, null);
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null);
if (expectedInfo == null)
{
Assert.Null(actual);

View file

@ -34,7 +34,7 @@ public class ImageParserTests
public void Parse_SeriesWithDirectoryName()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/",
RootDirectory, LibraryType.Image, true, null);
RootDirectory, LibraryType.Image, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
@ -48,7 +48,7 @@ public class ImageParserTests
public void Parse_SeriesWithNoNestedChapter()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/",
RootDirectory, LibraryType.Image, true, null);
RootDirectory, LibraryType.Image, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
@ -62,7 +62,7 @@ public class ImageParserTests
public void Parse_SeriesWithLooseImages()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/",
RootDirectory, LibraryType.Image, true, null);
RootDirectory, LibraryType.Image, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);

View file

@ -35,7 +35,7 @@ public class PdfParserTests
{
var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf",
"C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/",
RootDirectory, LibraryType.Book, true, null);
RootDirectory, LibraryType.Book, null);
Assert.NotNull(actual);
Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series);

View file

@ -34,7 +34,7 @@ public class ImageParsingTests
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, true, null);
var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -60,7 +60,7 @@ public class ImageParsingTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null);
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -86,7 +86,7 @@ public class ImageParsingTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null);
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);

View file

@ -68,8 +68,10 @@ public class MangaParsingTests
[InlineData("Манга Тома 1-4", "1-4")]
[InlineData("Манга Том 1-4", "1-4")]
[InlineData("조선왕조실톡 106화", "106")]
[InlineData("죽음 13회", "13")]
[InlineData("동의보감 13장", "13")]
[InlineData("몰?루 아카이브 7.5권", "7.5")]
[InlineData("주술회전 1.5권", "1.5")]
[InlineData("63권#200", "63")]
[InlineData("시즌34삽화2", "34")]
[InlineData("Accel World Chapter 001 Volume 002", "2")]

View file

@ -1,280 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Metadata.Browse;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Helpers;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Repository;
public class GenreRepositoryTests : AbstractDbTest
{
private AppUser _fullAccess;
private AppUser _restrictedAccess;
private AppUser _restrictedAgeAccess;
protected override async Task ResetDb()
{
Context.Genre.RemoveRange(Context.Genre);
Context.Library.RemoveRange(Context.Library);
await Context.SaveChangesAsync();
}
private TestGenreSet CreateTestGenres()
{
return new TestGenreSet
{
SharedSeriesChaptersGenre = new GenreBuilder("Shared Series Chapter Genre").Build(),
SharedSeriesGenre = new GenreBuilder("Shared Series Genre").Build(),
SharedChaptersGenre = new GenreBuilder("Shared Chapters Genre").Build(),
Lib0SeriesChaptersGenre = new GenreBuilder("Lib0 Series Chapter Genre").Build(),
Lib0SeriesGenre = new GenreBuilder("Lib0 Series Genre").Build(),
Lib0ChaptersGenre = new GenreBuilder("Lib0 Chapters Genre").Build(),
Lib1SeriesChaptersGenre = new GenreBuilder("Lib1 Series Chapter Genre").Build(),
Lib1SeriesGenre = new GenreBuilder("Lib1 Series Genre").Build(),
Lib1ChaptersGenre = new GenreBuilder("Lib1 Chapters Genre").Build(),
Lib1ChapterAgeGenre = new GenreBuilder("Lib1 Chapter Age Genre").Build()
};
}
private async Task SeedDbWithGenres(TestGenreSet genres)
{
await CreateTestUsers();
await AddGenresToContext(genres);
await CreateLibrariesWithGenres(genres);
await AssignLibrariesToUsers();
}
private async Task CreateTestUsers()
{
_fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build();
_restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build();
_restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build();
_restrictedAgeAccess.AgeRestriction = AgeRating.Teen;
_restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true;
Context.Users.Add(_fullAccess);
Context.Users.Add(_restrictedAccess);
Context.Users.Add(_restrictedAgeAccess);
await Context.SaveChangesAsync();
}
private async Task AddGenresToContext(TestGenreSet genres)
{
var allGenres = genres.GetAllGenres();
Context.Genre.AddRange(allGenres);
await Context.SaveChangesAsync();
}
private async Task CreateLibrariesWithGenres(TestGenreSet genres)
{
var lib0 = new LibraryBuilder("lib0")
.WithSeries(new SeriesBuilder("lib0-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0SeriesGenre])
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0ChaptersGenre])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre])
.Build())
.Build())
.Build())
.Build();
var lib1 = new LibraryBuilder("lib1")
.WithSeries(new SeriesBuilder("lib1-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre, genres.Lib1ChapterAgeGenre])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.Build())
.Build())
.WithSeries(new SeriesBuilder("lib1-s1")
.WithMetadata(new SeriesMetadataBuilder()
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre])
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre])
.Build())
.Build())
.Build())
.Build();
Context.Library.Add(lib0);
Context.Library.Add(lib1);
await Context.SaveChangesAsync();
}
private async Task AssignLibrariesToUsers()
{
var lib0 = Context.Library.First(l => l.Name == "lib0");
var lib1 = Context.Library.First(l => l.Name == "lib1");
_fullAccess.Libraries.Add(lib0);
_fullAccess.Libraries.Add(lib1);
_restrictedAccess.Libraries.Add(lib1);
_restrictedAgeAccess.Libraries.Add(lib1);
await Context.SaveChangesAsync();
}
private static Predicate<BrowseGenreDto> ContainsGenreCheck(Genre genre)
{
return g => g.Id == genre.Id;
}
private static void AssertGenrePresent(IEnumerable<BrowseGenreDto> genres, Genre expectedGenre)
{
Assert.Contains(genres, ContainsGenreCheck(expectedGenre));
}
private static void AssertGenreNotPresent(IEnumerable<BrowseGenreDto> genres, Genre expectedGenre)
{
Assert.DoesNotContain(genres, ContainsGenreCheck(expectedGenre));
}
private static BrowseGenreDto GetGenreDto(IEnumerable<BrowseGenreDto> genres, Genre genre)
{
return genres.First(dto => dto.Id == genre.Id);
}
[Fact]
public async Task GetBrowseableGenre_FullAccess_ReturnsAllGenresWithCorrectCounts()
{
// Arrange
await ResetDb();
var genres = CreateTestGenres();
await SeedDbWithGenres(genres);
// Act
var fullAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_fullAccess.Id, new UserParams());
// Assert
Assert.Equal(genres.GetAllGenres().Count, fullAccessGenres.TotalCount);
foreach (var genre in genres.GetAllGenres())
{
AssertGenrePresent(fullAccessGenres, genre);
}
// Verify counts - 1 lib0 series, 2 lib1 series = 3 total series
Assert.Equal(3, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount);
Assert.Equal(6, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount);
Assert.Equal(1, GetGenreDto(fullAccessGenres, genres.Lib0SeriesGenre).SeriesCount);
}
[Fact]
public async Task GetBrowseableGenre_RestrictedAccess_ReturnsOnlyAccessibleGenres()
{
// Arrange
await ResetDb();
var genres = CreateTestGenres();
await SeedDbWithGenres(genres);
// Act
var restrictedAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAccess.Id, new UserParams());
// Assert - Should see: 3 shared + 4 library 1 specific = 7 genres
Assert.Equal(7, restrictedAccessGenres.TotalCount);
// Verify shared and Library 1 genres are present
AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesChaptersGenre);
AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesGenre);
AssertGenrePresent(restrictedAccessGenres, genres.SharedChaptersGenre);
AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesChaptersGenre);
AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesGenre);
AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChaptersGenre);
AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChapterAgeGenre);
// Verify Library 0 specific genres are not present
AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesChaptersGenre);
AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesGenre);
AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0ChaptersGenre);
// Verify counts - 2 lib1 series
Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount);
Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount);
Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.Lib1SeriesGenre).SeriesCount);
Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.Lib1ChaptersGenre).ChapterCount);
Assert.Equal(1, GetGenreDto(restrictedAccessGenres, genres.Lib1ChapterAgeGenre).ChapterCount);
}
[Fact]
public async Task GetBrowseableGenre_RestrictedAgeAccess_FiltersAgeRestrictedContent()
{
// Arrange
await ResetDb();
var genres = CreateTestGenres();
await SeedDbWithGenres(genres);
// Act
var restrictedAgeAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAgeAccess.Id, new UserParams());
// Assert - Should see: 3 shared + 3 lib1 specific = 6 genres (age-restricted genre filtered out)
Assert.Equal(6, restrictedAgeAccessGenres.TotalCount);
// Verify accessible genres are present
AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedChaptersGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesChaptersGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesGenre);
AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre);
// Verify age-restricted genre is filtered out
AssertGenreNotPresent(restrictedAgeAccessGenres, genres.Lib1ChapterAgeGenre);
// Verify counts - 1 series lib1 (age-restricted series filtered out)
Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount);
Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1SeriesGenre).SeriesCount);
// These values represent a bug - chapters are not properly filtered when their series is age-restricted
// Should be 2, but currently returns 3 due to the filtering issue
Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount);
Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre).ChapterCount);
}
private class TestGenreSet
{
public Genre SharedSeriesChaptersGenre { get; set; }
public Genre SharedSeriesGenre { get; set; }
public Genre SharedChaptersGenre { get; set; }
public Genre Lib0SeriesChaptersGenre { get; set; }
public Genre Lib0SeriesGenre { get; set; }
public Genre Lib0ChaptersGenre { get; set; }
public Genre Lib1SeriesChaptersGenre { get; set; }
public Genre Lib1SeriesGenre { get; set; }
public Genre Lib1ChaptersGenre { get; set; }
public Genre Lib1ChapterAgeGenre { get; set; }
public List<Genre> GetAllGenres()
{
return
[
SharedSeriesChaptersGenre, SharedSeriesGenre, SharedChaptersGenre,
Lib0SeriesChaptersGenre, Lib0SeriesGenre, Lib0ChaptersGenre,
Lib1SeriesChaptersGenre, Lib1SeriesGenre, Lib1ChaptersGenre, Lib1ChapterAgeGenre
];
}
}
}

View file

@ -1,342 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Metadata.Browse;
using API.DTOs.Metadata.Browse.Requests;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
using API.Helpers;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Repository;
public class PersonRepositoryTests : AbstractDbTest
{
private AppUser _fullAccess;
private AppUser _restrictedAccess;
private AppUser _restrictedAgeAccess;
protected override async Task ResetDb()
{
Context.Person.RemoveRange(Context.Person.ToList());
Context.Library.RemoveRange(Context.Library.ToList());
Context.AppUser.RemoveRange(Context.AppUser.ToList());
await UnitOfWork.CommitAsync();
}
private async Task SeedDb()
{
_fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build();
_restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build();
_restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build();
_restrictedAgeAccess.AgeRestriction = AgeRating.Teen;
_restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true;
Context.AppUser.Add(_fullAccess);
Context.AppUser.Add(_restrictedAccess);
Context.AppUser.Add(_restrictedAgeAccess);
await Context.SaveChangesAsync();
var people = CreateTestPeople();
Context.Person.AddRange(people);
await Context.SaveChangesAsync();
var libraries = CreateTestLibraries(people);
Context.Library.AddRange(libraries);
await Context.SaveChangesAsync();
_fullAccess.Libraries.Add(libraries[0]); // lib0
_fullAccess.Libraries.Add(libraries[1]); // lib1
_restrictedAccess.Libraries.Add(libraries[1]); // lib1 only
_restrictedAgeAccess.Libraries.Add(libraries[1]); // lib1 only
await Context.SaveChangesAsync();
}
private static List<Person> CreateTestPeople()
{
return new List<Person>
{
new PersonBuilder("Shared Series Chapter Person").Build(),
new PersonBuilder("Shared Series Person").Build(),
new PersonBuilder("Shared Chapters Person").Build(),
new PersonBuilder("Lib0 Series Chapter Person").Build(),
new PersonBuilder("Lib0 Series Person").Build(),
new PersonBuilder("Lib0 Chapters Person").Build(),
new PersonBuilder("Lib1 Series Chapter Person").Build(),
new PersonBuilder("Lib1 Series Person").Build(),
new PersonBuilder("Lib1 Chapters Person").Build(),
new PersonBuilder("Lib1 Chapter Age Person").Build()
};
}
private static List<Library> CreateTestLibraries(List<Person> people)
{
var lib0 = new LibraryBuilder("lib0")
.WithSeries(new SeriesBuilder("lib0-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Writer)
.WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Writer)
.WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Writer)
.WithPerson(GetPersonByName(people, "Lib0 Series Person"), PersonRole.Writer)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Colorist)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Colorist)
.WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Colorist)
.WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Colorist)
.Build())
.WithChapter(new ChapterBuilder("2")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Editor)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Editor)
.WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Editor)
.WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Editor)
.Build())
.Build())
.Build())
.Build();
var lib1 = new LibraryBuilder("lib1")
.WithSeries(new SeriesBuilder("lib1-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Letterer)
.WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Letterer)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Letterer)
.WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Letterer)
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Imprint)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Imprint)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Imprint)
.WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Imprint)
.Build())
.WithChapter(new ChapterBuilder("2")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.CoverArtist)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.CoverArtist)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.CoverArtist)
.WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.CoverArtist)
.WithPerson(GetPersonByName(people, "Lib1 Chapter Age Person"), PersonRole.CoverArtist)
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.Build())
.Build())
.WithSeries(new SeriesBuilder("lib1-s1")
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Inker)
.WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Inker)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Inker)
.WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Inker)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Team)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Team)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Team)
.WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Team)
.Build())
.WithChapter(new ChapterBuilder("2")
.WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Translator)
.WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Translator)
.WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Translator)
.WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Translator)
.Build())
.Build())
.Build())
.Build();
return new List<Library> { lib0, lib1 };
}
private static Person GetPersonByName(List<Person> people, string name)
{
return people.First(p => p.Name == name);
}
private Person GetPersonByName(string name)
{
return Context.Person.First(p => p.Name == name);
}
private static Predicate<BrowsePersonDto> ContainsPersonCheck(Person person)
{
return p => p.Id == person.Id;
}
[Fact]
public async Task GetBrowsePersonDtos()
{
await ResetDb();
await SeedDb();
// Get people from database for assertions
var sharedSeriesChaptersPerson = GetPersonByName("Shared Series Chapter Person");
var lib0SeriesPerson = GetPersonByName("Lib0 Series Person");
var lib1SeriesPerson = GetPersonByName("Lib1 Series Person");
var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person");
var allPeople = Context.Person.ToList();
var fullAccessPeople =
await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_fullAccess.Id, new BrowsePersonFilterDto(),
new UserParams());
Assert.Equal(allPeople.Count, fullAccessPeople.TotalCount);
foreach (var person in allPeople)
Assert.Contains(fullAccessPeople, ContainsPersonCheck(person));
// 1 series in lib0, 2 series in lib1
Assert.Equal(3, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount);
// 3 series with each 2 chapters
Assert.Equal(6, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount);
// 1 series in lib0
Assert.Equal(1, fullAccessPeople.First(dto => dto.Id == lib0SeriesPerson.Id).SeriesCount);
// 2 series in lib1
Assert.Equal(2, fullAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount);
var restrictedAccessPeople =
await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAccess.Id, new BrowsePersonFilterDto(),
new UserParams());
Assert.Equal(7, restrictedAccessPeople.TotalCount);
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Chapter Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Chapters Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Chapter Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapters Person")));
Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapter Age Person")));
// 2 series in lib1, no series in lib0
Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount);
// 2 series with each 2 chapters
Assert.Equal(4, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount);
// 2 series in lib1
Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount);
var restrictedAgeAccessPeople = await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAgeAccess.Id,
new BrowsePersonFilterDto(), new UserParams());
// Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up
Assert.Equal(6, restrictedAgeAccessPeople.TotalCount);
// No access to the age restricted chapter
Assert.DoesNotContain(restrictedAgeAccessPeople, ContainsPersonCheck(lib1ChapterAgePerson));
}
[Fact]
public async Task GetRolesForPersonByName()
{
await ResetDb();
await SeedDb();
var sharedSeriesPerson = GetPersonByName("Shared Series Person");
var sharedChaptersPerson = GetPersonByName("Shared Chapters Person");
var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person");
var sharedSeriesRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _fullAccess.Id);
var chapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _fullAccess.Id);
var ageChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _fullAccess.Id);
Assert.Equal(3, sharedSeriesRoles.Count());
Assert.Equal(6, chapterRoles.Count());
Assert.Single(ageChapterRoles);
var restrictedRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAccess.Id);
var restrictedChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAccess.Id);
var restrictedAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAccess.Id);
Assert.Equal(2, restrictedRoles.Count());
Assert.Equal(4, restrictedChapterRoles.Count());
Assert.Single(restrictedAgePersonChapterRoles);
var restrictedAgeRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAgeAccess.Id);
var restrictedAgeChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAgeAccess.Id);
var restrictedAgeAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAgeAccess.Id);
Assert.Single(restrictedAgeRoles);
Assert.Equal(2, restrictedAgeChapterRoles.Count());
// Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up
Assert.Empty(restrictedAgeAgePersonChapterRoles);
}
[Fact]
public async Task GetPersonDtoByName()
{
await ResetDb();
await SeedDb();
var allPeople = Context.Person.ToList();
foreach (var person in allPeople)
{
Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName(person.Name, _fullAccess.Id));
}
Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAccess.Id));
Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Shared Series Person", _restrictedAccess.Id));
Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAccess.Id));
Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAgeAccess.Id));
Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAgeAccess.Id));
// Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up
Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Chapter Age Person", _restrictedAgeAccess.Id));
}
[Fact]
public async Task GetSeriesKnownFor()
{
await ResetDb();
await SeedDb();
var sharedSeriesPerson = GetPersonByName("Shared Series Person");
var lib1SeriesPerson = GetPersonByName("Lib1 Series Person");
var series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _fullAccess.Id);
Assert.Equal(3, series.Count());
series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAccess.Id);
Assert.Equal(2, series.Count());
series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAgeAccess.Id);
Assert.Single(series);
series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(lib1SeriesPerson.Id, _restrictedAgeAccess.Id);
Assert.Single(series);
}
[Fact]
public async Task GetChaptersForPersonByRole()
{
await ResetDb();
await SeedDb();
var sharedChaptersPerson = GetPersonByName("Shared Chapters Person");
// Lib0
var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Colorist);
var restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Colorist);
var restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Colorist);
Assert.Single(chapters);
Assert.Empty(restrictedChapters);
Assert.Empty(restrictedAgeChapters);
// Lib1 - age restricted series
chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Imprint);
restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Imprint);
restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Imprint);
Assert.Single(chapters);
Assert.Single(restrictedChapters);
Assert.Empty(restrictedAgeChapters);
// Lib1 - not age restricted series
chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Team);
restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Team);
restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Team);
Assert.Single(chapters);
Assert.Single(restrictedChapters);
Assert.Single(restrictedAgeChapters);
}
}

View file

@ -1,278 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Metadata.Browse;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Helpers;
using API.Helpers.Builders;
using Xunit;
namespace API.Tests.Repository;
public class TagRepositoryTests : AbstractDbTest
{
private AppUser _fullAccess;
private AppUser _restrictedAccess;
private AppUser _restrictedAgeAccess;
protected override async Task ResetDb()
{
Context.Tag.RemoveRange(Context.Tag);
Context.Library.RemoveRange(Context.Library);
await Context.SaveChangesAsync();
}
private TestTagSet CreateTestTags()
{
return new TestTagSet
{
SharedSeriesChaptersTag = new TagBuilder("Shared Series Chapter Tag").Build(),
SharedSeriesTag = new TagBuilder("Shared Series Tag").Build(),
SharedChaptersTag = new TagBuilder("Shared Chapters Tag").Build(),
Lib0SeriesChaptersTag = new TagBuilder("Lib0 Series Chapter Tag").Build(),
Lib0SeriesTag = new TagBuilder("Lib0 Series Tag").Build(),
Lib0ChaptersTag = new TagBuilder("Lib0 Chapters Tag").Build(),
Lib1SeriesChaptersTag = new TagBuilder("Lib1 Series Chapter Tag").Build(),
Lib1SeriesTag = new TagBuilder("Lib1 Series Tag").Build(),
Lib1ChaptersTag = new TagBuilder("Lib1 Chapters Tag").Build(),
Lib1ChapterAgeTag = new TagBuilder("Lib1 Chapter Age Tag").Build()
};
}
private async Task SeedDbWithTags(TestTagSet tags)
{
await CreateTestUsers();
await AddTagsToContext(tags);
await CreateLibrariesWithTags(tags);
await AssignLibrariesToUsers();
}
private async Task CreateTestUsers()
{
_fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build();
_restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build();
_restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build();
_restrictedAgeAccess.AgeRestriction = AgeRating.Teen;
_restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true;
Context.Users.Add(_fullAccess);
Context.Users.Add(_restrictedAccess);
Context.Users.Add(_restrictedAgeAccess);
await Context.SaveChangesAsync();
}
private async Task AddTagsToContext(TestTagSet tags)
{
var allTags = tags.GetAllTags();
Context.Tag.AddRange(allTags);
await Context.SaveChangesAsync();
}
private async Task CreateLibrariesWithTags(TestTagSet tags)
{
var lib0 = new LibraryBuilder("lib0")
.WithSeries(new SeriesBuilder("lib0-s0")
.WithMetadata(new SeriesMetadata
{
Tags = [tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib0SeriesChaptersTag, tags.Lib0SeriesTag]
})
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib0SeriesChaptersTag, tags.Lib0ChaptersTag])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag])
.Build())
.Build())
.Build())
.Build();
var lib1 = new LibraryBuilder("lib1")
.WithSeries(new SeriesBuilder("lib1-s0")
.WithMetadata(new SeriesMetadataBuilder()
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag, tags.Lib1ChapterAgeTag])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.Build())
.Build())
.WithSeries(new SeriesBuilder("lib1-s1")
.WithMetadata(new SeriesMetadataBuilder()
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag])
.Build())
.WithVolume(new VolumeBuilder("1")
.WithChapter(new ChapterBuilder("1")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag])
.Build())
.WithChapter(new ChapterBuilder("2")
.WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag])
.WithAgeRating(AgeRating.Mature17Plus)
.Build())
.Build())
.Build())
.Build();
Context.Library.Add(lib0);
Context.Library.Add(lib1);
await Context.SaveChangesAsync();
}
private async Task AssignLibrariesToUsers()
{
var lib0 = Context.Library.First(l => l.Name == "lib0");
var lib1 = Context.Library.First(l => l.Name == "lib1");
_fullAccess.Libraries.Add(lib0);
_fullAccess.Libraries.Add(lib1);
_restrictedAccess.Libraries.Add(lib1);
_restrictedAgeAccess.Libraries.Add(lib1);
await Context.SaveChangesAsync();
}
private static Predicate<BrowseTagDto> ContainsTagCheck(Tag tag)
{
return t => t.Id == tag.Id;
}
private static void AssertTagPresent(IEnumerable<BrowseTagDto> tags, Tag expectedTag)
{
Assert.Contains(tags, ContainsTagCheck(expectedTag));
}
private static void AssertTagNotPresent(IEnumerable<BrowseTagDto> tags, Tag expectedTag)
{
Assert.DoesNotContain(tags, ContainsTagCheck(expectedTag));
}
private static BrowseTagDto GetTagDto(IEnumerable<BrowseTagDto> tags, Tag tag)
{
return tags.First(dto => dto.Id == tag.Id);
}
[Fact]
public async Task GetBrowseableTag_FullAccess_ReturnsAllTagsWithCorrectCounts()
{
// Arrange
await ResetDb();
var tags = CreateTestTags();
await SeedDbWithTags(tags);
// Act
var fullAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_fullAccess.Id, new UserParams());
// Assert
Assert.Equal(tags.GetAllTags().Count, fullAccessTags.TotalCount);
foreach (var tag in tags.GetAllTags())
{
AssertTagPresent(fullAccessTags, tag);
}
// Verify counts - 1 series lib0, 2 series lib1 = 3 total series
Assert.Equal(3, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).SeriesCount);
Assert.Equal(6, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).ChapterCount);
Assert.Equal(1, GetTagDto(fullAccessTags, tags.Lib0SeriesTag).SeriesCount);
}
[Fact]
public async Task GetBrowseableTag_RestrictedAccess_ReturnsOnlyAccessibleTags()
{
// Arrange
await ResetDb();
var tags = CreateTestTags();
await SeedDbWithTags(tags);
// Act
var restrictedAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAccess.Id, new UserParams());
// Assert - Should see: 3 shared + 4 library 1 specific = 7 tags
Assert.Equal(7, restrictedAccessTags.TotalCount);
// Verify shared and Library 1 tags are present
AssertTagPresent(restrictedAccessTags, tags.SharedSeriesChaptersTag);
AssertTagPresent(restrictedAccessTags, tags.SharedSeriesTag);
AssertTagPresent(restrictedAccessTags, tags.SharedChaptersTag);
AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesChaptersTag);
AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesTag);
AssertTagPresent(restrictedAccessTags, tags.Lib1ChaptersTag);
AssertTagPresent(restrictedAccessTags, tags.Lib1ChapterAgeTag);
// Verify Library 0 specific tags are not present
AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesChaptersTag);
AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesTag);
AssertTagNotPresent(restrictedAccessTags, tags.Lib0ChaptersTag);
// Verify counts - 2 series lib1
Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).SeriesCount);
Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).ChapterCount);
Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.Lib1SeriesTag).SeriesCount);
Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.Lib1ChaptersTag).ChapterCount);
}
[Fact]
public async Task GetBrowseableTag_RestrictedAgeAccess_FiltersAgeRestrictedContent()
{
// Arrange
await ResetDb();
var tags = CreateTestTags();
await SeedDbWithTags(tags);
// Act
var restrictedAgeAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAgeAccess.Id, new UserParams());
// Assert - Should see: 3 shared + 3 lib1 specific = 6 tags (age-restricted tag filtered out)
Assert.Equal(6, restrictedAgeAccessTags.TotalCount);
// Verify accessible tags are present
AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag);
AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesTag);
AssertTagPresent(restrictedAgeAccessTags, tags.SharedChaptersTag);
AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesChaptersTag);
AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesTag);
AssertTagPresent(restrictedAgeAccessTags, tags.Lib1ChaptersTag);
// Verify age-restricted tag is filtered out
AssertTagNotPresent(restrictedAgeAccessTags, tags.Lib1ChapterAgeTag);
// Verify counts - 1 series lib1 (age-restricted series filtered out)
Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).SeriesCount);
Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).ChapterCount);
Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.Lib1SeriesTag).SeriesCount);
Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.Lib1ChaptersTag).ChapterCount);
}
private class TestTagSet
{
public Tag SharedSeriesChaptersTag { get; set; }
public Tag SharedSeriesTag { get; set; }
public Tag SharedChaptersTag { get; set; }
public Tag Lib0SeriesChaptersTag { get; set; }
public Tag Lib0SeriesTag { get; set; }
public Tag Lib0ChaptersTag { get; set; }
public Tag Lib1SeriesChaptersTag { get; set; }
public Tag Lib1SeriesTag { get; set; }
public Tag Lib1ChaptersTag { get; set; }
public Tag Lib1ChapterAgeTag { get; set; }
public List<Tag> GetAllTags()
{
return
[
SharedSeriesChaptersTag, SharedSeriesTag, SharedChaptersTag,
Lib0SeriesChaptersTag, Lib0SeriesTag, Lib0ChaptersTag,
Lib1SeriesChaptersTag, Lib1SeriesTag, Lib1ChaptersTag, Lib1ChapterAgeTag
];
}
}
}

View file

@ -137,7 +137,7 @@ public class BookServiceTests
var comicInfo = _bookService.GetComicInfo(filePath);
Assert.NotNull(comicInfo);
var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, true, comicInfo);
var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, comicInfo);
Assert.NotNull(parserInfo);
Assert.Equal(parserInfo.Title, comicInfo.Title);
Assert.Equal(parserInfo.Series, comicInfo.Title);

View file

@ -50,12 +50,12 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
throw new System.NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true)
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
{
throw new System.NotImplementedException();
}
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true)
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
{
throw new System.NotImplementedException();
}

View file

@ -15,7 +15,6 @@ using API.Entities.Person;
using API.Helpers.Builders;
using API.Services.Plus;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Hangfire;
using Microsoft.EntityFrameworkCore;
@ -43,7 +42,7 @@ public class ExternalMetadataServiceTests : AbstractDbTest
_externalMetadataService = new ExternalMetadataService(UnitOfWork, Substitute.For<ILogger<ExternalMetadataService>>(),
Mapper, Substitute.For<ILicenseService>(), Substitute.For<IScrobblingService>(), Substitute.For<IEventHub>(),
Substitute.For<ICoverDbService>(), Substitute.For<IKavitaPlusApiService>());
Substitute.For<ICoverDbService>());
}
#region Gloabl
@ -882,217 +881,6 @@ public class ExternalMetadataServiceTests : AbstractDbTest
}
[Fact]
public void IsSeriesCompleted_ExactMatch()
{
const string seriesName = "Test - Exact Match";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.WithMaxCount(5)
.WithTotalCount(5)
.Build())
.Build();
var chapters = new List<Chapter>();
var externalMetadata = new ExternalSeriesDetailDto { Chapters = 5, Volumes = 0 };
var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber);
Assert.True(result);
}
[Fact]
public void IsSeriesCompleted_Volumes_DecimalVolumes()
{
const string seriesName = "Test - Volume Complete";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.WithMaxCount(2)
.WithTotalCount(3)
.Build())
.WithVolume(new VolumeBuilder("1").WithNumber(1).Build())
.WithVolume(new VolumeBuilder("2").WithNumber(2).Build())
.WithVolume(new VolumeBuilder("2.5").WithNumber(2.5f).Build())
.Build();
var chapters = new List<Chapter>();
// External metadata includes decimal volume 2.5
var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 };
var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2);
Assert.True(result);
Assert.Equal(3, series.Metadata.MaxCount);
Assert.Equal(3, series.Metadata.TotalCount);
}
/// <summary>
/// This is validating that we get a completed even though we have a special chapter and AL doesn't count it
/// </summary>
[Fact]
public void IsSeriesCompleted_Volumes_HasSpecialAndDecimal_ExternalNoSpecial()
{
const string seriesName = "Test - Volume Complete";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.WithMaxCount(2)
.WithTotalCount(3)
.Build())
.WithVolume(new VolumeBuilder("1").WithNumber(1).Build())
.WithVolume(new VolumeBuilder("1.5").WithNumber(1.5f).Build())
.WithVolume(new VolumeBuilder("2").WithNumber(2).Build())
.WithVolume(new VolumeBuilder(Parser.SpecialVolume).Build())
.Build();
var chapters = new List<Chapter>();
// External metadata includes volume 1.5, but not the special
var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 };
var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2);
Assert.True(result);
Assert.Equal(3, series.Metadata.MaxCount);
Assert.Equal(3, series.Metadata.TotalCount);
}
/// <remarks>
/// This unit test also illustrates the bug where you may get a false positive if you had Volumes 1,2, and 2.1. While
/// missing volume 3. With the external metadata expecting non-decimal volumes.
/// i.e. it would fail if we only had one decimal volume
/// </remarks>
[Fact]
public void IsSeriesCompleted_Volumes_TooManyDecimalVolumes()
{
const string seriesName = "Test - Volume Complete";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.WithMaxCount(2)
.WithTotalCount(3)
.Build())
.WithVolume(new VolumeBuilder("1").WithNumber(1).Build())
.WithVolume(new VolumeBuilder("2").WithNumber(2).Build())
.WithVolume(new VolumeBuilder("2.1").WithNumber(2.1f).Build())
.WithVolume(new VolumeBuilder("2.2").WithNumber(2.2f).Build())
.Build();
var chapters = new List<Chapter>();
// External metadata includes no special or decimals. There are 3 volumes. And we're missing volume 3
var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 };
var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2);
Assert.False(result);
}
[Fact]
public void IsSeriesCompleted_NoVolumes_GEQChapterCheck()
{
// We own 11 chapters, the external metadata expects 10
const string seriesName = "Test - Chapter MaxCount, no volumes";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.WithMaxCount(11)
.WithTotalCount(10)
.Build())
.Build();
var chapters = new List<Chapter>();
var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 };
var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber);
Assert.True(result);
Assert.Equal(11, series.Metadata.TotalCount);
Assert.Equal(11, series.Metadata.MaxCount);
}
[Fact]
public void IsSeriesCompleted_NoVolumes_IncludeAllChaptersCheck()
{
const string seriesName = "Test - Chapter Count";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.WithMaxCount(7)
.WithTotalCount(10)
.Build())
.Build();
var chapters = new List<Chapter>
{
new ChapterBuilder("0").Build(),
new ChapterBuilder("2").Build(),
new ChapterBuilder("3").Build(),
new ChapterBuilder("4").Build(),
new ChapterBuilder("5").Build(),
new ChapterBuilder("6").Build(),
new ChapterBuilder("7").Build(),
new ChapterBuilder("7.1").Build(),
new ChapterBuilder("7.2").Build(),
new ChapterBuilder("7.3").Build()
};
// External metadata includes prologues (0) and extra's (7.X)
var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 };
var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber);
Assert.True(result);
Assert.Equal(10, series.Metadata.TotalCount);
Assert.Equal(10, series.Metadata.MaxCount);
}
[Fact]
public void IsSeriesCompleted_NotEnoughVolumes()
{
const string seriesName = "Test - Incomplete Volume";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.WithMaxCount(2)
.WithTotalCount(5)
.Build())
.WithVolume(new VolumeBuilder("1").WithNumber(1).Build())
.WithVolume(new VolumeBuilder("2").WithNumber(2).Build())
.Build();
var chapters = new List<Chapter>();
var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 5 };
var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2);
Assert.False(result);
}
[Fact]
public void IsSeriesCompleted_NoVolumes_NotEnoughChapters()
{
const string seriesName = "Test - Incomplete Chapter";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.WithMaxCount(5)
.WithTotalCount(8)
.Build())
.Build();
var chapters = new List<Chapter>
{
new ChapterBuilder("1").Build(),
new ChapterBuilder("2").Build(),
new ChapterBuilder("3").Build()
};
var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 };
var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber);
Assert.False(result);
}
#endregion
@ -1890,130 +1678,6 @@ public class ExternalMetadataServiceTests : AbstractDbTest
#endregion
#region People Alias
[Fact]
public async Task PeopleAliasing_AddAsAlias()
{
await ResetDb();
const string seriesName = "Test - People - Add as Alias";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.Build())
.Build();
Context.Series.Attach(series);
Context.Person.Add(new PersonBuilder("John Doe").Build());
await Context.SaveChangesAsync();
var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings();
metadataSettings.Enabled = true;
metadataSettings.EnablePeople = true;
metadataSettings.FirstLastPeopleNaming = true;
metadataSettings.Overrides = [MetadataSettingField.People];
metadataSettings.PersonRoles = [PersonRole.Writer];
Context.MetadataSettings.Update(metadataSettings);
await Context.SaveChangesAsync();
await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto()
{
Name = seriesName,
Staff = [CreateStaff("Doe", "John", "Story")]
}, 1);
var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata);
Assert.NotNull(postSeries);
var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList();
Assert.Single(allWriters);
var johnDoe = allWriters[0].Person;
Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias));
}
[Fact]
public async Task PeopleAliasing_AddOnAlias()
{
await ResetDb();
const string seriesName = "Test - People - Add as Alias";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.Build())
.Build();
Context.Series.Attach(series);
Context.Person.Add(new PersonBuilder("John Doe").WithAlias("Doe John").Build());
await Context.SaveChangesAsync();
var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings();
metadataSettings.Enabled = true;
metadataSettings.EnablePeople = true;
metadataSettings.FirstLastPeopleNaming = true;
metadataSettings.Overrides = [MetadataSettingField.People];
metadataSettings.PersonRoles = [PersonRole.Writer];
Context.MetadataSettings.Update(metadataSettings);
await Context.SaveChangesAsync();
await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto()
{
Name = seriesName,
Staff = [CreateStaff("Doe", "John", "Story")]
}, 1);
var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata);
Assert.NotNull(postSeries);
var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList();
Assert.Single(allWriters);
var johnDoe = allWriters[0].Person;
Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias));
}
[Fact]
public async Task PeopleAliasing_DontAddAsAlias_SameButNotSwitched()
{
await ResetDb();
const string seriesName = "Test - People - Add as Alias";
var series = new SeriesBuilder(seriesName)
.WithLibraryId(1)
.WithMetadata(new SeriesMetadataBuilder()
.Build())
.Build();
Context.Series.Attach(series);
await Context.SaveChangesAsync();
var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings();
metadataSettings.Enabled = true;
metadataSettings.EnablePeople = true;
metadataSettings.FirstLastPeopleNaming = true;
metadataSettings.Overrides = [MetadataSettingField.People];
metadataSettings.PersonRoles = [PersonRole.Writer];
Context.MetadataSettings.Update(metadataSettings);
await Context.SaveChangesAsync();
await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto()
{
Name = seriesName,
Staff = [CreateStaff("John", "Doe Doe", "Story"), CreateStaff("Doe", "John Doe", "Story")]
}, 1);
var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata);
Assert.NotNull(postSeries);
var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList();
Assert.Equal(2, allWriters.Count);
}
#endregion
#region People - Characters
[Fact]
@ -3147,8 +2811,6 @@ public class ExternalMetadataServiceTests : AbstractDbTest
metadataSettings.EnableTags = false;
metadataSettings.EnablePublicationStatus = false;
metadataSettings.EnableStartDate = false;
metadataSettings.FieldMappings = [];
metadataSettings.AgeRatingMappings = new Dictionary<string, AgeRating>();
Context.MetadataSettings.Update(metadataSettings);
await Context.SaveChangesAsync();

View file

@ -0,0 +1,156 @@
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs.Internal.Scanner;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner;
using API.Services.Tasks.Scanner.Parser;
using API.Tests.Helpers;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Services;
public class FileScannerTests : AbstractDbTest
{
private readonly FileScanner _fileScanner;
private readonly IDirectoryService _directoryService;
private readonly ScannerHelper _scannerHelper;
private readonly string _outputDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases");
public FileScannerTests(ITestOutputHelper testOutputHelper)
{
_directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
_fileScanner = new FileScanner(_directoryService, UnitOfWork);
_scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper);
}
#region ScanFiles - Basic Tests
/// <summary>
/// Validates that FileTypePattern works
/// </summary>
[Fact]
public async Task ScanFiles_ShouldIncludeOnlyArchiveTypes()
{
const string testcase = "Flat Series - Manga.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var folder = library.Folders.First().Path;
var options = new ScannerOption
{
FolderPaths = [folder],
FileTypePattern = [FileTypeGroup.Archive],
ExcludePatterns = []
};
var result = _fileScanner.ScanFiles(options);
Assert.Single(result); // One folder
var scanned = result[0];
Assert.Equal(Parser.NormalizePath(Path.Join(folder, "My Dress-Up Darling")), scanned.DirectoryPath);
Assert.All(scanned.Files, file =>
{
Assert.EndsWith(".cbz", file.FilePath);
});
}
[Fact]
public async Task ScanFiles_ShouldIncludeMultipleTypes()
{
const string testcase = "Mixed Formats - Manga.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var folder = library.Folders.First().Path;
var options = new ScannerOption
{
FolderPaths = [folder],
FileTypePattern = [FileTypeGroup.Archive, FileTypeGroup.Epub],
ExcludePatterns = []
};
var result = _fileScanner.ScanFiles(options);
Assert.Single(result); // One folder
var scanned = result[0];
Assert.Equal(Parser.NormalizePath(Path.Join(folder, "My Dress-Up Darling")), scanned.DirectoryPath);
var validExtensions = new[] { ".cbz", ".epub" };
Assert.All(scanned.Files, file =>
{
Assert.Contains(Path.GetExtension(file.FilePath)?.ToLowerInvariant(), validExtensions);
});
}
#endregion
#region ScannFiles - Exclude Patterns
[Fact]
public async Task ScanFiles_ShouldExcludeMatchingPattern()
{
const string testcase = "Flat Series - Manga.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var folder = library.Folders.First().Path;
var options = new ScannerOption
{
FolderPaths = [folder],
FileTypePattern = [FileTypeGroup.Archive],
ExcludePatterns = ["*ch 10.cbz"] // Exclude chapter 10
};
var result = _fileScanner.ScanFiles(options);
var scannedFiles = result.SelectMany(d => d.Files).ToList();
Assert.DoesNotContain(scannedFiles, f => f.FilePath.Contains("ch 10.cbz"));
Assert.Contains(scannedFiles, f => f.FilePath.Contains("v01.cbz"));
Assert.Contains(scannedFiles, f => f.FilePath.Contains("v02.cbz"));
}
#endregion
#region ScannFiles - Change Detection
[Fact]
public async Task ScanFiles_ShouldHaveAccurateLastModifiedUtc()
{
const string testcase = "Flat Series - Manga.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var folder = library.Folders.First().Path;
var options = new ScannerOption
{
FolderPaths = [folder],
FileTypePattern = [FileTypeGroup.Archive],
ExcludePatterns = []
};
var result = _fileScanner.ScanFiles(options);
Assert.Single(result);
var scannedDir = result[0];
var file = scannedDir.Files[0];
var expected = _directoryService.GetLastWriteTime(file.FilePath).ToUniversalTime();
Assert.Equal(expected, file.LastModifiedUtc);
}
#endregion
protected override async Task ResetDb()
{
Context.Series.RemoveRange(Context.Series);
Context.Library.RemoveRange(Context.Library);
await Context.SaveChangesAsync();
}
}

View file

@ -161,10 +161,10 @@ public class ImageServiceTests
private static void GenerateColorImage(string hexColor, string outputPath)
{
var (r, g, b) = ImageService.HexToRgb(hexColor);
using var blackImage = Image.Black(200, 100);
using var colorImage = blackImage.NewFromImage(r, g, b);
colorImage.WriteToFile(outputPath);
var color = ImageService.HexToRgb(hexColor);
using var colorImage = Image.Black(200, 100);
using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 };
output.WriteToFile(outputPath);
}
private void GenerateHtmlFileForColorScape()

View file

@ -58,35 +58,35 @@ public class MockReadingItemService : IReadingItemService
throw new NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata)
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
{
if (_comicVineParser.IsApplicable(path, type))
{
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
if (_imageParser.IsApplicable(path, type))
{
return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
if (_bookParser.IsApplicable(path, type))
{
return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
if (_pdfParser.IsApplicable(path, type))
{
return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
if (_basicParser.IsApplicable(path, type))
{
return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path));
return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
return null;
}
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata)
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
{
return Parse(path, rootPath, libraryRoot, type, enableMetadata);
return Parse(path, rootPath, libraryRoot, type);
}
}

View file

@ -1,286 +0,0 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using Xunit;
namespace API.Tests.Services;
public class PersonServiceTests: AbstractDbTest
{
[Fact]
public async Task PersonMerge_KeepNonEmptyMetadata()
{
var ps = new PersonService(UnitOfWork);
var person1 = new Person
{
Name = "Casey Delores",
NormalizedName = "Casey Delores".ToNormalized(),
HardcoverId = "ANonEmptyId",
MalId = 12,
};
var person2 = new Person
{
Name= "Delores Casey",
NormalizedName = "Delores Casey".ToNormalized(),
Description = "Hi, I'm Delores Casey!",
Aliases = [new PersonAliasBuilder("Casey, Delores").Build()],
AniListId = 27,
};
UnitOfWork.PersonRepository.Attach(person1);
UnitOfWork.PersonRepository.Attach(person2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person1);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var person = allPeople[0];
Assert.Equal("Casey Delores", person.Name);
Assert.NotEmpty(person.Description);
Assert.Equal(27, person.AniListId);
Assert.NotNull(person.HardcoverId);
Assert.NotEmpty(person.HardcoverId);
Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey");
Assert.Contains(person.Aliases, pa => pa.Alias == "Casey, Delores");
}
[Fact]
public async Task PersonMerge_MergedPersonDestruction()
{
var ps = new PersonService(UnitOfWork);
var person1 = new Person
{
Name = "Casey Delores",
NormalizedName = "Casey Delores".ToNormalized(),
};
var person2 = new Person
{
Name = "Delores Casey",
NormalizedName = "Delores Casey".ToNormalized(),
};
UnitOfWork.PersonRepository.Attach(person1);
UnitOfWork.PersonRepository.Attach(person2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person1);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
}
[Fact]
public async Task PersonMerge_RetentionChapters()
{
var ps = new PersonService(UnitOfWork);
var library = new LibraryBuilder("My Library").Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var user = new AppUserBuilder("Amelia", "amelia@localhost")
.WithLibrary(library).Build();
UnitOfWork.UserRepository.Add(user);
var person = new PersonBuilder("Jillian Cowan").Build();
var person2 = new PersonBuilder("Cowan Jillian").Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.Build();
var chapter2 = new ChapterBuilder("2")
.WithPerson(person2, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.Build();
var series2 = new SeriesBuilder("Test 2")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("2")
.WithChapter(chapter2)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
UnitOfWork.SeriesRepository.Add(series2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var mergedPerson = allPeople[0];
Assert.Equal("Jillian Cowan", mergedPerson.Name);
var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(1, 1, PersonRole.Editor);
Assert.Equal(2, chapters.Count());
chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(1, ChapterIncludes.People);
Assert.NotNull(chapter);
Assert.Single(chapter.People);
chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2, ChapterIncludes.People);
Assert.NotNull(chapter2);
Assert.Single(chapter2.People);
Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId);
}
[Fact]
public async Task PersonMerge_NoDuplicateChaptersOrSeries()
{
await ResetDb();
var ps = new PersonService(UnitOfWork);
var library = new LibraryBuilder("My Library").Build();
UnitOfWork.LibraryRepository.Add(library);
await UnitOfWork.CommitAsync();
var user = new AppUserBuilder("Amelia", "amelia@localhost")
.WithLibrary(library).Build();
UnitOfWork.UserRepository.Add(user);
var person = new PersonBuilder("Jillian Cowan").Build();
var person2 = new PersonBuilder("Cowan Jillian").Build();
var chapter = new ChapterBuilder("1")
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Colorist)
.Build();
var chapter2 = new ChapterBuilder("2")
.WithPerson(person2, PersonRole.Editor)
.WithPerson(person, PersonRole.Editor)
.Build();
var series = new SeriesBuilder("Test 1")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("1")
.WithChapter(chapter)
.Build())
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Editor)
.Build())
.Build();
var series2 = new SeriesBuilder("Test 2")
.WithLibraryId(library.Id)
.WithVolume(new VolumeBuilder("2")
.WithChapter(chapter2)
.Build())
.WithMetadata(new SeriesMetadataBuilder()
.WithPerson(person, PersonRole.Editor)
.WithPerson(person2, PersonRole.Colorist)
.Build())
.Build();
UnitOfWork.SeriesRepository.Add(series);
UnitOfWork.SeriesRepository.Add(series2);
await UnitOfWork.CommitAsync();
await ps.MergePeopleAsync(person2, person);
var allPeople = await UnitOfWork.PersonRepository.GetAllPeople();
Assert.Single(allPeople);
var mergedPerson = await UnitOfWork.PersonRepository.GetPersonById(person.Id, PersonIncludes.All);
Assert.NotNull(mergedPerson);
Assert.Equal(3, mergedPerson.ChapterPeople.Count);
Assert.Equal(3, mergedPerson.SeriesMetadataPeople.Count);
chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter.Id, ChapterIncludes.People);
Assert.NotNull(chapter);
Assert.Equal(2, chapter.People.Count);
Assert.Single(chapter.People.Select(p => p.Person.Id).Distinct());
Assert.Contains(chapter.People, p => p.Role == PersonRole.Editor);
Assert.Contains(chapter.People, p => p.Role == PersonRole.Colorist);
chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter2.Id, ChapterIncludes.People);
Assert.NotNull(chapter2);
Assert.Single(chapter2.People);
Assert.Contains(chapter2.People, p => p.Role == PersonRole.Editor);
Assert.DoesNotContain(chapter2.People, p => p.Role == PersonRole.Colorist);
series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Metadata);
Assert.NotNull(series);
Assert.Single(series.Metadata.People);
Assert.Contains(series.Metadata.People, p => p.Role == PersonRole.Editor);
Assert.DoesNotContain(series.Metadata.People, p => p.Role == PersonRole.Colorist);
series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series2.Id, SeriesIncludes.Metadata);
Assert.NotNull(series2);
Assert.Equal(2, series2.Metadata.People.Count);
Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Editor);
Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Colorist);
}
[Fact]
public async Task PersonAddAlias_NoOverlap()
{
await ResetDb();
UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jillian Cowan").Build());
UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jilly Cowan").WithAlias("Jolly Cowan").Build());
await UnitOfWork.CommitAsync();
var ps = new PersonService(UnitOfWork);
var person1 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jillian Cowan");
var person2 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jilly Cowan");
Assert.NotNull(person1);
Assert.NotNull(person2);
// Overlap on Name
var success = await ps.UpdatePersonAliasesAsync(person1, ["Jilly Cowan"]);
Assert.False(success);
// Overlap on alias
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan"]);
Assert.False(success);
// No overlap
success = await ps.UpdatePersonAliasesAsync(person2, ["Jilly Joy Cowan"]);
Assert.True(success);
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.False(success);
// Some overlap
success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]);
Assert.False(success);
Assert.Single(person2.Aliases);
}
protected override async Task ResetDb()
{
Context.Person.RemoveRange(Context.Person.ToList());
await Context.SaveChangesAsync();
}
}

View file

@ -1,561 +0,0 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Helpers.Builders;
using API.Services;
using API.Tests.Helpers;
using Kavita.Common;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class ReadingProfileServiceTest: AbstractDbTest
{
/// <summary>
/// Does not add a default reading profile
/// </summary>
/// <returns></returns>
public async Task<(ReadingProfileService, AppUser, Library, Series)> Setup()
{
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
Context.AppUser.Add(user);
await UnitOfWork.CommitAsync();
var series = new SeriesBuilder("Spice and Wolf").Build();
var library = new LibraryBuilder("Manga")
.WithSeries(series)
.Build();
user.Libraries.Add(library);
await UnitOfWork.CommitAsync();
var rps = new ReadingProfileService(UnitOfWork, Substitute.For<ILocalizationService>(), Mapper);
user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences);
return (rps, user, library, series);
}
[Fact]
public async Task ImplicitProfileFirst()
{
await ResetDb();
var (rps, user, library, series) = await Setup();
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Implicit)
.WithSeries(series)
.WithName("Implicit Profile")
.Build();
var profile2 = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Non-implicit Profile")
.Build();
user.ReadingProfiles.Add(profile);
user.ReadingProfiles.Add(profile2);
await UnitOfWork.CommitAsync();
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile);
Assert.Equal("Implicit Profile", seriesProfile.Name);
// Find parent
seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id, true);
Assert.NotNull(seriesProfile);
Assert.Equal("Non-implicit Profile", seriesProfile.Name);
}
[Fact]
public async Task CantDeleteDefaultReadingProfile()
{
await ResetDb();
var (rps, user, _, _) = await Setup();
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Default)
.Build();
Context.AppUserReadingProfiles.Add(profile);
await UnitOfWork.CommitAsync();
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await rps.DeleteReadingProfile(user.Id, profile.Id);
});
var profile2 = new AppUserReadingProfileBuilder(user.Id).Build();
Context.AppUserReadingProfiles.Add(profile2);
await UnitOfWork.CommitAsync();
await rps.DeleteReadingProfile(user.Id, profile2.Id);
await UnitOfWork.CommitAsync();
var allProfiles = await Context.AppUserReadingProfiles.ToListAsync();
Assert.Single(allProfiles);
}
[Fact]
public async Task CreateImplicitSeriesReadingProfile()
{
await ResetDb();
var (rps, user, _, series) = await Setup();
var dto = new UserReadingProfileDto
{
ReaderMode = ReaderMode.Webtoon,
ScalingOption = ScalingOption.FitToHeight,
WidthOverride = 53,
};
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Contains(profile.SeriesIds, s => s == series.Id);
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
}
[Fact]
public async Task UpdateImplicitReadingProfile_DoesNotCreateNew()
{
await ResetDb();
var (rps, user, _, series) = await Setup();
var dto = new UserReadingProfileDto
{
ReaderMode = ReaderMode.Webtoon,
ScalingOption = ScalingOption.FitToHeight,
WidthOverride = 53,
};
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Contains(profile.SeriesIds, s => s == series.Id);
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
dto = new UserReadingProfileDto
{
ReaderMode = ReaderMode.LeftRight,
};
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Contains(profile.SeriesIds, s => s == series.Id);
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
Assert.Equal(ReaderMode.LeftRight, profile.ReaderMode);
var implicitCount = await Context.AppUserReadingProfiles
.Where(p => p.Kind == ReadingProfileKind.Implicit)
.CountAsync();
Assert.Equal(1, implicitCount);
}
[Fact]
public async Task GetCorrectProfile()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Series Specific")
.Build();
var profile2 = new AppUserReadingProfileBuilder(user.Id)
.WithLibrary(lib)
.WithName("Library Specific")
.Build();
var profile3 = new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Default)
.WithName("Global")
.Build();
Context.AppUserReadingProfiles.Add(profile);
Context.AppUserReadingProfiles.Add(profile2);
Context.AppUserReadingProfiles.Add(profile3);
var series2 = new SeriesBuilder("Rainbows After Storms").Build();
lib.Series.Add(series2);
var lib2 = new LibraryBuilder("Manga2").Build();
var series3 = new SeriesBuilder("A Tropical Fish Yearns for Snow").Build();
lib2.Series.Add(series3);
user.Libraries.Add(lib2);
await UnitOfWork.CommitAsync();
var p = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(p);
Assert.Equal("Series Specific", p.Name);
p = await rps.GetReadingProfileDtoForSeries(user.Id, series2.Id);
Assert.NotNull(p);
Assert.Equal("Library Specific", p.Name);
p = await rps.GetReadingProfileDtoForSeries(user.Id, series3.Id);
Assert.NotNull(p);
Assert.Equal("Global", p.Name);
}
[Fact]
public async Task ReplaceReadingProfile()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var profile1 = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Profile 1")
.Build();
var profile2 = new AppUserReadingProfileBuilder(user.Id)
.WithName("Profile 2")
.Build();
Context.AppUserReadingProfiles.Add(profile1);
Context.AppUserReadingProfiles.Add(profile2);
await UnitOfWork.CommitAsync();
var profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Equal("Profile 1", profile.Name);
await rps.AddProfileToSeries(user.Id, profile2.Id, series.Id);
profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(profile);
Assert.Equal("Profile 2", profile.Name);
}
[Fact]
public async Task DeleteReadingProfile()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var profile1 = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Profile 1")
.Build();
Context.AppUserReadingProfiles.Add(profile1);
await UnitOfWork.CommitAsync();
await rps.ClearSeriesProfile(user.Id, series.Id);
var profiles = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
Assert.DoesNotContain(profiles, rp => rp.SeriesIds.Contains(series.Id));
}
[Fact]
public async Task BulkAddReadingProfiles()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
for (var i = 0; i < 10; i++)
{
var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build();
lib.Series.Add(generatedSeries);
}
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Profile")
.Build();
Context.AppUserReadingProfiles.Add(profile);
var profile2 = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Profile2")
.Build();
Context.AppUserReadingProfiles.Add(profile2);
await UnitOfWork.CommitAsync();
var someSeriesIds = lib.Series.Take(lib.Series.Count / 2).Select(s => s.Id).ToList();
await rps.BulkAddProfileToSeries(user.Id, profile.Id, someSeriesIds);
foreach (var id in someSeriesIds)
{
var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
Assert.NotNull(foundProfile);
Assert.Equal(profile.Id, foundProfile.Id);
}
var allIds = lib.Series.Select(s => s.Id).ToList();
await rps.BulkAddProfileToSeries(user.Id, profile2.Id, allIds);
foreach (var id in allIds)
{
var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
Assert.NotNull(foundProfile);
Assert.Equal(profile2.Id, foundProfile.Id);
}
}
[Fact]
public async Task BulkAssignDeletesImplicit()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
.Build());
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Profile 1")
.Build();
Context.AppUserReadingProfiles.Add(profile);
for (var i = 0; i < 10; i++)
{
var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build();
lib.Series.Add(generatedSeries);
}
await UnitOfWork.CommitAsync();
var ids = lib.Series.Select(s => s.Id).ToList();
foreach (var id in ids)
{
await rps.UpdateImplicitReadingProfile(user.Id, id, implicitProfile);
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
}
await rps.BulkAddProfileToSeries(user.Id, profile.Id, ids);
foreach (var id in ids)
{
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
}
var implicitCount = await Context.AppUserReadingProfiles
.Where(p => p.Kind == ReadingProfileKind.Implicit)
.CountAsync();
Assert.Equal(0, implicitCount);
}
[Fact]
public async Task AddDeletesImplicit()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Implicit)
.Build());
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Profile 1")
.Build();
Context.AppUserReadingProfiles.Add(profile);
await UnitOfWork.CommitAsync();
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile);
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
await rps.AddProfileToSeries(user.Id, profile.Id, series.Id);
seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
Assert.NotNull(seriesProfile);
Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
var implicitCount = await Context.AppUserReadingProfiles
.Where(p => p.Kind == ReadingProfileKind.Implicit)
.CountAsync();
Assert.Equal(0, implicitCount);
}
[Fact]
public async Task CreateReadingProfile()
{
await ResetDb();
var (rps, user, lib, series) = await Setup();
var dto = new UserReadingProfileDto
{
Name = "Profile 1",
ReaderMode = ReaderMode.LeftRight,
EmulateBook = false,
};
await rps.CreateReadingProfile(user.Id, dto);
var dto2 = new UserReadingProfileDto
{
Name = "Profile 2",
ReaderMode = ReaderMode.LeftRight,
EmulateBook = false,
};
await rps.CreateReadingProfile(user.Id, dto2);
var dto3 = new UserReadingProfileDto
{
Name = "Profile 1", // Not unique name
ReaderMode = ReaderMode.LeftRight,
EmulateBook = false,
};
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await rps.CreateReadingProfile(user.Id, dto3);
});
var allProfiles = Context.AppUserReadingProfiles.ToList();
Assert.Equal(2, allProfiles.Count);
}
[Fact]
public async Task ClearSeriesProfile_RemovesImplicitAndUnlinksExplicit()
{
await ResetDb();
var (rps, user, _, series) = await Setup();
var implicitProfile = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithKind(ReadingProfileKind.Implicit)
.WithName("Implicit Profile")
.Build();
var explicitProfile = new AppUserReadingProfileBuilder(user.Id)
.WithSeries(series)
.WithName("Explicit Profile")
.Build();
Context.AppUserReadingProfiles.Add(implicitProfile);
Context.AppUserReadingProfiles.Add(explicitProfile);
await UnitOfWork.CommitAsync();
var allBefore = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
Assert.Equal(2, allBefore.Count(rp => rp.SeriesIds.Contains(series.Id)));
await rps.ClearSeriesProfile(user.Id, series.Id);
var remainingProfiles = await Context.AppUserReadingProfiles.ToListAsync();
Assert.Single(remainingProfiles);
Assert.Equal("Explicit Profile", remainingProfiles[0].Name);
Assert.Empty(remainingProfiles[0].SeriesIds);
var profilesForSeries = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
Assert.DoesNotContain(profilesForSeries, rp => rp.SeriesIds.Contains(series.Id));
}
[Fact]
public async Task AddProfileToLibrary_AddsAndOverridesExisting()
{
await ResetDb();
var (rps, user, lib, _) = await Setup();
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Library Profile")
.Build();
Context.AppUserReadingProfiles.Add(profile);
await UnitOfWork.CommitAsync();
await rps.AddProfileToLibrary(user.Id, profile.Id, lib.Id);
await UnitOfWork.CommitAsync();
var linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
Assert.NotNull(linkedProfile);
Assert.Equal(profile.Id, linkedProfile.Id);
var newProfile = new AppUserReadingProfileBuilder(user.Id)
.WithName("New Profile")
.Build();
Context.AppUserReadingProfiles.Add(newProfile);
await UnitOfWork.CommitAsync();
await rps.AddProfileToLibrary(user.Id, newProfile.Id, lib.Id);
await UnitOfWork.CommitAsync();
linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
Assert.NotNull(linkedProfile);
Assert.Equal(newProfile.Id, linkedProfile.Id);
}
[Fact]
public async Task ClearLibraryProfile_RemovesImplicitOrUnlinksExplicit()
{
await ResetDb();
var (rps, user, lib, _) = await Setup();
var implicitProfile = new AppUserReadingProfileBuilder(user.Id)
.WithKind(ReadingProfileKind.Implicit)
.WithLibrary(lib)
.Build();
Context.AppUserReadingProfiles.Add(implicitProfile);
await UnitOfWork.CommitAsync();
await rps.ClearLibraryProfile(user.Id, lib.Id);
var profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
Assert.Null(profile);
var explicitProfile = new AppUserReadingProfileBuilder(user.Id)
.WithLibrary(lib)
.Build();
Context.AppUserReadingProfiles.Add(explicitProfile);
await UnitOfWork.CommitAsync();
await rps.ClearLibraryProfile(user.Id, lib.Id);
profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
Assert.Null(profile);
var stillExists = await Context.AppUserReadingProfiles.FindAsync(explicitProfile.Id);
Assert.NotNull(stillExists);
}
/// <summary>
/// As response to #3793, I'm not sure if we want to keep this. It's not the most nice. But I think the idea of this test
/// is worth having.
/// </summary>
[Fact]
public void UpdateFields_UpdatesAll()
{
// Repeat to ensure booleans are flipped and actually tested
for (int i = 0; i < 10; i++)
{
var profile = new AppUserReadingProfile();
var dto = new UserReadingProfileDto();
RandfHelper.SetRandomValues(profile);
RandfHelper.SetRandomValues(dto);
ReadingProfileService.UpdateReaderProfileFields(profile, dto);
var newDto = Mapper.Map<UserReadingProfileDto>(profile);
Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto,
["<Id>k__BackingField", "<UserId>k__BackingField"]));
}
}
protected override async Task ResetDb()
{
Context.AppUserReadingProfiles.RemoveRange(Context.AppUserReadingProfiles);
await UnitOfWork.CommitAsync();
}
}

View file

@ -0,0 +1,54 @@
using System.IO;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Tests.Helpers;
using Hangfire;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Services.Scanner;
/// <summary>
/// Responsible for testing Change Detection, Exclude Patterns,
/// </summary>
public class FileSystemParserTests : AbstractDbTest
{
private readonly ITestOutputHelper _testOutputHelper;
private readonly ScannerHelper _scannerHelper;
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
public FileSystemParserTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
// Set up Hangfire to use in-memory storage for testing
GlobalConfiguration.Configuration.UseInMemoryStorage();
_scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper);
}
protected override async Task ResetDb()
{
Context.Library.RemoveRange(Context.Library);
await Context.SaveChangesAsync();
}
#region Validate Change Detection
[Fact]
public async Task ScanLibrary_ComicVine_PublisherFolder()
{
var testcase = "Publisher - ComicVine.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(4, postLib.Series.Count);
}
#endregion
}

View file

@ -18,14 +18,11 @@ namespace API.Tests.Services;
public class ScannerServiceTests : AbstractDbTest
{
private readonly ITestOutputHelper _testOutputHelper;
private readonly ScannerHelper _scannerHelper;
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
public ScannerServiceTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
// Set up Hangfire to use in-memory storage for testing
GlobalConfiguration.Configuration.UseInMemoryStorage();
_scannerHelper = new ScannerHelper(UnitOfWork, testOutputHelper);
@ -483,7 +480,7 @@ public class ScannerServiceTests : AbstractDbTest
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**/Extra/*" }];
library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**/Extra/*"}];
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
@ -507,7 +504,7 @@ public class ScannerServiceTests : AbstractDbTest
var infos = new Dictionary<string, ComicInfo>();
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**\\Extra\\*" }];
library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**\\Extra\\*"}];
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
@ -939,60 +936,8 @@ public class ScannerServiceTests : AbstractDbTest
Assert.True(sortedChapters[2].SortOrder.Is(5f));
}
#region Scanner Overhaul
[Fact]
public async Task ScanLibrary_MetadataDisabled_NoOverrides()
{
const string testcase = "Series with Localized No Metadata - Manga.json";
// Get the first file and generate a ComicInfo
var infos = new Dictionary<string, ComicInfo>();
infos.Add("Immoral Guild v01.cbz", new ComicInfo()
{
Series = "Immoral Guild",
LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase
});
var library = await _scannerHelper.GenerateScannerData(testcase, infos);
// Disable metadata
library.EnableMetadata = false;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
// Validate that there are 2 series
Assert.NotNull(postLib);
Assert.Equal(2, postLib.Series.Count);
Assert.Contains(postLib.Series, x => x.Name == "Immoral Guild");
Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild");
}
[Fact]
public async Task ScanLibrary_SortName_NoPrefix()
{
const string testcase = "Series with Prefix - Book.json";
var library = await _scannerHelper.GenerateScannerData(testcase);
library.RemovePrefixForSortName = true;
UnitOfWork.LibraryRepository.Update(library);
await UnitOfWork.CommitAsync();
var scanner = _scannerHelper.CreateServices();
await scanner.ScanLibrary(library.Id);
var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
Assert.NotNull(postLib);
Assert.Equal(1, postLib.Series.Count);
Assert.Equal("The Avengers", postLib.Series.First().Name);
Assert.Equal("Avengers", postLib.Series.First().SortName);
}
#endregion
}

View file

@ -1,17 +1,11 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.DTOs.Scrobbling;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Scrobble;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@ -21,33 +15,11 @@ namespace API.Tests.Services;
public class ScrobblingServiceTests : AbstractDbTest
{
private const int ChapterPages = 100;
/// <summary>
/// {
/// "Issuer": "Issuer",
/// "Issued At": "2025-06-15T21:01:57.615Z",
/// "Expiration": "2200-06-15T21:01:57.615Z"
/// }
/// </summary>
/// <remarks>Our UnitTests will fail in 2200 :(</remarks>
private const string ValidJwtToken =
"eyJhbGciOiJIUzI1NiJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJleHAiOjcyNzI0NTAxMTcsImlhdCI6MTc1MDAyMTMxN30.zADmcGq_BfxbcV8vy4xw5Cbzn4COkmVINxgqpuL17Ng";
private readonly ScrobblingService _service;
private readonly ILicenseService _licenseService;
private readonly ILocalizationService _localizationService;
private readonly ILogger<ScrobblingService> _logger;
private readonly IEmailService _emailService;
private readonly IKavitaPlusApiService _kavitaPlusApiService;
/// <summary>
/// IReaderService, without the ScrobblingService injected
/// </summary>
private readonly IReaderService _readerService;
/// <summary>
/// IReaderService, with the _service injected
/// </summary>
private readonly IReaderService _hookedUpReaderService;
public ScrobblingServiceTests()
{
@ -55,24 +27,8 @@ public class ScrobblingServiceTests : AbstractDbTest
_localizationService = Substitute.For<ILocalizationService>();
_logger = Substitute.For<ILogger<ScrobblingService>>();
_emailService = Substitute.For<IEmailService>();
_kavitaPlusApiService = Substitute.For<IKavitaPlusApiService>();
_service = new ScrobblingService(UnitOfWork, Substitute.For<IEventHub>(), _logger, _licenseService,
_localizationService, _emailService, _kavitaPlusApiService);
_readerService = new ReaderService(UnitOfWork,
Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(),
Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(),
Substitute.For<IScrobblingService>()); // Do not use the actual one
_hookedUpReaderService = new ReaderService(UnitOfWork,
Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(),
Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(),
_service);
_service = new ScrobblingService(UnitOfWork, Substitute.For<IEventHub>(), _logger, _licenseService, _localizationService, _emailService);
}
protected override async Task ResetDb()
@ -90,30 +46,6 @@ public class ScrobblingServiceTests : AbstractDbTest
var series = new SeriesBuilder("Test Series")
.WithFormat(MangaFormat.Archive)
.WithMetadata(new SeriesMetadataBuilder().Build())
.WithVolume(new VolumeBuilder("Volume 1")
.WithChapters([
new ChapterBuilder("1")
.WithPages(ChapterPages)
.Build(),
new ChapterBuilder("2")
.WithPages(ChapterPages)
.Build(),
new ChapterBuilder("3")
.WithPages(ChapterPages)
.Build()])
.Build())
.WithVolume(new VolumeBuilder("Volume 2")
.WithChapters([
new ChapterBuilder("4")
.WithPages(ChapterPages)
.Build(),
new ChapterBuilder("5")
.WithPages(ChapterPages)
.Build(),
new ChapterBuilder("6")
.WithPages(ChapterPages)
.Build()])
.Build())
.Build();
var library = new LibraryBuilder("Test Library", LibraryType.Manga)
@ -135,296 +67,6 @@ public class ScrobblingServiceTests : AbstractDbTest
await UnitOfWork.CommitAsync();
}
private async Task<ScrobbleEvent> CreateScrobbleEvent(int? seriesId = null)
{
var evt = new ScrobbleEvent
{
ScrobbleEventType = ScrobbleEventType.ChapterRead,
Format = PlusMediaFormat.Manga,
SeriesId = seriesId ?? 0,
LibraryId = 0,
AppUserId = 0,
};
if (seriesId != null)
{
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId.Value);
if (series != null) evt.Series = series;
}
return evt;
}
#region K+ API Request Tests
[Fact]
public async Task PostScrobbleUpdate_AuthErrors()
{
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
.ReturnsForAnyArgs(new ScrobbleResponseDto()
{
ErrorMessage = "Unauthorized"
});
var evt = await CreateScrobbleEvent();
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
});
Assert.True(evt.IsErrored);
Assert.Equal("Kavita+ subscription no longer active", evt.ErrorDetails);
}
[Fact]
public async Task PostScrobbleUpdate_UnknownSeriesLoggedAsError()
{
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
.ReturnsForAnyArgs(new ScrobbleResponseDto()
{
ErrorMessage = "Unknown Series"
});
await SeedData();
var evt = await CreateScrobbleEvent(1);
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
await UnitOfWork.CommitAsync();
Assert.True(evt.IsErrored);
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
Assert.NotNull(series);
Assert.True(series.IsBlacklisted);
var errors = await UnitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(1);
Assert.Single(errors);
Assert.Equal("Series cannot be matched for Scrobbling", errors.First().Comment);
Assert.Equal(series.Id, errors.First().SeriesId);
}
[Fact]
public async Task PostScrobbleUpdate_InvalidAccessToken()
{
_kavitaPlusApiService.PostScrobbleUpdate(null!, "")
.ReturnsForAnyArgs(new ScrobbleResponseDto()
{
ErrorMessage = "Access token is invalid"
});
var evt = await CreateScrobbleEvent();
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await _service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
});
Assert.True(evt.IsErrored);
Assert.Equal("Access Token needs to be rotated to continue scrobbling", evt.ErrorDetails);
}
#endregion
#region K+ API Request data tests
[Fact]
public async Task ProcessReadEvents_CreatesNoEventsWhenNoProgress()
{
await ResetDb();
await SeedData();
// Set Returns
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
_kavitaPlusApiService.GetRateLimit(Arg.Any<string>(), Arg.Any<string>())
.Returns(100);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
// Ensure CanProcessScrobbleEvent returns true
user.AniListAccessToken = ValidJwtToken;
UnitOfWork.UserRepository.Update(user);
await UnitOfWork.CommitAsync();
var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4);
Assert.NotNull(chapter);
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
// Call Scrobble without having any progress
await _service.ScrobbleReadingUpdate(1, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
}
[Fact]
public async Task ProcessReadEvents_UpdateVolumeAndChapterData()
{
await ResetDb();
await SeedData();
// Set Returns
_licenseService.HasActiveLicense().Returns(Task.FromResult(true));
_kavitaPlusApiService.GetRateLimit(Arg.Any<string>(), Arg.Any<string>())
.Returns(100);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
// Ensure CanProcessScrobbleEvent returns true
user.AniListAccessToken = ValidJwtToken;
UnitOfWork.UserRepository.Update(user);
await UnitOfWork.CommitAsync();
var chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(4);
Assert.NotNull(chapter);
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
// Mark something as read to trigger event creation
await _readerService.MarkChaptersAsRead(user, 1, new List<Chapter>() {volume.Chapters[0]});
await UnitOfWork.CommitAsync();
// Call Scrobble while having some progress
await _service.ScrobbleReadingUpdate(user.Id, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events);
// Give it some (more) read progress
await _readerService.MarkChaptersAsRead(user, 1, volume.Chapters);
await _readerService.MarkChaptersAsRead(user, 1, [chapter]);
await UnitOfWork.CommitAsync();
await _service.ProcessUpdatesSinceLastSync();
await _kavitaPlusApiService.Received(1).PostScrobbleUpdate(
Arg.Is<ScrobbleDto>(data =>
data.ChapterNumber == (int)chapter.MaxNumber &&
data.VolumeNumber == (int)volume.MaxNumber
),
Arg.Any<string>());
}
#endregion
#region Scrobble Reading Update Tests
[Fact]
public async Task ScrobbleReadingUpdate_IgnoreNoLicense()
{
await ResetDb();
await SeedData();
_licenseService.HasActiveLicense().Returns(false);
await _service.ScrobbleReadingUpdate(1, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
}
[Fact]
public async Task ScrobbleReadingUpdate_RemoveWhenNoProgress()
{
await ResetDb();
await SeedData();
_licenseService.HasActiveLicense().Returns(true);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
var volume = await UnitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
await _readerService.MarkChaptersAsRead(user, 1, new List<Chapter>() {volume.Chapters[0]});
await UnitOfWork.CommitAsync();
await _service.ScrobbleReadingUpdate(1, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events);
var readEvent = events.First();
Assert.False(readEvent.IsProcessed);
await _hookedUpReaderService.MarkSeriesAsUnread(user, 1);
await UnitOfWork.CommitAsync();
// Existing event is deleted
await _service.ScrobbleReadingUpdate(1, 1);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
await _hookedUpReaderService.MarkSeriesAsUnread(user, 1);
await UnitOfWork.CommitAsync();
// No new events are added
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
}
[Fact]
public async Task ScrobbleReadingUpdate_UpdateExistingNotIsProcessed()
{
await ResetDb();
await SeedData();
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
var chapter1 = await UnitOfWork.ChapterRepository.GetChapterAsync(1);
var chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2);
var chapter3 = await UnitOfWork.ChapterRepository.GetChapterAsync(3);
Assert.NotNull(chapter1);
Assert.NotNull(chapter2);
Assert.NotNull(chapter3);
_licenseService.HasActiveLicense().Returns(true);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
await _readerService.MarkChaptersAsRead(user, 1, [chapter1]);
await UnitOfWork.CommitAsync();
// Scrobble update
await _service.ScrobbleReadingUpdate(1, 1);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events);
var readEvent = events[0];
Assert.False(readEvent.IsProcessed);
Assert.Equal(1, readEvent.ChapterNumber);
// Mark as processed
readEvent.IsProcessed = true;
await UnitOfWork.CommitAsync();
await _readerService.MarkChaptersAsRead(user, 1, [chapter2]);
await UnitOfWork.CommitAsync();
// Scrobble update
await _service.ScrobbleReadingUpdate(1, 1);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Equal(2, events.Count);
Assert.Single(events.Where(e => e.IsProcessed).ToList());
Assert.Single(events.Where(e => !e.IsProcessed).ToList());
// Should update the existing non processed event
await _readerService.MarkChaptersAsRead(user, 1, [chapter3]);
await UnitOfWork.CommitAsync();
// Scrobble update
await _service.ScrobbleReadingUpdate(1, 1);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Equal(2, events.Count);
Assert.Single(events.Where(e => e.IsProcessed).ToList());
Assert.Single(events.Where(e => !e.IsProcessed).ToList());
}
#endregion
#region ScrobbleWantToReadUpdate Tests
[Fact]
@ -561,59 +203,6 @@ public class ScrobblingServiceTests : AbstractDbTest
#endregion
#region Scrobble Rating Update Test
[Fact]
public async Task ScrobbleRatingUpdate_IgnoreNoLicense()
{
await ResetDb();
await SeedData();
_licenseService.HasActiveLicense().Returns(false);
await _service.ScrobbleRatingUpdate(1, 1, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Empty(events);
}
[Fact]
public async Task ScrobbleRatingUpdate_UpdateExistingNotIsProcessed()
{
await ResetDb();
await SeedData();
_licenseService.HasActiveLicense().Returns(true);
var user = await UnitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
var series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
Assert.NotNull(series);
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 1);
var events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events);
Assert.Equal(1, events.First().Rating);
// Mark as processed
events.First().IsProcessed = true;
await UnitOfWork.CommitAsync();
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Equal(2, events.Count);
Assert.Single(events, evt => evt.IsProcessed);
Assert.Single(events, evt => !evt.IsProcessed);
await _service.ScrobbleRatingUpdate(user.Id, series.Id, 5);
events = await UnitOfWork.ScrobbleRepository.GetAllEventsForSeries(1);
Assert.Single(events, evt => !evt.IsProcessed);
Assert.Equal(5, events.First(evt => !evt.IsProcessed).Rating);
}
#endregion
[Theory]
[InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)]
[InlineData("https://anilist.co/manga/30105", 30105)]

View file

@ -8,7 +8,6 @@ using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Metadata;
using API.DTOs.Person;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;

View file

@ -0,0 +1,5 @@
[
"Root 1/Series A/Series A 01.cbz",
"Root 1/Series B/Series B 01.cbz",
"Root 2/Series C/Series C 01.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Root 1/Series A/Series A/Series A 01.cbz",
"Root 1/Series A/Series A1/Series A1 01.cbz",
"Root 1/Series B/Series B 01.cbz",
"Root 2/Series C/Series C 01.cbz"
]

View file

@ -0,0 +1,4 @@
[
"Root 1/Books/book v1.pdf",
"Root 1/Books/book v2.pdf"
]

View file

@ -0,0 +1,5 @@
[
"Root 1/Books/Series A/ch 1/001.png",
"Root 1/Books/Series A/ch 1/002.png",
"Root 1/Books/Series A/ch 2/001.png"
]

View file

@ -0,0 +1,5 @@
[
"Root 1/Books/Series A/vol 1 ch 1/001.png",
"Root 1/Books/Series A/vol 1 ch 2/002.png",
"Root 1/Books/Series A/vol 2/001.png"
]

View file

@ -0,0 +1,5 @@
[
"Root 1/Books/Series A/vol 1/ch 1/001.png",
"Root 1/Books/Series A/vol 1/ch 2/002.png",
"Root 1/Books/Series A/vol 2/001.png"
]

View file

@ -0,0 +1,4 @@
[
"Root 1/Books/Series A [Digital]/001.png",
"Root 1/Books/Series A [Digital]/002.png"
]

View file

@ -0,0 +1,5 @@
[
"Root 1/Books/Series A/vol 1/001.png",
"Root 1/Books/Series A/vol 1/002.png",
"Root 1/Books/Series A/vol 2/001.png"
]

View file

@ -0,0 +1,5 @@
[
"Root 1/Genre/Series A 01.cbz",
"Root 1/Genre/Series B 01.cbz",
"Root 1/Genre/Series C 01.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Root 1/Publisher 1/Series A/Series A #1.cbz",
"Root 1/Publisher 1/Series B/Series B #1.cbz",
"Root 1/Publisher 2/Series C/Series C #1.cbz",
"Root 1/Publisher 2/Series D/Series D #1.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Root 1/Series A/Series A 01.cbz",
"Root 1/Series A/Series A 02.cbz",
"Root 1/Series A/Specials/Series A - Title.cbz",
"Root 1/Series A/Specials/Title Two.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Root 1/Series A/Series A 01.cbz",
"Root 1/Series A/Series A 02.cbz",
"Root 1/Series A/Series A - Title SP01.cbz",
"Root 1/Series A/Series A - Title SP02.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Root 1/Series A/Series A 01.cbz",
"Root 1/Series B/Series B 01.cbz",
"Root 1/Series B/Series B 02.cbz",
"Root 2/Series C/Series C 01.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Root 1/Series A/Series A 01.cbz",
"Root 1/Series B/Series B 01.cbz",
"Root 1/Series B/Ignore/Series B 02.cbz",
"Root 2/Ignore/Series C/Series C 01.cbz"
]

View file

@ -0,0 +1,6 @@
[
"Root 1/Series A/Series A 01.cbz",
"Root 1/Series B/Series B 01.cbz",
"Root 1/Series B/Ignore/Series B 02.cbz",
"Root 2/Series C/Series C 01.cbz"
]

View file

@ -0,0 +1,8 @@
[
"My Dress-Up Darling/My Dress-Up Darling v01.cbz",
"My Dress-Up Darling/My Dress-Up Darling v02.cbz",
"My Dress-Up Darling/My Dress-Up Darling ch 10.cbz",
"My Dress-Up Darling/My Dress-Up Darling ch 11.epub",
"My Dress-Up Darling/My Dress-Up Darling ch 12.png",
"My Dress-Up Darling/My Dress-Up Darling ch 13.pdf"
]

View file

@ -1,5 +0,0 @@
[
"Immoral Guild/Immoral Guild v01.cbz",
"Immoral Guild/Immoral Guild v02.cbz",
"Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz"
]

View file

@ -1,3 +0,0 @@
[
"The Avengers/The Avengers vol 1.pdf"
]

View file

@ -50,9 +50,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="MailKit" Version="4.12.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.6">
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -62,45 +62,45 @@
<PackageReference Include="ExCSS" Version="4.3.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Hangfire" Version="1.8.20" />
<PackageReference Include="Hangfire" Version="1.8.18" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.20" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="9.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="3.1.0" />
<PackageReference Include="NetVips.Native" Version="8.17.0.1" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="NetVips" Version="3.0.0" />
<PackageReference Include="NetVips.Native" Version="8.16.1" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.40.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.11.0.117924">
<PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
<PackageReference Include="System.Drawing.Common" Version="9.0.6" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
<PackageReference Include="VersOne.Epub" Version="3.3.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.13" />
<PackageReference Include="System.Drawing.Common" Version="9.0.4" />
<PackageReference Include="VersOne.Epub" Version="3.3.3" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
@ -111,16 +111,17 @@
<ItemGroup>
<None Remove="Hangfire-log.db" />
<None Remove="obj\**" />
<None Remove="cache\**" />
<None Remove="cache-long\**" />
<None Remove="backups\**" />
<None Remove="logs\**" />
<None Remove="temp\**" />
<None Remove="config\kavita.log" />
<None Remove="config\kavita.db" />
<None Remove="config\covers\**" />
<None Remove="kavita.log" />
<None Remove="kavita.db" />
<None Remove="covers\**" />
<None Remove="wwwroot\**" />
<None Remove="cache\cache-long\**" />
<None Remove="config\cache\**" />
<None Remove="config\logs\**" />
<None Remove="config\covers\**" />
@ -138,7 +139,6 @@
<Compile Remove="covers\**" />
<Compile Remove="wwwroot\**" />
<Compile Remove="config\cache\**" />
<Compile Remove="cache\cache-long\**" />
<Compile Remove="config\logs\**" />
<Compile Remove="config\covers\**" />
<Compile Remove="config\bookmarks\**" />
@ -188,6 +188,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="config\cache-long\" />
<Folder Include="config\themes" />
<Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View file

@ -153,9 +153,6 @@ public class AccountController : BaseApiController
// Assign default streams
AddDefaultStreamsToUser(user);
// Assign default reading profile
await AddDefaultReadingProfileToUser(user);
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "validate-email", token));
@ -612,7 +609,7 @@ public class AccountController : BaseApiController
}
/// <summary>
/// Requests the Invite Url for the AppUserId. Will return error if user is already validated.
/// Requests the Invite Url for the UserId. Will return error if user is already validated.
/// </summary>
/// <param name="userId"></param>
/// <param name="withBaseUrl">Include the "https://ip:port/" in the generated link</param>
@ -672,9 +669,6 @@ public class AccountController : BaseApiController
// Assign default streams
AddDefaultStreamsToUser(user);
// Assign default reading profile
await AddDefaultReadingProfileToUser(user);
// Assign Roles
var roles = dto.Roles;
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
@ -785,16 +779,6 @@ public class AccountController : BaseApiController
}
}
private async Task AddDefaultReadingProfileToUser(AppUser user)
{
var profile = new AppUserReadingProfileBuilder(user.Id)
.WithName("Default Profile")
.WithKind(ReadingProfileKind.Default)
.Build();
_unitOfWork.AppUserReadingProfileRepository.Add(profile);
await _unitOfWork.CommitAsync();
}
/// <summary>
/// Last step in authentication flow, confirms the email token for email
/// </summary>

View file

@ -9,7 +9,6 @@ using API.DTOs;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Entities.MetadataMatching;
using API.Entities.Person;
using API.Extensions;
using API.Helpers;
@ -209,7 +208,6 @@ public class ChapterController : BaseApiController
if (chapter.AgeRating != dto.AgeRating)
{
chapter.AgeRating = dto.AgeRating;
chapter.KPlusOverrides.Remove(MetadataSettingField.AgeRating);
}
dto.Summary ??= string.Empty;
@ -217,7 +215,6 @@ public class ChapterController : BaseApiController
if (chapter.Summary != dto.Summary.Trim())
{
chapter.Summary = dto.Summary.Trim();
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterSummary);
}
if (chapter.Language != dto.Language)
@ -233,13 +230,11 @@ public class ChapterController : BaseApiController
if (chapter.TitleName != dto.TitleName)
{
chapter.TitleName = dto.TitleName;
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterTitle);
}
if (chapter.ReleaseDate != dto.ReleaseDate)
{
chapter.ReleaseDate = dto.ReleaseDate;
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterReleaseDate);
}
if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) ||
@ -338,8 +333,6 @@ public class ChapterController : BaseApiController
_unitOfWork
);
// TODO: Only remove field if changes were made
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterPublisher);
// Update publishers
await PersonHelper.UpdateChapterPeopleAsync(
chapter,

View file

@ -1,119 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Koreader;
using API.Entities;
using API.Extensions;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using static System.Net.WebRequestMethods;
namespace API.Controllers;
#nullable enable
/// <summary>
/// The endpoint to interface with Koreader's Progress Sync plugin.
/// </summary>
/// <remarks>
/// Koreader uses a different form of authentication. It stores the username and password in headers.
/// https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua
/// </remarks>
[AllowAnonymous]
public class KoreaderController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IKoreaderService _koreaderService;
private readonly ILogger<KoreaderController> _logger;
public KoreaderController(IUnitOfWork unitOfWork, ILocalizationService localizationService,
IKoreaderService koreaderService, ILogger<KoreaderController> logger)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_koreaderService = koreaderService;
_logger = logger;
}
// We won't allow users to be created from Koreader. Rather, they
// must already have an account.
/*
[HttpPost("/users/create")]
public IActionResult CreateUser(CreateUserRequest request)
{
}
*/
[HttpGet("{apiKey}/users/auth")]
public async Task<IActionResult> Authenticate(string apiKey)
{
var userId = await GetUserId(apiKey);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
return Ok(new { username = user.UserName });
}
/// <summary>
/// Syncs book progress with Kavita. Will attempt to save the underlying reader position if possible.
/// </summary>
/// <param name="apiKey"></param>
/// <param name="request"></param>
/// <returns></returns>
[HttpPut("{apiKey}/syncs/progress")]
public async Task<ActionResult<KoreaderProgressUpdateDto>> UpdateProgress(string apiKey, KoreaderBookDto request)
{
try
{
var userId = await GetUserId(apiKey);
await _koreaderService.SaveProgress(request, userId);
return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow });
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
/// <summary>
/// Gets book progress from Kavita, if not found will return a 400
/// </summary>
/// <param name="apiKey"></param>
/// <param name="ebookHash"></param>
/// <returns></returns>
[HttpGet("{apiKey}/syncs/progress/{ebookHash}")]
public async Task<ActionResult<KoreaderBookDto>> GetProgress(string apiKey, string ebookHash)
{
try
{
var userId = await GetUserId(apiKey);
var response = await _koreaderService.GetProgress(ebookHash, userId);
_logger.LogDebug("Koreader response progress for User ({UserId}): {Progress}", userId, response.Progress.Sanitize());
return Ok(response);
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
private async Task<int> GetUserId(string apiKey)
{
try
{
return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
}
catch
{
throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
}
}
}

View file

@ -623,8 +623,14 @@ public class LibraryController : BaseApiController
library.ManageReadingLists = dto.ManageReadingLists;
library.AllowScrobbling = dto.AllowScrobbling;
library.AllowMetadataMatching = dto.AllowMetadataMatching;
library.EnableMetadata = dto.EnableMetadata;
library.RemovePrefixForSortName = dto.RemovePrefixForSortName;
if (!dto.AllowFilenameParsing && !dto.AllowMetadataParsing)
{
throw new InvalidOperationException("At least one of UseFilenameParsing or UseInternalMetadataParsing must be true.");
}
library.AllowFilenameParsing = dto.AllowFilenameParsing;
library.AllowMetadataParsing = dto.AllowMetadataParsing;
library.LibraryFileTypes = dto.FileGroupTypes
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})

View file

@ -9,8 +9,6 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
using API.DTOs.Metadata.Browse;
using API.DTOs.Person;
using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail;
using API.Entities.Enums;
@ -48,22 +46,6 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context));
}
/// <summary>
/// Returns a list of Genres with counts for counts when Genre is on Series/Chapter
/// </summary>
/// <returns></returns>
[HttpPost("genres-with-counts")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
public async Task<ActionResult<PagedList<BrowseGenreDto>>> GetBrowseGenres(UserParams? userParams = null)
{
userParams ??= UserParams.Default;
var list = await unitOfWork.GenreRepository.GetBrowseableGenre(User.GetUserId(), userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
/// <summary>
/// Fetches people from the instance by role
/// </summary>
@ -92,7 +74,6 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
{
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids));
}
return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId()));
}
@ -113,22 +94,6 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId()));
}
/// <summary>
/// Returns a list of Tags with counts for counts when Tag is on Series/Chapter
/// </summary>
/// <returns></returns>
[HttpPost("tags-with-counts")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute)]
public async Task<ActionResult<PagedList<BrowseTagDto>>> GetBrowseTags(UserParams? userParams = null)
{
userParams ??= UserParams.Default;
var list = await unitOfWork.TagRepository.GetBrowseableTag(User.GetUserId(), userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
/// <summary>
/// Fetches all age ratings from the instance
/// </summary>

View file

@ -15,7 +15,6 @@ using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
using API.DTOs.Person;
using API.DTOs.Progress;
using API.DTOs.Search;
using API.Entities;

View file

@ -1,13 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata.Browse;
using API.DTOs.Metadata.Browse.Requests;
using API.DTOs.Person;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
@ -30,10 +24,9 @@ public class PersonController : BaseApiController
private readonly ICoverDbService _coverDbService;
private readonly IImageService _imageService;
private readonly IEventHub _eventHub;
private readonly IPersonService _personService;
public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper,
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService)
ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
@ -41,7 +34,6 @@ public class PersonController : BaseApiController
_coverDbService = coverDbService;
_imageService = imageService;
_eventHub = eventHub;
_personService = personService;
}
@ -51,17 +43,6 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId()));
}
/// <summary>
/// Find a person by name or alias against a query string
/// </summary>
/// <param name="queryString"></param>
/// <returns></returns>
[HttpGet("search")]
public async Task<ActionResult<List<PersonDto>>> SearchPeople([FromQuery] string queryString)
{
return Ok(await _unitOfWork.PersonRepository.SearchPeople(queryString));
}
/// <summary>
/// Returns all roles for a Person
/// </summary>
@ -73,20 +54,17 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId()));
}
/// <summary>
/// Returns a list of authors and artists for browsing
/// </summary>
/// <param name="userParams"></param>
/// <returns></returns>
[HttpPost("all")]
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetPeopleForBrowse(BrowsePersonFilterDto filter, [FromQuery] UserParams? userParams)
public async Task<ActionResult<PagedList<BrowsePersonDto>>> GetAuthorsForBrowse([FromQuery] UserParams? userParams)
{
userParams ??= UserParams.Default;
var list = await _unitOfWork.PersonRepository.GetBrowsePersonDtos(User.GetUserId(), filter, userParams);
var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams);
Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
return Ok(list);
}
@ -100,7 +78,7 @@ public class PersonController : BaseApiController
public async Task<ActionResult<PersonDto>> UpdatePerson(UpdatePersonDto dto)
{
// This needs to get all people and update them equally
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases);
var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id);
if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required"));
@ -112,12 +90,7 @@ public class PersonController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique"));
}
var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases);
if (!success) return BadRequest(await _localizationService.Translate(User.GetUserId(), "aliases-have-overlap"));
person.Name = dto.Name?.Trim();
person.NormalizedName = person.Name.ToNormalized();
person.Description = dto.Description ?? string.Empty;
person.CoverImageLocked = dto.CoverImageLocked;
@ -185,7 +158,7 @@ public class PersonController : BaseApiController
[HttpGet("series-known-for")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetKnownSeries(int personId)
{
return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId, User.GetUserId()));
return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId));
}
/// <summary>
@ -200,42 +173,5 @@ public class PersonController : BaseApiController
return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role));
}
/// <summary>
/// Merges Persons into one, this action is irreversible
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("merge")]
[Authorize("RequireAdminRole")]
public async Task<ActionResult<PersonDto>> MergePeople(PersonMergeDto dto)
{
var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All);
if (dst == null) return BadRequest();
var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All);
if (src == null) return BadRequest();
await _personService.MergePeopleAsync(src, dst);
await _eventHub.SendMessageAsync(MessageFactory.PersonMerged, MessageFactory.PersonMergedMessage(dst, src));
return Ok(_mapper.Map<PersonDto>(dst));
}
/// <summary>
/// Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias.
/// </summary>
/// <param name="personId"></param>
/// <param name="alias"></param>
/// <returns></returns>
[HttpGet("valid-alias")]
public async Task<ActionResult<bool>> IsValidAlias(int personId, string alias)
{
var person = await _unitOfWork.PersonRepository.GetPersonById(personId, PersonIncludes.Aliases);
if (person == null) return NotFound();
var existingAlias = await _unitOfWork.PersonRepository.AnyAliasExist(alias);
return Ok(!existingAlias && person.NormalizedName != alias.ToNormalized());
}
}

View file

@ -45,7 +45,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
throw new KavitaUnauthenticatedUserException();
}
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId);
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({AppUserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
return new UserDto
{

View file

@ -4,7 +4,7 @@ using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Person;
using API.DTOs;
using API.DTOs.ReadingLists;
using API.Entities.Enums;
using API.Extensions;

View file

@ -1,198 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Extensions;
using API.Services;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers;
[Route("api/reading-profile")]
public class ReadingProfileController(ILogger<ReadingProfileController> logger, IUnitOfWork unitOfWork,
IReadingProfileService readingProfileService): BaseApiController
{
/// <summary>
/// Gets all non-implicit reading profiles for a user
/// </summary>
/// <returns></returns>
[HttpGet("all")]
public async Task<ActionResult<IList<UserReadingProfileDto>>> GetAllReadingProfiles()
{
return Ok(await unitOfWork.AppUserReadingProfileRepository.GetProfilesDtoForUser(User.GetUserId(), true));
}
/// <summary>
/// Returns the ReadingProfile that should be applied to the given series, walks up the tree.
/// Series -> Library -> Default
/// </summary>
/// <param name="seriesId"></param>
/// <param name="skipImplicit"></param>
/// <returns></returns>
[HttpGet("{seriesId:int}")]
public async Task<ActionResult<UserReadingProfileDto>> GetProfileForSeries(int seriesId, [FromQuery] bool skipImplicit)
{
return Ok(await readingProfileService.GetReadingProfileDtoForSeries(User.GetUserId(), seriesId, skipImplicit));
}
/// <summary>
/// Returns the (potential) Reading Profile bound to the library
/// </summary>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpGet("library")]
public async Task<ActionResult<UserReadingProfileDto?>> GetProfileForLibrary(int libraryId)
{
return Ok(await readingProfileService.GetReadingProfileDtoForLibrary(User.GetUserId(), libraryId));
}
/// <summary>
/// Creates a new reading profile for the current user
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("create")]
public async Task<ActionResult<UserReadingProfileDto>> CreateReadingProfile([FromBody] UserReadingProfileDto dto)
{
return Ok(await readingProfileService.CreateReadingProfile(User.GetUserId(), dto));
}
/// <summary>
/// Promotes the implicit profile to a user profile. Removes the series from other profiles
/// </summary>
/// <param name="profileId"></param>
/// <returns></returns>
[HttpPost("promote")]
public async Task<ActionResult<UserReadingProfileDto>> PromoteImplicitReadingProfile([FromQuery] int profileId)
{
return Ok(await readingProfileService.PromoteImplicitProfile(User.GetUserId(), profileId));
}
/// <summary>
/// Update the implicit reading profile for a series, creates one if none exists
/// </summary>
/// <remarks>Any modification to the reader settings during reading will create an implicit profile. Use "update-parent" to save to the bound series profile.</remarks>
/// <param name="dto"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("series")]
public async Task<ActionResult<UserReadingProfileDto>> UpdateReadingProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId)
{
var updatedProfile = await readingProfileService.UpdateImplicitReadingProfile(User.GetUserId(), seriesId, dto);
return Ok(updatedProfile);
}
/// <summary>
/// Updates the non-implicit reading profile for the given series, and removes implicit profiles
/// </summary>
/// <param name="dto"></param>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpPost("update-parent")]
public async Task<ActionResult<UserReadingProfileDto>> UpdateParentProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId)
{
var newParentProfile = await readingProfileService.UpdateParent(User.GetUserId(), seriesId, dto);
return Ok(newParentProfile);
}
/// <summary>
/// Updates the given reading profile, must belong to the current user
/// </summary>
/// <param name="dto"></param>
/// <returns>The updated reading profile</returns>
/// <remarks>
/// This does not update connected series and libraries.
/// </remarks>
[HttpPost]
public async Task<ActionResult<UserReadingProfileDto>> UpdateReadingProfile(UserReadingProfileDto dto)
{
return Ok(await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto));
}
/// <summary>
/// Deletes the given profile, requires the profile to belong to the logged-in user
/// </summary>
/// <param name="profileId"></param>
/// <returns></returns>
/// <exception cref="KavitaException"></exception>
/// <exception cref="UnauthorizedAccessException"></exception>
[HttpDelete]
public async Task<IActionResult> DeleteReadingProfile([FromQuery] int profileId)
{
await readingProfileService.DeleteReadingProfile(User.GetUserId(), profileId);
return Ok();
}
/// <summary>
/// Sets the reading profile for a given series, removes the old one
/// </summary>
/// <param name="seriesId"></param>
/// <param name="profileId"></param>
/// <returns></returns>
[HttpPost("series/{seriesId:int}")]
public async Task<IActionResult> AddProfileToSeries(int seriesId, [FromQuery] int profileId)
{
await readingProfileService.AddProfileToSeries(User.GetUserId(), profileId, seriesId);
return Ok();
}
/// <summary>
/// Clears the reading profile for the given series for the currently logged-in user
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpDelete("series/{seriesId:int}")]
public async Task<IActionResult> ClearSeriesProfile(int seriesId)
{
await readingProfileService.ClearSeriesProfile(User.GetUserId(), seriesId);
return Ok();
}
/// <summary>
/// Sets the reading profile for a given library, removes the old one
/// </summary>
/// <param name="libraryId"></param>
/// <param name="profileId"></param>
/// <returns></returns>
[HttpPost("library/{libraryId:int}")]
public async Task<IActionResult> AddProfileToLibrary(int libraryId, [FromQuery] int profileId)
{
await readingProfileService.AddProfileToLibrary(User.GetUserId(), profileId, libraryId);
return Ok();
}
/// <summary>
/// Clears the reading profile for the given library for the currently logged-in user
/// </summary>
/// <param name="libraryId"></param>
/// <param name="profileId"></param>
/// <returns></returns>
[HttpDelete("library/{libraryId:int}")]
public async Task<IActionResult> ClearLibraryProfile(int libraryId)
{
await readingProfileService.ClearLibraryProfile(User.GetUserId(), libraryId);
return Ok();
}
/// <summary>
/// Assigns the reading profile to all passes series, and deletes their implicit profiles
/// </summary>
/// <param name="profileId"></param>
/// <param name="seriesIds"></param>
/// <returns></returns>
[HttpPost("bulk")]
public async Task<IActionResult> BulkAddReadingProfile([FromQuery] int profileId, [FromBody] IList<int> seriesIds)
{
await readingProfileService.BulkAddProfileToSeries(User.GetUserId(), profileId, seriesIds);
return Ok();
}
}

View file

@ -254,7 +254,7 @@ public class ScrobblingController : BaseApiController
}
/// <summary>
/// Remove a hold against the Series for user's scrobbling
/// Adds a hold against the Series for user's scrobbling
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
@ -281,18 +281,4 @@ public class ScrobblingController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
return Ok(user is {HasRunScrobbleEventGeneration: true});
}
/// <summary>
/// Delete the given scrobble events if they belong to that user
/// </summary>
/// <param name="eventIds"></param>
/// <returns></returns>
[HttpPost("bulk-remove-events")]
public async Task<ActionResult> BulkRemoveScrobbleEvents(IList<long> eventIds)
{
var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), eventIds);
_unitOfWork.ScrobbleRepository.Remove(events);
await _unitOfWork.CommitAsync();
return Ok();
}
}

View file

@ -63,7 +63,6 @@ public class SearchController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized();
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted"));

View file

@ -14,7 +14,6 @@ using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Entities.MetadataMatching;
using API.Extensions;
using API.Helpers;
using API.Services;
@ -225,7 +224,6 @@ public class SeriesController : BaseApiController
needsRefreshMetadata = true;
series.CoverImage = null;
series.CoverImageLocked = false;
series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Covers);
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id);
series.ResetColorScape();
@ -312,7 +310,7 @@ public class SeriesController : BaseApiController
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId">This is not in use</param>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("all-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams,
@ -323,6 +321,8 @@ public class SeriesController : BaseApiController
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);

View file

@ -6,7 +6,6 @@ using API.Data;
using API.Data.Repositories;
using API.DTOs.Uploads;
using API.Entities.Enums;
using API.Entities.MetadataMatching;
using API.Extensions;
using API.Services;
using API.Services.Tasks.Metadata;
@ -113,10 +112,8 @@ public class UploadController : BaseApiController
series.CoverImage = filePath;
series.CoverImageLocked = lockState;
series.Metadata.KPlusOverrides.Remove(MetadataSettingField.Covers);
_imageService.UpdateColorScape(series);
_unitOfWork.SeriesRepository.Update(series);
_unitOfWork.SeriesRepository.Update(series.Metadata);
if (_unitOfWork.HasChanges())
{
@ -280,7 +277,6 @@ public class UploadController : BaseApiController
chapter.CoverImage = filePath;
chapter.CoverImageLocked = lockState;
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterCovers);
_unitOfWork.ChapterRepository.Update(chapter);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId);
if (volume != null)

View file

@ -103,13 +103,38 @@ public class UsersController : BaseApiController
var existingPreferences = user!.UserPreferences;
existingPreferences.ReadingDirection = preferencesDto.ReadingDirection;
existingPreferences.ScalingOption = preferencesDto.ScalingOption;
existingPreferences.PageSplitOption = preferencesDto.PageSplitOption;
existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu;
existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints;
existingPreferences.EmulateBook = preferencesDto.EmulateBook;
existingPreferences.ReaderMode = preferencesDto.ReaderMode;
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor;
existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin;
existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing;
existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily;
existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize;
existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate;
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
existingPreferences.BookReaderWritingStyle = preferencesDto.BookReaderWritingStyle;
existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName;
existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode;
existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode;
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries;
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
existingPreferences.PdfTheme = preferencesDto.PdfTheme;
existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
if (await _licenseService.HasActiveLicense())
{
existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled;

View file

@ -2,15 +2,15 @@
namespace API.DTOs.Account;
public sealed record AgeRestrictionDto
public class AgeRestrictionDto
{
/// <summary>
/// The maximum age rating a user has access to. -1 if not applicable
/// </summary>
public required AgeRating AgeRating { get; init; } = AgeRating.NotApplicable;
public required AgeRating AgeRating { get; set; } = AgeRating.NotApplicable;
/// <summary>
/// Are Unknowns explicitly allowed against age rating
/// </summary>
/// <remarks>Unknown is always lowest and default age rating. Setting this to false will ensure Teen age rating applies and unknowns are still filtered</remarks>
public required bool IncludeUnknowns { get; init; } = false;
public required bool IncludeUnknowns { get; set; } = false;
}

View file

@ -2,7 +2,7 @@
namespace API.DTOs.Account;
public sealed record ConfirmEmailDto
public class ConfirmEmailDto
{
[Required]
public string Email { get; set; } = default!;

View file

@ -2,7 +2,7 @@
namespace API.DTOs.Account;
public sealed record ConfirmEmailUpdateDto
public class ConfirmEmailUpdateDto
{
[Required]
public string Email { get; set; } = default!;

View file

@ -1,6 +1,6 @@
namespace API.DTOs.Account;
public sealed record ConfirmMigrationEmailDto
public class ConfirmMigrationEmailDto
{
public string Email { get; set; } = default!;
public string Token { get; set; } = default!;

View file

@ -2,7 +2,7 @@
namespace API.DTOs.Account;
public sealed record ConfirmPasswordResetDto
public class ConfirmPasswordResetDto
{
[Required]
public string Email { get; set; } = default!;

View file

@ -3,7 +3,7 @@ using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Account;
public sealed record InviteUserDto
public class InviteUserDto
{
[Required]
public string Email { get; set; } = default!;

View file

@ -1,6 +1,6 @@
namespace API.DTOs.Account;
public sealed record InviteUserResponse
public class InviteUserResponse
{
/// <summary>
/// Email link used to setup the user account

View file

@ -1,7 +1,7 @@
namespace API.DTOs.Account;
#nullable enable
public sealed record LoginDto
public class LoginDto
{
public string Username { get; init; } = default!;
public string Password { get; set; } = default!;

View file

@ -1,6 +1,6 @@
namespace API.DTOs.Account;
public sealed record MigrateUserEmailDto
public class MigrateUserEmailDto
{
public string Email { get; set; } = default!;
public string Username { get; set; } = default!;

View file

@ -2,7 +2,7 @@
namespace API.DTOs.Account;
public sealed record ResetPasswordDto
public class ResetPasswordDto
{
/// <summary>
/// The Username of the User

View file

@ -1,6 +1,6 @@
namespace API.DTOs.Account;
public sealed record TokenRequestDto
public class TokenRequestDto
{
public string Token { get; init; } = default!;
public string RefreshToken { get; init; } = default!;

View file

@ -3,7 +3,7 @@ using API.Entities.Enums;
namespace API.DTOs.Account;
public sealed record UpdateAgeRestrictionDto
public class UpdateAgeRestrictionDto
{
[Required]
public AgeRating AgeRating { get; set; }

View file

@ -1,6 +1,6 @@
namespace API.DTOs.Account;
public sealed record UpdateEmailDto
public class UpdateEmailDto
{
public string Email { get; set; } = default!;
public string Password { get; set; } = default!;

View file

@ -4,16 +4,12 @@ using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Account;
#nullable enable
public sealed record UpdateUserDto
public record UpdateUserDto
{
/// <inheritdoc cref="API.Entities.AppUser.Id"/>
public int UserId { get; set; }
/// <inheritdoc cref="API.Entities.AppUser.UserName"/>
public string Username { get; set; } = default!;
/// <summary>
/// List of Roles to assign to user. If admin not present, Pleb will be applied.
/// If admin present, all libraries will be granted access and will ignore those from DTO.
/// </summary>
public IList<string> Roles { get; init; } = default!;
/// <summary>
/// A list of libraries to grant access to
@ -23,6 +19,8 @@ public sealed record UpdateUserDto
/// An Age Rating which will limit the account to seeing everything equal to or below said rating.
/// </summary>
public AgeRestrictionDto AgeRestriction { get; init; } = default!;
/// <inheritdoc cref="API.Entities.AppUser.Email"/>
/// <summary>
/// Email of the user
/// </summary>
public string? Email { get; set; } = default!;
}

View file

@ -2,7 +2,7 @@
namespace API.DTOs;
public sealed record BulkActionDto
public class BulkActionDto
{
public List<int> Ids { get; set; }
/**

View file

@ -4,7 +4,7 @@ using API.DTOs.SeriesDetail;
namespace API.DTOs;
public sealed record ChapterDetailPlusDto
public class ChapterDetailPlusDto
{
public float Rating { get; set; }
public bool HasBeenRated { get; set; }

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.DTOs.Person;
using API.Entities.Enums;
using API.Entities.Interfaces;
@ -14,24 +13,37 @@ namespace API.DTOs;
/// </summary>
public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
{
/// <inheritdoc cref="API.Entities.Chapter.Id"/>
public int Id { get; init; }
/// <inheritdoc cref="API.Entities.Chapter.Range"/>
/// <summary>
/// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If special, will be special name.
/// </summary>
/// <remarks>This can be something like 19.HU or Alpha as some comics are like this</remarks>
public string Range { get; init; } = default!;
/// <inheritdoc cref="API.Entities.Chapter.Number"/>
/// <summary>
/// Smallest number of the Range.
/// </summary>
[Obsolete("Use MinNumber and MaxNumber instead")]
public string Number { get; init; } = default!;
/// <inheritdoc cref="API.Entities.Chapter.MinNumber"/>
/// <summary>
/// This may be 0 under the circumstance that the Issue is "Alpha" or other non-standard numbers.
/// </summary>
public float MinNumber { get; init; }
/// <inheritdoc cref="API.Entities.Chapter.MaxNumber"/>
public float MaxNumber { get; init; }
/// <inheritdoc cref="API.Entities.Chapter.SortOrder"/>
/// <summary>
/// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.
/// </summary>
public float SortOrder { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.Pages"/>
/// <summary>
/// Total number of pages in all MangaFiles
/// </summary>
public int Pages { get; init; }
/// <inheritdoc cref="API.Entities.Chapter.IsSpecial"/>
/// <summary>
/// If this Chapter contains files that could only be identified as Series or has Special Identifier from filename
/// </summary>
public bool IsSpecial { get; init; }
/// <inheritdoc cref="API.Entities.Chapter.Title"/>
/// <summary>
/// Used for books/specials to display custom title. For non-specials/books, will be set to <see cref="Range"/>
/// </summary>
public string Title { get; set; } = default!;
/// <summary>
/// The files that represent this Chapter
@ -49,25 +61,46 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
/// The last time a chapter was read by current authenticated user
/// </summary>
public DateTime LastReadingProgress { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.CoverImageLocked"/>
/// <summary>
/// If the Cover Image is locked for this entity
/// </summary>
public bool CoverImageLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.VolumeId"/>
/// <summary>
/// Volume Id this Chapter belongs to
/// </summary>
public int VolumeId { get; init; }
/// <inheritdoc cref="API.Entities.Chapter.CreatedUtc"/>
/// <summary>
/// When chapter was created
/// </summary>
public DateTime CreatedUtc { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.LastModifiedUtc"/>
public DateTime LastModifiedUtc { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.Created"/>
/// <summary>
/// When chapter was created in local server time
/// </summary>
/// <remarks>This is required for Tachiyomi Extension</remarks>
public DateTime Created { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.ReleaseDate"/>
/// <summary>
/// When the chapter was released.
/// </summary>
/// <remarks>Metadata field</remarks>
public DateTime ReleaseDate { get; init; }
/// <inheritdoc cref="API.Entities.Chapter.TitleName"/>
/// <summary>
/// Title of the Chapter/Issue
/// </summary>
/// <remarks>Metadata field</remarks>
public string TitleName { get; set; } = default!;
/// <inheritdoc cref="API.Entities.Chapter.Summary"/>
/// <summary>
/// Summary of the Chapter
/// </summary>
/// <remarks>This is not set normally, only for Series Detail</remarks>
public string Summary { get; init; } = default!;
/// <inheritdoc cref="API.Entities.Chapter.AgeRating"/>
/// <summary>
/// Age Rating for the issue/chapter
/// </summary>
public AgeRating AgeRating { get; init; }
/// <inheritdoc cref="API.Entities.Chapter.WordCount"/>
/// <summary>
/// Total words in a Chapter (books only)
/// </summary>
public long WordCount { get; set; } = 0L;
/// <summary>
/// Formatted Volume title ie) Volume 2.
@ -80,9 +113,14 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public float AvgHoursToRead { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.WebLinks"/>
/// <summary>
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>
public string WebLinks { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.ISBN"/>
/// <summary>
/// ISBN-13 (usually) of the Chapter
/// </summary>
/// <remarks>This is guaranteed to be Valid</remarks>
public string ISBN { get; set; }
#region Metadata
@ -108,60 +146,51 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
/// </summary>
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
public PublicationStatus PublicationStatus { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.Language"/>
/// <summary>
/// Language for the Chapter/Issue
/// </summary>
public string? Language { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.Count"/>
/// <summary>
/// Number in the TotalCount of issues
/// </summary>
public int Count { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.TotalCount"/>
/// <summary>
/// Total number of issues for the series
/// </summary>
public int TotalCount { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.LanguageLocked"/>
public bool LanguageLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.SummaryLocked"/>
public bool SummaryLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.AgeRatingLocked"/>
/// <summary>
/// Locked by user so metadata updates from scan loop will not override AgeRating
/// </summary>
public bool AgeRatingLocked { get; set; }
/// <summary>
/// Locked by user so metadata updates from scan loop will not override PublicationStatus
/// </summary>
public bool PublicationStatusLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.GenresLocked"/>
public bool GenresLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.TagsLocked"/>
public bool TagsLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.WriterLocked"/>
public bool WriterLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.CharacterLocked"/>
public bool CharacterLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.ColoristLocked"/>
public bool ColoristLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.EditorLocked"/>
public bool EditorLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.InkerLocked"/>
public bool InkerLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.ImprintLocked"/>
public bool ImprintLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.LettererLocked"/>
public bool LettererLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.PencillerLocked"/>
public bool PencillerLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.PublisherLocked"/>
public bool PublisherLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.TranslatorLocked"/>
public bool TranslatorLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.TeamLocked"/>
public bool TeamLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.LocationLocked"/>
public bool LocationLocked { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.CoverArtistLocked"/>
public bool CoverArtistLocked { get; set; }
public bool ReleaseYearLocked { get; set; }
#endregion
/// <inheritdoc cref="API.Entities.Chapter.CoverImage"/>
public string? CoverImage { get; set; }
/// <inheritdoc cref="API.Entities.Chapter.PrimaryColor"/>
public string? PrimaryColor { get; set; } = string.Empty;
/// <inheritdoc cref="API.Entities.Chapter.SecondaryColor"/>
public string? SecondaryColor { get; set; } = string.Empty;
public string CoverImage { get; set; }
public string PrimaryColor { get; set; } = string.Empty;
public string SecondaryColor { get; set; } = string.Empty;
public void ResetColorScape()
{

View file

@ -6,52 +6,52 @@ using API.Services.Plus;
namespace API.DTOs.Collection;
#nullable enable
public sealed record AppUserCollectionDto : IHasCoverImage
public class AppUserCollectionDto : IHasCoverImage
{
public int Id { get; init; }
public string Title { get; init; } = default!;
public string? Summary { get; init; } = default!;
public bool Promoted { get; init; }
public AgeRating AgeRating { get; init; }
public string Title { get; set; } = default!;
public string? Summary { get; set; } = default!;
public bool Promoted { get; set; }
public AgeRating AgeRating { get; set; }
/// <summary>
/// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set.
/// </summary>
public string? CoverImage { get; set; } = string.Empty;
public string? PrimaryColor { get; set; } = string.Empty;
public string? SecondaryColor { get; set; } = string.Empty;
public bool CoverImageLocked { get; init; }
public string PrimaryColor { get; set; } = string.Empty;
public string SecondaryColor { get; set; } = string.Empty;
public bool CoverImageLocked { get; set; }
/// <summary>
/// Number of Series in the Collection
/// </summary>
public int ItemCount { get; init; }
public int ItemCount { get; set; }
/// <summary>
/// Owner of the Collection
/// </summary>
public string? Owner { get; init; }
public string? Owner { get; set; }
/// <summary>
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
/// </summary>
public DateTime LastSyncUtc { get; init; }
public DateTime LastSyncUtc { get; set; }
/// <summary>
/// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
/// </summary>
public ScrobbleProvider Source { get; init; } = ScrobbleProvider.Kavita;
public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
/// <summary>
/// For Non-Kavita sourced collections, the url to sync from
/// </summary>
public string? SourceUrl { get; init; }
public string? SourceUrl { get; set; }
/// <summary>
/// Total number of items as of the last sync. Not applicable for Kavita managed collections.
/// </summary>
public int TotalSourceCount { get; init; }
public int TotalSourceCount { get; set; }
/// <summary>
/// A <br/> separated string of all missing series
/// </summary>
public string? MissingSeriesFromSource { get; init; }
public string? MissingSeriesFromSource { get; set; }
public void ResetColorScape()
{

View file

@ -2,7 +2,7 @@
namespace API.DTOs.CollectionTags;
public sealed record CollectionTagBulkAddDto
public class CollectionTagBulkAddDto
{
/// <summary>
/// Collection Tag Id

View file

@ -3,21 +3,15 @@
namespace API.DTOs.CollectionTags;
[Obsolete("Use AppUserCollectionDto")]
public sealed record CollectionTagDto
public class CollectionTagDto
{
/// <inheritdoc cref="API.Entities.CollectionTag.Id"/>
public int Id { get; set; }
/// <inheritdoc cref="API.Entities.CollectionTag.Title"/>
public string Title { get; set; } = default!;
/// <inheritdoc cref="API.Entities.CollectionTag.Summary"/>
public string Summary { get; set; } = default!;
/// <inheritdoc cref="API.Entities.CollectionTag.Promoted"/>
public bool Promoted { get; set; }
/// <summary>
/// The cover image string. This is used on Frontend to show or hide the Cover Image
/// </summary>
/// <inheritdoc cref="API.Entities.CollectionTag.CoverImage"/>
public string CoverImage { get; set; } = default!;
/// <inheritdoc cref="API.Entities.CollectionTag.CoverImageLocked"/>
public bool CoverImageLocked { get; set; }
}

View file

@ -4,7 +4,7 @@ using API.DTOs.Collection;
namespace API.DTOs.CollectionTags;
public sealed record UpdateSeriesForTagDto
public class UpdateSeriesForTagDto
{
public AppUserCollectionDto Tag { get; init; } = default!;
public IEnumerable<int> SeriesIdsToRemove { get; init; } = default!;

View file

@ -4,7 +4,7 @@
/// <summary>
/// A primary and secondary color
/// </summary>
public sealed record ColorScape
public class ColorScape
{
public required string? Primary { get; set; }
public required string? Secondary { get; set; }

View file

@ -2,7 +2,7 @@
namespace API.DTOs;
public sealed record CopySettingsFromLibraryDto
public class CopySettingsFromLibraryDto
{
public int SourceLibraryId { get; set; }
public List<int> TargetLibraryIds { get; set; }

View file

@ -3,7 +3,7 @@ using YamlDotNet.Serialization;
namespace API.DTOs.CoverDb;
public sealed record CoverDbAuthor
public class CoverDbAuthor
{
[YamlMember(Alias = "name", ApplyNamingConventions = false)]
public string Name { get; set; }

View file

@ -3,7 +3,7 @@ using YamlDotNet.Serialization;
namespace API.DTOs.CoverDb;
public sealed record CoverDbPeople
public class CoverDbPeople
{
[YamlMember(Alias = "people", ApplyNamingConventions = false)]
public List<CoverDbAuthor> People { get; set; } = new List<CoverDbAuthor>();

View file

@ -3,7 +3,7 @@
namespace API.DTOs.CoverDb;
#nullable enable
public sealed record CoverDbPersonIds
public class CoverDbPersonIds
{
[YamlMember(Alias = "hardcover_id", ApplyNamingConventions = false)]
public string? HardcoverId { get; set; } = null;

View file

@ -4,7 +4,7 @@ using API.Entities.Enums;
namespace API.DTOs.Dashboard;
public sealed record DashboardStreamDto
public class DashboardStreamDto
{
public int Id { get; set; }
public required string Name { get; set; }

View file

@ -5,7 +5,7 @@ namespace API.DTOs.Dashboard;
/// <summary>
/// This is a representation of a Series with some amount of underlying files within it. This is used for Recently Updated Series section
/// </summary>
public sealed record GroupedSeriesDto
public class GroupedSeriesDto
{
public string SeriesName { get; set; } = default!;
public int SeriesId { get; set; }

View file

@ -6,7 +6,7 @@ namespace API.DTOs.Dashboard;
/// <summary>
/// A mesh of data for Recently added volume/chapters
/// </summary>
public sealed record RecentlyAddedItemDto
public class RecentlyAddedItemDto
{
public string SeriesName { get; set; } = default!;
public int SeriesId { get; set; }

View file

@ -2,7 +2,7 @@
namespace API.DTOs.Dashboard;
public sealed record SmartFilterDto
public class SmartFilterDto
{
public int Id { get; set; }
public required string Name { get; set; }

View file

@ -1,6 +1,6 @@
namespace API.DTOs.Dashboard;
public sealed record UpdateDashboardStreamPositionDto
public class UpdateDashboardStreamPositionDto
{
public int FromPosition { get; set; }
public int ToPosition { get; set; }

View file

@ -1,6 +1,6 @@
namespace API.DTOs.Dashboard;
public sealed record UpdateStreamPositionDto
public class UpdateStreamPositionDto
{
public int FromPosition { get; set; }
public int ToPosition { get; set; }

View file

@ -2,7 +2,7 @@
namespace API.DTOs;
public sealed record DeleteChaptersDto
public class DeleteChaptersDto
{
public IList<int> ChapterIds { get; set; } = default!;
}

Some files were not shown because too many files have changed in this diff Show more