Skip to content
Prev Previous commit
Next Next commit
introduce service wrapper
- created eventgrid wrapper
- added additional payload tests
  • Loading branch information
jonfuller committed Sep 11, 2025
commit 22a5e3caa9cb674c8bc57ff9c32a4d767c63c73b
10 changes: 5 additions & 5 deletions sama/Services/EventGridNotificationService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using Azure;
using Azure.Messaging.EventGrid;
using Microsoft.Extensions.Logging;
using sama.Models;
Expand All @@ -12,12 +11,14 @@ public class EventGridNotificationService : INotificationService
private readonly ILogger<EventGridNotificationService> _logger;
private readonly SettingsService _settings;
private readonly BackgroundExecutionWrapper _bgExec;
private readonly EventGridPublisherClientWrapper _eventGridWrapper;

public EventGridNotificationService(ILogger<EventGridNotificationService> logger, SettingsService settings, BackgroundExecutionWrapper bgExec)
public EventGridNotificationService(ILogger<EventGridNotificationService> logger, SettingsService settings, BackgroundExecutionWrapper bgExec, EventGridPublisherClientWrapper eventGridWrapper)
{
_logger = logger;
_settings = settings;
_bgExec = bgExec;
_eventGridWrapper = eventGridWrapper;
}

private static class EventTypes
Expand Down Expand Up @@ -128,8 +129,7 @@ private async Task SendEventAsync(string eventType, string subject, object data)
}

var topicEndpoint = new Uri(_settings.Notifications_EventGrid_TopicEndpoint!);
var credential = new AzureKeyCredential(_settings.Notifications_EventGrid_AccessKey!);
var client = new EventGridPublisherClient(topicEndpoint, credential);
var accessKey = _settings.Notifications_EventGrid_AccessKey!;

var eventGridEvent = new EventGridEvent(
subject: subject,
Expand All @@ -138,7 +138,7 @@ private async Task SendEventAsync(string eventType, string subject, object data)
data: BinaryData.FromObjectAsJson(data)
);

await client.SendEventAsync(eventGridEvent);
await _eventGridWrapper.SendEventAsync(topicEndpoint, accessKey, eventGridEvent);

_logger.LogDebug("Successfully sent Event Grid event: {EventType} for {Subject}", eventType, subject);
}
Expand Down
33 changes: 33 additions & 0 deletions sama/Services/EventGridPublisherClientWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Azure;
using Azure.Messaging.EventGrid;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;

namespace sama.Services
{
/// <summary>
/// This is a wrapper around EventGridPublisherClient functionality that cannot be (easily) tested.
/// </summary>
[ExcludeFromCodeCoverage]
public class EventGridPublisherClientWrapper
{
public virtual async Task SendEventAsync(Uri topicEndpoint, string accessKey, EventGridEvent eventGridEvent)
{
if (topicEndpoint == null)
{
throw new ArgumentNullException(nameof(topicEndpoint), "Topic endpoint is not configured");
}

if (string.IsNullOrWhiteSpace(accessKey))
{
throw new ArgumentNullException(nameof(accessKey), "Access key is not configured");
}

var credential = new AzureKeyCredential(accessKey);
var client = new EventGridPublisherClient(topicEndpoint, credential);

await client.SendEventAsync(eventGridEvent);
}
}
}
1 change: 1 addition & 0 deletions sama/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddSingleton<PingWrapper>();
services.AddSingleton<TcpClientWrapper>();
services.AddSingleton<SqlConnectionWrapper>();
services.AddSingleton<EventGridPublisherClientWrapper>();
services.AddSingleton<CertificateValidationService>();
services.AddSingleton<BackgroundExecutionWrapper>();

Expand Down
210 changes: 207 additions & 3 deletions unit-tests/Services/EventGridNotificationServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using Azure.Messaging.EventGrid;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NSubstitute;
using sama.Models;
using sama.Services;
using System;
using System.Text.Json;

namespace TestSama.Services
{
Expand All @@ -13,6 +15,7 @@ public class EventGridNotificationServiceTests
private ILogger<EventGridNotificationService> _logger;
private SettingsService _settings;
private BackgroundExecutionWrapper _bgExec;
private EventGridPublisherClientWrapper _eventGridWrapper;
private EventGridNotificationService _service;

[TestInitialize]
Expand All @@ -21,8 +24,9 @@ public void Setup()
_logger = Substitute.For<ILogger<EventGridNotificationService>>();
_settings = Substitute.For<SettingsService>((IServiceProvider)null);
_bgExec = Substitute.For<BackgroundExecutionWrapper>();
_eventGridWrapper = Substitute.For<EventGridPublisherClientWrapper>();

_service = new EventGridNotificationService(_logger, _settings, _bgExec);
_service = new EventGridNotificationService(_logger, _settings, _bgExec, _eventGridWrapper);

// Configure the service with mock settings
_settings.Notifications_EventGrid_TopicEndpoint.Returns("https://test-topic.eastus-1.eventgrid.azure.net/api/events");
Expand Down Expand Up @@ -176,9 +180,209 @@ private Endpoint CreateTestHttpEndpoint()
return TestUtility.CreateHttpEndpoint("test-endpoint", true, 1, "https://example.com");
}

private Endpoint CreateTestIcmpEndpoint()
[TestMethod]
public void NotifySingleResultShouldSendCorrectEventPayload()
{
var endpoint = CreateTestHttpEndpoint();
var startTime = DateTimeOffset.UtcNow;
var stopTime = startTime.AddMilliseconds(250);
var result = new EndpointCheckResult
{
Start = startTime,
Stop = stopTime,
Success = true,
ResponseTime = TimeSpan.FromMilliseconds(250),
Error = null
};

// Capture the action passed to background execution
Action capturedAction = null;
_bgExec.When(x => x.Execute(Arg.Any<Action>())).Do(call => capturedAction = call.Arg<Action>());

_service.NotifySingleResult(endpoint, result);

// Execute the captured action synchronously to trigger the wrapper call
capturedAction?.Invoke();

var result1 = _eventGridWrapper.Received(1).SendEventAsync(
Arg.Is<Uri>(uri => uri.ToString() == "https://test-topic.eastus-1.eventgrid.azure.net/api/events"),
Arg.Is<string>(key => key == "test-access-key"),
Arg.Is<EventGridEvent>(evt =>
evt.EventType == "sama.endpoint.check.completed" &&
evt.Subject == "Sama/1" &&
evt.DataVersion == "1.0" &&
VerifyCheckCompletedEventData(evt.Data, endpoint, result)
)
);

// Avoid async warning by not awaiting the result since we're testing the call was made
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe, verifying async calls is generally done with await. Not sure what warning this would be. For some examples, take a look at SlackNotificationServiceTests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it was confused a bit. The warning shows up I think when you don't mark the method async. I marked them async and changed it back to await. All seems well!

_ = result1;
}

[TestMethod]
public void NotifyUpShouldSendCorrectEventPayload()
{
var endpoint = CreateTestHttpEndpoint();
var downAsOf = DateTimeOffset.UtcNow.AddMinutes(-10);

// Capture the action passed to background execution
Action capturedAction = null;
_bgExec.When(x => x.Execute(Arg.Any<Action>())).Do(call => capturedAction = call.Arg<Action>());

_service.NotifyUp(endpoint, downAsOf);

// Execute the captured action synchronously to trigger the wrapper call
capturedAction?.Invoke();

var result2 = _eventGridWrapper.Received(1).SendEventAsync(
Arg.Any<Uri>(),
Arg.Any<string>(),
Arg.Is<EventGridEvent>(evt =>
evt.EventType == "sama.endpoint.status.up" &&
evt.Subject == "Sama/endpoints/1" &&
evt.DataVersion == "1.0" &&
VerifyUpEventData(evt.Data, endpoint, downAsOf)
)
);

// Avoid async warning by not awaiting the result since we're testing the call was made
_ = result2;
Copy link

Copilot AI Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment and discard pattern is unnecessary. The variable assignment can be removed entirely since the verification is already complete in the Received() call.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not entirely wrong. We should be awaiting rather than assigning to a result variable, though. (Why is it named result2 when there isn't a result1?)

}

[TestMethod]
public void NotifyDownShouldSendCorrectEventPayload()
{
var endpoint = CreateTestHttpEndpoint();
var downAsOf = DateTimeOffset.UtcNow;
var exception = new InvalidOperationException("Connection timeout");

// Capture the action passed to background execution
Action capturedAction = null;
_bgExec.When(x => x.Execute(Arg.Any<Action>())).Do(call => capturedAction = call.Arg<Action>());

_service.NotifyDown(endpoint, downAsOf, exception);

// Execute the captured action synchronously to trigger the wrapper call
capturedAction?.Invoke();

var result3 = _eventGridWrapper.Received(1).SendEventAsync(
Arg.Any<Uri>(),
Arg.Any<string>(),
Arg.Is<EventGridEvent>(evt =>
evt.EventType == "sama.endpoint.status.down" &&
evt.Subject == "Sama/endpoints/1" &&
evt.DataVersion == "1.0" &&
VerifyDownEventData(evt.Data, endpoint, downAsOf, exception)
)
);

// Avoid async warning by not awaiting the result since we're testing the call was made
_ = result3;
}

[TestMethod]
public void NotifyMiscShouldSendCorrectEventPayloadForEndpointAdded()
{
var endpoint = CreateTestHttpEndpoint();

// Capture the action passed to background execution
Action capturedAction = null;
_bgExec.When(x => x.Execute(Arg.Any<Action>())).Do(call => capturedAction = call.Arg<Action>());

_service.NotifyMisc(endpoint, NotificationType.EndpointAdded);

// Execute the captured action synchronously to trigger the wrapper call
capturedAction?.Invoke();

var result4 = _eventGridWrapper.Received(1).SendEventAsync(
Arg.Any<Uri>(),
Arg.Any<string>(),
Arg.Is<EventGridEvent>(evt =>
evt.EventType == "sama.endpoint.management.added" &&
evt.Subject == "Sama/endpoints/1" &&
evt.DataVersion == "1.0" &&
VerifyManagementEventData(evt.Data, endpoint, NotificationType.EndpointAdded)
)
);

// Avoid async warning by not awaiting the result since we're testing the call was made
_ = result4;
}

[TestMethod]
public void NotifyMiscShouldSendCorrectEventPayloadForEndpointRemoved()
{
var endpoint = CreateTestHttpEndpoint();

// Capture the action passed to background execution
Action capturedAction = null;
_bgExec.When(x => x.Execute(Arg.Any<Action>())).Do(call => capturedAction = call.Arg<Action>());

_service.NotifyMisc(endpoint, NotificationType.EndpointRemoved);

// Execute the captured action synchronously to trigger the wrapper call
capturedAction?.Invoke();

var result = _eventGridWrapper.Received(1).SendEventAsync(
Arg.Any<Uri>(),
Arg.Any<string>(),
Arg.Is<EventGridEvent>(evt =>
evt.EventType == "sama.endpoint.management.removed" &&
evt.Subject == "Sama/endpoints/1" &&
VerifyManagementEventData(evt.Data, endpoint, NotificationType.EndpointRemoved)
)
);

// Avoid async warning by not awaiting the result since we're testing the call was made
_ = result;
}

private bool VerifyCheckCompletedEventData(BinaryData data, Endpoint endpoint, EndpointCheckResult result)
{
var json = JsonSerializer.Deserialize<JsonElement>(data.ToString());

return json.GetProperty("endpointId").GetInt32() == endpoint.Id &&
json.GetProperty("endpointName").GetString() == endpoint.Name &&
json.GetProperty("success").GetBoolean() == result.Success &&
json.GetProperty("responseTime").GetDouble() == result.ResponseTime?.TotalMilliseconds &&
json.GetProperty("startTime").GetDateTimeOffset() == result.Start &&
json.GetProperty("stopTime").GetDateTimeOffset() == result.Stop &&
json.GetProperty("error").ValueKind == JsonValueKind.Null;
}

private bool VerifyUpEventData(BinaryData data, Endpoint endpoint, DateTimeOffset? downAsOf)
{
var json = JsonSerializer.Deserialize<JsonElement>(data.ToString());
var expectedDowntimeMinutes = downAsOf.HasValue
? (int)DateTimeOffset.UtcNow.Subtract(downAsOf.Value).TotalMinutes
: 0;

return json.GetProperty("endpointId").GetInt32() == endpoint.Id &&
json.GetProperty("endpointName").GetString() == endpoint.Name &&
json.GetProperty("downAsOf").GetDateTimeOffset() == downAsOf &&
json.GetProperty("downtimeMinutes").GetInt32() == expectedDowntimeMinutes &&
json.TryGetProperty("recoveredAt", out var recoveredAt) && recoveredAt.ValueKind == JsonValueKind.String;
}

private bool VerifyDownEventData(BinaryData data, Endpoint endpoint, DateTimeOffset downAsOf, Exception exception)
{
var json = JsonSerializer.Deserialize<JsonElement>(data.ToString());

return json.GetProperty("endpointId").GetInt32() == endpoint.Id &&
json.GetProperty("endpointName").GetString() == endpoint.Name &&
json.GetProperty("downAsOf").GetDateTimeOffset() == downAsOf &&
json.GetProperty("reason").GetString() == exception.Message &&
json.GetProperty("reasonType").GetString() == exception.GetType().Name;
}

private bool VerifyManagementEventData(BinaryData data, Endpoint endpoint, NotificationType notificationType)
{
return TestUtility.CreateIcmpEndpoint("test-ping", true, 2, "192.168.1.1");
var json = JsonSerializer.Deserialize<JsonElement>(data.ToString());

return json.GetProperty("endpointId").GetInt32() == endpoint.Id &&
json.GetProperty("endpointName").GetString() == endpoint.Name &&
json.GetProperty("notificationType").GetString() == notificationType.ToString() &&
json.TryGetProperty("timestamp", out var timestamp) && timestamp.ValueKind == JsonValueKind.String;
}
}
}
Loading