using FictionArchive.Common.Enums; using FictionArchive.Service.FileService.IntegrationEvents; using FictionArchive.Service.NovelService.Models.Configuration; using FictionArchive.Service.NovelService.Models.Enums; using FictionArchive.Service.NovelService.Models.Images; using FictionArchive.Service.NovelService.Models.Localization; using FictionArchive.Service.NovelService.Models.Novels; using FictionArchive.Service.NovelService.Models.SourceAdapters; using FictionArchive.Service.NovelService.Services; using FictionArchive.Service.NovelService.Services.SourceAdapters; using FictionArchive.Service.Shared.Services.EventBus; using FluentAssertions; using HtmlAgilityPack; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using Xunit; namespace FictionArchive.Service.NovelService.Tests; public class NovelUpdateServiceTests { private static NovelServiceDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"NovelUpdateServiceTests-{Guid.NewGuid()}") .Options; return new NovelServiceDbContext(options, NullLogger.Instance); } private static NovelCreateResult CreateNovelWithSingleChapter(NovelServiceDbContext dbContext, Source source) { var chapter = new Chapter { Order = 1, Revision = 1, Url = "http://demo/chapter-1", Name = LocalizationKey.CreateFromText("Chapter 1", Language.En), Body = new LocalizationKey { Texts = new List() }, Images = new List() }; var novel = new Novel { Url = "http://demo/novel", ExternalId = "demo-1", Author = new Person { Name = LocalizationKey.CreateFromText("Author", Language.En) }, RawLanguage = Language.En, RawStatus = NovelStatus.InProgress, Source = source, Name = LocalizationKey.CreateFromText("Demo Novel", Language.En), Description = LocalizationKey.CreateFromText("Description", Language.En), Chapters = new List { chapter }, Tags = new List() }; dbContext.Novels.Add(novel); dbContext.SaveChanges(); return new NovelCreateResult(novel, chapter); } private static NovelUpdateService CreateService( NovelServiceDbContext dbContext, ISourceAdapter adapter, IEventBus eventBus, string pendingImageUrl = "https://pending/placeholder.jpg") { var options = Options.Create(new NovelUpdateServiceConfiguration { PendingImageUrl = pendingImageUrl }); return new NovelUpdateService(dbContext, NullLogger.Instance, new[] { adapter }, eventBus, options); } [Fact] public async Task PullChapterContents_rewrites_images_and_publishes_requests() { using var dbContext = CreateDbContext(); var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); var rawHtml = "

Hello

\"first\"\"second\""; var image1 = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 1, 2, 3 } }; var image2 = new ImageData { Url = "http://img/x2.jpg", Data = new byte[] { 4, 5, 6 } }; var adapter = Substitute.For(); adapter.SourceDescriptor.Returns(new SourceDescriptor { Key = "demo", Name = "Demo", Url = "http://demo" }); adapter.GetRawChapter(chapter.Url).Returns(Task.FromResult(new ChapterFetchResult { Text = rawHtml, ImageData = new List { image1, image2 } })); var publishedEvents = new List(); var eventBus = Substitute.For(); eventBus.Publish(Arg.Do(publishedEvents.Add)).Returns(Task.CompletedTask); eventBus.Publish(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); var pendingImageUrl = "https://pending/placeholder.jpg"; var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl); var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order); updatedChapter.Images.Should().HaveCount(2); updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url }); updatedChapter.Images.All(i => i.Id != Guid.Empty).Should().BeTrue(); var storedHtml = updatedChapter.Body.Texts.Single().Text; var doc = new HtmlDocument(); doc.LoadHtml(storedHtml); var imgNodes = doc.DocumentNode.SelectNodes("//img"); imgNodes.Should().NotBeNull(); imgNodes!.Count.Should().Be(2); imgNodes.Should().OnlyContain(node => node.GetAttributeValue("src", string.Empty) == pendingImageUrl); imgNodes.Select(node => node.GetAttributeValue("alt", string.Empty)) .Should() .BeEquivalentTo(updatedChapter.Images.Select(img => img.Id.ToString())); publishedEvents.Should().HaveCount(2); publishedEvents.Select(e => e.RequestId).Should().BeEquivalentTo(updatedChapter.Images.Select(i => i.Id)); publishedEvents.Select(e => e.FileData).Should().BeEquivalentTo(new[] { image1.Data, image2.Data }); publishedEvents.Should().OnlyContain(e => e.FilePath.StartsWith($"{novel.Id}/Images/Chapter-{updatedChapter.Id}/")); } [Fact] public async Task PullChapterContents_adds_alt_when_missing() { using var dbContext = CreateDbContext(); var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); var rawHtml = "

Hi

"; var image = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 7, 8, 9 } }; var adapter = Substitute.For(); adapter.SourceDescriptor.Returns(new SourceDescriptor { Key = "demo", Name = "Demo", Url = "http://demo" }); adapter.GetRawChapter(chapter.Url).Returns(Task.FromResult(new ChapterFetchResult { Text = rawHtml, ImageData = new List { image } })); var eventBus = Substitute.For(); eventBus.Publish(Arg.Any()).Returns(Task.CompletedTask); eventBus.Publish(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); var service = CreateService(dbContext, adapter, eventBus); var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order); var storedHtml = updatedChapter.Body.Texts.Single().Text; var doc = new HtmlDocument(); doc.LoadHtml(storedHtml); var imgNode = doc.DocumentNode.SelectSingleNode("//img"); imgNode.Should().NotBeNull(); imgNode!.GetAttributeValue("alt", string.Empty).Should().Be(updatedChapter.Images.Single().Id.ToString()); imgNode.GetAttributeValue("src", string.Empty).Should().Be("https://pending/placeholder.jpg"); } private record NovelCreateResult(Novel Novel, Chapter Chapter); }