Add novel-writing app: .NET 10 API, React front end, agent and MCP server
Builds out the vertical slice for planning and writing a novel. Three front ends — the React UI, an embedded Claude agent, and an MCP stdio server — all go through one REST API, so an edit made from Claude Code and one made in the browser are the same edit. Layout: Domain entities and enums, no dependencies Application services, DTOs, the agent tool-use loop and its 15 tools Infrastructure EF Core 10 + SQLite, Anthropic SDK client Api ASP.NET Core 10 minimal APIs, OpenAPI, ProblemDetails Mcp MCP stdio server, 21 tools over the same REST API Web React 19 + Vite + TanStack Query + Tailwind v4 Data model is Project > Characters / OutlineNodes / Chapters > Scenes, plus agent conversations. The outline is a self-nesting tree so acts, sequences and beats can be arranged however the book wants; scenes carry goal/conflict/outcome because that is what the agent drafts prose from. Notes on a few choices: - Conversation history replays to the model as text only. The agent re-reads current state through its tools rather than trusting a record of edits that may since have changed in the UI. - The user's turn is persisted before the tool loop runs, so a question is recorded even when the model call fails. Turn order uses an explicit sequence column; timestamps tie when a turn completes inside one tick. - Tool failures return is_error results rather than throwing, so the model can read the message and correct itself. MCP tools do the same via CallToolResult, which keeps the API's own message instead of a generic SDK error. - The Anthropic client is constructed lazily. It is injected into the agent service, which also serves read-only endpoints, and those should keep working on an install with no key. Sending without one returns 503, not 400. - DateTimeOffset is stored as UTC ticks. SQLite refuses to ORDER BY the default text form, which every "recently updated first" listing depends on. Tests run against real in-memory SQLite rather than the EF in-memory provider so they exercise the cascade deletes and query translation that actually ship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
3c85bab4a4
commit
0d7b7a6f30
@@ -0,0 +1,42 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NovelSoftware.Application;
|
||||
using NovelSoftware.Application.Agent;
|
||||
using NovelSoftware.Infrastructure.Anthropic;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
public class AnthropicClientTests
|
||||
{
|
||||
[Fact]
|
||||
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.
|
||||
var construct = () => new AnthropicAgentModelClient(Options.Create(new AgentOptions()));
|
||||
|
||||
construct.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 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()));
|
||||
|
||||
var send = async () => await client.CompleteAsync("system", [], []);
|
||||
|
||||
(await send.Should().ThrowAsync<AgentNotConfiguredException>())
|
||||
.WithMessage("*ANTHROPIC_API_KEY*");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ANTHROPIC_API_KEY", previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NovelSoftware.Application.Agent;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
|
||||
namespace NovelSoftware.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>
|
||||
public class ListingTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly ProjectService _projects;
|
||||
private readonly ChapterService _chapters;
|
||||
private readonly SceneService _scenes;
|
||||
private readonly CharacterService _characters;
|
||||
|
||||
public ListingTests()
|
||||
{
|
||||
_projects = new ProjectService(_db.Context);
|
||||
_chapters = new ChapterService(_db.Context);
|
||||
_scenes = new SceneService(_db.Context);
|
||||
_characters = new CharacterService(_db.Context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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();
|
||||
|
||||
listed.Select(p => p.Title).Should().Equal("Older Book", "Newer Book");
|
||||
listed.Select(p => p.Id).Should().Contain(newer.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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();
|
||||
|
||||
summary.CharacterCount.Should().Be(2);
|
||||
summary.ChapterCount.Should().Be(2);
|
||||
summary.WordCount.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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();
|
||||
|
||||
summary.WordCount.Should().Be(0);
|
||||
summary.ChapterCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
|
||||
listed.Select(c => c.Title).Should().Equal("First", "Second");
|
||||
listed.Single(c => c.Id == second.Id).SceneCount.Should().Be(2);
|
||||
listed.Single(c => c.Id == second.Id).WordCount.Should().Be(3);
|
||||
listed.Single(c => c.Id == first.Id).WordCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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, new OutlineService(_db.Context), _chapters, _scenes),
|
||||
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);
|
||||
|
||||
listed.Should().HaveCount(2);
|
||||
listed[0].Title.Should().Be("Second question.");
|
||||
listed[0].MessageCount.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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", NovelSoftware.Domain.CharacterRole.Supporting));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
||||
"Ines", NovelSoftware.Domain.CharacterRole.Protagonist));
|
||||
await _characters.CreateAsync(project.Id, new CreateCharacterRequest(
|
||||
"Anders", NovelSoftware.Domain.CharacterRole.Supporting));
|
||||
|
||||
var listed = await _characters.ListAsync(project.Id);
|
||||
|
||||
listed.Select(c => c.Name).Should().Equal("Ines", "Anders", "Zeno");
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NovelSoftware.Application.Agent;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
public class NovelAgentServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly ProjectService _projects;
|
||||
private readonly CharacterService _characters;
|
||||
private readonly NovelAgentToolset _toolset;
|
||||
|
||||
public NovelAgentServiceTests()
|
||||
{
|
||||
_projects = new ProjectService(_db.Context);
|
||||
_characters = new CharacterService(_db.Context);
|
||||
_toolset = new NovelAgentToolset(
|
||||
_projects,
|
||||
_characters,
|
||||
new OutlineService(_db.Context),
|
||||
new ChapterService(_db.Context),
|
||||
new SceneService(_db.Context));
|
||||
}
|
||||
|
||||
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
|
||||
_db.Context,
|
||||
model,
|
||||
_toolset,
|
||||
Options.Create(new AgentOptions { MaxIterations = 4 }),
|
||||
NullLogger<NovelAgentService>.Instance);
|
||||
|
||||
[Fact]
|
||||
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?"));
|
||||
|
||||
turn.Message.Content.Should().Be("Tell me about the ending.");
|
||||
|
||||
var conversation = await agent.GetConversationAsync(turn.ConversationId);
|
||||
conversation.Messages.Should().HaveCount(2);
|
||||
conversation.Messages[0].Content.Should().Be("Where do I start?");
|
||||
conversation.Title.Should().Be("Where do I start?");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
characters.Should().ContainSingle().Which.Name.Should().Be("Ines");
|
||||
|
||||
turn.Message.Content.Should().Be("Added Ines as the protagonist.");
|
||||
turn.Message.ToolCalls.Should().ContainSingle().Which.Name.Should().Be("create_character");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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 secondRequest = model.Transcripts[1];
|
||||
var resultTurn = secondRequest[^1];
|
||||
|
||||
resultTurn.Role.Should().Be("user");
|
||||
resultTurn.Content.OfType<AgentToolResultBlock>().Should().HaveCount(2);
|
||||
(await _characters.ListAsync(projectId)).Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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();
|
||||
errorResult.IsError.Should().BeTrue();
|
||||
errorResult.Content.Should().Contain("was not found");
|
||||
|
||||
turn.Message.Content.Should().Contain("does not exist yet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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();
|
||||
result.IsError.Should().BeTrue();
|
||||
result.Content.Should().Contain("No such tool");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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."));
|
||||
|
||||
model.Transcripts.Should().HaveCount(4);
|
||||
turn.Message.Content.Should().Contain("tool-call limit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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));
|
||||
|
||||
second.ConversationId.Should().Be(first.ConversationId);
|
||||
|
||||
// The second request replays the earlier turns so the model has the history.
|
||||
model.Transcripts[1].Should().HaveCount(3);
|
||||
model.Transcripts[1].Select(m => m.Role).Should().Equal("user", "assistant", "user");
|
||||
|
||||
var conversation = await agent.GetConversationAsync(first.ConversationId);
|
||||
conversation.Messages.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_tool_declares_an_object_schema_and_a_description()
|
||||
{
|
||||
_toolset.Definitions.Should().NotBeEmpty();
|
||||
|
||||
foreach (var tool in _toolset.Definitions)
|
||||
{
|
||||
tool.Description.Should().NotBeNullOrWhiteSpace();
|
||||
tool.InputSchema.GetProperty("type").GetString().Should().Be("object");
|
||||
tool.InputSchema.TryGetProperty("properties", out _).Should().BeTrue();
|
||||
}
|
||||
|
||||
_toolset.Definitions.Select(t => t.Name).Should().OnlyHaveUniqueItems();
|
||||
}
|
||||
|
||||
private static AgentToolUseBlock ToolUse(string id, string name, object input) =>
|
||||
new(id, name, JsonSerializer.SerializeToElement(input));
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
/// <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 sealed 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,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NovelSoftware.Infrastructure\NovelSoftware.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\src\NovelSoftware.Api\NovelSoftware.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,121 @@
|
||||
using FluentAssertions;
|
||||
using NovelSoftware.Application;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
using NovelSoftware.Domain;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
public class OutlineServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly OutlineService _outlines;
|
||||
private readonly Guid _projectId;
|
||||
|
||||
public OutlineServiceTests()
|
||||
{
|
||||
_outlines = new OutlineService(_db.Context);
|
||||
_projectId = new ProjectService(_db.Context)
|
||||
.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nested_nodes_come_back_as_a_tree()
|
||||
{
|
||||
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Act One", OutlineNodeType.Act));
|
||||
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"She finds the map", OutlineNodeType.Beat, ParentId: act.Id));
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"The harbour burns", OutlineNodeType.Beat, ParentId: act.Id));
|
||||
|
||||
var tree = await _outlines.GetTreeAsync(_projectId);
|
||||
|
||||
tree.Should().ContainSingle();
|
||||
tree[0].Title.Should().Be("Act One");
|
||||
tree[0].Children.Select(c => c.Title)
|
||||
.Should().Equal("She finds the map", "The harbour burns");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sibling_order_follows_sort_order_not_insertion_order()
|
||||
{
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Third", SortOrder: 30));
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("First", SortOrder: 10));
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Second", SortOrder: 20));
|
||||
|
||||
var tree = await _outlines.GetTreeAsync(_projectId);
|
||||
|
||||
tree.Select(n => n.Title).Should().Equal("First", "Second", "Third");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Moving_a_node_under_its_own_descendant_is_rejected()
|
||||
{
|
||||
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
|
||||
var sequence = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Sequence", ParentId: act.Id));
|
||||
var beat = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Beat", ParentId: sequence.Id));
|
||||
|
||||
var move = async () => await _outlines.MoveAsync(act.Id, new MoveOutlineNodeRequest(beat.Id, 1));
|
||||
|
||||
await move.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*beneath its own descendant*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_node_cannot_be_its_own_parent()
|
||||
{
|
||||
var node = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
|
||||
|
||||
var move = async () => await _outlines.MoveAsync(node.Id, new MoveOutlineNodeRequest(node.Id, 1));
|
||||
|
||||
await move.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*its own parent*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Moving_to_the_root_detaches_from_the_old_parent()
|
||||
{
|
||||
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
|
||||
var beat = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Beat", ParentId: act.Id));
|
||||
|
||||
await _outlines.MoveAsync(beat.Id, new MoveOutlineNodeRequest(null, 2));
|
||||
|
||||
var tree = await _outlines.GetTreeAsync(_projectId);
|
||||
tree.Should().HaveCount(2);
|
||||
tree.Single(n => n.Title == "Act One").Children.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_node_takes_its_whole_subtree()
|
||||
{
|
||||
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
|
||||
var sequence = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
|
||||
"Sequence", ParentId: act.Id));
|
||||
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Beat", ParentId: sequence.Id));
|
||||
var survivor = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act Two"));
|
||||
|
||||
await _outlines.DeleteAsync(act.Id);
|
||||
|
||||
var tree = await _outlines.GetTreeAsync(_projectId);
|
||||
tree.Should().ContainSingle().Which.Id.Should().Be(survivor.Id);
|
||||
|
||||
using var verification = _db.CreateContext();
|
||||
verification.OutlineNodes.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creating_under_a_missing_parent_reports_not_found()
|
||||
{
|
||||
var create = async () => await _outlines.CreateAsync(_projectId,
|
||||
new CreateOutlineNodeRequest("Orphan", ParentId: Guid.NewGuid()));
|
||||
|
||||
await create.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Application;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Application.Services;
|
||||
using NovelSoftware.Domain;
|
||||
|
||||
namespace NovelSoftware.Tests;
|
||||
|
||||
public class ProjectDataTests : IDisposable
|
||||
{
|
||||
private readonly TestDatabase _db = new();
|
||||
private readonly ProjectService _projects;
|
||||
private readonly CharacterService _characters;
|
||||
private readonly ChapterService _chapters;
|
||||
private readonly SceneService _scenes;
|
||||
|
||||
public ProjectDataTests()
|
||||
{
|
||||
_projects = new ProjectService(_db.Context);
|
||||
_characters = new CharacterService(_db.Context);
|
||||
_chapters = new ChapterService(_db.Context);
|
||||
_scenes = new SceneService(_db.Context);
|
||||
}
|
||||
|
||||
private async Task<Guid> NewProjectAsync() =>
|
||||
(await _projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||
|
||||
[Fact]
|
||||
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"));
|
||||
|
||||
afterPartialUpdate.Title.Should().Be("The Salt Road");
|
||||
afterPartialUpdate.Genre.Should().Be("Fantasy");
|
||||
afterPartialUpdate.Logline.Should().Be("A cartographer goes to sea.");
|
||||
|
||||
var afterClear = await _projects.UpdateAsync(id, new UpdateProjectRequest(Genre: ""));
|
||||
|
||||
afterClear.Genre.Should().BeNull();
|
||||
afterClear.Logline.Should().Be("A cartographer goes to sea.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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"));
|
||||
|
||||
first.Number.Should().Be(1);
|
||||
second.Number.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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"));
|
||||
|
||||
scene.WordCount.Should().Be(5);
|
||||
|
||||
var rewritten = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
|
||||
Prose: "Now\nthere are seven words in total"));
|
||||
|
||||
rewritten.WordCount.Should().Be(7);
|
||||
|
||||
var cleared = await _scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: ""));
|
||||
|
||||
cleared.Prose.Should().BeNull();
|
||||
cleared.WordCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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));
|
||||
|
||||
updated.Prose.Should().Be("The tide came in slow.");
|
||||
updated.WordCount.Should().Be(5);
|
||||
updated.Status.Should().Be(DraftStatus.Revised);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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();
|
||||
(await verification.Projects.CountAsync()).Should().Be(0);
|
||||
(await verification.Characters.CountAsync()).Should().Be(0);
|
||||
(await verification.Chapters.CountAsync()).Should().Be(0);
|
||||
(await verification.Scenes.CountAsync()).Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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"));
|
||||
|
||||
var relate = async () => await _characters.AddRelationshipAsync(
|
||||
ines.Id, new CreateRelationshipRequest(stranger.Id, "sister"));
|
||||
|
||||
await relate.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*same project*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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."));
|
||||
|
||||
updated.Relationships.Should().ContainSingle()
|
||||
.Which.RelatedCharacterName.Should().Be("Mara");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_missing_project_reports_not_found()
|
||||
{
|
||||
var get = async () => await _projects.GetAsync(Guid.NewGuid());
|
||||
|
||||
await get.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Infrastructure.Persistence;
|
||||
|
||||
namespace NovelSoftware.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>
|
||||
public sealed 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user