Sandbox source paths under a configured Imports:RootPath, browse it from the web dialog, upload a zip that extracts into staging, and import a single markdown file (agent infers chapter vs character).
221 lines
7.4 KiB
C#
221 lines
7.4 KiB
C#
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<Guid> _queue = null!;
|
|
private ImportService _imports = null!;
|
|
|
|
protected override void OnSetUp()
|
|
{
|
|
_root = Directory.CreateTempSubdirectory("novelly-import-test-").FullName;
|
|
_queue = Channel.CreateUnbounded<Guid>();
|
|
_imports = new ImportService(
|
|
Db.Context,
|
|
Novels,
|
|
_queue,
|
|
UserContext,
|
|
Options.Create(new ImportOptions()),
|
|
new ImportZipExtractor(new CapturingLogger<ImportZipExtractor>()),
|
|
new CapturingLogger<ImportService>(),
|
|
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<ImportZipExtractor>()),
|
|
new CapturingLogger<ImportService>(),
|
|
new InspectImportRequestValidator(),
|
|
new StartImportRequestValidator());
|
|
|
|
Assert.That(
|
|
async () => await restricted.StartOrResumeAsync(new StartImportRequest(_root)),
|
|
Throws.TypeOf<ArgumentException>());
|
|
}
|
|
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<ArgumentException>());
|
|
}
|
|
|
|
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);
|
|
}
|