using System.Threading.Channels; using Microsoft.Extensions.Options; using Novelly.Api.Imports; using Novelly.Api.Novels; namespace Novelly.Api.Tests; [TestFixture] public class ImportServiceTests : ServiceTestFixture { private string _root = null!; private Channel _queue = null!; private ImportService _imports = null!; protected override void OnSetUp() { _root = Directory.CreateTempSubdirectory("novelly-import-test-").FullName; _queue = Channel.CreateUnbounded(); _imports = new ImportService( Db.Context, Novels, _queue, UserContext, Options.Create(new ImportOptions()), new ImportZipExtractor(new CapturingLogger()), new CapturingLogger(), new InspectImportRequestValidator(), new StartImportRequestValidator()); } [TearDown] public void CleanUpTempFolder() { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } var staging = Path.Combine(Path.GetTempPath(), "novelly-import-staging"); if (Directory.Exists(staging)) { Directory.Delete(staging, recursive: true); } } [Test] public async Task Inspecting_a_folder_with_no_ledger_reports_fresh() { WriteChapterFiles(3); var inspection = await _imports.InspectAsync(new InspectImportRequest(_root)); Assert.Multiple(() => { Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Fresh)); Assert.That(inspection.NovelId, Is.Null); Assert.That(inspection.ChaptersTotal, Is.EqualTo(3)); Assert.That(inspection.ChaptersCompleted, Is.EqualTo(0)); }); } [Test] public async Task Inspecting_a_folder_with_an_incomplete_ledger_reports_resumable() { WriteChapterFiles(3); WriteLedger("""{"novelId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["novel"], "completedChapters": [1]}"""); var inspection = await _imports.InspectAsync(new InspectImportRequest(_root)); Assert.Multiple(() => { Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Resumable)); Assert.That(inspection.ChaptersCompleted, Is.EqualTo(1)); Assert.That(inspection.ChaptersTotal, Is.EqualTo(3)); }); } [Test] public async Task Inspecting_a_folder_whose_ledger_covers_every_pass_and_chapter_reports_complete() { WriteChapterFiles(2); WriteLedger(""" { "novelId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["novel", "characters", "chapters", "arcs"], "completedChapters": [1, 2] } """); var inspection = await _imports.InspectAsync(new InspectImportRequest(_root)); Assert.That(inspection.Readiness, Is.EqualTo(ImportReadiness.Complete)); } [Test] public async Task Starting_an_import_enqueues_a_pending_job() { var job = await _imports.StartOrResumeAsync(new StartImportRequest(_root)); Assert.Multiple(() => { Assert.That(job.Status, Is.EqualTo(ImportJobStatus.Pending)); Assert.That(job.SourceRoot, Is.EqualTo(_root)); }); Assert.Multiple(() => { Assert.That(_queue.Reader.TryRead(out var queued), Is.True); Assert.That(queued, Is.EqualTo(job.Id)); }); } [Test] public async Task Starting_an_import_a_second_time_reuses_the_pending_job_instead_of_duplicating_it() { var first = await _imports.StartOrResumeAsync(new StartImportRequest(_root)); var second = await _imports.StartOrResumeAsync(new StartImportRequest(_root)); Assert.That(second.Id, Is.EqualTo(first.Id)); Assert.Multiple(() => { Assert.That(_queue.Reader.TryRead(out _), Is.True); Assert.That(_queue.Reader.TryRead(out _), Is.False); }); } [Test] public async Task Force_restarting_a_completed_import_deletes_the_ledger_and_its_novel() { var novel = await Novels.CreateAsync(new CreateNovelRequest("The Blade Itself")); WriteLedger($$"""{"novelId": "{{novel.Id}}", "completedPasses": ["novel", "characters", "chapters", "arcs"], "completedChapters": [1]}"""); await _imports.StartOrResumeAsync(new StartImportRequest(_root, ForceRestart: true)); Assert.Multiple(() => { Assert.That(File.Exists(Path.Combine(_root, ".novelly-import.json")), Is.False); Assert.That(Novels.GetAsync(novel.Id).Result, Is.Null); }); } [Test] public async Task Starting_an_import_outside_a_configured_import_root_is_rejected() { var configuredRoot = Directory.CreateTempSubdirectory("novelly-import-root-").FullName; try { var restricted = new ImportService( Db.Context, Novels, _queue, UserContext, Options.Create(new ImportOptions { RootPath = configuredRoot }), new ImportZipExtractor(new CapturingLogger()), new CapturingLogger(), new InspectImportRequestValidator(), new StartImportRequestValidator()); Assert.That( async () => await restricted.StartOrResumeAsync(new StartImportRequest(_root)), Throws.TypeOf()); } finally { Directory.Delete(configuredRoot, recursive: true); } } [Test] public async Task Starting_an_import_of_a_single_markdown_file_stages_it_into_a_folder() { var file = Path.Combine(_root, "01-chapter.md"); File.WriteAllText(file, "# Chapter 1"); var job = await _imports.StartOrResumeAsync(new StartImportRequest(file)); Assert.Multiple(() => { Assert.That(Directory.Exists(job.SourceRoot), Is.True); Assert.That(File.Exists(Path.Combine(job.SourceRoot, "01-chapter.md")), Is.True); }); } [Test] public async Task Starting_an_import_of_the_same_single_file_twice_dedupes_to_the_same_staged_job() { var file = Path.Combine(_root, "01-chapter.md"); File.WriteAllText(file, "# Chapter 1"); var first = await _imports.StartOrResumeAsync(new StartImportRequest(file)); var second = await _imports.StartOrResumeAsync(new StartImportRequest(file)); Assert.That(second.Id, Is.EqualTo(first.Id)); } [Test] public async Task Starting_an_import_of_a_non_markdown_file_is_rejected() { var file = Path.Combine(_root, "notes.txt"); File.WriteAllText(file, "not markdown"); Assert.That( async () => await _imports.StartOrResumeAsync(new StartImportRequest(file)), Throws.TypeOf()); } private void WriteChapterFiles(int count) { var outlines = Path.Combine(_root, "outlines"); Directory.CreateDirectory(outlines); for (var i = 1; i <= count; i++) { File.WriteAllText(Path.Combine(outlines, $"{i:D2}-chapter.md"), $"# Chapter {i}"); } } private void WriteLedger(string json) => File.WriteAllText(Path.Combine(_root, ".novelly-import.json"), json); }