Add zip upload and mapped-path picker for outline import
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).
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Imports;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ImportBrowseServiceTests
|
||||
{
|
||||
private string _root = null!;
|
||||
private ImportBrowseService _browse = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_root = Directory.CreateTempSubdirectory("novelly-browse-test-").FullName;
|
||||
_browse = new ImportBrowseService(
|
||||
Options.Create(new ImportOptions { RootPath = _root }),
|
||||
new CapturingLogger<ImportBrowseService>());
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Listing_the_root_returns_directories_before_files_ordered_by_name()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_root, "notes.md"), "# Notes");
|
||||
Directory.CreateDirectory(Path.Combine(_root, "zeta"));
|
||||
Directory.CreateDirectory(Path.Combine(_root, "alpha"));
|
||||
|
||||
var listing = _browse.List(null);
|
||||
|
||||
Assert.That(listing.Entries.Select(e => e.Name), Is.EqualTo(new[] { "alpha", "zeta", "notes.md" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_folder_with_an_outline_file_is_flagged_importable()
|
||||
{
|
||||
var folder = Path.Combine(_root, "my-novel");
|
||||
Directory.CreateDirectory(folder);
|
||||
File.WriteAllText(Path.Combine(folder, "outline.md"), "# My Novel");
|
||||
|
||||
var listing = _browse.List(null);
|
||||
|
||||
Assert.That(listing.Entries.Single(e => e.Name == "my-novel").LooksImportable, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_empty_folder_is_not_flagged_importable()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "empty"));
|
||||
|
||||
var listing = _browse.List(null);
|
||||
|
||||
Assert.That(listing.Entries.Single(e => e.Name == "empty").LooksImportable, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void The_staging_folder_is_hidden_from_listings()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, ".novelly-staging"));
|
||||
Directory.CreateDirectory(Path.Combine(_root, "visible"));
|
||||
|
||||
var listing = _browse.List(null);
|
||||
|
||||
Assert.That(listing.Entries.Select(e => e.Name), Is.EqualTo(new[] { "visible" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Escaping_the_import_root_is_rejected()
|
||||
{
|
||||
Assert.That(() => _browse.List(".."), Throws.TypeOf<ArgumentException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Browsing_without_a_configured_root_fails()
|
||||
{
|
||||
var unconfigured = new ImportBrowseService(
|
||||
Options.Create(new ImportOptions()),
|
||||
new CapturingLogger<ImportBrowseService>());
|
||||
|
||||
Assert.That(() => unconfigured.List(null), Throws.TypeOf<InvalidOperationException>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Listing_a_subfolder_reports_its_parent()
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(_root, "child"));
|
||||
|
||||
var listing = _browse.List("child");
|
||||
|
||||
Assert.That(listing.ParentRelativePath, Is.EqualTo(""));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Novels;
|
||||
|
||||
@@ -20,6 +21,8 @@ public class ImportServiceTests : ServiceTestFixture
|
||||
Novels,
|
||||
_queue,
|
||||
UserContext,
|
||||
Options.Create(new ImportOptions()),
|
||||
new ImportZipExtractor(new CapturingLogger<ImportZipExtractor>()),
|
||||
new CapturingLogger<ImportService>(),
|
||||
new InspectImportRequestValidator(),
|
||||
new StartImportRequestValidator());
|
||||
@@ -32,6 +35,12 @@ public class ImportServiceTests : ServiceTestFixture
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
|
||||
var staging = Path.Combine(Path.GetTempPath(), "novelly-import-staging");
|
||||
if (Directory.Exists(staging))
|
||||
{
|
||||
Directory.Delete(staging, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -131,6 +140,71 @@ public class ImportServiceTests : ServiceTestFixture
|
||||
});
|
||||
}
|
||||
|
||||
[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");
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.IO.Compression;
|
||||
using Novelly.Api.Imports;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ImportZipExtractorTests
|
||||
{
|
||||
private string _stagingParent = null!;
|
||||
private ImportZipExtractor _extractor = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_stagingParent = Directory.CreateTempSubdirectory("novelly-zip-test-").FullName;
|
||||
_extractor = new ImportZipExtractor(new CapturingLogger<ImportZipExtractor>());
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_stagingParent))
|
||||
{
|
||||
Directory.Delete(_stagingParent, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_zip_of_the_expected_folder_layout_extracts_intact()
|
||||
{
|
||||
using var zip = BuildZip(("outline.md", "# My Novel"), ("outlines/01-chapter.md", "# Chapter 1"));
|
||||
var staging = StagingDir();
|
||||
|
||||
_extractor.Extract(zip, staging);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(Path.Combine(staging, "outline.md")), Is.True);
|
||||
Assert.That(File.Exists(Path.Combine(staging, "outlines", "01-chapter.md")), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_single_wrapper_directory_is_flattened_away()
|
||||
{
|
||||
using var zip = BuildZip(("my-novel/outline.md", "# My Novel"), ("my-novel/outlines/01-chapter.md", "# Chapter 1"));
|
||||
var staging = StagingDir();
|
||||
|
||||
_extractor.Extract(zip, staging);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(File.Exists(Path.Combine(staging, "outline.md")), Is.True);
|
||||
Assert.That(Directory.Exists(Path.Combine(staging, "my-novel")), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_zip_slip_entry_is_rejected_and_staging_is_cleaned_up()
|
||||
{
|
||||
using var zip = BuildZip(("outline.md", "# ok"), ("../evil.md", "pwned"));
|
||||
var staging = StagingDir();
|
||||
|
||||
Assert.That(() => _extractor.Extract(zip, staging), Throws.TypeOf<ArgumentException>());
|
||||
Assert.That(Directory.Exists(staging), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_disallowed_file_extension_is_rejected()
|
||||
{
|
||||
using var zip = BuildZip(("outline.md", "# ok"), ("script.sh", "echo hi"));
|
||||
var staging = StagingDir();
|
||||
|
||||
Assert.That(() => _extractor.Extract(zip, staging), Throws.TypeOf<ArgumentException>());
|
||||
Assert.That(Directory.Exists(staging), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void The_ledger_file_is_allowed_through()
|
||||
{
|
||||
using var zip = BuildZip(("outline.md", "# ok"), (".novelly-import.json", "{}"));
|
||||
var staging = StagingDir();
|
||||
|
||||
_extractor.Extract(zip, staging);
|
||||
|
||||
Assert.That(File.Exists(Path.Combine(staging, ".novelly-import.json")), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_empty_zip_is_rejected()
|
||||
{
|
||||
using var zip = BuildZip();
|
||||
var staging = StagingDir();
|
||||
|
||||
Assert.That(() => _extractor.Extract(zip, staging), Throws.TypeOf<ArgumentException>());
|
||||
}
|
||||
|
||||
private string StagingDir() => Path.Combine(_stagingParent, $"staging-{Guid.NewGuid():N}");
|
||||
|
||||
private static MemoryStream BuildZip(params (string Path, string Content)[] entries)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var (path, content) in entries)
|
||||
{
|
||||
var entry = archive.CreateEntry(path);
|
||||
using var writer = new StreamWriter(entry.Open());
|
||||
writer.Write(content);
|
||||
}
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user