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:
@@ -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(() =>
|
||||
{
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
|
||||
@@ -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(() =>
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(() =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user