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