Add users, roles, and per-novel permissions
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.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class ChapterServiceTests : ServiceTestFixture
|
||||
{
|
||||
private Guid _projectId;
|
||||
|
||||
protected override void OnSetUp()
|
||||
{
|
||||
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Setting_a_number_that_already_exists_is_still_stored_as_given()
|
||||
{
|
||||
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall", Number: 5));
|
||||
var second = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Harbour", Number: 5));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(first.Number, Is.EqualTo(5));
|
||||
Assert.That(second.Number, Is.EqualTo(5));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Updating_leaves_omitted_fields_alone_and_clears_notes_on_empty_string()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest(
|
||||
"Landfall", Summary: "The ship makes shore.", Notes: "Check the tide tables."));
|
||||
|
||||
var renamed = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Title: "First Landfall")))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(renamed.Title, Is.EqualTo("First Landfall"));
|
||||
Assert.That(renamed.Summary, Is.EqualTo("The ship makes shore."));
|
||||
Assert.That(renamed.Notes, Is.EqualTo("Check the tide tables."));
|
||||
});
|
||||
|
||||
var cleared = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Notes: "")))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(cleared.Notes, Is.Null);
|
||||
Assert.That(cleared.Summary, Is.EqualTo("The ship makes shore."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_chapter_takes_its_beats_with_it()
|
||||
{
|
||||
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
|
||||
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map"));
|
||||
|
||||
await Chapters.DeleteAsync(chapter.Id);
|
||||
|
||||
using var verification = Db.CreateContext();
|
||||
Assert.That(await verification.Beats.CountAsync(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Creating_a_chapter_under_a_missing_project_returns_null_rather_than_throwing() =>
|
||||
Assert.That(
|
||||
await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")),
|
||||
Is.Null);
|
||||
|
||||
[Test]
|
||||
public async Task Reading_a_missing_chapter_returns_null_rather_than_throwing() =>
|
||||
Assert.That(await Chapters.GetAsync(Guid.NewGuid()), Is.Null);
|
||||
|
||||
[Test]
|
||||
public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() =>
|
||||
Assert.That(await Chapters.DeleteAsync(Guid.NewGuid()), Is.False);
|
||||
}
|
||||
Reference in New Issue
Block a user