Reorganise by feature, rename to Novelly, add Aspire and a pre-push hook

The layered split into Domain/Application/Infrastructure/Api was forcing
organisation by layer: adding one capability meant touching four projects and
four folders that each held a slice of it. Those four projects are now one
feature-organised Novelly.Api, where each folder — Projects, Characters,
Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and
endpoints together. Common/ holds what genuinely crosses features (the patch
semantics, the two exception types, DraftStatus) and Data/ holds the DbContext
and migrations.

Six .NET projects become five: the three layer projects are gone, and
Novelly.AppHost and Novelly.ServiceDefaults are new.

- Namespaces move from NovelSoftware.* to Novelly.*, including the entity type
  names recorded in the EF model snapshots. The migration ids are untouched, so
  an existing novel.db still migrates cleanly — verified against a fresh file.
- Aspire orchestration mirrors the mic-check setup: the AppHost starts the API
  on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry,
  health checks and service discovery from ServiceDefaults. /health and /alive
  now answer in development.
- A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web
  build. The scripts are plain bash so CI can run the same steps.
- The MCP server's env var is now NOVELLY_API_URL.

Verified beyond the build: 44 tests pass, the web client builds, the API was
exercised over curl (project/chapter/beat/tag round trip, tag cross-reference,
503 on the agent without a key while conversation listing still returns 200),
the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface
the API's own message rather than being flattened), and the AppHost was run to
confirm both resources come up and Vite proxies /api through to the API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 30e0c6926e
commit 725758ccd9
120 changed files with 811 additions and 421 deletions
@@ -0,0 +1,38 @@
using Microsoft.Extensions.Options;
using Novelly.Api.Agent;
using Novelly.Api.Common;
namespace Novelly.Api.Tests;
[TestFixture]
public class AnthropicClientTests
{
[Test]
public void Constructing_without_a_key_does_not_throw() =>
// The agent service takes this as a dependency and also serves read-only endpoints
// (listing conversations, reading a transcript). Throwing at construction would
// take those down on any install that has not configured a key yet.
Assert.That(
() => new AnthropicAgentModelClient(Options.Create(new AgentOptions())),
Throws.Nothing);
[Test]
public void Sending_without_a_key_reports_a_configuration_problem()
{
var previous = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY");
Environment.SetEnvironmentVariable("ANTHROPIC_API_KEY", null);
try
{
var client = new AnthropicAgentModelClient(Options.Create(new AgentOptions()));
Assert.That(
async () => await client.CompleteAsync("system", [], []),
Throws.TypeOf<AgentNotConfiguredException>().With.Message.Contains("ANTHROPIC_API_KEY"));
}
finally
{
Environment.SetEnvironmentVariable("ANTHROPIC_API_KEY", previous);
}
}
}
+167
View File
@@ -0,0 +1,167 @@
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests;
[TestFixture]
public class BeatServiceTests : ServiceTestFixture
{
private Guid _projectId;
private Guid _chapterId;
protected override void OnSetUp()
{
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
_chapterId = Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id;
}
[Test]
public async Task Beats_are_appended_in_order_and_listed_that_way()
{
await Beats.CreateAsync(_chapterId, new CreateBeatRequest("She finds the map"));
await Beats.CreateAsync(_chapterId, new CreateBeatRequest("The harbour burns"));
await Beats.CreateAsync(_chapterId, new CreateBeatRequest("She boards anyway"));
var listed = await Beats.ListAsync(_chapterId);
Assert.Multiple(() =>
{
Assert.That(
listed.Select(b => b.Title),
Is.EqualTo(new[] { "She finds the map", "The harbour burns", "She boards anyway" }));
Assert.That(listed.Select(b => b.SortOrder), Is.EqualTo(new[] { 1, 2, 3 }));
});
}
[Test]
public async Task Reordering_renumbers_to_match_the_order_given()
{
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var third = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Third"));
var reordered = await Beats.ReorderAsync(
_chapterId, new ReorderBeatsRequest([third.Id, first.Id, second.Id]));
Assert.That(
reordered.Select(b => b.Title),
Is.EqualTo(new[] { "Third", "First", "Second" }));
}
[Test]
public async Task Beats_left_out_of_a_reorder_keep_their_relative_position_at_the_end()
{
var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var third = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Third"));
var reordered = await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([third.Id, first.Id]));
Assert.That(
reordered.Select(b => b.Title),
Is.EqualTo(new[] { "Third", "First", "Second" }));
}
[Test]
public void Reordering_with_an_unknown_beat_is_refused()
{
Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")).Wait();
Assert.That(
async () => await Beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([Guid.NewGuid()])),
Throws.TypeOf<NotFoundException>());
}
[Test]
public async Task A_beat_resolves_its_character_and_scene_names()
{
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas",
CharacterId: ines.Id,
WhatHappened: "The pages go up faster than she expected.",
WhatsNext: "Nothing to navigate by but memory.",
SceneId: scene.Id));
Assert.Multiple(() =>
{
Assert.That(beat.CharacterName, Is.EqualTo("Ines"));
Assert.That(beat.SceneTitle, Is.EqualTo("The dock at dawn"));
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
});
}
[Test]
public async Task A_beat_cannot_borrow_a_character_from_another_project()
{
var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
Assert.That(
async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
}
[Test]
public async Task A_beat_cannot_be_grouped_under_a_scene_from_another_chapter()
{
var elsewhere = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere"));
var scene = await Scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
Assert.That(
async () => await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same chapter"));
}
[Test]
public async Task Deleting_a_scene_leaves_its_beats_alone()
{
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await Beats.CreateAsync(
_chapterId, new CreateBeatRequest("She burns the atlas", SceneId: scene.Id));
await Scenes.DeleteAsync(scene.Id);
// The plan outlives a decision about prose — the beat is simply ungrouped.
var survivor = await Beats.GetAsync(beat.Id);
Assert.Multiple(() =>
{
Assert.That(survivor.SceneId, Is.Null);
Assert.That(survivor.Title, Is.EqualTo("She burns the atlas"));
});
}
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She finds the map",
WhatHappened: "Behind the lining of the case.",
WhatsNext: "She books passage."));
var renamed = await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She finds it"));
Assert.Multiple(() =>
{
Assert.That(renamed.WhatHappened, Is.EqualTo("Behind the lining of the case."));
Assert.That(renamed.WhatsNext, Is.EqualTo("She books passage."));
});
var cleared = await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: ""));
Assert.Multiple(() =>
{
Assert.That(cleared.WhatsNext, Is.Null);
Assert.That(cleared.WhatHappened, Is.EqualTo("Behind the lining of the case."));
});
}
}
+129
View File
@@ -0,0 +1,129 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Novelly.Api.Agent;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests;
/// <summary>
/// Covers the list endpoints, which sort and aggregate in SQL rather than in memory.
/// SQLite is fussier than the in-memory provider about what it will translate — ordering
/// by a DateTimeOffset, for one — so these have to run against real SQLite to be worth anything.
/// </summary>
[TestFixture]
public class ListingTests : ServiceTestFixture
{
[Test]
public async Task Projects_are_listed_most_recently_updated_first()
{
var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book"));
var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book"));
// Touching the older project should float it to the top.
await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
var listed = await Projects.ListAsync();
Assert.Multiple(() =>
{
Assert.That(listed.Select(p => p.Title), Is.EqualTo(new[] { "Older Book", "Newer Book" }));
Assert.That(listed.Select(p => p.Id), Does.Contain(newer.Id));
});
}
[Test]
public async Task Project_summaries_aggregate_counts_and_words_across_chapters()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour"));
await Scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
var summary = (await Projects.ListAsync()).Single();
Assert.Multiple(() =>
{
Assert.That(summary.CharacterCount, Is.EqualTo(2));
Assert.That(summary.ChapterCount, Is.EqualTo(2));
Assert.That(summary.WordCount, Is.EqualTo(5));
});
}
[Test]
public async Task A_project_with_no_chapters_reports_zero_words_rather_than_failing()
{
await Projects.CreateAsync(new CreateProjectRequest("Empty"));
var summary = (await Projects.ListAsync()).Single();
Assert.Multiple(() =>
{
Assert.That(summary.WordCount, Is.EqualTo(0));
Assert.That(summary.ChapterCount, Is.EqualTo(0));
});
}
[Test]
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2));
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("A", Prose: "One two"));
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
var listed = await Chapters.ListAsync(project.Id);
Assert.Multiple(() =>
{
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
Assert.That(listed.Single(c => c.Id == second.Id).SceneCount, Is.EqualTo(2));
Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0));
});
}
[Test]
public async Task Conversations_are_listed_most_recently_updated_first()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
var agent = new NovelAgentService(
Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags),
Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance);
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question."));
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question."));
var listed = await agent.ListConversationsAsync(project.Id);
Assert.Multiple(() =>
{
Assert.That(listed, Has.Count.EqualTo(2));
Assert.That(listed[0].Title, Is.EqualTo("Second question."));
Assert.That(listed[0].MessageCount, Is.EqualTo(2));
});
}
[Test]
public async Task Characters_are_listed_by_role_then_name()
{
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Zeno", CharacterRole.Supporting));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines", CharacterRole.Protagonist));
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Anders", CharacterRole.Supporting));
var listed = await Characters.ListAsync(project.Id);
Assert.That(listed.Select(c => c.Name), Is.EqualTo(new[] { "Ines", "Anders", "Zeno" }));
}
}
@@ -0,0 +1,236 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Novelly.Api.Agent;
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
[TestFixture]
public class NovelAgentServiceTests : ServiceTestFixture
{
private NovelAgentToolset _toolset = null!;
protected override void OnSetUp() =>
_toolset = new NovelAgentToolset(Projects, Characters, Chapters, Beats, Scenes, Tags);
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
Db.Context,
model,
_toolset,
Options.Create(new AgentOptions { MaxIterations = 4 }),
NullLogger<NovelAgentService>.Instance);
[Test]
public async Task A_plain_reply_is_persisted_as_a_conversation()
{
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([[new AgentTextBlock("Tell me about the ending.")]]);
var agent = BuildAgent(model);
var turn = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Where do I start?"));
Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending."));
var conversation = await agent.GetConversationAsync(turn.ConversationId);
Assert.Multiple(() =>
{
Assert.That(conversation.Messages, Has.Count.EqualTo(2));
Assert.That(conversation.Messages[0].Content, Is.EqualTo("Where do I start?"));
Assert.That(conversation.Title, Is.EqualTo("Where do I start?"));
});
}
[Test]
public async Task Tool_calls_are_executed_against_real_project_data()
{
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([
[ToolUse("t1", "create_character", new { name = "Ines", role = "Protagonist" })],
[new AgentTextBlock("Added Ines as the protagonist.")]
]);
var turn = await BuildAgent(model).SendMessageAsync(
projectId, new SendAgentMessageRequest("Add a protagonist called Ines."));
var characters = await Characters.ListAsync(projectId);
Assert.Multiple(() =>
{
Assert.That(characters, Has.Count.EqualTo(1));
Assert.That(characters[0].Name, Is.EqualTo("Ines"));
Assert.That(turn.Message.Content, Is.EqualTo("Added Ines as the protagonist."));
Assert.That(turn.Message.ToolCalls, Has.Count.EqualTo(1));
Assert.That(turn.Message.ToolCalls[0].Name, Is.EqualTo("create_character"));
});
}
[Test]
public async Task Every_tool_result_comes_back_in_a_single_user_turn()
{
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([
[
ToolUse("t1", "create_character", new { name = "Ines" }),
ToolUse("t2", "create_character", new { name = "Mara" })
],
[new AgentTextBlock("Both added.")]
]);
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Add two characters."));
var resultTurn = model.Transcripts[1][^1];
var listed = await Characters.ListAsync(projectId);
Assert.Multiple(() =>
{
Assert.That(resultTurn.Role, Is.EqualTo("user"));
Assert.That(resultTurn.Content.OfType<AgentToolResultBlock>().Count(), Is.EqualTo(2));
Assert.That(listed, Has.Count.EqualTo(2));
});
}
[Test]
public async Task A_failing_tool_is_reported_back_rather_than_thrown()
{
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([
[ToolUse("t1", "update_character", new { character_id = Guid.NewGuid().ToString(), name = "Ines" })],
[new AgentTextBlock("That character does not exist yet — shall I create her?")]
]);
var turn = await BuildAgent(model).SendMessageAsync(
projectId, new SendAgentMessageRequest("Rename her."));
var errorResult = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
Assert.Multiple(() =>
{
Assert.That(errorResult.IsError, Is.True);
Assert.That(errorResult.Content, Does.Contain("was not found"));
Assert.That(turn.Message.Content, Does.Contain("does not exist yet"));
});
}
[Test]
public async Task Unknown_tools_are_reported_without_breaking_the_loop()
{
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([
[ToolUse("t1", "summon_muse", new { })],
[new AgentTextBlock("Sorry — I do not have that tool.")]
]);
await BuildAgent(model).SendMessageAsync(projectId, new SendAgentMessageRequest("Summon the muse."));
var result = model.Transcripts[1][^1].Content.OfType<AgentToolResultBlock>().Single();
Assert.Multiple(() =>
{
Assert.That(result.IsError, Is.True);
Assert.That(result.Content, Does.Contain("No such tool"));
});
}
[Test]
public async Task The_loop_stops_at_the_iteration_ceiling()
{
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
// A model that only ever asks for more tools would otherwise loop forever.
var model = new ScriptedModelClient(
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
[ToolUse("t", "list_characters", new { })], 20).ToList());
var turn = await BuildAgent(model).SendMessageAsync(
projectId, new SendAgentMessageRequest("Keep going forever."));
Assert.Multiple(() =>
{
Assert.That(model.Transcripts, Has.Count.EqualTo(4));
Assert.That(turn.Message.Content, Does.Contain("tool-call limit"));
});
}
[Test]
public async Task Follow_up_messages_continue_the_same_conversation()
{
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
var model = new ScriptedModelClient([
[new AgentTextBlock("First answer.")],
[new AgentTextBlock("Second answer.")]
]);
var agent = BuildAgent(model);
var first = await agent.SendMessageAsync(projectId, new SendAgentMessageRequest("Question one."));
var second = await agent.SendMessageAsync(
projectId, new SendAgentMessageRequest("Question two.", first.ConversationId));
var conversation = await agent.GetConversationAsync(first.ConversationId);
Assert.Multiple(() =>
{
Assert.That(second.ConversationId, Is.EqualTo(first.ConversationId));
// The second request replays the earlier turns so the model has the history.
Assert.That(model.Transcripts[1], Has.Count.EqualTo(3));
Assert.That(
model.Transcripts[1].Select(m => m.Role),
Is.EqualTo(new[] { "user", "assistant", "user" }));
Assert.That(conversation.Messages, Has.Count.EqualTo(4));
});
}
[Test]
public void Every_tool_declares_an_object_schema_and_a_description()
{
Assert.That(_toolset.Definitions, Is.Not.Empty);
Assert.Multiple(() =>
{
foreach (var tool in _toolset.Definitions)
{
Assert.That(string.IsNullOrWhiteSpace(tool.Description), Is.False, tool.Name);
Assert.That(tool.InputSchema.GetProperty("type").GetString(), Is.EqualTo("object"), tool.Name);
Assert.That(tool.InputSchema.TryGetProperty("properties", out _), Is.True, tool.Name);
}
Assert.That(_toolset.Definitions.Select(t => t.Name), Is.Unique);
});
}
private static AgentToolUseBlock ToolUse(string id, string name, object input) =>
new(id, name, JsonSerializer.SerializeToElement(input));
}
/// <summary>
/// A model stand-in that returns a fixed script of turns and records every transcript it
/// was sent, so tests can assert on what the loop actually put in front of the model.
/// </summary>
internal class ScriptedModelClient(IReadOnlyList<IReadOnlyList<AgentContentBlock>> script)
: IAgentModelClient
{
private int _turn;
public List<IReadOnlyList<AgentChatMessage>> Transcripts { get; } = [];
public Task<AgentModelResponse> CompleteAsync(
string systemPrompt,
IReadOnlyList<AgentChatMessage> messages,
IReadOnlyList<AgentToolDefinition> tools,
CancellationToken ct = default)
{
Transcripts.Add([.. messages]);
var content = _turn < script.Count ? script[_turn] : [new AgentTextBlock("(no more script)")];
_turn++;
var stopReason = content.OfType<AgentToolUseBlock>().Any() ? "tool_use" : "end_turn";
return Task.FromResult(new AgentModelResponse(content, stopReason));
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit.Analyzers" Version="4.14.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Novelly.Api\Novelly.Api.csproj" />
</ItemGroup>
</Project>
+156
View File
@@ -0,0 +1,156 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
namespace Novelly.Api.Tests;
[TestFixture]
public class ProjectDataTests : ServiceTestFixture
{
private async Task<Guid> NewProjectAsync() =>
(await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
[Test]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var id = (await Projects.CreateAsync(
new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id;
var afterPartialUpdate = await Projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road"));
Assert.Multiple(() =>
{
Assert.That(afterPartialUpdate.Title, Is.EqualTo("The Salt Road"));
Assert.That(afterPartialUpdate.Genre, Is.EqualTo("Fantasy"));
Assert.That(afterPartialUpdate.Logline, Is.EqualTo("A cartographer goes to sea."));
});
var afterClear = await Projects.UpdateAsync(id, new UpdateProjectRequest(Genre: ""));
Assert.Multiple(() =>
{
Assert.That(afterClear.Genre, Is.Null);
Assert.That(afterClear.Logline, Is.EqualTo("A cartographer goes to sea."));
});
}
[Test]
public async Task Chapters_are_numbered_in_sequence_when_no_number_is_given()
{
var projectId = await NewProjectAsync();
var first = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var second = await Chapters.CreateAsync(projectId, new CreateChapterRequest("The Harbour"));
Assert.Multiple(() =>
{
Assert.That(first.Number, Is.EqualTo(1));
Assert.That(second.Number, Is.EqualTo(2));
});
}
[Test]
public async Task Word_count_is_recomputed_whenever_prose_changes()
{
var projectId = await NewProjectAsync();
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "Five words go right here"));
Assert.That(scene.WordCount, Is.EqualTo(5));
var rewritten = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
Prose: "Now\nthere are seven words in total"));
Assert.That(rewritten.WordCount, Is.EqualTo(7));
var cleared = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: ""));
Assert.Multiple(() =>
{
Assert.That(cleared.Prose, Is.Null);
Assert.That(cleared.WordCount, Is.EqualTo(0));
});
}
[Test]
public async Task Scene_updates_that_omit_prose_leave_the_draft_untouched()
{
var projectId = await NewProjectAsync();
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "The tide came in slow."));
var updated = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised));
Assert.Multiple(() =>
{
Assert.That(updated.Prose, Is.EqualTo("The tide came in slow."));
Assert.That(updated.WordCount, Is.EqualTo(5));
Assert.That(updated.Status, Is.EqualTo(DraftStatus.Revised));
});
}
[Test]
public async Task Deleting_a_project_takes_its_characters_chapters_and_scenes()
{
var projectId = await NewProjectAsync();
await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest("The dock at dawn"));
await Projects.DeleteAsync(projectId);
using var verification = Db.CreateContext();
Assert.Multiple(async () =>
{
Assert.That(await verification.Projects.CountAsync(), Is.EqualTo(0));
Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0));
Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0));
Assert.That(await verification.Scenes.CountAsync(), Is.EqualTo(0));
});
}
[Test]
public async Task Relating_characters_across_projects_is_refused()
{
var firstProject = await NewProjectAsync();
var secondProject = (await Projects.CreateAsync(new CreateProjectRequest("Other Book"))).Id;
var ines = await Characters.CreateAsync(firstProject, new CreateCharacterRequest("Ines"));
var stranger = await Characters.CreateAsync(secondProject, 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 Relationships_resolve_the_other_character_by_name()
{
var projectId = await NewProjectAsync();
var ines = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Mara"));
var updated = await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire."));
Assert.Multiple(() =>
{
Assert.That(updated.Relationships, Has.Count.EqualTo(1));
Assert.That(updated.Relationships[0].RelatedCharacterName, Is.EqualTo("Mara"));
});
}
[Test]
public void Reading_a_missing_project_reports_not_found() =>
Assert.That(
async () => await Projects.GetAsync(Guid.NewGuid()),
Throws.TypeOf<NotFoundException>());
}
@@ -0,0 +1,50 @@
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Tests;
/// <summary>
/// Shared plumbing for the service tests: a fresh in-memory database and a matching set
/// of services per test.
/// </summary>
/// <remarks>
/// The setup lives in <c>[SetUp]</c> rather than a constructor or field initialisers
/// because NUnit builds one fixture instance for the whole class — anything created once
/// would leak state from one test into the next.
/// </remarks>
public abstract class ServiceTestFixture
{
protected TestDatabase Db { get; private set; } = null!;
protected TagService Tags { get; private set; } = null!;
protected ProjectService Projects { get; private set; } = null!;
protected CharacterService Characters { get; private set; } = null!;
protected ChapterService Chapters { get; private set; } = null!;
protected SceneService Scenes { get; private set; } = null!;
protected BeatService Beats { get; private set; } = null!;
[SetUp]
public void SetUpFixture()
{
Db = new TestDatabase();
Tags = new TagService(Db.Context);
Projects = new ProjectService(Db.Context);
Characters = new CharacterService(Db.Context, Tags);
Chapters = new ChapterService(Db.Context, Tags);
Scenes = new SceneService(Db.Context);
Beats = new BeatService(Db.Context, Tags);
OnSetUp();
}
/// <summary>Runs after the services exist, for per-class seed data.</summary>
protected virtual void OnSetUp()
{
}
[TearDown]
public void TearDownFixture() => Db.Dispose();
}
+193
View File
@@ -0,0 +1,193 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Projects;
using Novelly.Api.Tags;
namespace Novelly.Api.Tests;
[TestFixture]
public class TagServiceTests : ServiceTestFixture
{
private Guid _projectId;
protected override void OnSetUp() =>
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
[Test]
public async Task Applying_an_unknown_tag_by_name_creates_it()
{
var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
Assert.Multiple(async () =>
{
Assert.That(
character.Tags.Select(t => t.Name),
Is.EquivalentTo(new[] { "betrayal", "the sea" }));
Assert.That(await Tags.ListAsync(_projectId), Has.Count.EqualTo(2));
});
}
[Test]
public async Task The_same_name_resolves_to_one_tag_regardless_of_casing()
{
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"]));
var chapter = await Chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
var listed = await Tags.ListAsync(_projectId);
Assert.Multiple(() =>
{
Assert.That(listed, Has.Count.EqualTo(1));
Assert.That(listed[0].Name, Is.EqualTo("Betrayal"));
Assert.That(chapter.Tags, Has.Count.EqualTo(1));
Assert.That(chapter.Tags[0].Id, Is.EqualTo(listed[0].Id));
});
}
[Test]
public async Task Supplying_a_tag_list_replaces_the_existing_tags()
{
var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
var updated = await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"]));
Assert.That(updated.Tags.Select(t => t.Name), Is.EquivalentTo(new[] { "the sea", "maps" }));
}
[Test]
public async Task Omitting_the_tag_list_leaves_tags_alone()
{
var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var updated = await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer"));
Assert.Multiple(() =>
{
Assert.That(updated.Tags, Has.Count.EqualTo(1));
Assert.That(updated.Tags[0].Name, Is.EqualTo("betrayal"));
Assert.That(updated.Occupation, Is.EqualTo("Cartographer"));
});
}
[Test]
public async Task Cross_reference_gathers_everything_carrying_a_tag()
{
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var chapter = await Chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest(
"She burns the atlas", WhatHappened: "In the galley stove.", Tags: ["betrayal"]));
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat"));
var tagId = (await Tags.ListAsync(_projectId)).Single().Id;
var references = await Tags.GetReferencesAsync(tagId);
Assert.Multiple(() =>
{
Assert.That(references.Characters, Has.Count.EqualTo(1));
Assert.That(references.Characters[0].Name, Is.EqualTo("Ines"));
Assert.That(references.Chapters, Has.Count.EqualTo(1));
Assert.That(references.Chapters[0].Title, Is.EqualTo("Landfall"));
Assert.That(references.Beats, Has.Count.EqualTo(1));
Assert.That(references.Beats[0].Title, Is.EqualTo("She burns the atlas"));
Assert.That(references.Beats[0].ChapterTitle, Is.EqualTo("Landfall"));
Assert.That(references.Beats[0].ChapterNumber, Is.EqualTo(1));
});
}
[Test]
public async Task Usage_counts_are_reported_per_kind()
{
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"]));
var chapter = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"]));
var summary = (await Tags.ListAsync(_projectId)).Single();
Assert.Multiple(() =>
{
Assert.That(summary.CharacterCount, Is.EqualTo(2));
Assert.That(summary.ChapterCount, Is.EqualTo(0));
Assert.That(summary.BeatCount, Is.EqualTo(1));
Assert.That(summary.TotalCount, Is.EqualTo(3));
});
}
[Test]
public async Task Duplicate_tag_names_are_refused_on_create_and_rename()
{
await Tags.CreateAsync(_projectId, new CreateTagRequest("betrayal"));
Assert.That(
async () => await Tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a tag"));
var other = await Tags.CreateAsync(_projectId, new CreateTagRequest("the sea"));
Assert.That(
async () => await Tags.UpdateAsync(other.Id, new UpdateTagRequest(Name: "betrayal")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a tag"));
}
[Test]
public async Task Tags_are_scoped_to_their_project()
{
var otherProject = await Projects.CreateAsync(new CreateProjectRequest("Other Book"));
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await Characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"]));
using var verification = Db.CreateContext();
Assert.Multiple(async () =>
{
Assert.That(await Tags.ListAsync(_projectId), Has.Count.EqualTo(1));
Assert.That(await Tags.ListAsync(otherProject.Id), Has.Count.EqualTo(1));
Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(2));
});
}
[Test]
public async Task Deleting_a_tag_leaves_what_carried_it_intact()
{
var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var tagId = (await Tags.ListAsync(_projectId)).Single().Id;
await Tags.DeleteAsync(tagId);
var survivor = await Characters.GetAsync(character.Id);
Assert.Multiple(() =>
{
Assert.That(survivor.Name, Is.EqualTo("Ines"));
Assert.That(survivor.Tags, Is.Empty);
});
}
[Test]
public async Task Deleting_a_project_takes_its_tags()
{
await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
await Projects.DeleteAsync(_projectId);
using var verification = Db.CreateContext();
Assert.That(await verification.Tags.CountAsync(), Is.EqualTo(0));
}
[Test]
public void A_blank_tag_name_is_refused() =>
Assert.That(
async () => await Tags.CreateAsync(_projectId, new CreateTagRequest(" ")),
Throws.TypeOf<ArgumentException>());
}
+42
View File
@@ -0,0 +1,42 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Tests;
/// <summary>
/// A throwaway SQLite database held in memory. Using real SQLite rather than the
/// in-memory provider means the tests exercise the same relational behaviour the app
/// ships with — cascade deletes, foreign keys and all.
/// </summary>
/// <remarks>
/// NUnit reuses one fixture instance across every test in a class, so this must be built
/// in <c>[SetUp]</c> and disposed in <c>[TearDown]</c>. A field initialiser would share
/// one database for the whole class and let tests see each other's rows.
/// </remarks>
public class TestDatabase : IDisposable
{
private readonly SqliteConnection _connection;
public TestDatabase()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
Context = CreateContext();
Context.Database.EnsureCreated();
}
public NovelDbContext Context { get; }
/// <summary>A second context over the same database, for asserting on persisted state.</summary>
public NovelDbContext CreateContext() =>
new(new DbContextOptionsBuilder<NovelDbContext>().UseSqlite(_connection).Options);
public void Dispose()
{
Context.Dispose();
_connection.Dispose();
GC.SuppressFinalize(this);
}
}