diff --git a/.editorconfig b/.editorconfig index c58ac87..ee3e4f5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -110,7 +110,7 @@ csharp_style_prefer_null_check_over_type_check = true : suggestion csharp_style_implicit_object_creation_when_type_is_apparent = true : suggestion # primary constructors (C# 12+) -csharp_style_prefer_primary_constructors = true : suggestion +csharp_style_prefer_primary_constructors = true : warning # collection expressions (C# 12+) dotnet_style_prefer_collection_expression = true : suggestion diff --git a/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryServiceTests.cs b/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryServiceTests.cs index c2a1374..c455dc3 100644 --- a/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryServiceTests.cs @@ -115,7 +115,7 @@ public void RecordDeliveryAttempt_UpdatesDeliveryStatus() var attempt = new DeliveryAttempt { AttemptNumber = 1, - AttemptTime = DateTime.UtcNow, + AttemptTime = DateTimeOffset.UtcNow, Outcome = DeliveryOutcome.Success, HttpStatusCode = 200, }; @@ -136,7 +136,7 @@ public void RecordDeliveryAttempt_NonExistingEvent_DoesNotThrow() var attempt = new DeliveryAttempt { AttemptNumber = 1, - AttemptTime = DateTime.UtcNow, + AttemptTime = DateTimeOffset.UtcNow, Outcome = DeliveryOutcome.Success, }; @@ -283,7 +283,7 @@ private static SimulatorEvent CreateTestEvent(string id) Id = id, Subject = "/test/subject", EventType = "Test.EventType", - EventTime = DateTime.UtcNow.ToString("o"), + EventTime = DateTimeOffset.UtcNow.ToString("o"), DataVersion = "1.0", Data = new { Property = "Value" }, } diff --git a/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryStoreTests.cs b/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryStoreTests.cs index c67bb51..9a2825c 100644 --- a/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryStoreTests.cs +++ b/src/AzureEventGridSimulator.Tests/Domain/Services/Dashboard/EventHistoryStoreTests.cs @@ -258,7 +258,7 @@ private static EventHistoryRecord CreateTestRecord(string id, string topicName = EventType = "Test.EventType", Subject = "/test/subject", Source = "/test/source", - EventTime = DateTime.UtcNow.ToString("o"), + EventTime = DateTimeOffset.UtcNow.ToString("o"), InputSchema = EventSchema.EventGridSchema, PayloadJson = "{}", }; diff --git a/src/AzureEventGridSimulator.Tests/Helpers/FakeTimeProvider.cs b/src/AzureEventGridSimulator.Tests/Helpers/FakeTimeProvider.cs new file mode 100644 index 0000000..67f4cbe --- /dev/null +++ b/src/AzureEventGridSimulator.Tests/Helpers/FakeTimeProvider.cs @@ -0,0 +1,31 @@ +namespace AzureEventGridSimulator.Tests.Helpers; + +/// +/// A fake TimeProvider for testing time-dependent code. +/// Allows tests to control the current time for deterministic behavior. +/// +public class FakeTimeProvider(DateTimeOffset startTime) : TimeProvider +{ + private DateTimeOffset _utcNow = startTime; + + public override DateTimeOffset GetUtcNow() + { + return _utcNow; + } + + /// + /// Sets the current time to a specific value. + /// + public void SetUtcNow(DateTimeOffset value) + { + _utcNow = value; + } + + /// + /// Advances the current time by the specified duration. + /// + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } +} diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaFormatterTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaFormatterTests.cs index fa2efbd..5fa3407 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaFormatterTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/CloudEventSchemaFormatterTests.cs @@ -72,8 +72,8 @@ public void GivenEventGridEvent_WhenSerializedAsCloudEvent_ThenConvertedCorrectl EventType = "Test.Event.Type", EventTime = "2025-01-15T10:30:00Z", Data = new { Property = "Value" }, - Topic = "/test/topic", }; + eventGridEvent.SetTopic("/test/topic"); var simulatorEvent = SimulatorEvent.FromEventGridEvent(eventGridEvent); var json = _formatter.Serialize(simulatorEvent); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/SimulatorEventTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/SimulatorEventTests.cs index f75ef96..ac6824e 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/SimulatorEventTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/CloudEvents/SimulatorEventTests.cs @@ -18,8 +18,8 @@ public void GivenEventGridEvent_WhenWrappedInSimulatorEvent_ThenPropertiesMapped EventTime = "2025-01-15T10:30:00Z", Data = new { Property = "Value" }, DataVersion = "1.0", - Topic = "/test/topic", }; + eventGridEvent.SetTopic("/test/topic"); var simulatorEvent = SimulatorEvent.FromEventGridEvent(eventGridEvent); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Commands/SendNotificationEventsToSubscriberCommandHandlerTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Commands/SendNotificationEventsToSubscriberCommandHandlerTests.cs index 5294e4e..a7d3708 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Commands/SendNotificationEventsToSubscriberCommandHandlerTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Commands/SendNotificationEventsToSubscriberCommandHandlerTests.cs @@ -407,7 +407,7 @@ private static SimulatorEvent CreateTestEventGridEvent(string id) Id = id, Subject = "/test/subject", EventType = "Test.EventType", - EventTime = DateTime.UtcNow.ToString("o"), + EventTime = DateTimeOffset.UtcNow.ToString("o"), DataVersion = "1.0", Data = new { Property = "Value" }, } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Commands/ValidateSubscriptionCommandHandlerTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Commands/ValidateSubscriptionCommandHandlerTests.cs index 94cd777..7e4ba7e 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Commands/ValidateSubscriptionCommandHandlerTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Commands/ValidateSubscriptionCommandHandlerTests.cs @@ -16,7 +16,7 @@ public class ValidateSubscriptionCommandHandlerTests public ValidateSubscriptionCommandHandlerTests() { _logger = Substitute.For>(); - _handler = new ValidateSubscriptionCommandHandler(_logger); + _handler = new ValidateSubscriptionCommandHandler(TimeProvider.System, _logger); } [Fact] diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridSchemaFormatterTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridSchemaFormatterTests.cs index 5fced06..ea3dad5 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridSchemaFormatterTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridSchemaFormatterTests.cs @@ -9,7 +9,7 @@ namespace AzureEventGridSimulator.Tests.UnitTests.EventGrid; [Trait("Category", "unit")] public class EventGridSchemaFormatterTests { - private readonly EventGridSchemaFormatter _formatter = new(); + private readonly EventGridSchemaFormatter _formatter = new(TimeProvider.System); [Fact] public void GivenEventGridEvent_WhenSerialized_ThenJsonContainsAllFields() @@ -22,9 +22,9 @@ public void GivenEventGridEvent_WhenSerialized_ThenJsonContainsAllFields() EventTime = "2025-01-15T10:30:00Z", Data = new { Property = "Value" }, DataVersion = "1.0", - Topic = "/test/topic", MetadataVersion = "1", }; + eventGridEvent.SetTopic("/test/topic"); var simulatorEvent = SimulatorEvent.FromEventGridEvent(eventGridEvent); var json = _formatter.Serialize(simulatorEvent); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridValidationTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridValidationTests.cs index 44acc3d..0958219 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridValidationTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/EventGrid/EventGridValidationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using AzureEventGridSimulator.Domain.Entities; using Shouldly; using Xunit; @@ -258,36 +259,55 @@ string metadataVersion public void GivenEventWithNonNullTopic_WhenValidated_ThenExceptionThrown() { // Topic should be null/empty when publishing - it's set by the service - var eventGridEvent = new EventGridEvent - { - Id = "test-id-123", - Subject = "/test/subject", - EventType = "Test.EventType", - EventTime = "2025-01-15T10:30:00Z", - Topic = "/subscriptions/some/topic", - }; - - var exception = Should.Throw(() => eventGridEvent.Validate()); + // Use JSON deserialization to simulate a publisher sending a Topic value + var json = """ + { + "id": "test-id-123", + "subject": "/test/subject", + "eventType": "Test.EventType", + "eventTime": "2025-01-15T10:30:00Z", + "topic": "/subscriptions/some/topic" + } + """; + var eventGridEvent = JsonSerializer.Deserialize(json); + + var exception = Should.Throw(() => eventGridEvent!.Validate()); exception.Message.ShouldContain("Topic"); } - [Theory] - [InlineData(null)] - [InlineData("")] - public void GivenEventWithNullOrEmptyTopic_WhenValidated_ThenNoExceptionThrown(string topic) + [Fact] + public void GivenEventWithNullTopic_WhenValidated_ThenNoExceptionThrown() { + // Topic defaults to null when not set var eventGridEvent = new EventGridEvent { Id = "test-id-123", Subject = "/test/subject", EventType = "Test.EventType", EventTime = "2025-01-15T10:30:00Z", - Topic = topic, }; Should.NotThrow(() => eventGridEvent.Validate()); } + [Fact] + public void GivenEventWithEmptyTopic_WhenValidated_ThenNoExceptionThrown() + { + // Use JSON deserialization to set empty Topic + var json = """ + { + "id": "test-id-123", + "subject": "/test/subject", + "eventType": "Test.EventType", + "eventTime": "2025-01-15T10:30:00Z", + "topic": "" + } + """; + var eventGridEvent = JsonSerializer.Deserialize(json); + + Should.NotThrow(() => eventGridEvent!.Validate()); + } + [Fact] public void GivenEventWithNullData_WhenValidated_ThenNoExceptionThrown() { diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/AdvancedFilterEventAcceptanceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/AdvancedFilterEventAcceptanceTests.cs index 80c1af3..a31a2a4 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/AdvancedFilterEventAcceptanceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Filtering/AdvancedFilterEventAcceptanceTests.cs @@ -9,25 +9,31 @@ namespace AzureEventGridSimulator.Tests.UnitTests.Filtering; [Trait("Category", "unit")] public class AdvancedFilterEventAcceptanceTests { - private static readonly EventGridEvent _gridEvent = new() + private static readonly EventGridEvent _gridEvent = CreateTestEvent(); + + private static EventGridEvent CreateTestEvent() { - Id = "EventId", - Data = new + var evt = new EventGridEvent { - NumberValue = 1, - IsTrue = true, - Name = "StringValue", - DoubleValue = 0.12345d, - NumberMaxValue = ulong.MaxValue, - SubObject = new { Id = 1, Name = "Test" }, - }, - DataVersion = "5.0", - EventTime = DateTime.UtcNow.ToString("O"), - EventType = "this.is.a.test.event.type", - MetadataVersion = "2.3.4", - Subject = "TheEventSubject", - Topic = "THE_EVENT_TOPIC", - }; + Id = "EventId", + Data = new + { + NumberValue = 1, + IsTrue = true, + Name = "StringValue", + DoubleValue = 0.12345d, + NumberMaxValue = ulong.MaxValue, + SubObject = new { Id = 1, Name = "Test" }, + }, + DataVersion = "5.0", + EventTime = DateTimeOffset.UtcNow.ToString("O"), + EventType = "this.is.a.test.event.type", + MetadataVersion = "2.3.4", + Subject = "TheEventSubject", + }; + evt.SetTopic("THE_EVENT_TOPIC"); + return evt; + } [Theory] [ClassData(typeof(PositiveFilterTestCaseContainer))] diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasTokenTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasTokenTests.cs index ddd3ee5..76c9a9b 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasTokenTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAegSasTokenTests.cs @@ -14,7 +14,7 @@ public void GivenValidAegSasToken_WhenValidated_ThenReturnsTrue() var token = GenerateValidSasToken( ValidTopicKey, "http://localhost", - DateTime.UtcNow.AddMinutes(5) + DateTimeOffset.UtcNow.AddMinutes(5) ); var headers = new HeaderDictionary { { Constants.AegSasTokenHeader, token } }; @@ -29,7 +29,7 @@ public void GivenExpiredAegSasToken_WhenValidated_ThenReturnsFalse() var token = GenerateValidSasToken( ValidTopicKey, "http://localhost", - DateTime.UtcNow.AddMinutes(-5) + DateTimeOffset.UtcNow.AddMinutes(-5) ); var headers = new HeaderDictionary { { Constants.AegSasTokenHeader, token } }; @@ -44,7 +44,7 @@ public void GivenAegSasTokenWithWrongSignature_WhenValidated_ThenReturnsFalse() var token = GenerateValidSasToken( "WrongKey123456789012345=", "http://localhost", - DateTime.UtcNow.AddMinutes(5) + DateTimeOffset.UtcNow.AddMinutes(5) ); var headers = new HeaderDictionary { { Constants.AegSasTokenHeader, token } }; @@ -59,7 +59,7 @@ public void GivenInvalidAegSasToken_WhenValidated_ThenLogsError() var token = GenerateValidSasToken( "WrongKey123456789012345=", "http://localhost", - DateTime.UtcNow.AddMinutes(5) + DateTimeOffset.UtcNow.AddMinutes(5) ); var headers = new HeaderDictionary { { Constants.AegSasTokenHeader, token } }; @@ -82,7 +82,7 @@ public void GivenAegSasTokenWithDifferentCase_WhenValidated_ThenHeaderIsFoundCas var token = GenerateValidSasToken( ValidTopicKey, "http://localhost", - DateTime.UtcNow.AddMinutes(5) + DateTimeOffset.UtcNow.AddMinutes(5) ); var headers = new HeaderDictionary { { "AEG-SAS-TOKEN", token } }; @@ -94,7 +94,7 @@ public void GivenAegSasTokenWithDifferentCase_WhenValidated_ThenHeaderIsFoundCas [Fact] public void GivenTokenExpiringExactlyNow_WhenValidated_ThenReturnsFalse() { - var token = GenerateValidSasToken(ValidTopicKey, "http://localhost", DateTime.UtcNow); + var token = GenerateValidSasToken(ValidTopicKey, "http://localhost", DateTimeOffset.UtcNow); var headers = new HeaderDictionary { { Constants.AegSasTokenHeader, token } }; var result = Validator.IsValid(headers, ValidTopicKey); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAuthorizationHeaderTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAuthorizationHeaderTests.cs index 2bdfc12..eb25f5f 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAuthorizationHeaderTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorAuthorizationHeaderTests.cs @@ -15,7 +15,7 @@ public void GivenValidAuthorizationHeader_WhenValidated_ThenReturnsTrue() var token = GenerateValidSasToken( ValidTopicKey, "http://localhost", - DateTime.UtcNow.AddMinutes(5) + DateTimeOffset.UtcNow.AddMinutes(5) ); var headers = new HeaderDictionary { @@ -33,7 +33,7 @@ public void GivenExpiredAuthorizationHeader_WhenValidated_ThenReturnsFalse() var token = GenerateValidSasToken( ValidTopicKey, "http://localhost", - DateTime.UtcNow.AddMinutes(-5) + DateTimeOffset.UtcNow.AddMinutes(-5) ); var headers = new HeaderDictionary { @@ -51,7 +51,7 @@ public void GivenAuthorizationHeaderWithWrongSignature_WhenValidated_ThenReturns var token = GenerateValidSasToken( "WrongKey123456789012345=", "http://localhost", - DateTime.UtcNow.AddMinutes(5) + DateTimeOffset.UtcNow.AddMinutes(5) ); var headers = new HeaderDictionary { @@ -69,7 +69,7 @@ public void GivenInvalidAuthorizationHeader_WhenValidated_ThenLogsError() var token = GenerateValidSasToken( "WrongKey123456789012345=", "http://localhost", - DateTime.UtcNow.AddMinutes(5) + DateTimeOffset.UtcNow.AddMinutes(5) ); var headers = new HeaderDictionary { @@ -108,7 +108,7 @@ public void GivenAuthorizationHeaderWithDifferentCase_WhenValidated_ThenHeaderIs var token = GenerateValidSasToken( ValidTopicKey, "http://localhost", - DateTime.UtcNow.AddMinutes(5) + DateTimeOffset.UtcNow.AddMinutes(5) ); var headers = new HeaderDictionary { diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorTestBase.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorTestBase.cs index 8525cd8..91c9650 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorTestBase.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Middleware/SasKeyValidatorTestBase.cs @@ -15,12 +15,16 @@ public abstract class SasKeyValidatorTestBase protected SasKeyValidatorTestBase() { Logger = Substitute.For>(); - Validator = new SasKeyValidator(Logger); + Validator = new SasKeyValidator(TimeProvider.System, Logger); } - protected static string GenerateValidSasToken(string key, string resource, DateTime expiry) + protected static string GenerateValidSasToken( + string key, + string resource, + DateTimeOffset expiry + ) { - var decodedExpiration = expiry.ToString("o"); + var decodedExpiration = expiry.UtcDateTime.ToString("o"); var encodedResource = HttpUtility.UrlEncode(resource); var encodedExpiration = HttpUtility.UrlEncode(decodedExpiration); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs index afa57bf..2a3a294 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/DeadLetterServiceTests.cs @@ -68,7 +68,7 @@ private static PendingDelivery CreatePendingDelivery( Id = eventId ?? Guid.NewGuid().ToString(), Subject = "test/subject", EventType = "Test.EventType", - EventTime = DateTime.UtcNow.ToString("o"), + EventTime = DateTimeOffset.UtcNow.ToString("o"), DataVersion = "1.0", Data = new { test = "data" }, } @@ -130,7 +130,7 @@ public async Task GivenDelivery_WhenWriting_ThenFileContainsCorrectJson() new DeliveryAttempt { AttemptNumber = 5, - AttemptTime = DateTime.UtcNow, + AttemptTime = DateTimeOffset.UtcNow, Outcome = DeliveryOutcome.HttpError, HttpStatusCode = 503, ErrorMessage = "Service Unavailable", diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs index 2752140..8eea3fd 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/HttpEventDeliveryServiceTests.cs @@ -23,7 +23,7 @@ public HttpEventDeliveryServiceTests() { _logger = Substitute.For>(); _formatterFactory = new EventSchemaFormatterFactory( - new EventGridSchemaFormatter(), + new EventGridSchemaFormatter(TimeProvider.System), new CloudEventSchemaFormatter() ); } @@ -264,7 +264,7 @@ private static SimulatorEvent CreateTestEvent() Id = Guid.NewGuid().ToString(), Subject = "test/subject", EventType = "Test.EventType", - EventTime = DateTime.UtcNow.ToString("o"), + EventTime = DateTimeOffset.UtcNow.ToString("o"), DataVersion = "1.0", Data = new { test = "data" }, } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/InMemoryDeliveryQueueTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/InMemoryDeliveryQueueTests.cs index 3750ccc..ea31629 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/InMemoryDeliveryQueueTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/InMemoryDeliveryQueueTests.cs @@ -2,6 +2,7 @@ using AzureEventGridSimulator.Domain.Services.Retry; using AzureEventGridSimulator.Infrastructure.Settings; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; +using AzureEventGridSimulator.Tests.Helpers; using NSubstitute; using Shouldly; using Xunit; @@ -11,16 +12,18 @@ namespace AzureEventGridSimulator.Tests.UnitTests.Retry; [Trait("Category", "unit")] public class InMemoryDeliveryQueueTests { + private static readonly DateTimeOffset FixedTime = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero); private readonly ILogger _logger; private readonly InMemoryDeliveryQueue _queue; + private readonly FakeTimeProvider _timeProvider = new(FixedTime); public InMemoryDeliveryQueueTests() { _logger = Substitute.For>(); - _queue = new InMemoryDeliveryQueue(_logger); + _queue = new InMemoryDeliveryQueue(_timeProvider, _logger); } - private static PendingDelivery CreatePendingDelivery() + private static PendingDelivery CreatePendingDelivery(DateTimeOffset? nextAttemptTime = null) { var subscriber = new HttpSubscriberSettings { @@ -43,7 +46,7 @@ private static PendingDelivery CreatePendingDelivery() Id = Guid.NewGuid().ToString(), Subject = "test/subject", EventType = "Test.EventType", - EventTime = DateTime.UtcNow.ToString("o"), + EventTime = FixedTime.ToString("o"), DataVersion = "1.0", Data = new { test = "data" }, } @@ -55,6 +58,8 @@ private static PendingDelivery CreatePendingDelivery() Subscriber = subscriber, Topic = topic, InputSchema = EventSchema.EventGridSchema, + EnqueuedTime = FixedTime, + NextAttemptTime = nextAttemptTime ?? FixedTime, }; } @@ -131,7 +136,7 @@ public void GivenExistingDelivery_WhenRequeuing_ThenUpdatesDelivery() var delivery = CreatePendingDelivery(); _queue.Enqueue(delivery); - var newNextAttemptTime = DateTime.UtcNow.AddMinutes(5); + var newNextAttemptTime = FixedTime.AddMinutes(5); delivery.NextAttemptTime = newNextAttemptTime; delivery.AttemptCount = 2; @@ -163,8 +168,7 @@ public void GivenNoDeliveries_WhenGettingDueDeliveries_ThenReturnsEmpty() [Fact] public void GivenDeliveryDueNow_WhenGettingDueDeliveries_ThenReturnsDelivery() { - var delivery = CreatePendingDelivery(); - delivery.NextAttemptTime = DateTime.UtcNow.AddSeconds(-1); // Due 1 second ago + var delivery = CreatePendingDelivery(FixedTime.AddSeconds(-1)); _queue.Enqueue(delivery); var dueDeliveries = _queue.GetDueDeliveries().ToList(); @@ -176,8 +180,7 @@ public void GivenDeliveryDueNow_WhenGettingDueDeliveries_ThenReturnsDelivery() [Fact] public void GivenDeliveryNotYetDue_WhenGettingDueDeliveries_ThenReturnsEmpty() { - var delivery = CreatePendingDelivery(); - delivery.NextAttemptTime = DateTime.UtcNow.AddMinutes(5); // Due in 5 minutes + var delivery = CreatePendingDelivery(FixedTime.AddMinutes(5)); _queue.Enqueue(delivery); var dueDeliveries = _queue.GetDueDeliveries().ToList(); @@ -188,14 +191,9 @@ public void GivenDeliveryNotYetDue_WhenGettingDueDeliveries_ThenReturnsEmpty() [Fact] public void GivenMixedDueAndNotDue_WhenGettingDueDeliveries_ThenReturnsOnlyDue() { - var dueDelivery1 = CreatePendingDelivery(); - dueDelivery1.NextAttemptTime = DateTime.UtcNow.AddSeconds(-10); - - var dueDelivery2 = CreatePendingDelivery(); - dueDelivery2.NextAttemptTime = DateTime.UtcNow.AddSeconds(-5); - - var notDueDelivery = CreatePendingDelivery(); - notDueDelivery.NextAttemptTime = DateTime.UtcNow.AddMinutes(5); + var dueDelivery1 = CreatePendingDelivery(FixedTime.AddSeconds(-10)); + var dueDelivery2 = CreatePendingDelivery(FixedTime.AddSeconds(-5)); + var notDueDelivery = CreatePendingDelivery(FixedTime.AddMinutes(5)); _queue.Enqueue(dueDelivery1); _queue.Enqueue(dueDelivery2); @@ -212,14 +210,9 @@ public void GivenMixedDueAndNotDue_WhenGettingDueDeliveries_ThenReturnsOnlyDue() [Fact] public void GivenMultipleDueDeliveries_WhenGettingDueDeliveries_ThenOrderedByNextAttemptTime() { - var delivery1 = CreatePendingDelivery(); - delivery1.NextAttemptTime = DateTime.UtcNow.AddSeconds(-5); - - var delivery2 = CreatePendingDelivery(); - delivery2.NextAttemptTime = DateTime.UtcNow.AddSeconds(-10); // Earlier - - var delivery3 = CreatePendingDelivery(); - delivery3.NextAttemptTime = DateTime.UtcNow.AddSeconds(-1); // Latest + var delivery1 = CreatePendingDelivery(FixedTime.AddSeconds(-5)); + var delivery2 = CreatePendingDelivery(FixedTime.AddSeconds(-10)); // Earliest + var delivery3 = CreatePendingDelivery(FixedTime.AddSeconds(-1)); // Latest _queue.Enqueue(delivery1); _queue.Enqueue(delivery2); @@ -232,4 +225,22 @@ public void GivenMultipleDueDeliveries_WhenGettingDueDeliveries_ThenOrderedByNex dueDeliveries[1].Id.ShouldBe(delivery1.Id); dueDeliveries[2].Id.ShouldBe(delivery3.Id); // Latest last } + + [Fact] + public void GivenDeliveryInFuture_WhenTimeAdvances_ThenBecomesDue() + { + var delivery = CreatePendingDelivery(FixedTime.AddMinutes(5)); + _queue.Enqueue(delivery); + + // Initially not due + _queue.GetDueDeliveries().ShouldBeEmpty(); + + // Advance time by 6 minutes + _timeProvider.Advance(TimeSpan.FromMinutes(6)); + + // Now should be due + var dueDeliveries = _queue.GetDueDeliveries().ToList(); + dueDeliveries.Count.ShouldBe(1); + dueDeliveries[0].Id.ShouldBe(delivery.Id); + } } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs index aada4bc..f3d83bd 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/PendingDeliveryTests.cs @@ -9,11 +9,13 @@ namespace AzureEventGridSimulator.Tests.UnitTests.Retry; [Trait("Category", "unit")] public class PendingDeliveryTests { + private static readonly DateTimeOffset FixedTime = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero); + private static PendingDelivery CreatePendingDelivery( int? ttlMinutes = 1440, int? maxAttempts = 30, bool? retryEnabled = true, - DateTime? enqueuedTime = null + DateTimeOffset? enqueuedTime = null ) { RetryPolicySettings retryPolicy = null; @@ -50,59 +52,46 @@ private static PendingDelivery CreatePendingDelivery( Id = Guid.NewGuid().ToString(), Subject = "test/subject", EventType = "Test.EventType", - EventTime = DateTime.UtcNow.ToString("o"), + EventTime = FixedTime.ToString("o"), DataVersion = "1.0", Data = new { test = "data" }, } ); - var delivery = new PendingDelivery + return new PendingDelivery { Event = evt, Subscriber = subscriber, Topic = topic, InputSchema = EventSchema.EventGridSchema, + EnqueuedTime = enqueuedTime ?? FixedTime, + NextAttemptTime = enqueuedTime ?? FixedTime, }; - - // Set EnqueuedTime if provided (using object initializer since it's init-only) - if (enqueuedTime.HasValue) - { - return new PendingDelivery - { - Event = evt, - Subscriber = subscriber, - Topic = topic, - InputSchema = EventSchema.EventGridSchema, - EnqueuedTime = enqueuedTime.Value, - }; - } - - return delivery; } [Fact] public void GivenNewDelivery_WhenChecking_ThenIsNotExpired() { - var delivery = CreatePendingDelivery(60); + var delivery = CreatePendingDelivery(60, enqueuedTime: FixedTime); - delivery.IsExpired.ShouldBeFalse(); + delivery.IsExpired(FixedTime).ShouldBeFalse(); } [Fact] public void GivenDeliveryOlderThanTtl_WhenChecking_ThenIsExpired() { - var delivery = CreatePendingDelivery(60, enqueuedTime: DateTime.UtcNow.AddMinutes(-61)); + var delivery = CreatePendingDelivery(60, enqueuedTime: FixedTime.AddMinutes(-61)); - delivery.IsExpired.ShouldBeTrue(); + delivery.IsExpired(FixedTime).ShouldBeTrue(); } [Fact] public void GivenDeliveryJustBeforeTtl_WhenChecking_ThenIsNotExpired() { - var delivery = CreatePendingDelivery(60, enqueuedTime: DateTime.UtcNow.AddMinutes(-59)); + var delivery = CreatePendingDelivery(60, enqueuedTime: FixedTime.AddMinutes(-59)); // Just before TTL, should not be expired yet - delivery.IsExpired.ShouldBeFalse(); + delivery.IsExpired(FixedTime).ShouldBeFalse(); } [Fact] @@ -111,10 +100,10 @@ public void GivenDeliveryWithDefaultTtl_WhenChecking_ThenExpiredAfter24Hours() // Default TTL is 1440 minutes (24 hours) var delivery = CreatePendingDelivery( null, // Use default - enqueuedTime: DateTime.UtcNow.AddMinutes(-1441) + enqueuedTime: FixedTime.AddMinutes(-1441) ); - delivery.IsExpired.ShouldBeTrue(); + delivery.IsExpired(FixedTime).ShouldBeTrue(); } [Fact] @@ -122,10 +111,10 @@ public void GivenDeliveryWithShortTtl_WhenChecking_ThenExpiredQuickly() { var delivery = CreatePendingDelivery( 1, // 1 minute TTL - enqueuedTime: DateTime.UtcNow.AddMinutes(-2) + enqueuedTime: FixedTime.AddMinutes(-2) ); - delivery.IsExpired.ShouldBeTrue(); + delivery.IsExpired(FixedTime).ShouldBeTrue(); } [Fact] @@ -225,7 +214,7 @@ public void GivenOneAttempt_WhenGettingLastAttempt_ThenReturnsThatAttempt() var attempt = new DeliveryAttempt { AttemptNumber = 1, - AttemptTime = DateTime.UtcNow, + AttemptTime = FixedTime, Outcome = DeliveryOutcome.HttpError, HttpStatusCode = 500, }; @@ -241,20 +230,20 @@ public void GivenMultipleAttempts_WhenGettingLastAttempt_ThenReturnsLast() var attempt1 = new DeliveryAttempt { AttemptNumber = 1, - AttemptTime = DateTime.UtcNow.AddMinutes(-10), + AttemptTime = FixedTime.AddMinutes(-10), Outcome = DeliveryOutcome.HttpError, HttpStatusCode = 500, }; var attempt2 = new DeliveryAttempt { AttemptNumber = 2, - AttemptTime = DateTime.UtcNow.AddMinutes(-5), + AttemptTime = FixedTime.AddMinutes(-5), Outcome = DeliveryOutcome.Timeout, }; var attempt3 = new DeliveryAttempt { AttemptNumber = 3, - AttemptTime = DateTime.UtcNow, + AttemptTime = FixedTime, Outcome = DeliveryOutcome.HttpError, HttpStatusCode = 503, }; @@ -283,13 +272,7 @@ public void GivenNewDelivery_WhenCreated_ThenHasCorrectDefaults() delivery.AttemptCount.ShouldBe(0); delivery.Attempts.ShouldBeEmpty(); - delivery.EnqueuedTime.ShouldBeInRange( - DateTime.UtcNow.AddSeconds(-1), - DateTime.UtcNow.AddSeconds(1) - ); - delivery.NextAttemptTime.ShouldBeInRange( - DateTime.UtcNow.AddSeconds(-1), - DateTime.UtcNow.AddSeconds(1) - ); + delivery.EnqueuedTime.ShouldBe(FixedTime); + delivery.NextAttemptTime.ShouldBe(FixedTime); } } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/RetrySchedulerTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/RetrySchedulerTests.cs index 742f2f3..5a454e6 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Retry/RetrySchedulerTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Retry/RetrySchedulerTests.cs @@ -1,4 +1,5 @@ using AzureEventGridSimulator.Domain.Services.Retry; +using AzureEventGridSimulator.Tests.Helpers; using Shouldly; using Xunit; @@ -7,6 +8,15 @@ namespace AzureEventGridSimulator.Tests.UnitTests.Retry; [Trait("Category", "unit")] public class RetrySchedulerTests { + private static readonly DateTimeOffset FixedTime = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero); + private readonly RetryScheduler _scheduler; + private readonly FakeTimeProvider _timeProvider = new(FixedTime); + + public RetrySchedulerTests() + { + _scheduler = new RetryScheduler(_timeProvider); + } + [Theory] [InlineData(1, 10)] // 10 seconds [InlineData(2, 30)] // 30 seconds @@ -23,16 +33,10 @@ public void GivenAttemptNumber_WhenGettingNextRetryTime_ThenFollowsAzureSchedule int expectedDelaySeconds ) { - var before = DateTime.UtcNow; + var nextRetryTime = _scheduler.GetNextRetryTime(attemptNumber); - var nextRetryTime = RetryScheduler.GetNextRetryTime(attemptNumber); - - var after = DateTime.UtcNow; - var expectedMin = before.AddSeconds(expectedDelaySeconds); - var expectedMax = after.AddSeconds(expectedDelaySeconds + 1); // Allow 1 second tolerance - - nextRetryTime.ShouldBeGreaterThanOrEqualTo(expectedMin); - nextRetryTime.ShouldBeLessThanOrEqualTo(expectedMax); + var expectedTime = FixedTime.AddSeconds(expectedDelaySeconds); + nextRetryTime.ShouldBe(expectedTime); } [Theory] @@ -44,17 +48,12 @@ public void GivenAttemptBeyondSchedule_WhenGettingNextRetryTime_ThenReturns12Hou int attemptNumber ) { - var before = DateTime.UtcNow; var expectedDelaySeconds = 12 * 60 * 60; // 12 hours - var nextRetryTime = RetryScheduler.GetNextRetryTime(attemptNumber); + var nextRetryTime = _scheduler.GetNextRetryTime(attemptNumber); - var after = DateTime.UtcNow; - var expectedMin = before.AddSeconds(expectedDelaySeconds); - var expectedMax = after.AddSeconds(expectedDelaySeconds + 1); - - nextRetryTime.ShouldBeGreaterThanOrEqualTo(expectedMin); - nextRetryTime.ShouldBeLessThanOrEqualTo(expectedMax); + var expectedTime = FixedTime.AddSeconds(expectedDelaySeconds); + nextRetryTime.ShouldBe(expectedTime); } [Theory] @@ -64,59 +63,57 @@ public void GivenInvalidAttemptNumber_WhenGettingNextRetryTime_ThenReturnsImmedi int attemptNumber ) { - var before = DateTime.UtcNow; - - var nextRetryTime = RetryScheduler.GetNextRetryTime(attemptNumber); + var nextRetryTime = _scheduler.GetNextRetryTime(attemptNumber); - nextRetryTime.ShouldBeLessThanOrEqualTo(before.AddSeconds(1)); + nextRetryTime.ShouldBe(FixedTime); } [Fact] public void GivenHttp404_WhenGettingNextRetryTime_ThenMinimum5MinuteDelay() { - var before = DateTime.UtcNow; var expectedMinDelaySeconds = 5 * 60; // 5 minutes // First attempt would normally be 10s, but 404 requires minimum 5 minutes - var nextRetryTime = RetryScheduler.GetNextRetryTime(1, 404); + var nextRetryTime = _scheduler.GetNextRetryTime(1, 404); - nextRetryTime.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(expectedMinDelaySeconds)); + var expectedTime = FixedTime.AddSeconds(expectedMinDelaySeconds); + nextRetryTime.ShouldBe(expectedTime); } [Fact] public void GivenHttp408_WhenGettingNextRetryTime_ThenMinimum2MinuteDelay() { - var before = DateTime.UtcNow; var expectedMinDelaySeconds = 2 * 60; // 2 minutes // First attempt would normally be 10s, but 408 requires minimum 2 minutes - var nextRetryTime = RetryScheduler.GetNextRetryTime(1, 408); + var nextRetryTime = _scheduler.GetNextRetryTime(1, 408); - nextRetryTime.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(expectedMinDelaySeconds)); + var expectedTime = FixedTime.AddSeconds(expectedMinDelaySeconds); + nextRetryTime.ShouldBe(expectedTime); } [Fact] public void GivenHttp503_WhenGettingNextRetryTime_ThenMinimum30SecondDelay() { - var before = DateTime.UtcNow; var expectedMinDelaySeconds = 30; // 30 seconds // First attempt would normally be 10s, but 503 requires minimum 30 seconds - var nextRetryTime = RetryScheduler.GetNextRetryTime(1, 503); + var nextRetryTime = _scheduler.GetNextRetryTime(1, 503); - nextRetryTime.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(expectedMinDelaySeconds)); + var expectedTime = FixedTime.AddSeconds(expectedMinDelaySeconds); + nextRetryTime.ShouldBe(expectedTime); } [Fact] public void GivenHttp503OnLaterAttempt_WhenStandardDelayIsLarger_ThenUsesStandardDelay() { - var before = DateTime.UtcNow; var expectedMinDelaySeconds = 5 * 60; // 5 minutes (attempt 4 standard delay) // Attempt 4 standard delay is 5 minutes, which is larger than 503's 30 seconds - var nextRetryTime = RetryScheduler.GetNextRetryTime(4, 503); + var nextRetryTime = _scheduler.GetNextRetryTime(4, 503); - nextRetryTime.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(expectedMinDelaySeconds)); + var expectedTime = FixedTime.AddSeconds(expectedMinDelaySeconds); + nextRetryTime.ShouldBe(expectedTime); } [Theory] @@ -127,7 +124,7 @@ public void GivenHttp503OnLaterAttempt_WhenStandardDelayIsLarger_ThenUsesStandar [InlineData(204)] public void GivenSuccessStatusCode_WhenChecking_ThenReturnsTrue(int statusCode) { - RetryScheduler.IsSuccessStatusCode(statusCode).ShouldBeTrue(); + _scheduler.IsSuccessStatusCode(statusCode).ShouldBeTrue(); } [Theory] @@ -140,7 +137,7 @@ public void GivenSuccessStatusCode_WhenChecking_ThenReturnsTrue(int statusCode) [InlineData(503)] public void GivenNonSuccessStatusCode_WhenChecking_ThenReturnsFalse(int statusCode) { - RetryScheduler.IsSuccessStatusCode(statusCode).ShouldBeFalse(); + _scheduler.IsSuccessStatusCode(statusCode).ShouldBeFalse(); } [Theory] @@ -150,7 +147,7 @@ public void GivenNonSuccessStatusCode_WhenChecking_ThenReturnsFalse(int statusCo [InlineData(413)] public void GivenImmediateDeadLetterStatusCode_WhenChecking_ThenReturnsTrue(int statusCode) { - RetryScheduler.ShouldImmediatelyDeadLetter(statusCode).ShouldBeTrue(); + _scheduler.ShouldImmediatelyDeadLetter(statusCode).ShouldBeTrue(); } [Theory] @@ -163,7 +160,7 @@ public void GivenImmediateDeadLetterStatusCode_WhenChecking_ThenReturnsTrue(int [InlineData(504)] public void GivenRetryableStatusCode_WhenChecking_ThenReturnsFalse(int statusCode) { - RetryScheduler.ShouldImmediatelyDeadLetter(statusCode).ShouldBeFalse(); + _scheduler.ShouldImmediatelyDeadLetter(statusCode).ShouldBeFalse(); } [Theory] @@ -176,7 +173,7 @@ public void GivenKnownStatusCode_WhenGettingDeadLetterReason_ThenReturnsSpecific string expectedReason ) { - RetryScheduler.GetDeadLetterReasonForStatusCode(statusCode).ShouldBe(expectedReason); + _scheduler.GetDeadLetterReasonForStatusCode(statusCode).ShouldBe(expectedReason); } [Theory] @@ -188,6 +185,6 @@ public void GivenUnknownStatusCode_WhenGettingDeadLetterReason_ThenReturnsGeneri string expectedReason ) { - RetryScheduler.GetDeadLetterReasonForStatusCode(statusCode).ShouldBe(expectedReason); + _scheduler.GetDeadLetterReasonForStatusCode(statusCode).ShouldBe(expectedReason); } } diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/EventHubEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/EventHubEventDeliveryServiceTests.cs index 9521512..4dbefe3 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/EventHubEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/EventHubEventDeliveryServiceTests.cs @@ -21,7 +21,7 @@ public EventHubEventDeliveryServiceTests() { _logger = Substitute.For>(); _formatterFactory = new EventSchemaFormatterFactory( - new EventGridSchemaFormatter(), + new EventGridSchemaFormatter(TimeProvider.System), new CloudEventSchemaFormatter() ); _propertyResolver = new DeliveryPropertyResolver(); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/ServiceBusEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/ServiceBusEventDeliveryServiceTests.cs index 5c3beeb..e4c8335 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/ServiceBusEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/ServiceBusEventDeliveryServiceTests.cs @@ -21,7 +21,7 @@ public ServiceBusEventDeliveryServiceTests() { _logger = Substitute.For>(); _formatterFactory = new EventSchemaFormatterFactory( - new EventGridSchemaFormatter(), + new EventGridSchemaFormatter(TimeProvider.System), new CloudEventSchemaFormatter() ); _propertyResolver = new DeliveryPropertyResolver(); diff --git a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/StorageQueueEventDeliveryServiceTests.cs b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/StorageQueueEventDeliveryServiceTests.cs index 6f81deb..52350ff 100644 --- a/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/StorageQueueEventDeliveryServiceTests.cs +++ b/src/AzureEventGridSimulator.Tests/UnitTests/Subscribers/Delivery/StorageQueueEventDeliveryServiceTests.cs @@ -21,7 +21,7 @@ public StorageQueueEventDeliveryServiceTests() { _logger = Substitute.For>(); _formatterFactory = new EventSchemaFormatterFactory( - new EventGridSchemaFormatter(), + new EventGridSchemaFormatter(TimeProvider.System), new CloudEventSchemaFormatter() ); _service = new StorageQueueEventDeliveryService(_logger, _formatterFactory); diff --git a/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs index a6c3442..bd3a8dc 100644 --- a/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs +++ b/src/AzureEventGridSimulator/Domain/Commands/SendNotificationEventsToSubscriberCommandHandler.cs @@ -164,7 +164,7 @@ private static void EnrichEvents(SimulatorEvent[] events, string topicName) { if (evt.Schema == EventSchema.EventGridSchema && evt.EventGridEvent != null) { - evt.EventGridEvent.Topic = topicPath; + evt.EventGridEvent.SetTopic(topicPath); evt.EventGridEvent.MetadataVersion = "1"; } else if (evt.Schema == EventSchema.CloudEventV1_0 && evt.CloudEvent != null) diff --git a/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs index 5d411ff..7ae0a7b 100644 --- a/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs +++ b/src/AzureEventGridSimulator/Domain/Commands/ValidateAllSubscriptionsCommandHandler.cs @@ -16,7 +16,8 @@ public class ValidateAllSubscriptionsCommandHandler( ILogger logger, IHttpClientFactory httpClientFactory, SimulatorSettings simulatorSettings, - ValidationIpAddressProvider validationIpAddress + ValidationIpAddressProvider validationIpAddress, + TimeProvider timeProvider ) : IRequestHandler { public async Task Handle( @@ -55,7 +56,7 @@ HttpSubscriberSettings subscription var evt = new EventGridEvent { - EventTime = DateTime.UtcNow.ToString("O"), + EventTime = timeProvider.GetUtcNow().ToString("o"), DataVersion = "1", EventType = "Microsoft.EventGrid.SubscriptionValidationEvent", Id = Guid.NewGuid().ToString(), diff --git a/src/AzureEventGridSimulator/Domain/Commands/ValidateSubscriptionCommandHandler.cs b/src/AzureEventGridSimulator/Domain/Commands/ValidateSubscriptionCommandHandler.cs index 7356e84..2cc8889 100644 --- a/src/AzureEventGridSimulator/Domain/Commands/ValidateSubscriptionCommandHandler.cs +++ b/src/AzureEventGridSimulator/Domain/Commands/ValidateSubscriptionCommandHandler.cs @@ -3,8 +3,10 @@ namespace AzureEventGridSimulator.Domain.Commands; -public class ValidateSubscriptionCommandHandler(ILogger logger) - : IRequestHandler +public class ValidateSubscriptionCommandHandler( + TimeProvider timeProvider, + ILogger logger +) : IRequestHandler { public Task Handle( ValidateSubscriptionCommand request, @@ -19,7 +21,7 @@ CancellationToken cancellationToken if ( subscriber != null && subscriber.ValidationCode == request.ValidationCode - && !subscriber.ValidationPeriodExpired + && !subscriber.ValidationPeriodExpired(timeProvider.GetUtcNow()) ) { subscriber.ValidationStatus = SubscriptionValidationStatus.ValidationSuccessful; diff --git a/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs index 382a756..af18a15 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/CloudEvent.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json.Serialization; namespace AzureEventGridSimulator.Domain.Entities; @@ -76,10 +77,22 @@ public class CloudEvent public string DataBase64 { get; set; } [JsonIgnore] - private DateTime? TimeParsed => string.IsNullOrEmpty(Time) ? null : DateTime.Parse(Time); + private DateTimeOffset? TimeParsed => + string.IsNullOrEmpty(Time) + ? null + : DateTimeOffset.Parse(Time, CultureInfo.InvariantCulture); [JsonIgnore] - private bool TimeIsValid => string.IsNullOrEmpty(Time) || DateTime.TryParse(Time, out _); + private bool TimeIsValid => + string.IsNullOrEmpty(Time) + || DateTimeOffset.TryParse(Time, CultureInfo.InvariantCulture, out _); + + [JsonIgnore] + private bool TimeHasTimezone => + string.IsNullOrEmpty(Time) + || Time.Contains('Z') + || Time.Contains('+') + || (Time.Length > 10 && Time[10..].Contains('-')); /// /// Validate the CloudEvent according to the CloudEvents v1.0 specification. @@ -141,10 +154,10 @@ public void Validate() ); } - if (TimeParsed is { Kind: DateTimeKind.Unspecified }) + if (!TimeHasTimezone) { throw new InvalidOperationException( - $"Property '{nameof(Time)}' must include timezone information." + $"Property '{nameof(Time)}' must include timezone information (e.g., 'Z' for UTC or an offset like '+00:00')." ); } } diff --git a/src/AzureEventGridSimulator/Domain/Entities/DeadLetterEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/DeadLetterEvent.cs index 0b22cbd..8e2d654 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/DeadLetterEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/DeadLetterEvent.cs @@ -44,13 +44,13 @@ public class DeadLetterEvent /// Gets or sets the time the event was originally published. /// [JsonPropertyName("publishTime")] - public DateTime PublishTime { get; init; } + public DateTimeOffset PublishTime { get; init; } /// /// Gets or sets the time of the last delivery attempt. /// [JsonPropertyName("lastDeliveryAttemptTime")] - public DateTime LastDeliveryAttemptTime { get; init; } + public DateTimeOffset LastDeliveryAttemptTime { get; init; } /// /// Gets or sets the topic name. diff --git a/src/AzureEventGridSimulator/Domain/Entities/DeliveryAttempt.cs b/src/AzureEventGridSimulator/Domain/Entities/DeliveryAttempt.cs index 6eca0d7..ab1da10 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/DeliveryAttempt.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/DeliveryAttempt.cs @@ -8,7 +8,7 @@ public class DeliveryAttempt /// /// Gets or sets the time of the attempt. /// - public DateTime AttemptTime { get; init; } = DateTime.UtcNow; + public DateTimeOffset AttemptTime { get; init; } = DateTimeOffset.UtcNow; /// /// Gets or sets the attempt number (1-based). diff --git a/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs b/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs index f719aa1..7486d68 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/EventGridEvent.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json.Serialization; namespace AzureEventGridSimulator.Domain.Entities; @@ -38,10 +39,18 @@ public class EventGridEvent public string EventTime { get; set; } [JsonIgnore] - private DateTime EventTimeParsed => DateTime.Parse(EventTime); + private DateTimeOffset EventTimeParsed => + DateTimeOffset.Parse(EventTime, CultureInfo.InvariantCulture); [JsonIgnore] - private bool EventTimeIsValid => DateTime.TryParse(EventTime, out _); + private bool EventTimeIsValid => + DateTimeOffset.TryParse(EventTime, CultureInfo.InvariantCulture, out _); + + [JsonIgnore] + private bool EventTimeHasTimezone => + EventTime.Contains('Z') + || EventTime.Contains('+') + || (EventTime.Length > 10 && EventTime[10..].Contains('-')); /// /// Gets or sets the schema version of the data object. @@ -56,10 +65,27 @@ public class EventGridEvent public string MetadataVersion { get; set; } /// - /// Gets or sets the resource path of the event source. + /// Gets the resource path of the event source. + /// This property is set by Event Grid, not by publishers. /// [JsonPropertyName("topic")] - public string Topic { get; set; } + [JsonInclude] + public string Topic { get; private set; } + + /// + /// Indicates whether the Topic has been set by the simulator. + /// + [JsonIgnore] + internal bool TopicHasBeenSet { get; private set; } + + /// + /// Sets the topic path. This should only be called by the simulator. + /// + internal void SetTopic(string topic) + { + Topic = topic; + TopicHasBeenSet = true; + } /// /// Validate the object. @@ -102,10 +128,10 @@ public void Validate() ); } - if (EventTimeParsed.Kind == DateTimeKind.Unspecified) + if (!EventTimeHasTimezone) { throw new InvalidOperationException( - $"Property '{nameof(EventTime)}' must be either Local or UTC." + $"Property '{nameof(EventTime)}' must include a timezone indicator (e.g., 'Z' for UTC or an offset like '+00:00')." ); } @@ -116,7 +142,9 @@ public void Validate() ); } - if (!string.IsNullOrEmpty(Topic)) + // Topic must NOT be set by the publisher - Event Grid sets this automatically + // Skip this check if the simulator has already set the topic via SetTopic() + if (!TopicHasBeenSet && !string.IsNullOrEmpty(Topic)) { throw new InvalidOperationException( $"Property '{nameof(Topic)}' was found to be set to '{Topic}', but was expected to either be null/empty." diff --git a/src/AzureEventGridSimulator/Domain/Entities/PendingDelivery.cs b/src/AzureEventGridSimulator/Domain/Entities/PendingDelivery.cs index 0bb6c53..b3112b3 100644 --- a/src/AzureEventGridSimulator/Domain/Entities/PendingDelivery.cs +++ b/src/AzureEventGridSimulator/Domain/Entities/PendingDelivery.cs @@ -36,12 +36,12 @@ public class PendingDelivery /// /// Gets or sets the time the event was enqueued. /// - public DateTime EnqueuedTime { get; init; } = DateTime.UtcNow; + public DateTimeOffset EnqueuedTime { get; init; } = DateTimeOffset.UtcNow; /// /// Gets or sets the next scheduled attempt time. /// - public DateTime NextAttemptTime { get; set; } = DateTime.UtcNow; + public DateTimeOffset NextAttemptTime { get; set; } = DateTimeOffset.UtcNow; /// /// Gets or sets the number of delivery attempts made. @@ -59,12 +59,6 @@ public class PendingDelivery private RetryPolicySettings EffectiveRetryPolicy => Subscriber.RetryPolicy ?? new RetryPolicySettings(); - /// - /// Gets whether the event has expired based on TTL. - /// - public bool IsExpired => - DateTime.UtcNow > EnqueuedTime.AddMinutes(EffectiveRetryPolicy.EventTimeToLiveInMinutes); - /// /// Gets whether the maximum delivery attempts have been reached. /// @@ -79,4 +73,18 @@ public class PendingDelivery /// Gets the last delivery attempt, if any. /// public DeliveryAttempt LastAttempt => Attempts.Count > 0 ? Attempts[^1] : null; + + /// + /// Determines whether the event has expired based on TTL. + /// + /// + /// The current UTC time. + /// + /// + /// True if the event has expired. + /// + public bool IsExpired(DateTimeOffset now) + { + return now > EnqueuedTime.AddMinutes(EffectiveRetryPolicy.EventTimeToLiveInMinutes); + } } diff --git a/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs b/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs index d8ae119..1d4a718 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Delivery/DeliveryPropertyResolver.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using AzureEventGridSimulator.Domain.Entities; using AzureEventGridSimulator.Infrastructure.Settings.Subscribers; @@ -202,7 +203,7 @@ JsonValueKind.Number when element.TryGetInt64(out var l) => l, }; } - private static bool TryParseDateTime(string value, out DateTime result) + private static bool TryParseDateTime(string value, out DateTimeOffset result) { result = default; if (string.IsNullOrEmpty(value)) @@ -210,7 +211,7 @@ private static bool TryParseDateTime(string value, out DateTime result) return false; } - return DateTime.TryParse(value, out result); + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, out result); } private static bool TryParseGuid(string value, out Guid result) diff --git a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs index e02bc60..1083499 100644 --- a/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs +++ b/src/AzureEventGridSimulator/Domain/Services/EventGridSchemaFormatter.cs @@ -6,7 +6,7 @@ namespace AzureEventGridSimulator.Domain.Services; /// /// Formats events in the Azure Event Grid schema for delivery. /// -public class EventGridSchemaFormatter : IEventSchemaFormatter +public class EventGridSchemaFormatter(TimeProvider timeProvider) : IEventSchemaFormatter { /// public EventSchema Schema => EventSchema.EventGridSchema; @@ -62,17 +62,18 @@ private EventGridEvent ConvertToEventGridEvent(SimulatorEvent evt) /// private EventGridEvent ConvertCloudEventToEventGrid(CloudEvent cloudEvent) { - return new EventGridEvent + var eventGridEvent = new EventGridEvent { Id = cloudEvent.Id, Subject = cloudEvent.Subject ?? cloudEvent.Source, EventType = cloudEvent.Type, - EventTime = cloudEvent.Time ?? DateTime.UtcNow.ToString("o"), + EventTime = cloudEvent.Time ?? timeProvider.GetUtcNow().ToString("o"), Data = cloudEvent.Data, DataVersion = ExtractDataVersion(cloudEvent.DataSchema), - Topic = cloudEvent.Source, MetadataVersion = "1", }; + eventGridEvent.SetTopic(cloudEvent.Source); + return eventGridEvent; } /// diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs index 11dc997..82b49b4 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/InMemoryDeliveryQueue.cs @@ -7,7 +7,8 @@ namespace AzureEventGridSimulator.Domain.Services.Retry; /// Thread-safe in-memory implementation of the delivery queue. /// Events are lost on restart (acceptable for a simulator). /// -public class InMemoryDeliveryQueue(ILogger logger) : IDeliveryQueue +public class InMemoryDeliveryQueue(TimeProvider timeProvider, ILogger logger) + : IDeliveryQueue { private readonly ConcurrentDictionary _queue = new(); @@ -55,7 +56,7 @@ public void RequeueForRetry(PendingDelivery delivery) /// public IEnumerable GetDueDeliveries() { - var now = DateTime.UtcNow; + var now = timeProvider.GetUtcNow(); return _queue .Values.Where(d => d.NextAttemptTime <= now) diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/RetryDeliveryBackgroundService.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/RetryDeliveryBackgroundService.cs index b054f11..eae4438 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/RetryDeliveryBackgroundService.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/RetryDeliveryBackgroundService.cs @@ -14,6 +14,8 @@ public class RetryDeliveryBackgroundService( IServiceProvider serviceProvider, DeadLetterService deadLetterService, IEventHistoryService eventHistoryService, + RetryScheduler retryScheduler, + TimeProvider timeProvider, ILogger logger ) : BackgroundService { @@ -84,7 +86,7 @@ CancellationToken cancellationToken ) { // Check if event has expired (TTL) - if (delivery.IsExpired) + if (delivery.IsExpired(timeProvider.GetUtcNow())) { logger.LogWarning( "Event {EventId} expired for subscriber '{SubscriberName}'. TTL exceeded", @@ -97,7 +99,7 @@ CancellationToken cancellationToken delivery.Event.Id, delivery.Subscriber.Name, DeliveryStatus.DeadLettered, - DateTimeOffset.UtcNow + timeProvider.GetUtcNow() ); return; } @@ -117,7 +119,7 @@ CancellationToken cancellationToken delivery.Event.Id, delivery.Subscriber.Name, DeliveryStatus.DeadLettered, - DateTimeOffset.UtcNow + timeProvider.GetUtcNow() ); return; } @@ -129,7 +131,7 @@ CancellationToken cancellationToken delivery.AttemptCount++; var attempt = new DeliveryAttempt { - AttemptTime = DateTime.UtcNow, + AttemptTime = timeProvider.GetUtcNow(), AttemptNumber = delivery.AttemptCount, Outcome = result.Outcome, HttpStatusCode = result.HttpStatusCode, @@ -156,7 +158,7 @@ CancellationToken cancellationToken delivery.Event.Id, delivery.Subscriber.Name, DeliveryStatus.Delivered, - DateTimeOffset.UtcNow + timeProvider.GetUtcNow() ); return; } @@ -315,10 +317,10 @@ CancellationToken cancellationToken // Check for immediate dead-letter conditions (HTTP 400, 401, 403, 413) if ( result.HttpStatusCode.HasValue - && RetryScheduler.ShouldImmediatelyDeadLetter(result.HttpStatusCode.Value) + && retryScheduler.ShouldImmediatelyDeadLetter(result.HttpStatusCode.Value) ) { - var reason = RetryScheduler.GetDeadLetterReasonForStatusCode( + var reason = retryScheduler.GetDeadLetterReasonForStatusCode( result.HttpStatusCode.Value ); @@ -334,7 +336,7 @@ CancellationToken cancellationToken delivery.Event.Id, delivery.Subscriber.Name, DeliveryStatus.DeadLettered, - DateTimeOffset.UtcNow + timeProvider.GetUtcNow() ); return; } @@ -353,7 +355,7 @@ CancellationToken cancellationToken delivery.Event.Id, delivery.Subscriber.Name, DeliveryStatus.DeadLettered, - DateTimeOffset.UtcNow + timeProvider.GetUtcNow() ); return; } @@ -372,13 +374,13 @@ CancellationToken cancellationToken delivery.Event.Id, delivery.Subscriber.Name, DeliveryStatus.DeadLettered, - DateTimeOffset.UtcNow + timeProvider.GetUtcNow() ); return; } // Check if TTL will be exceeded before next retry - if (delivery.IsExpired) + if (delivery.IsExpired(timeProvider.GetUtcNow())) { logger.LogWarning( "Event {EventId} TTL expired during retry for '{SubscriberName}'", @@ -391,13 +393,13 @@ CancellationToken cancellationToken delivery.Event.Id, delivery.Subscriber.Name, DeliveryStatus.DeadLettered, - DateTimeOffset.UtcNow + timeProvider.GetUtcNow() ); return; } // Schedule retry - delivery.NextAttemptTime = RetryScheduler.GetNextRetryTime( + delivery.NextAttemptTime = retryScheduler.GetNextRetryTime( delivery.AttemptCount, result.HttpStatusCode ); diff --git a/src/AzureEventGridSimulator/Domain/Services/Retry/RetryScheduler.cs b/src/AzureEventGridSimulator/Domain/Services/Retry/RetryScheduler.cs index e93f56e..5fb8dbf 100644 --- a/src/AzureEventGridSimulator/Domain/Services/Retry/RetryScheduler.cs +++ b/src/AzureEventGridSimulator/Domain/Services/Retry/RetryScheduler.cs @@ -3,7 +3,7 @@ namespace AzureEventGridSimulator.Domain.Services.Retry; /// /// Calculates next retry time based on Azure Event Grid retry schedule. /// -public static class RetryScheduler +public class RetryScheduler { /// /// Azure Event Grid retry schedule with exponential backoff. @@ -32,6 +32,13 @@ public static class RetryScheduler /// private static readonly int[] ImmediateDeadLetterStatusCodes = [400, 401, 403, 413]; + private readonly TimeProvider _timeProvider; + + public RetryScheduler(TimeProvider timeProvider) + { + _timeProvider = timeProvider; + } + /// /// Calculates the next retry time based on attempt number and HTTP status code. /// @@ -44,10 +51,10 @@ public static class RetryScheduler /// /// The next retry time. /// - public static DateTime GetNextRetryTime(int attemptNumber, int? httpStatusCode = null) + public DateTimeOffset GetNextRetryTime(int attemptNumber, int? httpStatusCode = null) { var delay = GetRetryDelay(attemptNumber, httpStatusCode); - return DateTime.UtcNow.Add(delay); + return _timeProvider.GetUtcNow().Add(delay); } /// @@ -101,7 +108,7 @@ private static TimeSpan GetStandardDelay(int attemptNumber) /// /// True if the status code indicates success. /// - public static bool IsSuccessStatusCode(int statusCode) + public bool IsSuccessStatusCode(int statusCode) { return SuccessStatusCodes.Contains(statusCode); } @@ -115,7 +122,7 @@ public static bool IsSuccessStatusCode(int statusCode) /// /// True if the event should be immediately dead-lettered. /// - public static bool ShouldImmediatelyDeadLetter(int statusCode) + public bool ShouldImmediatelyDeadLetter(int statusCode) { return ImmediateDeadLetterStatusCodes.Contains(statusCode); } @@ -129,7 +136,7 @@ public static bool ShouldImmediatelyDeadLetter(int statusCode) /// /// The dead-letter reason string. /// - public static string GetDeadLetterReasonForStatusCode(int statusCode) + public string GetDeadLetterReasonForStatusCode(int statusCode) { return statusCode switch { diff --git a/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs b/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs index 128ddd2..dbbccc8 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Middleware/SasKeyValidator.cs @@ -1,4 +1,5 @@ -using System.Security.Cryptography; +using System.Globalization; +using System.Security.Cryptography; using System.Text; using System.Web; using AzureEventGridSimulator.Domain; @@ -6,7 +7,7 @@ namespace AzureEventGridSimulator.Infrastructure.Middleware; -public class SasKeyValidator(ILogger logger) +public class SasKeyValidator(TimeProvider timeProvider, ILogger logger) { public bool IsValid(IHeaderDictionary requestHeaders, string topicKey) { @@ -77,8 +78,12 @@ private bool TokenIsValid(string token, string key) var encodedSignature = query["s"]; if ( - !DateTime.TryParse(decodedExpiration, out var tokenExpiryDateTime) - || tokenExpiryDateTime.ToUniversalTime() <= DateTime.UtcNow + !DateTimeOffset.TryParse( + decodedExpiration, + CultureInfo.InvariantCulture, + out var tokenExpiryDateTime + ) + || tokenExpiryDateTime.ToUniversalTime() <= timeProvider.GetUtcNow() ) { return false; diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs index 4f61fb0..9231f5a 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/Subscribers/HttpSubscriberSettings.cs @@ -9,7 +9,7 @@ namespace AzureEventGridSimulator.Infrastructure.Settings.Subscribers; /// public class HttpSubscriberSettings : ISubscriberSettings { - private readonly DateTime _expired = DateTime.UtcNow.AddMinutes(5); + private readonly DateTimeOffset _createdAt = DateTimeOffset.UtcNow; [JsonPropertyName("endpoint")] public string Endpoint { get; set; } @@ -23,9 +23,6 @@ public class HttpSubscriberSettings : ISubscriberSettings [JsonIgnore] public Guid ValidationCode => GetValidationCode(); - [JsonIgnore] - public bool ValidationPeriodExpired => DateTime.UtcNow > _expired; - [JsonPropertyName("name")] public string Name { get; set; } @@ -91,6 +88,20 @@ public void Validate() DeadLetter?.Validate(); } + /// + /// Determines if the validation period has expired. + /// + /// + /// The current UTC time. + /// + /// + /// True if the 5-minute validation window has expired. + /// + public bool ValidationPeriodExpired(DateTimeOffset now) + { + return now > _createdAt.AddMinutes(5); + } + public Guid GetValidationCode() { return new Guid( diff --git a/src/AzureEventGridSimulator/Infrastructure/Settings/SubscriptionSettings.cs b/src/AzureEventGridSimulator/Infrastructure/Settings/SubscriptionSettings.cs index 7df2cc7..b897d85 100644 --- a/src/AzureEventGridSimulator/Infrastructure/Settings/SubscriptionSettings.cs +++ b/src/AzureEventGridSimulator/Infrastructure/Settings/SubscriptionSettings.cs @@ -6,7 +6,7 @@ namespace AzureEventGridSimulator.Infrastructure.Settings; public class SubscriptionSettings { - private readonly DateTime _expired = DateTime.UtcNow.AddMinutes(5); + private readonly DateTimeOffset _createdAt = DateTimeOffset.UtcNow; [JsonPropertyName("name")] public string Name { get; set; } @@ -37,8 +37,19 @@ public class SubscriptionSettings [JsonIgnore] public Guid ValidationCode => GetValidationCode(); - [JsonIgnore] - public bool ValidationPeriodExpired => DateTime.UtcNow > _expired; + /// + /// Determines if the validation period has expired. + /// + /// + /// The current UTC time. + /// + /// + /// True if the 5-minute validation window has expired. + /// + public bool ValidationPeriodExpired(DateTimeOffset now) + { + return now > _createdAt.AddMinutes(5); + } public Guid GetValidationCode() { diff --git a/src/AzureEventGridSimulator/Program.cs b/src/AzureEventGridSimulator/Program.cs index 6257b67..3e48d80 100644 --- a/src/AzureEventGridSimulator/Program.cs +++ b/src/AzureEventGridSimulator/Program.cs @@ -279,6 +279,9 @@ IConfiguration configuration builder.Services.AddSimulatorSettings(configuration); builder.Services.AddMediator(Assembly.GetExecutingAssembly()); + // Register TimeProvider for testability + builder.Services.AddSingleton(TimeProvider.System); + // Register event schema services (stateless, can be singletons) builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -296,6 +299,7 @@ IConfiguration configuration builder.Services.AddSingleton(); // Register retry and dead-letter services + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService();