Skip to content

Commit 7cbb77c

Browse files
authored
Merge pull request #2617 from MarcelMichau/feat/survey-validation-logging
Add survey validation logging and tests
2 parents 5e182f7 + 70d5a07 commit 7cbb77c

File tree

5 files changed

+236
-6
lines changed

5 files changed

+236
-6
lines changed

src/server/FakeSurveyGenerator.Api.Tests.Integration/Setup/IntegrationTestWebApplicationFactory.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ protected override IHost CreateHost(IHostBuilder builder)
3535

3636
protected override void ConfigureWebHost(IWebHostBuilder builder)
3737
{
38-
builder.ConfigureLogging(logging => { logging.ClearProviders(); });
38+
builder.ConfigureLogging(logging =>
39+
{
40+
logging.ClearProviders();
41+
logging.AddProvider(new TestLoggerProvider(TestLogSink.Shared));
42+
logging.SetMinimumLevel(LogLevel.Information);
43+
});
3944

4045
builder.ConfigureTestServices(ConfigureMockServices);
4146
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.Collections.Concurrent;
2+
using Microsoft.Extensions.Logging;
3+
4+
namespace FakeSurveyGenerator.Api.Tests.Integration.Setup;
5+
6+
public sealed class TestLogSink
7+
{
8+
public static TestLogSink Shared { get; } = new();
9+
10+
private readonly ConcurrentQueue<TestLogEntry> _entries = new();
11+
12+
public IReadOnlyList<TestLogEntry> Entries => _entries.ToArray();
13+
14+
public void Add(TestLogEntry entry)
15+
{
16+
_entries.Enqueue(entry);
17+
}
18+
19+
public void Clear()
20+
{
21+
while (_entries.TryDequeue(out _))
22+
{
23+
}
24+
}
25+
}
26+
27+
public sealed record TestLogEntry(
28+
string Category,
29+
LogLevel Level,
30+
EventId EventId,
31+
string Message,
32+
IReadOnlyList<KeyValuePair<string, object?>>? State,
33+
Exception? Exception);
34+
35+
public sealed class TestLoggerProvider(TestLogSink sink) : ILoggerProvider
36+
{
37+
private readonly TestLogSink _sink = sink ?? throw new ArgumentNullException(nameof(sink));
38+
39+
public ILogger CreateLogger(string categoryName)
40+
{
41+
return new TestLogger(categoryName, _sink);
42+
}
43+
44+
public void Dispose()
45+
{
46+
}
47+
48+
private sealed class TestLogger(string categoryName, TestLogSink sink) : ILogger
49+
{
50+
private readonly string _categoryName = categoryName;
51+
private readonly TestLogSink _sink = sink;
52+
53+
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
54+
{
55+
return null;
56+
}
57+
58+
public bool IsEnabled(LogLevel logLevel)
59+
{
60+
return logLevel != LogLevel.None;
61+
}
62+
63+
public void Log<TState>(
64+
LogLevel logLevel,
65+
EventId eventId,
66+
TState state,
67+
Exception? exception,
68+
Func<TState, Exception?, string> formatter)
69+
{
70+
if (!IsEnabled(logLevel))
71+
{
72+
return;
73+
}
74+
75+
var message = formatter(state, exception);
76+
var stateValues = state as IReadOnlyList<KeyValuePair<string, object?>>;
77+
78+
_sink.Add(new TestLogEntry(_categoryName, logLevel, eventId, message, stateValues, exception));
79+
}
80+
}
81+
}

src/server/FakeSurveyGenerator.Api.Tests.Integration/Surveys/SurveyEndpointsTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using FakeSurveyGenerator.Application.Features.Surveys;
66
using FakeSurveyGenerator.Application.Features.Users;
77
using FakeSurveyGenerator.Application.TestHelpers;
8+
using Microsoft.Extensions.Logging;
89

910
namespace FakeSurveyGenerator.Api.Tests.Integration.Surveys;
1011

@@ -91,6 +92,79 @@ public async Task
9192
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.UnprocessableEntity);
9293
}
9394

95+
[Test]
96+
public async Task
97+
GivenInvalidCreateSurveyCommand_WhenCallingPostSurvey_ThenValidationErrorsShouldBeLogged()
98+
{
99+
TestLogSink.Shared.Clear();
100+
101+
var createSurveyCommand = new CreateSurveyCommand
102+
{
103+
SurveyTopic = "",
104+
NumberOfRespondents = 0,
105+
RespondentType = "",
106+
SurveyOptions = new List<SurveyOptionDto>
107+
{
108+
new()
109+
{
110+
OptionText = ""
111+
}
112+
}
113+
};
114+
115+
using var response = await AuthenticatedClient.PostAsJsonAsync("/api/survey", createSurveyCommand);
116+
117+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.UnprocessableEntity);
118+
119+
var validationLog = TestLogSink.Shared.Entries.LastOrDefault(entry =>
120+
entry.Level == LogLevel.Warning
121+
&& entry.EventId.Id == 1
122+
&& entry.Category == "FakeSurveyGenerator.Api.Filters.ValidationLoggingEndpointFilter");
123+
124+
await Assert.That(validationLog).IsNotNull();
125+
126+
// LoggerMessage payload doesn't include structured state in this test host, so validate via message text.
127+
await Assert.That(validationLog!.Message.Contains("Validation failure on Endpoint: CreateSurvey")).IsTrue();
128+
await Assert.That(validationLog.Message.Contains("User:")).IsTrue();
129+
await Assert.That(validationLog.Message.Contains("Unknown Identity")).IsFalse();
130+
await Assert.That(validationLog.Message.Contains("SurveyTopic")).IsTrue();
131+
await Assert.That(validationLog.Message.Contains("SurveyOptions[0].OptionText")).IsTrue();
132+
}
133+
134+
[Test]
135+
public async Task
136+
GivenInvalidCreateSurveyCommand_WhenCallingPostSurvey_ThenRequestShouldBeLogged()
137+
{
138+
TestLogSink.Shared.Clear();
139+
140+
var createSurveyCommand = new CreateSurveyCommand
141+
{
142+
SurveyTopic = "",
143+
NumberOfRespondents = 0,
144+
RespondentType = "",
145+
SurveyOptions = new List<SurveyOptionDto>
146+
{
147+
new()
148+
{
149+
OptionText = ""
150+
}
151+
}
152+
};
153+
154+
using var response = await AuthenticatedClient.PostAsJsonAsync("/api/survey", createSurveyCommand);
155+
156+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.UnprocessableEntity);
157+
158+
var hasRequestLog = TestLogSink.Shared.Entries.Any(entry =>
159+
entry.Category == "FakeSurveyGenerator.Api.Filters.RequestLoggingEndpointFilter"
160+
&& entry.Message.Contains("Request to Endpoint: CreateSurvey")
161+
&& entry.Message.Contains("User:")
162+
&& !entry.Message.Contains("Unknown Identity"));
163+
164+
await Assert.That(hasRequestLog).IsTrue();
165+
}
166+
167+
94168
[Test]
95169
public async Task GivenExistingSurveyId_WhenCallingGetSurvey_ThenExistingSurveyShouldBeReturned()
96170
{
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using FakeSurveyGenerator.Application.Shared.Identity;
2+
using Microsoft.AspNetCore.Http;
3+
4+
namespace FakeSurveyGenerator.Api.Filters;
5+
6+
public sealed class ValidationLoggingEndpointFilter(ILoggerFactory loggerFactory, IUserService userService) : IEndpointFilter
7+
{
8+
public const string ValidationErrorsKey = "ValidationErrors";
9+
10+
private readonly ILogger _logger = loggerFactory.CreateLogger<ValidationLoggingEndpointFilter>();
11+
12+
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
13+
{
14+
var endpointMetadataCollection = context.HttpContext.GetEndpoint()?.Metadata;
15+
var endpointName = endpointMetadataCollection?.GetMetadata<EndpointNameMetadata>()?.EndpointName
16+
?? "Unknown Endpoint";
17+
18+
var result = await next(context);
19+
20+
if (context.HttpContext.Items.TryGetValue(ValidationErrorsKey, out var value)
21+
&& value is IDictionary<string, string[]> errors
22+
&& errors.Count > 0)
23+
{
24+
var userIdentity = userService?.GetUserIdentity() ?? "Unknown Identity";
25+
26+
_logger.LogValidationErrors(endpointName, userIdentity, errors);
27+
}
28+
29+
return result;
30+
}
31+
}
32+
33+
public static partial class ValidationLoggingEndpointFilterLogging
34+
{
35+
[LoggerMessage(
36+
EventId = 1,
37+
Level = LogLevel.Warning,
38+
Message = "Validation failure on Endpoint: {EndpointName} for User: {UserIdentity}. Errors: {Errors}")]
39+
public static partial void LogValidationErrors(
40+
this ILogger logger,
41+
string endpointName,
42+
string userIdentity,
43+
IDictionary<string, string[]> errors);
44+
}

src/server/FakeSurveyGenerator.Api/Surveys/SurveyEndpoints.cs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using CSharpFunctionalExtensions;
2-
using FakeSurveyGenerator.Api.Shared;
32
using FakeSurveyGenerator.Application.Abstractions;
43
using FakeSurveyGenerator.Application.Features.Surveys;
54
using FakeSurveyGenerator.Application.Shared.Errors;
@@ -15,7 +14,8 @@ internal static void MapSurveyEndpoints(this IEndpointRouteBuilder app)
1514
{
1615
var surveyGroup = app.MapGroup("/api/survey")
1716
.RequireAuthorization()
18-
.AddEndpointFilter<RequestLoggingEndpointFilter>();
17+
.AddEndpointFilter<RequestLoggingEndpointFilter>()
18+
.AddEndpointFilter<ValidationLoggingEndpointFilter>();
1919

2020
surveyGroup.MapGet("/{id:int}", GetSurvey)
2121
.WithName(nameof(GetSurvey))
@@ -33,27 +33,30 @@ internal static void MapSurveyEndpoints(this IEndpointRouteBuilder app)
3333
private static async Task<Results<Ok<SurveyModel>, ProblemHttpResult>> GetSurvey(
3434
IQueryHandler<GetSurveyDetailQuery, Result<SurveyModel, Error>> handler,
3535
[Description("Primary key of the Survey")] int id,
36+
HttpContext httpContext,
3637
CancellationToken cancellationToken)
3738
{
3839
var result = await handler.Handle(new GetSurveyDetailQuery(id), cancellationToken);
3940

40-
return ApiResultExtensions.FromResult(result);
41+
return FromResultWithValidationLogging(httpContext, result);
4142
}
4243

4344
private static async Task<Results<Ok<List<UserSurveyModel>>, ProblemHttpResult>> GetUserSurveys(
4445
IQueryHandler<GetUserSurveysQuery, Result<List<UserSurveyModel>, Error>> handler,
46+
HttpContext httpContext,
4547
CancellationToken cancellationToken)
4648
{
4749
var result = await handler.Handle(new GetUserSurveysQuery(), cancellationToken);
4850

49-
return ApiResultExtensions.FromResult(result);
51+
return FromResultWithValidationLogging(httpContext, result);
5052
}
5153

5254
private static async
5355
Task<Results<CreatedAtRoute<SurveyModel>, ProblemHttpResult,
5456
UnprocessableEntity<IDictionary<string, string[]>>>> CreateSurvey(
5557
ICommandHandler<CreateSurveyCommand, Result<SurveyModel, Error>> handler,
56-
CreateSurveyCommand command,
58+
CreateSurveyCommand command,
59+
HttpContext httpContext,
5760
CancellationToken cancellationToken)
5861
{
5962
var result = await handler.Handle(command, cancellationToken);
@@ -63,10 +66,33 @@ UnprocessableEntity<IDictionary<string, string[]>>>> CreateSurvey(
6366

6467
if (result.Error is ValidationError validationError)
6568
{
69+
httpContext.Items[ValidationLoggingEndpointFilter.ValidationErrorsKey] = validationError.Errors;
6670
return TypedResults.UnprocessableEntity(validationError.Errors);
6771
}
6872

6973
return TypedResults.Problem($"Error Code: {result.Error.Code}. Error Message: {result.Error.Message}",
7074
statusCode: StatusCodes.Status400BadRequest);
7175
}
76+
77+
private static Results<Ok<T>, ProblemHttpResult> FromResultWithValidationLogging<T>(
78+
HttpContext httpContext,
79+
Result<T, Error> result)
80+
{
81+
if (result.IsSuccess)
82+
{
83+
return TypedResults.Ok(result.Value);
84+
}
85+
86+
if (result.Error is ValidationError validationError)
87+
{
88+
httpContext.Items[ValidationLoggingEndpointFilter.ValidationErrorsKey] = validationError.Errors;
89+
}
90+
91+
var statusCode = Equals(result.Error, Errors.General.NotFound())
92+
? StatusCodes.Status404NotFound
93+
: StatusCodes.Status400BadRequest;
94+
95+
return TypedResults.Problem($"Error Code: {result.Error.Code}. Error Message: {result.Error.Message}",
96+
statusCode: statusCode);
97+
}
7298
}

0 commit comments

Comments
 (0)