Introduces accounts (ASP.NET Identity + cookie auth), four global roles (Admin/Writer/Editor/Reviewer), per-novel ownership and grants via ProjectMember, and a service-API-key principal for the MCP server and background import jobs. Enforcement lives in the application services (not endpoint filters) so the embedded agent and MCP tools, which call the same services directly, can't bypass it. Web client gets a login page, session-aware routing, and a People section for managing per-novel access. Also includes prior in-flight changes from this branch (CLAUDE.md compliance pass, dev-deploy docker-compose setup) that were uncommitted when this feature work started.
147 lines
4.8 KiB
C#
147 lines
4.8 KiB
C#
using System.Threading.Channels;
|
|
using Novelly.Api.Imports;
|
|
using Novelly.Api.Projects;
|
|
|
|
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,
|
|
Projects,
|
|
_queue,
|
|
UserContext,
|
|
new CapturingLogger<ImportService>(),
|
|
new InspectImportRequestValidator(),
|
|
new StartImportRequestValidator());
|
|
}
|
|
|
|
[TearDown]
|
|
public void CleanUpTempFolder()
|
|
{
|
|
if (Directory.Exists(_root))
|
|
{
|
|
Directory.Delete(_root, 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.ProjectId, 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("""{"projectId": "11111111-1111-1111-1111-111111111111", "completedPasses": ["project"], "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("""
|
|
{
|
|
"projectId": "11111111-1111-1111-1111-111111111111",
|
|
"completedPasses": ["project", "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_project()
|
|
{
|
|
var project = await Projects.CreateAsync(new CreateProjectRequest("The Blade Itself"));
|
|
WriteLedger($$"""{"projectId": "{{project.Id}}", "completedPasses": ["project", "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(Projects.GetAsync(project.Id).Result, Is.Null);
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|