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.
95 lines
3.6 KiB
C#
95 lines
3.6 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Novelly.Api.Beats;
|
|
using Novelly.Api.Chapters;
|
|
using Novelly.Api.Characters;
|
|
using Novelly.Api.Common;
|
|
using Novelly.Api.Projects;
|
|
|
|
namespace Novelly.Api.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers the logging behaviour added across the services: a warning fires before a
|
|
/// not-found is thrown, and prose bodies never leak into a log message.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public class LoggingTests : ServiceTestFixture
|
|
{
|
|
[Test]
|
|
public async Task Fetching_a_missing_chapter_returns_null_and_logs_at_information_not_warning()
|
|
{
|
|
var missingId = Guid.NewGuid();
|
|
|
|
var result = await Chapters.GetAsync(missingId);
|
|
|
|
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]
|
|
public async Task Creating_a_chapter_logs_the_project_and_title_at_information()
|
|
{
|
|
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
|
ChapterLogs.Entries.Clear();
|
|
|
|
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
|
|
|
|
var info = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Information);
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(info.Message, Does.Contain("Landfall"));
|
|
Assert.That(info.Message, Does.Contain(project.Id.ToString()));
|
|
});
|
|
}
|
|
|
|
[Test]
|
|
public async Task Logged_values_never_include_a_chapter_summary_body()
|
|
{
|
|
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
|
const string secretSummary = "A very specific plot twist nobody should see in a log line.";
|
|
ChapterLogs.Entries.Clear();
|
|
|
|
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Summary: secretSummary));
|
|
|
|
Assert.That(ChapterLogs.Entries.Select(e => e.Message), Has.None.Contain(secretSummary));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Deleting_a_project_logs_information_before_the_lookup()
|
|
{
|
|
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
|
ProjectLogs.Entries.Clear();
|
|
|
|
await Projects.DeleteAsync(project.Id);
|
|
|
|
Assert.That(
|
|
ProjectLogs.Entries,
|
|
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Information && e.Message.Contains(project.Id.ToString())));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Rejecting_a_beat_with_a_foreign_character_logs_a_warning_not_an_error()
|
|
{
|
|
var projectA = await Projects.CreateAsync(new CreateProjectRequest("Project A"));
|
|
var projectB = await Projects.CreateAsync(new CreateProjectRequest("Project B"));
|
|
var chapter = await Chapters.CreateAsync(projectA.Id, new CreateChapterRequest("Landfall"));
|
|
var foreignCharacter = await Characters.CreateAsync(projectB.Id, new CreateCharacterRequest("Ines"));
|
|
BeatLogs.Entries.Clear();
|
|
|
|
Assert.That(
|
|
() => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterId: foreignCharacter.Id)),
|
|
Throws.TypeOf<InvalidOperationException>());
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(BeatLogs.Entries.Where(e => e.Level == LogLevel.Error), Is.Empty);
|
|
Assert.That(BeatLogs.Entries.Any(e => e.Level == LogLevel.Warning), Is.True);
|
|
});
|
|
}
|
|
}
|