Skip to content

Commit 5f02c11

Browse files
committed
fix: harden event processing and edge case handling
1 parent 73a3add commit 5f02c11

14 files changed

Lines changed: 521 additions & 13 deletions

src/WART-Client/WartTestClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public static async Task ConnectAsync(string wartHubUrl)
3838
{
3939
Console.WriteLine(exception);
4040
Console.WriteLine(Environment.NewLine);
41-
await Task.Delay(new Random().Next(0, 5) * 1000);
41+
await Task.Delay(Random.Shared.Next(0, 5) * 1000);
4242
await hubConnection.StartAsync();
4343
};
4444

src/WART-Client/WartTestClientCookie.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public static async Task ConnectAsync(string hubUrl)
2828
AllowAutoRedirect = true
2929
};
3030

31-
using var httpClient = new HttpClient(handler);
31+
using var httpClient = new HttpClient(handler, disposeHandler: false);
3232

3333
var loginContent = new FormUrlEncodedContent(new[]
3434
{
@@ -66,7 +66,7 @@ public static async Task ConnectAsync(string hubUrl)
6666
hubConnection.Closed += async (ex) =>
6767
{
6868
Console.WriteLine($"Connection closed: {ex?.Message}");
69-
await Task.Delay(new Random().Next(0, 5) * 1000);
69+
await Task.Delay(Random.Shared.Next(0, 5) * 1000);
7070
if (hubConnection != null)
7171
await hubConnection.StartAsync();
7272
};

src/WART-Client/WartTestClientJwt.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public static async Task ConnectAsync(string wartHubUrl, string key)
4343
{
4444
Console.WriteLine(exception);
4545
Console.WriteLine(Environment.NewLine);
46-
await Task.Delay(new Random().Next(0, 5) * 1000);
46+
await Task.Delay(Random.Shared.Next(0, 5) * 1000);
4747
await hubConnection.StartAsync();
4848
};
4949

src/WART-Client/appsettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"Scheme": "https",
33
"Host": "localhost",
4-
"Port": "54644",
4+
"Port": "62198",
55
"Hubname": "warthub",
66
"AuthenticationType": "JWT",
77
"Key": "dn3341fmcscscwe28419brhwbwgbss4t",

src/WART-Core/Entity/WartEventWithFilters.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// (c) 2024 Francesco Del Re <francesco.delre.87@gmail.com>
22
// This code is licensed under MIT license (see LICENSE.txt for details)
33
using Microsoft.AspNetCore.Mvc.Filters;
4+
using System;
45
using System.Collections.Generic;
56

67
namespace WART_Core.Entity
@@ -20,13 +21,20 @@ public class WartEventWithFilters
2021
/// </summary>
2122
public List<IFilterMetadata> Filters { get; set; }
2223

24+
/// <summary>
25+
/// The number of times this event has been retried.
26+
/// </summary>
27+
public int RetryCount { get; set; }
28+
2329
/// <summary>
2430
/// Initializes a new instance of the WartEventWithFilters class.
2531
/// </summary>
2632
/// <param name="wartEvent">The WartEvent to associate with the filters.</param>
2733
/// <param name="filters">The list of filters applied to the event.</param>
2834
public WartEventWithFilters(WartEvent wartEvent, List<IFilterMetadata> filters)
2935
{
36+
ArgumentNullException.ThrowIfNull(wartEvent);
37+
3038
// Initialize the WartEvent and Filters properties
3139
WartEvent = wartEvent;
3240
Filters = filters;

src/WART-Core/Middleware/WartApplicationBuilderExtension.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
107107
/// <exception cref="ArgumentException">Thrown when the hub name is null or empty.</exception>
108108
public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName)
109109
{
110-
if (string.IsNullOrEmpty(hubName))
110+
if (string.IsNullOrWhiteSpace(hubName))
111111
throw new ArgumentException("Invalid hub name");
112112

113113
app.UseForwardedHeaders();
@@ -143,7 +143,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
143143
var unique = hubNameList
144144
.Where(s => !string.IsNullOrWhiteSpace(s))
145145
.Select(NormalizeHubPath)
146-
.Distinct()
146+
.Distinct(StringComparer.Ordinal)
147147
.ToList();
148148

149149
app.UseEndpoints(endpoints =>
@@ -167,7 +167,7 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app
167167
/// <exception cref="ArgumentException">Thrown when the hub name is null or empty.</exception>
168168
public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app, string hubName, HubType hubType)
169169
{
170-
if (string.IsNullOrEmpty(hubName))
170+
if (string.IsNullOrWhiteSpace(hubName))
171171
throw new ArgumentException("Invalid hub name");
172172

173173
app.UseForwardedHeaders();

src/WART-Core/Serialization/JsonArrayOrObjectStringConverter.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS
2525
JsonTokenType.String => reader.GetString(),
2626
JsonTokenType.StartObject or JsonTokenType.StartArray => JsonDocument.ParseValue(ref reader).RootElement.GetRawText(),
2727
JsonTokenType.Null => null,
28-
_ => reader.GetString()
28+
JsonTokenType.Number => JsonDocument.ParseValue(ref reader).RootElement.GetRawText(),
29+
JsonTokenType.True or JsonTokenType.False => reader.GetBoolean().ToString(),
30+
_ => throw new JsonException($"Unexpected token type: {reader.TokenType}")
2931
};
3032
}
3133

src/WART-Core/Services/WartEventWorker.cs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class WartEventWorker<THub> : BackgroundService where THub : Hub
2626

2727
private const int NoClientsDelayMs = 500;
2828
private const int IdleDelayMs = 200;
29+
private const int MaxRetryCount = 5;
2930

3031
/// <summary>
3132
/// Constructor that initializes the worker with the event queue, hub context, and logger.
@@ -68,14 +69,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
6869

6970
_logger.LogInformation("Event sent: {Event}", wartEvent);
7071
}
72+
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
73+
{
74+
// Shutting down — re-enqueue without logging an error so
75+
// the event is not lost, then exit the loop.
76+
_eventQueue.Enqueue(wartEventWithFilters);
77+
break;
78+
}
7179
catch (Exception ex)
7280
{
7381
// Log any errors that occur while sending the event.
7482
_logger.LogError(ex, "Error while sending event.");
7583

76-
// Re-enqueue the event for retry
77-
// We lost the order of the events, but we can't lose the events
78-
_eventQueue.Enqueue(wartEventWithFilters);
84+
// Re-enqueue the event for retry up to the maximum retry count.
85+
wartEventWithFilters.RetryCount++;
86+
if (wartEventWithFilters.RetryCount <= MaxRetryCount)
87+
{
88+
_eventQueue.Enqueue(wartEventWithFilters);
89+
}
90+
else
91+
{
92+
_logger.LogWarning("Event {EventId} dropped after {MaxRetries} retries.",
93+
wartEventWithFilters.WartEvent?.EventId, MaxRetryCount);
94+
}
7995
}
8096
}
8197

src/WART-Tests/Entity/WartEventTests.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,75 @@ public void WartEvent_GetResponseObject_ShouldDeserializeJsonResponse()
108108
// Assert
109109
Assert.NotNull(deserializedResponse);
110110
}
111+
112+
[Fact]
113+
public void WartEvent_Constructor_NullParameters_DefaultsToEmpty()
114+
{
115+
// Act
116+
var wartEvent = new WartEvent(null!, null!, null!);
117+
118+
// Assert
119+
Assert.Equal(string.Empty, wartEvent.HttpMethod);
120+
Assert.Equal(string.Empty, wartEvent.HttpPath);
121+
Assert.Equal(string.Empty, wartEvent.RemoteAddress);
122+
}
123+
124+
[Fact]
125+
public void WartEvent_FullConstructor_NullRequestResponse_DoesNotThrow()
126+
{
127+
// Act
128+
var wartEvent = new WartEvent(null, null, "PUT", "/api/items", "10.0.0.1");
129+
130+
// Assert
131+
Assert.NotEqual(Guid.Empty, wartEvent.EventId);
132+
Assert.Equal("PUT", wartEvent.HttpMethod);
133+
}
134+
135+
[Fact]
136+
public void WartEvent_ToDictionary_ContainsAllKeys()
137+
{
138+
// Arrange
139+
var wartEvent = new WartEvent("DELETE", "/api/items/1", "192.168.0.1")
140+
{
141+
ExtraInfo = "test-info"
142+
};
143+
144+
// Act
145+
var dict = wartEvent.ToDictionary();
146+
147+
// Assert
148+
Assert.Equal(9, dict.Count);
149+
Assert.Equal(wartEvent.EventId, dict["EventId"]);
150+
Assert.Equal("DELETE", dict["HttpMethod"]);
151+
Assert.Equal("/api/items/1", dict["HttpPath"]);
152+
Assert.Equal("192.168.0.1", dict["RemoteAddress"]);
153+
Assert.Equal("test-info", dict["ExtraInfo"]);
154+
Assert.True(dict.ContainsKey("TimeStamp"));
155+
Assert.True(dict.ContainsKey("UtcTimeStamp"));
156+
Assert.True(dict.ContainsKey("JsonRequestPayload"));
157+
Assert.True(dict.ContainsKey("JsonResponsePayload"));
158+
}
159+
160+
[Fact]
161+
public void WartEvent_Timestamps_AreConsistent()
162+
{
163+
// Arrange & Act
164+
var before = DateTime.UtcNow;
165+
var wartEvent = new WartEvent("GET", "/", "::1");
166+
var after = DateTime.UtcNow;
167+
168+
// Assert
169+
Assert.InRange(wartEvent.UtcTimeStamp, before, after);
170+
Assert.Equal(wartEvent.UtcTimeStamp.ToLocalTime(), wartEvent.TimeStamp);
171+
}
172+
173+
[Fact]
174+
public void WartEvent_EventId_IsUnique()
175+
{
176+
var a = new WartEvent("GET", "/", "::1");
177+
var b = new WartEvent("GET", "/", "::1");
178+
179+
Assert.NotEqual(a.EventId, b.EventId);
180+
}
111181
}
112182
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// (c) 2025 Francesco Del Re <francesco.delre.87@gmail.com>
2+
// This code is licensed under MIT license (see LICENSE.txt for details)
3+
using Microsoft.AspNetCore.Mvc.Filters;
4+
using WART_Core.Entity;
5+
using WART_Core.Filters;
6+
7+
namespace WART_Tests.Entity
8+
{
9+
public class WartEventWithFiltersTests
10+
{
11+
[Fact]
12+
public void Constructor_SetsProperties()
13+
{
14+
var evt = new WartEvent("POST", "/api/items", "10.0.0.1");
15+
var filters = new List<IFilterMetadata> { new GroupWartAttribute("g1") };
16+
17+
var item = new WartEventWithFilters(evt, filters);
18+
19+
Assert.Same(evt, item.WartEvent);
20+
Assert.Same(filters, item.Filters);
21+
Assert.Equal(0, item.RetryCount);
22+
}
23+
24+
[Fact]
25+
public void Constructor_NullWartEvent_ThrowsArgumentNullException()
26+
{
27+
Assert.Throws<ArgumentNullException>(() =>
28+
new WartEventWithFilters(null!, []));
29+
}
30+
31+
[Fact]
32+
public void Constructor_NullFilters_DoesNotThrow()
33+
{
34+
var evt = new WartEvent("GET", "/", "::1");
35+
36+
var item = new WartEventWithFilters(evt, null!);
37+
38+
Assert.Null(item.Filters);
39+
}
40+
41+
[Fact]
42+
public void RetryCount_DefaultsToZero()
43+
{
44+
var item = new WartEventWithFilters(new WartEvent("GET", "/", "::1"), []);
45+
46+
Assert.Equal(0, item.RetryCount);
47+
}
48+
49+
[Fact]
50+
public void RetryCount_CanBeIncremented()
51+
{
52+
var item = new WartEventWithFilters(new WartEvent("GET", "/", "::1"), []);
53+
54+
item.RetryCount++;
55+
item.RetryCount++;
56+
57+
Assert.Equal(2, item.RetryCount);
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)