[FA-5] Adds image support with proper S3 upload and replacement after upload

This commit is contained in:
gamer147
2025-11-23 21:16:26 -05:00
parent 573a0f6e3f
commit 16ed16ff62
33 changed files with 1321 additions and 267 deletions

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\FictionArchive.Service.NovelService\\FictionArchive.Service.NovelService.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,165 @@
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<NovelServiceDbContext>()
.UseInMemoryDatabase($"NovelUpdateServiceTests-{Guid.NewGuid()}")
.Options;
return new NovelServiceDbContext(options, NullLogger<NovelServiceDbContext>.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<LocalizationText>() },
Images = new List<Image>()
};
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> { chapter },
Tags = new List<NovelTag>()
};
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<NovelUpdateService>.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 = "<p>Hello</p><img src=\"http://img/x1.jpg\" alt=\"first\" /><img src=\"http://img/x2.jpg\" alt=\"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<ISourceAdapter>();
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<ImageData> { image1, image2 }
}));
var publishedEvents = new List<FileUploadRequestCreatedEvent>();
var eventBus = Substitute.For<IEventBus>();
eventBus.Publish(Arg.Do<FileUploadRequestCreatedEvent>(publishedEvents.Add)).Returns(Task.CompletedTask);
eventBus.Publish(Arg.Any<object>(), Arg.Any<string>()).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 = "<p>Hi</p><img src=\"http://img/x1.jpg\">";
var image = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 7, 8, 9 } };
var adapter = Substitute.For<ISourceAdapter>();
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<ImageData> { image }
}));
var eventBus = Substitute.For<IEventBus>();
eventBus.Publish(Arg.Any<FileUploadRequestCreatedEvent>()).Returns(Task.CompletedTask);
eventBus.Publish(Arg.Any<object>(), Arg.Any<string>()).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);
}