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
+35 -35
View File
@@ -1,7 +1,7 @@
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
namespace Novelly.Api.Tests;
@@ -9,21 +9,21 @@ namespace Novelly.Api.Tests;
[TestFixture]
public class OpenQuestionTests : ServiceTestFixture
{
private Guid _projectId;
private Guid _novelId;
private Guid _chapterId;
private Guid _characterId;
protected override void OnSetUp()
{
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
_chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id;
_characterId = Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")).Result.Id;
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
_chapterId = Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")).Result.Id;
_characterId = Characters.CreateAsync(_novelId, new CreateCharacterRequest("Ines")).Result.Id;
}
[Test]
public async Task A_question_can_hang_off_a_chapter_and_a_character_at_once()
{
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter before the harbour?",
ChapterId: _chapterId,
CharacterId: _characterId));
@@ -41,7 +41,7 @@ public class OpenQuestionTests : ServiceTestFixture
public async Task A_question_about_the_book_as_a_whole_needs_no_association()
{
var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Is this one book or two?"));
_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
Assert.Multiple(() =>
{
@@ -53,21 +53,21 @@ public class OpenQuestionTests : ServiceTestFixture
[Test]
public async Task The_outline_and_the_character_page_each_see_only_their_own_questions()
{
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId));
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"What does she actually want?", CharacterId: _characterId));
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?"));
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
var forChapter = await Questions.ListAsync(_projectId, chapterId: _chapterId);
var forCharacter = await Questions.ListAsync(_projectId, characterId: _characterId);
var forProject = await Questions.ListAsync(_projectId);
var forChapter = await Questions.ListAsync(_novelId, chapterId: _chapterId);
var forCharacter = await Questions.ListAsync(_novelId, characterId: _characterId);
var forNovel = await Questions.ListAsync(_novelId);
Assert.Multiple(() =>
{
Assert.That(forChapter.Select(q => q.Question), Is.EqualTo(new[] { "Where does the chapter break?" }));
Assert.That(forCharacter.Select(q => q.Question), Is.EqualTo(new[] { "What does she actually want?" }));
Assert.That(forProject, Has.Count.EqualTo(3));
Assert.That(forNovel, Has.Count.EqualTo(3));
});
}
@@ -75,13 +75,13 @@ public class OpenQuestionTests : ServiceTestFixture
public async Task Resolved_questions_drop_off_the_list_unless_asked_for()
{
var settled = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?"));
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?"));
_novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
await Questions.ResolveAsync(settled.Id, new ResolveOpenQuestionRequest("After the harbour."));
var open = await Questions.ListAsync(_projectId);
var everything = await Questions.ListAsync(_projectId, includeResolved: true);
var open = await Questions.ListAsync(_novelId);
var everything = await Questions.ListAsync(_novelId, includeResolved: true);
Assert.Multiple(() =>
{
@@ -97,7 +97,7 @@ public class OpenQuestionTests : ServiceTestFixture
public async Task Resolving_records_what_was_decided()
{
var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?"));
_novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
var resolved = (await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")))!;
@@ -115,7 +115,7 @@ public class OpenQuestionTests : ServiceTestFixture
{
await Chapters.UpdateAsync(_chapterId, new UpdateChapterRequest(Notes: "Runs long."));
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
await Questions.ResolveAsync(
@@ -136,7 +136,7 @@ public class OpenQuestionTests : ServiceTestFixture
[Test]
public async Task A_resolution_stays_off_the_notes_unless_asked_for()
{
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId));
await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour."));
@@ -147,7 +147,7 @@ public class OpenQuestionTests : ServiceTestFixture
[Test]
public async Task Reopening_clears_the_resolution_but_leaves_the_note_behind()
{
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId));
await Questions.ResolveAsync(
@@ -167,7 +167,7 @@ public class OpenQuestionTests : ServiceTestFixture
[Test]
public async Task A_question_can_be_detached_from_what_it_was_about()
{
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
var detached = (await Questions.UpdateAsync(
@@ -184,7 +184,7 @@ public class OpenQuestionTests : ServiceTestFixture
[Test]
public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them()
{
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter?", ChapterId: _chapterId));
await Chapters.DeleteAsync(_chapterId);
@@ -202,52 +202,52 @@ public class OpenQuestionTests : ServiceTestFixture
public async Task A_question_can_be_deleted_outright()
{
var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?"));
_novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
await Questions.DeleteAsync(question.Id);
Assert.Multiple(async () =>
{
Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty);
Assert.That(await Questions.ListAsync(_novelId, includeResolved: true), Is.Empty);
Assert.That(await Questions.GetAsync(question.Id), Is.Null);
});
}
[Test]
public async Task Deleting_a_project_takes_its_questions()
public async Task Deleting_a_novel_takes_its_questions()
{
await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest("Is this one book or two?"));
await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest("Is this one book or two?"));
await Projects.DeleteAsync(_projectId);
await Novels.DeleteAsync(_novelId);
using var verification = Db.CreateContext();
Assert.That(verification.OpenQuestions.Count(), Is.EqualTo(0));
}
[Test]
public void A_question_cannot_be_attached_to_another_project_s_chapter()
public void A_question_cannot_be_attached_to_another_novel_s_chapter()
{
var elsewhere = Chapters.CreateAsync(
Projects.CreateAsync(new CreateProjectRequest("Other Book")).Result.Id,
Novels.CreateAsync(new CreateNovelRequest("Other Book")).Result.Id,
new CreateChapterRequest("Elsewhere")).Result;
Assert.That(
async () => await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
_novelId, new CreateOpenQuestionRequest("A question", ChapterId: elsewhere.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same novel"));
}
[Test]
public void A_blank_question_is_refused() =>
Assert.That(
async () => await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(" ")),
async () => await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(" ")),
Throws.TypeOf<ArgumentException>());
[Test]
public async Task Resolving_with_nothing_decided_is_refused()
{
var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?"));
_novelId, new CreateOpenQuestionRequest("Where does the chapter break?"));
Assert.That(
async () => await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest(" ")),