Files
novelly/tests/Novelly.Api.Tests/CharacterServiceTests.cs
T
James Wampler e598c18d67 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.
2026-08-15 22:29:33 -07:00

128 lines
4.9 KiB
C#

using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
[TestFixture]
public class CharacterServiceTests : ServiceTestFixture
{
private Guid _projectId;
protected override void OnSetUp()
{
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
}
[Test]
public async Task New_characters_default_to_supporting_role_and_importance()
{
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
Assert.Multiple(() =>
{
Assert.That(character.Role, Is.EqualTo(CharacterRole.Supporting));
Assert.That(character.Importance, Is.EqualTo(CharacterImportance.Supporting));
});
}
[Test]
public async Task Promoting_a_character_to_main_sticks_until_changed_again()
{
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var promoted = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!;
Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main));
var afterUnrelatedUpdate = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!;
Assert.That(afterUnrelatedUpdate.Importance, Is.EqualTo(CharacterImportance.Main));
}
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
"Ines", Want: "To find her sister.", Need: "To let go of the guilt."));
var renamed = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Name: "Ines Vell")))!;
Assert.Multiple(() =>
{
Assert.That(renamed.Name, Is.EqualTo("Ines Vell"));
Assert.That(renamed.Want, Is.EqualTo("To find her sister."));
Assert.That(renamed.Need, Is.EqualTo("To let go of the guilt."));
});
var cleared = (await Characters.UpdateAsync(character.Id, new UpdateCharacterRequest(Need: "")))!;
Assert.Multiple(() =>
{
Assert.That(cleared.Need, Is.Null);
Assert.That(cleared.Want, Is.EqualTo("To find her sister."));
});
}
[Test]
public async Task Relating_characters_across_projects_is_refused()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That(
async () => await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public async Task Removing_a_relationship_leaves_both_characters_in_place()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var withRelationship = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister")))!;
var relationshipId = withRelationship.Relationships[0].Id;
var removed = await Characters.RemoveRelationshipAsync(relationshipId);
var afterRemoval = (await Characters.GetAsync(ines.Id))!;
Assert.Multiple(() =>
{
Assert.That(removed, Is.True);
Assert.That(afterRemoval.Relationships, Is.Empty);
});
}
[Test]
public async Task Deleting_a_character_detaches_it_from_beats_rather_than_deleting_them()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map", CharacterIds: [ines.Id]));
await Characters.DeleteAsync(ines.Id);
using var verification = Db.CreateContext();
var survivingBeat = await verification.Beats.FirstAsync(b => b.Id == beat.Id);
Assert.That(survivingBeat, Is.Not.Null);
}
[Test]
public async Task Creating_a_character_under_a_missing_project_returns_null_rather_than_throwing() =>
Assert.That(
await Characters.CreateAsync(Guid.NewGuid(), new CreateCharacterRequest("Ines")),
Is.Null);
[Test]
public async Task Reading_a_missing_character_returns_null_rather_than_throwing() =>
Assert.That(await Characters.GetAsync(Guid.NewGuid()), Is.Null);
}