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); #region UpdateImage Tests [Fact] public async Task UpdateImage_sets_NewPath_on_image_without_chapter() { // Arrange using var dbContext = CreateDbContext(); var image = new Image { OriginalPath = "http://original/cover.jpg", NewPath = null }; dbContext.Images.Add(image); await dbContext.SaveChangesAsync(); var adapter = Substitute.For(); var eventBus = Substitute.For(); var service = CreateService(dbContext, adapter, eventBus); var newUrl = "https://cdn.example.com/uploaded/cover.jpg"; // Act await service.UpdateImage(image.Id, newUrl); // Assert var updatedImage = await dbContext.Images.FindAsync(image.Id); updatedImage!.NewPath.Should().Be(newUrl); updatedImage.OriginalPath.Should().Be("http://original/cover.jpg"); } [Fact] public async Task UpdateImage_updates_chapter_body_html_with_new_url() { // Arrange using var dbContext = CreateDbContext(); var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); var image = new Image { OriginalPath = "http://original/image.jpg", NewPath = null, Chapter = chapter }; chapter.Images.Add(image); await dbContext.SaveChangesAsync(); // Set up the chapter body with an img tag referencing the image by ID (as PullChapterContents does) var pendingUrl = "https://pending/placeholder.jpg"; var bodyHtml = $"

Content

\"{image.Id}\""; chapter.Body.Texts.Add(new LocalizationText { Language = Language.En, Text = bodyHtml }); await dbContext.SaveChangesAsync(); var adapter = Substitute.For(); var eventBus = Substitute.For(); var service = CreateService(dbContext, adapter, eventBus, pendingUrl); var newUrl = "https://cdn.example.com/uploaded/image.jpg"; // Act await service.UpdateImage(image.Id, newUrl); // Assert var updatedImage = await dbContext.Images .Include(i => i.Chapter) .ThenInclude(c => c.Body) .ThenInclude(b => b.Texts) .FirstAsync(i => i.Id == image.Id); updatedImage.NewPath.Should().Be(newUrl); var updatedBodyText = updatedImage.Chapter!.Body.Texts.Single().Text; var doc = new HtmlDocument(); doc.LoadHtml(updatedBodyText); var imgNode = doc.DocumentNode.SelectSingleNode("//img"); imgNode.Should().NotBeNull(); imgNode!.GetAttributeValue("src", string.Empty).Should().Be(newUrl); } [Fact] public async Task UpdateImage_does_not_modify_other_images_in_chapter_body() { // Arrange using var dbContext = CreateDbContext(); var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); var image1 = new Image { OriginalPath = "http://original/img1.jpg", Chapter = chapter }; var image2 = new Image { OriginalPath = "http://original/img2.jpg", Chapter = chapter }; chapter.Images.Add(image1); chapter.Images.Add(image2); await dbContext.SaveChangesAsync(); var pendingUrl = "https://pending/placeholder.jpg"; var bodyHtml = $"

Content

\"{image1.Id}\"\"{image2.Id}\""; chapter.Body.Texts.Add(new LocalizationText { Language = Language.En, Text = bodyHtml }); await dbContext.SaveChangesAsync(); var adapter = Substitute.For(); var eventBus = Substitute.For(); var service = CreateService(dbContext, adapter, eventBus, pendingUrl); var newUrl = "https://cdn.example.com/uploaded/img1.jpg"; // Act - only update image1 await service.UpdateImage(image1.Id, newUrl); // Assert var updatedChapter = await dbContext.Chapters .Include(c => c.Body) .ThenInclude(b => b.Texts) .FirstAsync(c => c.Id == chapter.Id); var updatedBodyText = updatedChapter.Body.Texts.Single().Text; var doc = new HtmlDocument(); doc.LoadHtml(updatedBodyText); var img1Node = doc.DocumentNode.SelectSingleNode($"//img[@alt='{image1.Id}']"); var img2Node = doc.DocumentNode.SelectSingleNode($"//img[@alt='{image2.Id}']"); img1Node!.GetAttributeValue("src", string.Empty).Should().Be(newUrl); img2Node!.GetAttributeValue("src", string.Empty).Should().Be(pendingUrl); } #endregion }