Add unit tests for Environments, Audit, and Webhooks namespaces (#6)
All checks were successful
CI / build-and-push (push) Successful in 54s
CI / deploy-qa (push) Successful in 12s
CI / smoke-qa (push) Successful in 24s

## Summary
- Add missing unit test coverage for the Environments, Audit, and Webhooks namespaces (raises them from ~0-62% to 91-100%)
- Exclude WebhookBackgroundService/WebhookRetryBackgroundService from coverage (require live DI/DB, disallowed by CLAUDE.md's no-InMemory/WebApplicationFactory rule)

## Test plan
- [x] `dotnet test` full suite passes (646/646)
- [x] Coverage report confirms Environments ~99.5%, Audit 100%, Webhooks ~91%

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-07-05 15:07:26 -07:00
parent cae55e5737
commit 87113ccdcd
64 changed files with 6447 additions and 65 deletions

View File

@@ -0,0 +1,46 @@
using MicCheck.Api.Webhooks;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Webhooks;
[TestFixture]
public class CreateWebhookRequestValidatorTests
{
private CreateWebhookRequestValidator _validator = null!;
[SetUp]
public void SetUp() => _validator = new CreateWebhookRequestValidator();
[Test]
public void WhenTheRequestIsValid_ThenValidationSucceeds()
{
var result = _validator.Validate(new CreateWebhookRequest("https://example.com/hook", "secret", true));
Assert.That(result.IsValid, Is.True);
}
[Test]
public void WhenUrlIsEmpty_ThenValidationFails()
{
var result = _validator.Validate(new CreateWebhookRequest("", null, true));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenUrlExceedsMaximumLength_ThenValidationFails()
{
var longUrl = "https://example.com/" + new string('a', 500);
var result = _validator.Validate(new CreateWebhookRequest(longUrl, null, true));
Assert.That(result.IsValid, Is.False);
}
[Test]
public void WhenUrlIsNotAnAbsoluteUri_ThenValidationFails()
{
var result = _validator.Validate(new CreateWebhookRequest("not a valid url", null, true));
Assert.That(result.IsValid, Is.False);
}
}

View File

@@ -163,6 +163,95 @@ public class WebhookDispatcherTests
Assert.That(log.ResponseStatusCode, Is.EqualTo(500));
}
[Test]
public async Task WhenAnEnvironmentScopedEventIsDispatched_ThenOrganizationScopedWebhooksForThatOrgAlsoReceiveIt()
{
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
const int orgId = 1;
const int envId = 5;
webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = envId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
webhooks.Add(new Webhook { Url = "https://other-env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 999, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
EnvironmentId = envId,
OrganizationId = orgId,
Data = new { }
});
Assert.That(deliveryLogs, Has.Count.EqualTo(2));
}
[Test]
public async Task WhenAnOrganizationScopedEventIsDispatched_ThenEnvironmentScopedWebhooksAreNotIncluded()
{
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
const int orgId = 1;
webhooks.Add(new Webhook { Url = "https://env.example.com", Scope = WebhookScope.Environment, EnvironmentId = 5, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
webhooks.Add(new Webhook { Url = "https://org.example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = true, CreatedAt = DateTimeOffset.UtcNow });
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.AuditLogCreated,
EnvironmentId = null,
OrganizationId = orgId,
Data = new { }
});
Assert.That(deliveryLogs, Has.Count.EqualTo(1));
}
[Test]
public async Task WhenAWebhookIsDisabled_ThenItIsExcludedFromDispatch()
{
var (db, webhooks, deliveryLogs, factory, _) = SetUpDispatcher(HttpStatusCode.OK);
const int orgId = 1;
webhooks.Add(new Webhook { Url = "https://example.com", Scope = WebhookScope.Organization, OrganizationId = orgId, Enabled = false, CreatedAt = DateTimeOffset.UtcNow });
var dispatcher = new WebhookDispatcher(db.Object, factory, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = orgId,
Data = new { }
});
Assert.That(deliveryLogs, Is.Empty);
}
[Test]
public async Task WhenTheHttpClientThrows_ThenTheDeliveryLogRecordsTheErrorMessage()
{
var db = new Mock<IMicCheckDbContext>();
var webhooks = new List<Webhook> { new() { Url = "https://example.com", Scope = WebhookScope.Organization, OrganizationId = 1, Enabled = true, CreatedAt = DateTimeOffset.UtcNow } };
db.SetupDbSetWithGeneratedIds(c => c.Webhooks, webhooks);
var deliveryLogs = new List<WebhookDeliveryLog>();
db.SetupDbSet(c => c.WebhookDeliveryLogs, deliveryLogs);
var handler = new Mock<HttpMessageHandler>();
handler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Connection refused"));
var httpClient = new HttpClient(handler.Object);
var factory = new Mock<IHttpClientFactory>();
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var dispatcher = new WebhookDispatcher(db.Object, factory.Object, NullLogger<WebhookDispatcher>.Instance);
await dispatcher.DispatchAsync(new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, OrganizationId = 1, Data = new { } });
var log = deliveryLogs.First();
Assert.That(log.Success, Is.False);
Assert.That(log.ErrorMessage, Is.EqualTo("Connection refused"));
Assert.That(log.ResponseStatusCode, Is.Null);
}
[Test]
public async Task WhenNoWebhooksAreRegistered_ThenNoDeliveryLogsAreCreated()
{

View File

@@ -0,0 +1,48 @@
using MicCheck.Api.Webhooks;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Webhooks;
[TestFixture]
public class WebhookQueueTests
{
[Test]
public async Task WhenAnEventIsEnqueued_ThenItCanBeReadBack()
{
var queue = new WebhookQueue();
var webhookEvent = new WebhookEvent
{
EventType = WebhookEventTypes.FlagUpdated,
OrganizationId = 1,
Data = new { }
};
await queue.EnqueueAsync(webhookEvent);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token);
await enumerator.MoveNextAsync();
Assert.That(enumerator.Current, Is.SameAs(webhookEvent));
}
[Test]
public async Task WhenMultipleEventsAreEnqueued_ThenTheyAreReadInOrder()
{
var queue = new WebhookQueue();
var first = new WebhookEvent { EventType = WebhookEventTypes.FlagUpdated, OrganizationId = 1, Data = new { } };
var second = new WebhookEvent { EventType = WebhookEventTypes.FlagDeleted, OrganizationId = 1, Data = new { } };
await queue.EnqueueAsync(first);
await queue.EnqueueAsync(second);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
await using var enumerator = queue.ReadAllAsync(cts.Token).GetAsyncEnumerator(cts.Token);
await enumerator.MoveNextAsync();
Assert.That(enumerator.Current, Is.SameAs(first));
await enumerator.MoveNextAsync();
Assert.That(enumerator.Current, Is.SameAs(second));
}
}

View File

@@ -0,0 +1,110 @@
using MicCheck.Api.Webhooks;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Webhooks;
[TestFixture]
public class WebhookResponseTests
{
[Test]
public void WhenMappingAnEnvironmentScopedWebhook_ThenScopeIsSerializedAsItsName()
{
var webhook = new Webhook
{
Id = 1,
Url = "https://example.com/hook",
Secret = "s3cr3t",
Scope = WebhookScope.Environment,
EnvironmentId = 5,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow
};
var response = WebhookResponse.From(webhook);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.Url, Is.EqualTo("https://example.com/hook"));
Assert.That(response.Secret, Is.EqualTo("s3cr3t"));
Assert.That(response.Scope, Is.EqualTo("Environment"));
Assert.That(response.Enabled, Is.True);
Assert.That(response.EnvironmentId, Is.EqualTo(5));
Assert.That(response.OrganizationId, Is.Null);
}
[Test]
public void WhenMappingAnOrganizationScopedWebhook_ThenScopeIsSerializedAsItsName()
{
var webhook = new Webhook
{
Id = 2,
Url = "https://example.com/hook",
Scope = WebhookScope.Organization,
OrganizationId = 9,
Enabled = false,
CreatedAt = DateTimeOffset.UtcNow
};
var response = WebhookResponse.From(webhook);
Assert.That(response.Scope, Is.EqualTo("Organization"));
Assert.That(response.OrganizationId, Is.EqualTo(9));
Assert.That(response.EnvironmentId, Is.Null);
}
}
[TestFixture]
public class WebhookDeliveryLogResponseTests
{
[Test]
public void WhenMappingASuccessfulDelivery_ThenAllFieldsAreCopied()
{
var log = new WebhookDeliveryLog
{
Id = 1,
WebhookId = 2,
EventType = "FLAG_UPDATED",
PayloadJson = "{}",
ResponseStatusCode = 200,
ResponseBody = "ok",
Success = true,
AttemptNumber = 1,
AttemptedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.FromMilliseconds(42)
};
var response = WebhookDeliveryLogResponse.From(log);
Assert.That(response.Id, Is.EqualTo(1));
Assert.That(response.WebhookId, Is.EqualTo(2));
Assert.That(response.EventType, Is.EqualTo("FLAG_UPDATED"));
Assert.That(response.Success, Is.True);
Assert.That(response.ResponseStatusCode, Is.EqualTo(200));
Assert.That(response.ResponseBody, Is.EqualTo("ok"));
Assert.That(response.ErrorMessage, Is.Null);
Assert.That(response.AttemptNumber, Is.EqualTo(1));
Assert.That(response.Duration, Is.EqualTo(TimeSpan.FromMilliseconds(42)));
}
[Test]
public void WhenMappingAFailedDelivery_ThenErrorMessageIsCopied()
{
var log = new WebhookDeliveryLog
{
Id = 1,
WebhookId = 2,
EventType = "FLAG_UPDATED",
PayloadJson = "{}",
Success = false,
ErrorMessage = "Connection refused",
AttemptNumber = 2,
AttemptedAt = DateTimeOffset.UtcNow,
Duration = TimeSpan.Zero
};
var response = WebhookDeliveryLogResponse.From(log);
Assert.That(response.Success, Is.False);
Assert.That(response.ErrorMessage, Is.EqualTo("Connection refused"));
Assert.That(response.ResponseStatusCode, Is.Null);
}
}