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:
co-authored by
Claude Opus 5
parent
30e0c6926e
commit
725758ccd9
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user