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
+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);
});
}