using System.Collections.Generic; using System.IO.Abstractions; using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.KavitaPlus.Metadata; using API.Entities; using API.Entities.Enums; using API.Entities.MetadataMatching; using API.Services; using API.Services.Tasks.Scanner; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace API.Tests.Services; public class SettingsServiceTests { private readonly ISettingsService _settingsService; private readonly IUnitOfWork _mockUnitOfWork; private const string DefaultAgeKey = "default_age"; private const string DefaultFieldSource = "default_source"; private readonly static AgeRating DefaultAgeRating = AgeRating.Everyone; private readonly static MetadataFieldType DefaultSourceField = MetadataFieldType.Genre; public SettingsServiceTests() { var ds = new DirectoryService(Substitute.For>(), new FileSystem()); _mockUnitOfWork = Substitute.For(); _settingsService = new SettingsService(_mockUnitOfWork, ds, Substitute.For(), Substitute.For(), Substitute.For>()); } #region ImportMetadataSettings [Fact] public async Task ImportFieldMappings_ReplaceMode() { var existingSettings = CreateDefaultMetadataSettingsDto(); var newSettings = new MetadataSettingsDto { Whitelist = ["new_whitelist_item"], Blacklist = ["new_blacklist_item"], AgeRatingMappings = new Dictionary { ["new_age"] = AgeRating.R18Plus }, FieldMappings = [ new MetadataFieldMappingDto { Id = 10, SourceValue = "new_source", SourceType = MetadataFieldType.Genre, DestinationValue = "new_dest", DestinationType = MetadataFieldType.Tag } ], }; var importSettings = new ImportSettingsDto { ImportMode = ImportMode.Replace, Whitelist = true, Blacklist = true, AgeRatings = true, FieldMappings = true, Resolution = ConflictResolution.Manual, AgeRatingConflictResolutions = [], }; var settingsRepo = Substitute.For(); settingsRepo.GetMetadataSettingDto().Returns(existingSettings); _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); var result = await _settingsService.ImportFieldMappings(newSettings, importSettings); Assert.True(result.Success); Assert.Empty(result.AgeRatingConflicts); Assert.Equal(existingSettings.Whitelist, newSettings.Whitelist); Assert.Equal(existingSettings.Blacklist, newSettings.Blacklist); Assert.Equal(existingSettings.AgeRatingMappings, newSettings.AgeRatingMappings); Assert.Equal(existingSettings.FieldMappings, newSettings.FieldMappings); } [Fact] public async Task ImportFieldMappings_MergeMode_WithNoConflicts() { var existingSettingsDto = CreateDefaultMetadataSettingsDto(); var existingSettings = CreateDefaultMetadataSettings(); var newSettings = new MetadataSettingsDto { Whitelist = ["new_whitelist_item"], Blacklist = ["new_blacklist_item"], AgeRatingMappings = new Dictionary { ["new_age"] = AgeRating.R18Plus }, FieldMappings = [ new MetadataFieldMappingDto { Id = 10, SourceValue = "new_source", SourceType = MetadataFieldType.Genre, DestinationValue = "new_dest", DestinationType = MetadataFieldType.Tag }, ], }; var importSettings = new ImportSettingsDto { ImportMode = ImportMode.Merge, Whitelist = true, Blacklist = true, AgeRatings = true, FieldMappings = true, Resolution = ConflictResolution.Manual, AgeRatingConflictResolutions = [], }; var settingsRepo = Substitute.For(); settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto); settingsRepo.GetMetadataSettings().Returns(existingSettings); _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); var result = await _settingsService.ImportFieldMappings(newSettings, importSettings); Assert.True(result.Success); Assert.Empty(result.AgeRatingConflicts); Assert.Contains("default_white", existingSettingsDto.Whitelist); Assert.Contains("new_whitelist_item", existingSettingsDto.Whitelist); Assert.Contains("default_black", existingSettingsDto.Blacklist); Assert.Contains("new_blacklist_item", existingSettingsDto.Blacklist); Assert.Equal(2, existingSettingsDto.AgeRatingMappings.Count); Assert.Equal(2, existingSettingsDto.FieldMappings.Count); } [Fact] public async Task ImportFieldMappings_MergeMode_UseConfiguredOverrides() { var existingSettingsDto = CreateDefaultMetadataSettingsDto(); var existingSettings = CreateDefaultMetadataSettings(); var newSettings = new MetadataSettingsDto { Whitelist = [], Blacklist = [], AgeRatingMappings = new Dictionary { [DefaultAgeKey] = AgeRating.R18Plus }, FieldMappings = [ new MetadataFieldMappingDto { Id = 20, SourceValue = DefaultFieldSource, SourceType = DefaultSourceField, DestinationValue = "different_dest", DestinationType = MetadataFieldType.Genre, } ], }; var importSettings = new ImportSettingsDto { ImportMode = ImportMode.Merge, Whitelist = false, Blacklist = false, AgeRatings = true, FieldMappings = true, Resolution = ConflictResolution.Manual, AgeRatingConflictResolutions = new Dictionary { [DefaultAgeKey] = ConflictResolution.Replace }, }; var settingsRepo = Substitute.For(); settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto); settingsRepo.GetMetadataSettings().Returns(existingSettings); _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); var result = await _settingsService.ImportFieldMappings(newSettings, importSettings); Assert.True(result.Success); Assert.Empty(result.AgeRatingConflicts); Assert.Equal(AgeRating.R18Plus, existingSettingsDto.AgeRatingMappings[DefaultAgeKey]); } [Fact] public async Task ImportFieldMappings_MergeMode_SkipIdenticalMappings() { var existingSettingsDto = CreateDefaultMetadataSettingsDto(); var existingSettings = CreateDefaultMetadataSettings(); var newSettings = new MetadataSettingsDto { Whitelist = [], Blacklist = [], AgeRatingMappings = new Dictionary { ["existing_age"] = AgeRating.Mature }, // Same value FieldMappings = [ new MetadataFieldMappingDto { Id = 20, SourceValue = "existing_source", SourceType = MetadataFieldType.Genre, DestinationValue = "existing_dest", // Same destination DestinationType = MetadataFieldType.Tag // Same destination type } ], }; var importSettings = new ImportSettingsDto { ImportMode = ImportMode.Merge, Whitelist = false, Blacklist = false, AgeRatings = true, FieldMappings = true, Resolution = ConflictResolution.Manual, AgeRatingConflictResolutions = [], }; var settingsRepo = Substitute.For(); settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto); settingsRepo.GetMetadataSettings().Returns(existingSettings); _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); var result = await _settingsService.ImportFieldMappings(newSettings, importSettings); Assert.True(result.Success); Assert.Empty(result.AgeRatingConflicts); } #endregion #region UpdateMetadataSettings [Fact] public async Task UpdateMetadataSettings_ShouldUpdateExistingSettings() { // Arrange var existingSettings = new MetadataSettings { Id = 1, Enabled = false, EnableSummary = false, EnableLocalizedName = false, EnablePublicationStatus = false, EnableRelationships = false, EnablePeople = false, EnableStartDate = false, EnableGenres = false, EnableTags = false, FirstLastPeopleNaming = false, EnableCoverImage = false, AgeRatingMappings = new Dictionary(), Blacklist = [], Whitelist = [], Overrides = [], PersonRoles = [], FieldMappings = [] }; var settingsRepo = Substitute.For(); settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); var updateDto = new MetadataSettingsDto { Enabled = true, EnableSummary = true, EnableLocalizedName = true, EnablePublicationStatus = true, EnableRelationships = true, EnablePeople = true, EnableStartDate = true, EnableGenres = true, EnableTags = true, FirstLastPeopleNaming = true, EnableCoverImage = true, AgeRatingMappings = new Dictionary { { "Adult", AgeRating.R18Plus } }, Blacklist = ["blacklisted-tag"], Whitelist = ["whitelisted-tag"], Overrides = [MetadataSettingField.Summary], PersonRoles = [PersonRole.Writer], FieldMappings = [ new MetadataFieldMappingDto { SourceType = MetadataFieldType.Genre, DestinationType = MetadataFieldType.Tag, SourceValue = "Action", DestinationValue = "Fight", ExcludeFromSource = true } ] }; // Act await _settingsService.UpdateMetadataSettings(updateDto); // Assert await _mockUnitOfWork.Received(1).CommitAsync(); // Verify properties were updated Assert.True(existingSettings.Enabled); Assert.True(existingSettings.EnableSummary); Assert.True(existingSettings.EnableLocalizedName); Assert.True(existingSettings.EnablePublicationStatus); Assert.True(existingSettings.EnableRelationships); Assert.True(existingSettings.EnablePeople); Assert.True(existingSettings.EnableStartDate); Assert.True(existingSettings.EnableGenres); Assert.True(existingSettings.EnableTags); Assert.True(existingSettings.FirstLastPeopleNaming); Assert.True(existingSettings.EnableCoverImage); // Verify collections were updated Assert.Single(existingSettings.AgeRatingMappings); Assert.Equal(AgeRating.R18Plus, existingSettings.AgeRatingMappings["Adult"]); Assert.Single(existingSettings.Blacklist); Assert.Equal("blacklisted-tag", existingSettings.Blacklist[0]); Assert.Single(existingSettings.Whitelist); Assert.Equal("whitelisted-tag", existingSettings.Whitelist[0]); Assert.Single(existingSettings.Overrides); Assert.Equal(MetadataSettingField.Summary, existingSettings.Overrides[0]); Assert.Single(existingSettings.PersonRoles); Assert.Equal(PersonRole.Writer, existingSettings.PersonRoles[0]); Assert.Single(existingSettings.FieldMappings); Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].SourceType); Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].DestinationType); Assert.Equal("Action", existingSettings.FieldMappings[0].SourceValue); Assert.Equal("Fight", existingSettings.FieldMappings[0].DestinationValue); Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); } [Fact] public async Task UpdateMetadataSettings_WithNullCollections_ShouldUseEmptyCollections() { // Arrange var existingSettings = new MetadataSettings { Id = 1, FieldMappings = [new MetadataFieldMapping {Id = 1, SourceValue = "OldValue"}] }; var settingsRepo = Substitute.For(); settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); var updateDto = new MetadataSettingsDto { AgeRatingMappings = null, Blacklist = null, Whitelist = null, Overrides = null, PersonRoles = null, FieldMappings = null }; // Act await _settingsService.UpdateMetadataSettings(updateDto); // Assert await _mockUnitOfWork.Received(1).CommitAsync(); Assert.Empty(existingSettings.AgeRatingMappings); Assert.Empty(existingSettings.Blacklist); Assert.Empty(existingSettings.Whitelist); Assert.Empty(existingSettings.Overrides); Assert.Empty(existingSettings.PersonRoles); // Verify existing field mappings were cleared settingsRepo.Received(1).RemoveRange(Arg.Any>()); Assert.Empty(existingSettings.FieldMappings); } [Fact] public async Task UpdateMetadataSettings_WithFieldMappings_ShouldReplaceExistingMappings() { // Arrange var existingSettings = new MetadataSettings { Id = 1, FieldMappings = [ new MetadataFieldMapping { Id = 1, SourceType = MetadataFieldType.Genre, DestinationType = MetadataFieldType.Genre, SourceValue = "OldValue", DestinationValue = "OldDestination", ExcludeFromSource = false } ] }; var settingsRepo = Substitute.For(); settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); var updateDto = new MetadataSettingsDto { FieldMappings = [ new MetadataFieldMappingDto { SourceType = MetadataFieldType.Tag, DestinationType = MetadataFieldType.Genre, SourceValue = "NewValue", DestinationValue = "NewDestination", ExcludeFromSource = true }, new MetadataFieldMappingDto { SourceType = MetadataFieldType.Tag, DestinationType = MetadataFieldType.Tag, SourceValue = "AnotherValue", DestinationValue = "AnotherDestination", ExcludeFromSource = false } ] }; // Act await _settingsService.UpdateMetadataSettings(updateDto); // Assert await _mockUnitOfWork.Received(1).CommitAsync(); // Verify existing field mappings were cleared and new ones added settingsRepo.Received(1).RemoveRange(Arg.Any>()); Assert.Equal(2, existingSettings.FieldMappings.Count); // Verify first mapping Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].SourceType); Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].DestinationType); Assert.Equal("NewValue", existingSettings.FieldMappings[0].SourceValue); Assert.Equal("NewDestination", existingSettings.FieldMappings[0].DestinationValue); Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); // Verify second mapping Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].SourceType); Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].DestinationType); Assert.Equal("AnotherValue", existingSettings.FieldMappings[1].SourceValue); Assert.Equal("AnotherDestination", existingSettings.FieldMappings[1].DestinationValue); Assert.False(existingSettings.FieldMappings[1].ExcludeFromSource); } [Fact] public async Task UpdateMetadataSettings_WithBlacklistWhitelist_ShouldNormalizeAndDeduplicateEntries() { // Arrange var existingSettings = new MetadataSettings { Id = 1, Blacklist = [], Whitelist = [] }; // We need to mock the repository and provide a custom implementation for ToNormalized var settingsRepo = Substitute.For(); settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); var updateDto = new MetadataSettingsDto { // Include duplicates with different casing and whitespace Blacklist = ["tag1", "Tag1", " tag2 ", "", " ", "tag3"], Whitelist = ["allowed1", "Allowed1", " allowed2 ", "", "allowed3"] }; // Act await _settingsService.UpdateMetadataSettings(updateDto); // Assert await _mockUnitOfWork.Received(1).CommitAsync(); Assert.Equal(3, existingSettings.Blacklist.Count); Assert.Equal(3, existingSettings.Whitelist.Count); } #endregion private MetadataSettingsDto CreateDefaultMetadataSettingsDto() { return new MetadataSettingsDto { Whitelist = ["default_white"], Blacklist = ["default_black"], AgeRatingMappings = new Dictionary { ["default_age"] = AgeRating.Everyone }, FieldMappings = [ new MetadataFieldMappingDto { Id = 1, SourceValue = "default_source", SourceType = MetadataFieldType.Genre, DestinationValue = "default_dest", DestinationType = MetadataFieldType.Tag }, ], }; } private MetadataSettings CreateDefaultMetadataSettings() { return new MetadataSettings { Whitelist = ["default_white"], Blacklist = ["default_black"], AgeRatingMappings = new Dictionary { [DefaultAgeKey] = DefaultAgeRating }, FieldMappings = [ new MetadataFieldMapping { Id = 1, SourceValue = DefaultFieldSource, SourceType = DefaultSourceField, DestinationValue = "default_dest", DestinationType = MetadataFieldType.Tag }, ], }; } }