Stop throwing for not-found; add Guard and request validation

Not-found lookups return null/false instead of throwing NotFoundException
across all services — a missing row is expected control flow, not an
exceptional condition. NotFoundException stays for embedded precondition
checks inside mutations (missing parent, invalid foreign reference).

Guard (copied from mic-check) enforces required arguments at the top of
every service method. A ported IModelValidator<T> framework validates
every request DTO at the API layer via a new ValidationEndpointFilter,
returning a 400 with field-level messages; services re-run the same
validator and throw for direct callers that bypass the API.

Endpoints translate null/false into 404 via a new ToApiResult() helper.
The agent toolset boundary translates the same nullable/bool results
into the tool-error text the model already expected.
This commit is contained in:
James Wampler
2026-08-06 15:13:36 -07:00
parent 04917fa09e
commit 40f93e40a8
45 changed files with 1523 additions and 377 deletions
+3 -3
View File
@@ -131,7 +131,7 @@ public class BeatServiceTests : ServiceTestFixture
await Scenes.DeleteAsync(scene.Id);
// The plan outlives a decision about prose — the beat is simply ungrouped.
var survivor = await Beats.GetAsync(beat.Id);
var survivor = (await Beats.GetAsync(beat.Id))!;
Assert.Multiple(() =>
{
@@ -148,7 +148,7 @@ public class BeatServiceTests : ServiceTestFixture
WhatHappened: "Behind the lining of the case.",
WhatsNext: "She books passage."));
var renamed = await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She finds it"));
var renamed = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She finds it")))!;
Assert.Multiple(() =>
{
@@ -156,7 +156,7 @@ public class BeatServiceTests : ServiceTestFixture
Assert.That(renamed.WhatsNext, Is.EqualTo("She books passage."));
});
var cleared = await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: ""));
var cleared = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: "")))!;
Assert.Multiple(() =>
{
+7 -9
View File
@@ -28,8 +28,8 @@ public class CharacterArcTests : ServiceTestFixture
Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting));
var promoted = await Characters.UpdateAsync(
mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main));
var promoted = (await Characters.UpdateAsync(
mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!;
Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main));
}
@@ -102,7 +102,7 @@ public class CharacterArcTests : ServiceTestFixture
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest(
"She trusts the map", Description: "Because her mother drew it."));
var character = await Characters.GetAsync(_characterId);
var character = (await Characters.GetAsync(_characterId))!;
Assert.Multiple(() =>
{
@@ -163,7 +163,7 @@ public class CharacterArcTests : ServiceTestFixture
await Chapters.DeleteAsync(chapter.Id);
// How a character changes outlives a decision about where the chapter break falls.
var survivor = await Arcs.GetAsync(stage.Id);
var survivor = (await Arcs.GetAsync(stage.Id))!;
Assert.Multiple(() =>
{
@@ -201,7 +201,7 @@ public class CharacterArcTests : ServiceTestFixture
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat"));
var beats = await Beats.ListForCharacterAsync(_characterId);
var beats = (await Beats.ListForCharacterAsync(_characterId))!;
Assert.Multiple(() =>
{
@@ -213,8 +213,6 @@ public class CharacterArcTests : ServiceTestFixture
}
[Test]
public void Asking_for_the_beats_of_a_character_who_does_not_exist_reports_not_found() =>
Assert.That(
async () => await Beats.ListForCharacterAsync(Guid.NewGuid()),
Throws.TypeOf<NotFoundException>());
public async Task Asking_for_the_beats_of_a_character_who_does_not_exist_returns_null_rather_than_throwing() =>
Assert.That(await Beats.ListForCharacterAsync(Guid.NewGuid()), Is.Null);
}
@@ -0,0 +1,57 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
/// <summary>
/// Covers the exception-handling rework: a missing entity is an ordinary result, not a
/// thrown exception; <see cref="Guard"/> rejects missing required arguments; and a
/// service re-validates a request even when a direct caller skips the API's own filter.
/// </summary>
[TestFixture]
public class ExceptionHandlingTests : ServiceTestFixture
{
[Test]
public void Guard_rejects_an_empty_guid_passed_as_a_required_id() =>
Assert.That(() => Projects.GetAsync(Guid.Empty), Throws.TypeOf<ArgumentException>());
[Test]
public void Guard_rejects_a_null_request_object() =>
Assert.That(
() => Projects.CreateAsync(null!),
Throws.TypeOf<ArgumentNullException>());
[Test]
public async Task Deleting_a_missing_project_returns_false_rather_than_throwing() =>
Assert.That(await Projects.DeleteAsync(Guid.NewGuid()), Is.False);
[Test]
public void A_blank_title_fails_the_create_project_validator()
{
var result = new CreateProjectRequestValidator().Validate(new CreateProjectRequest(""));
Assert.Multiple(() =>
{
Assert.That(result.IsInvalid, Is.True);
Assert.That(result.Errors.Select(e => e.PropertyName), Has.Member("Title"));
});
}
[Test]
public void Calling_a_service_directly_with_an_invalid_request_throws_rather_than_silently_accepting_it() =>
Assert.That(
() => Projects.CreateAsync(new CreateProjectRequest("")),
Throws.TypeOf<ArgumentException>());
[Test]
public async Task An_embedded_reference_to_a_missing_parent_still_throws()
{
// Creating a chapter under a nonexistent project isn't a "look this up" miss — it's
// an invalid precondition for the create, so it stays exceptional.
Assert.That(
async () => await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")),
Throws.TypeOf<NotFoundException>());
}
}
+2 -1
View File
@@ -99,7 +99,8 @@ public class ListingTests : ServiceTestFixture
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance);
NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator());
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question."));
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question."));
+10 -4
View File
@@ -15,14 +15,20 @@ namespace Novelly.Api.Tests;
public class LoggingTests : ServiceTestFixture
{
[Test]
public void Fetching_a_missing_chapter_logs_a_warning_before_throwing()
public async Task Fetching_a_missing_chapter_returns_null_and_logs_at_information_not_warning()
{
var missingId = Guid.NewGuid();
Assert.That(() => Chapters.GetAsync(missingId), Throws.TypeOf<NotFoundException>());
var result = await Chapters.GetAsync(missingId);
var warning = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Warning);
Assert.That(warning.Message, Does.Contain(missingId.ToString()));
Assert.Multiple(() =>
{
Assert.That(result, Is.Null);
Assert.That(ChapterLogs.Entries.Where(e => e.Level == LogLevel.Warning), Is.Empty);
Assert.That(
ChapterLogs.Entries,
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Information && e.Message.Contains(missingId.ToString())));
});
}
[Test]
@@ -19,7 +19,8 @@ public class NovelAgentServiceTests : ServiceTestFixture
model,
_toolset,
Options.Create(new AgentOptions { MaxIterations = 4 }),
NullLogger<NovelAgentService>.Instance);
NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator());
[Test]
public async Task A_plain_reply_is_persisted_as_a_conversation()
@@ -32,7 +33,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending."));
var conversation = await agent.GetConversationAsync(turn.ConversationId);
var conversation = (await agent.GetConversationAsync(turn.ConversationId))!;
Assert.Multiple(() =>
{
@@ -171,7 +172,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
var second = await agent.SendMessageAsync(
projectId, new SendAgentMessageRequest("Question two.", first.ConversationId));
var conversation = await agent.GetConversationAsync(first.ConversationId);
var conversation = (await agent.GetConversationAsync(first.ConversationId))!;
Assert.Multiple(() =>
{
+11 -13
View File
@@ -100,8 +100,8 @@ public class OpenQuestionTests : ServiceTestFixture
var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?"));
var resolved = await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour burns."));
var resolved = (await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")))!;
Assert.Multiple(() =>
{
@@ -123,8 +123,8 @@ public class OpenQuestionTests : ServiceTestFixture
question.Id,
new ResolveOpenQuestionRequest("After the harbour burns.", AppendToNotes: true));
var chapter = await Chapters.GetAsync(_chapterId);
var character = await Characters.GetAsync(_characterId);
var chapter = (await Chapters.GetAsync(_chapterId))!;
var character = (await Characters.GetAsync(_characterId))!;
Assert.Multiple(() =>
{
@@ -143,7 +143,7 @@ public class OpenQuestionTests : ServiceTestFixture
await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour."));
Assert.That((await Chapters.GetAsync(_chapterId)).Notes, Is.Null);
Assert.That((await Chapters.GetAsync(_chapterId))!.Notes, Is.Null);
}
[Test]
@@ -155,8 +155,8 @@ public class OpenQuestionTests : ServiceTestFixture
await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour.", AppendToNotes: true));
var reopened = await Questions.ReopenAsync(question.Id);
var chapter = await Chapters.GetAsync(_chapterId);
var reopened = (await Questions.ReopenAsync(question.Id))!;
var chapter = (await Chapters.GetAsync(_chapterId))!;
Assert.Multiple(() =>
{
@@ -172,8 +172,8 @@ public class OpenQuestionTests : ServiceTestFixture
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
var detached = await Questions.UpdateAsync(
question.Id, new UpdateOpenQuestionRequest(ClearChapter: true));
var detached = (await Questions.UpdateAsync(
question.Id, new UpdateOpenQuestionRequest(ClearChapter: true)))!;
Assert.Multiple(() =>
{
@@ -192,7 +192,7 @@ public class OpenQuestionTests : ServiceTestFixture
await Chapters.DeleteAsync(_chapterId);
var survivor = await Questions.GetAsync(question.Id);
var survivor = (await Questions.GetAsync(question.Id))!;
Assert.Multiple(() =>
{
@@ -212,9 +212,7 @@ public class OpenQuestionTests : ServiceTestFixture
Assert.Multiple(async () =>
{
Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty);
Assert.That(
async () => await Questions.GetAsync(question.Id),
Throws.TypeOf<NotFoundException>());
Assert.That(await Questions.GetAsync(question.Id), Is.Null);
});
}
+10 -12
View File
@@ -19,7 +19,7 @@ public class ProjectDataTests : ServiceTestFixture
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"));
var afterPartialUpdate = (await Projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road")))!;
Assert.Multiple(() =>
{
@@ -28,7 +28,7 @@ public class ProjectDataTests : ServiceTestFixture
Assert.That(afterPartialUpdate.Logline, Is.EqualTo("A cartographer goes to sea."));
});
var afterClear = await Projects.UpdateAsync(id, new UpdateProjectRequest(Genre: ""));
var afterClear = (await Projects.UpdateAsync(id, new UpdateProjectRequest(Genre: "")))!;
Assert.Multiple(() =>
{
@@ -63,12 +63,12 @@ public class ProjectDataTests : ServiceTestFixture
Assert.That(scene.WordCount, Is.EqualTo(5));
var rewritten = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
Prose: "Now\nthere are seven words in total"));
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: ""));
var cleared = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: "")))!;
Assert.Multiple(() =>
{
@@ -85,7 +85,7 @@ public class ProjectDataTests : ServiceTestFixture
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));
var updated = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised)))!;
Assert.Multiple(() =>
{
@@ -138,8 +138,8 @@ public class ProjectDataTests : ServiceTestFixture
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."));
var updated = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire.")))!;
Assert.Multiple(() =>
{
@@ -149,8 +149,6 @@ public class ProjectDataTests : ServiceTestFixture
}
[Test]
public void Reading_a_missing_project_reports_not_found() =>
Assert.That(
async () => await Projects.GetAsync(Guid.NewGuid()),
Throws.TypeOf<NotFoundException>());
public async Task Reading_a_missing_project_returns_null_rather_than_throwing() =>
Assert.That(await Projects.GetAsync(Guid.NewGuid()), Is.Null);
}
+16 -8
View File
@@ -52,14 +52,22 @@ public abstract class ServiceTestFixture
ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>();
Tags = new TagService(Db.Context, TagLogs);
Projects = new ProjectService(Db.Context, ProjectLogs);
Characters = new CharacterService(Db.Context, Tags, CharacterLogs);
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs);
Scenes = new SceneService(Db.Context, SceneLogs);
Beats = new BeatService(Db.Context, Tags, BeatLogs);
Arcs = new CharacterArcService(Db.Context, ArcLogs);
Questions = new OpenQuestionService(Db.Context, QuestionLogs);
Tags = new TagService(Db.Context, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Projects = new ProjectService(Db.Context, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
Characters = new CharacterService(
Db.Context, Tags, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Scenes = new SceneService(Db.Context, SceneLogs, new CreateSceneRequestValidator(), new UpdateSceneRequestValidator());
Beats = new BeatService(
Db.Context, Tags, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator());
Arcs = new CharacterArcService(
Db.Context, ArcLogs,
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());
Questions = new OpenQuestionService(
Db.Context, QuestionLogs,
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
OnSetUp();
}
+6 -6
View File
@@ -54,8 +54,8 @@ public class TagServiceTests : ServiceTestFixture
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"]));
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" }));
}
@@ -66,8 +66,8 @@ public class TagServiceTests : ServiceTestFixture
var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var updated = await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer"));
var updated = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!;
Assert.Multiple(() =>
{
@@ -88,7 +88,7 @@ public class TagServiceTests : ServiceTestFixture
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat"));
var tagId = (await Tags.ListAsync(_projectId)).Single().Id;
var references = await Tags.GetReferencesAsync(tagId);
var references = (await Tags.GetReferencesAsync(tagId))!;
Assert.Multiple(() =>
{
@@ -165,7 +165,7 @@ public class TagServiceTests : ServiceTestFixture
await Tags.DeleteAsync(tagId);
var survivor = await Characters.GetAsync(character.Id);
var survivor = (await Characters.GetAsync(character.Id))!;
Assert.Multiple(() =>
{