Rename Project concept to Novel across the stack

Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
This commit is contained in:
James Wampler
2026-08-17 23:03:09 -07:00
parent 0ab4f568b5
commit 4313c8f206
95 changed files with 3192 additions and 1660 deletions
@@ -2,24 +2,24 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
using Novelly.Api.Novels;
namespace Novelly.Api.Tests;
[TestFixture]
public class CharacterServiceTests : ServiceTestFixture
{
private Guid _projectId;
private Guid _novelId;
protected override void OnSetUp()
{
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
_novelId = Novels.CreateAsync(new CreateNovelRequest("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"));
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
Assert.Multiple(() =>
{
@@ -31,7 +31,7 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Promoting_a_character_to_main_sticks_until_changed_again()
{
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var promoted = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!;
@@ -47,7 +47,7 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var character = await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
var character = await Characters.CreateAsync(_novelId, 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")))!;
@@ -69,23 +69,23 @@ public class CharacterServiceTests : ServiceTestFixture
}
[Test]
public async Task Relating_characters_across_projects_is_refused()
public async Task Relating_characters_across_novels_is_refused()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
var ines = await Characters.CreateAsync(_novelId, 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"));
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
}
[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 ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
var withRelationship = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister")))!;
@@ -104,8 +104,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Adding_a_relationship_records_it_on_both_characters()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother"));
@@ -127,8 +127,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task A_relationship_with_no_reciprocal_type_mirrors_the_same_type_both_ways()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
await Characters.AddRelationshipAsync(ines.Id, new CreateRelationshipRequest(mara.Id, "rival"));
@@ -140,8 +140,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Removing_a_relationship_removes_the_reciprocal_side_too()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mara"));
var withRelationship = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", ReciprocalRelationshipType: "brother")))!;
@@ -156,8 +156,8 @@ public class CharacterServiceTests : ServiceTestFixture
[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 chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var ines = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines"));
var beat = await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("She finds the map", CharacterIds: [ines.Id]));
await Characters.DeleteAsync(ines.Id);
@@ -168,7 +168,7 @@ public class CharacterServiceTests : ServiceTestFixture
}
[Test]
public async Task Creating_a_character_under_a_missing_project_returns_null_rather_than_throwing() =>
public async Task Creating_a_character_under_a_missing_novel_returns_null_rather_than_throwing() =>
Assert.That(
await Characters.CreateAsync(Guid.NewGuid(), new CreateCharacterRequest("Ines")),
Is.Null);
@@ -181,7 +181,7 @@ public class CharacterServiceTests : ServiceTestFixture
public async Task Aliases_round_trip_on_create_and_update()
{
var created = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"]));
_novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man", "Kael"]));
Assert.That(created.Aliases, Is.EqualTo(new[] { "The Grey Man", "Kael" }));
@@ -195,7 +195,7 @@ public class CharacterServiceTests : ServiceTestFixture
public async Task Clearing_aliases_with_an_empty_list_empties_them()
{
var created = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"]));
_novelId, new CreateCharacterRequest("Ines", Aliases: ["The Grey Man"]));
var cleared = (await Characters.UpdateAsync(created.Id, new UpdateCharacterRequest(Aliases: [])))!;
@@ -205,8 +205,8 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Linking_a_character_to_its_true_identity_records_it_on_both_sides()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
var linked = (await Characters.LinkIdentityAsync(
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, Note: "Same man, after the exile.")))!;
@@ -225,9 +225,9 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Linking_to_a_character_that_is_itself_an_alias_flattens_to_the_canonical()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
var exile = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Exile"));
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
var exile = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Exile"));
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
var linked = (await Characters.LinkIdentityAsync(exile.Id, new LinkCharacterIdentityRequest(stranger.Id)))!;
@@ -238,7 +238,7 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Linking_a_character_to_itself_is_rejected()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
Assert.That(
async () => await Characters.LinkIdentityAsync(kael.Id, new LinkCharacterIdentityRequest(kael.Id)),
@@ -246,22 +246,22 @@ public class CharacterServiceTests : ServiceTestFixture
}
[Test]
public async Task Linking_identities_across_projects_is_refused()
public async Task Linking_identities_across_novels_is_refused()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var other = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That(
async () => await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
}
[Test]
public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive()
{
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
await Characters.LinkIdentityAsync(stranger.Id, new LinkCharacterIdentityRequest(kael.Id));
await Characters.DeleteAsync(kael.Id);
@@ -274,9 +274,9 @@ public class CharacterServiceTests : ServiceTestFixture
[Test]
public async Task Unlinking_an_identity_clears_the_reveal_chapter_and_note()
{
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("The Reveal"));
var kael = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("The Stranger"));
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Reveal"));
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
await Characters.LinkIdentityAsync(
stranger.Id, new LinkCharacterIdentityRequest(kael.Id, chapter.Id, "Same man."));