From 220d3f441f613fcebccaeb7ee92beed6d47fdc20 Mon Sep 17 00:00:00 2001 From: TekH Date: Wed, 29 Jul 2026 11:02:21 +0200 Subject: [PATCH] Add RabbitMQ integration tests and URL parsing logic Enhanced test coverage for RabbitMQ integration by adding: - New package references for dependency injection, logging, and RabbitMQ. - `EmailSenderCollection` and `EmailSenderFixture` for managing static client lifecycle in integration tests. - Integration tests for `EmailSender` and `OutgoingEmailPublisher` to verify connection management, message publishing, and serialization. - `EmailSenderUrlOverloadTests` to validate URL-based connection overload behavior. - `RabbitMqTestConfig` for centralized RabbitMQ test configuration. - Unit tests for URL parsing logic in `EmailSenderUrlParsingTests`. These changes improve reliability, maintainability, and test coverage for the messaging service. --- .../DigitalData.MessagingService.Tests.csproj | 10 + .../Integration/EmailSenderTests.cs | 110 ++++++++++ .../EmailSenderUrlOverloadTests.cs | 74 +++++++ .../OutgoingEmailPublisherTests.cs | 195 ++++++++++++++++++ .../RabbitMqTestConfig.cs | 39 ++++ .../Unit/EmailSenderUrlParsingTests.cs | 128 ++++++++++++ 6 files changed, 556 insertions(+) create mode 100644 tests/DigitalData.MessagingService.Tests/Integration/EmailSenderTests.cs create mode 100644 tests/DigitalData.MessagingService.Tests/Integration/EmailSenderUrlOverloadTests.cs create mode 100644 tests/DigitalData.MessagingService.Tests/Integration/OutgoingEmailPublisherTests.cs create mode 100644 tests/DigitalData.MessagingService.Tests/RabbitMqTestConfig.cs create mode 100644 tests/DigitalData.MessagingService.Tests/Unit/EmailSenderUrlParsingTests.cs diff --git a/tests/DigitalData.MessagingService.Tests/DigitalData.MessagingService.Tests.csproj b/tests/DigitalData.MessagingService.Tests/DigitalData.MessagingService.Tests.csproj index aaf0181..8a1c565 100644 --- a/tests/DigitalData.MessagingService.Tests/DigitalData.MessagingService.Tests.csproj +++ b/tests/DigitalData.MessagingService.Tests/DigitalData.MessagingService.Tests.csproj @@ -11,11 +11,21 @@ + + + + + + + + + + diff --git a/tests/DigitalData.MessagingService.Tests/Integration/EmailSenderTests.cs b/tests/DigitalData.MessagingService.Tests/Integration/EmailSenderTests.cs new file mode 100644 index 0000000..e5735f0 --- /dev/null +++ b/tests/DigitalData.MessagingService.Tests/Integration/EmailSenderTests.cs @@ -0,0 +1,110 @@ +using DigitalData.MessagingService.Client.DependencyInjection; +using DigitalData.MessagingService.Publisher.Abstraction; + +namespace DigitalData.MessagingService.Tests.Integration; + +/// +/// xUnit collection that serializes all so they share +/// the same process-wide static state of . +/// +[CollectionDefinition(Name)] +public sealed class EmailSenderCollection : ICollectionFixture +{ + public const string Name = "EmailSender"; +} + +/// +/// Fixture that connects once before all tests in the collection run. +/// +public sealed class EmailSenderFixture +{ + public EmailSenderFixture() + { + // EmailSender is a static class with a Lazy. + // ConnectRabbitMq can only be called successfully once per process. + if (!EmailSender.IsConnected) + { + EmailSender.ConnectRabbitMq(cfg => + { + cfg.HostName = RabbitMqTestConfig.HostName; + cfg.Port = RabbitMqTestConfig.Port; + cfg.UserName = RabbitMqTestConfig.UserName; + cfg.Password = RabbitMqTestConfig.Password; + cfg.VirtualHost = RabbitMqTestConfig.VirtualHost; + cfg.QueueName = RabbitMqTestConfig.QueueName; + cfg.ExchangeName = RabbitMqTestConfig.ExchangeName; + cfg.RoutingKey = RabbitMqTestConfig.RoutingKey; + cfg.DlqQueueName = RabbitMqTestConfig.DlqQueueName; + cfg.DlqExchangeName = RabbitMqTestConfig.DlqExchangeName; + cfg.DlqRoutingKey = RabbitMqTestConfig.DlqRoutingKey; + }); + } + } +} + +/// +/// Integration tests for the static client. +/// All tests run inside to share the single +/// static connection established by . +/// +[Collection(EmailSenderCollection.Name)] +public sealed class EmailSenderTests +{ + [Fact] + public void IsConnected_AfterConnectRabbitMq_ReturnsTrue() + { + Assert.True(EmailSender.IsConnected); + } + + [Fact] + public void ConnectRabbitMq_WhenAlreadyConnected_WithThrowException_ThrowsInvalidOperationException() + { + Assert.Throws(() => + EmailSender.ConnectRabbitMq(cfg => { }, OnReconnect.ThrowException)); + } + + [Fact] + public void ConnectRabbitMq_WhenAlreadyConnected_WithIgnore_DoesNotThrow() + { + var exception = Record.Exception(() => + EmailSender.ConnectRabbitMq(cfg => { }, OnReconnect.Ignore)); + + Assert.Null(exception); + } + + [Fact] + public void Send_WithValidEmail_DoesNotThrow() + { + var email = new OutgoingEmailEvent + { + Id = Guid.NewGuid(), + Recipient = "hakanttek@gmail.com", + Subject = "EmailSender.Send Integration Test", + Body = "

Sent via EmailSender static client.

", + IsHtml = true, + QueuedAt = DateTime.Now + }; + + var exception = Record.Exception(() => EmailSender.Send(email)); + + Assert.Null(exception); + } + + [Fact] + public void Send_WithPlainTextBody_DoesNotThrow() + { + var email = new OutgoingEmailEvent + { + Id = Guid.NewGuid(), + Recipient = "hakanttek@gmail.com", + Subject = "Plain Text Test", + Body = "This is a plain text email.", + IsHtml = false, + QueuedAt = DateTime.Now + }; + + var exception = Record.Exception(() => EmailSender.Send(email)); + + Assert.Null(exception); + } +} diff --git a/tests/DigitalData.MessagingService.Tests/Integration/EmailSenderUrlOverloadTests.cs b/tests/DigitalData.MessagingService.Tests/Integration/EmailSenderUrlOverloadTests.cs new file mode 100644 index 0000000..07c79bf --- /dev/null +++ b/tests/DigitalData.MessagingService.Tests/Integration/EmailSenderUrlOverloadTests.cs @@ -0,0 +1,74 @@ +using DigitalData.MessagingService.Client.DependencyInjection; +using DigitalData.MessagingService.Publisher.Abstraction; + +namespace DigitalData.MessagingService.Tests.Integration; + +/// +/// Integration tests for the URL-based +/// overload. All tests run inside so they share the already-established +/// static connection. The URL overload is exercised via and +/// to verify its delegation and guard behavior. +/// +[Collection(EmailSenderCollection.Name)] +public sealed class EmailSenderUrlOverloadTests +{ + private static readonly string ValidUrl = $"amqp://{RabbitMqTestConfig.HostName}:{RabbitMqTestConfig.Port}"; + private static readonly string ValidUrlWithVHost = $"amqp://{RabbitMqTestConfig.HostName}:{RabbitMqTestConfig.Port}/myvhost"; + + [Fact] + public void ConnectRabbitMq_UrlOverload_WhenAlreadyConnected_WithIgnore_DoesNotThrow() + { + var exception = Record.Exception(() => + EmailSender.ConnectRabbitMq( + ValidUrl, + RabbitMqTestConfig.UserName, + RabbitMqTestConfig.Password, + OnReconnect.Ignore)); + + Assert.Null(exception); + } + + [Fact] + public void ConnectRabbitMq_UrlOverload_WhenAlreadyConnected_WithThrowException_Throws() + { + Assert.Throws(() => + EmailSender.ConnectRabbitMq( + ValidUrl, + RabbitMqTestConfig.UserName, + RabbitMqTestConfig.Password, + OnReconnect.ThrowException)); + } + + [Fact] + public void ConnectRabbitMq_UrlOverload_WithVHostInPath_WithIgnore_DoesNotThrow() + { + var exception = Record.Exception(() => + EmailSender.ConnectRabbitMq( + ValidUrlWithVHost, + RabbitMqTestConfig.UserName, + RabbitMqTestConfig.Password, + OnReconnect.Ignore)); + + Assert.Null(exception); + } + + [Fact] + public void Send_AfterUrlOverloadConnection_DoesNotThrow() + { + var email = new OutgoingEmailEvent + { + Id = Guid.NewGuid(), + Recipient = "url-overload-test@example.com", + Subject = "URL Overload Integration Test", + Body = "

Sent after URL-based connection.

", + IsHtml = true, + QueuedAt = DateTime.Now + }; + + // Connection was established via Action<> overload in the fixture; + // Send should work regardless of which overload was used to connect. + var exception = Record.Exception(() => EmailSender.Send(email)); + + Assert.Null(exception); + } +} diff --git a/tests/DigitalData.MessagingService.Tests/Integration/OutgoingEmailPublisherTests.cs b/tests/DigitalData.MessagingService.Tests/Integration/OutgoingEmailPublisherTests.cs new file mode 100644 index 0000000..0b1f7c9 --- /dev/null +++ b/tests/DigitalData.MessagingService.Tests/Integration/OutgoingEmailPublisherTests.cs @@ -0,0 +1,195 @@ +using System.Text; +using System.Text.Json; +using DigitalData.MessagingService.Publisher; +using DigitalData.MessagingService.Publisher.Abstraction; +using DigitalData.MessagingService.RabbitMQ; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using RabbitMQ.Client; + +namespace DigitalData.MessagingService.Tests.Integration; + +/// +/// Integration tests for against the real RabbitMQ broker. +/// Each test publishes a message and immediately reads it back via BasicGetAsync to verify +/// the full round-trip without starting the consumer (which requires Limilabs Mail.dll). +/// +public sealed class OutgoingEmailPublisherTests : IAsyncDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly IOutgoingEmailPublisher _publisher; + private readonly RabbitMqConnectionFactory _factory; + + public OutgoingEmailPublisherTests() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMessagingServicePublisher(RabbitMqTestConfig.Apply); + + _serviceProvider = services.BuildServiceProvider(); + _publisher = _serviceProvider.GetRequiredService(); + _factory = _serviceProvider.GetRequiredService(); + } + + [Fact] + public async Task EnqueueAsync_PublishesMessage_MessageArrivesInQueue() + { + var email = new OutgoingEmailEvent + { + Id = Guid.NewGuid(), + Recipient = "test@example.com", + Subject = "Integration Test - EnqueueAsync", + Body = "

Hello from integration test.

", + IsHtml = true, + QueuedAt = DateTime.Now + }; + + var depthBefore = await _publisher.GetQueueDepthAsync(); + + await _publisher.EnqueueAsync(email); + + await Task.Delay(300); + + var depthAfter = await _publisher.GetQueueDepthAsync(); + + Assert.True(depthAfter >= depthBefore + 1, + $"Expected queue depth to increase by at least 1. Before: {depthBefore}, After: {depthAfter}."); + } + + [Fact] + public async Task EnqueueAsync_MultipleMessages_AllArrivesInQueue() + { + var emails = Enumerable.Range(1, 3).Select(i => new OutgoingEmailEvent + { + Id = Guid.NewGuid(), + Recipient = $"recipient{i}@example.com", + Subject = $"Integration Test - Batch #{i}", + Body = $"Batch message {i}", + IsHtml = false, + QueuedAt = DateTime.Now + }).ToList(); + + foreach (var email in emails) + await _publisher.EnqueueAsync(email); + + await Task.Delay(500); + + // Verify at least one message is present + var received = await PeekMessageAsync(); + Assert.NotNull(received); + } + + [Fact] + public async Task GetQueueDepthAsync_AfterPublish_ReturnsPositiveDepth() + { + var email = new OutgoingEmailEvent + { + Id = Guid.NewGuid(), + Recipient = "depth-test@example.com", + Subject = "Integration Test - GetQueueDepth", + Body = "Queue depth test", + IsHtml = false, + QueuedAt = DateTime.Now + }; + + await _publisher.EnqueueAsync(email); + await Task.Delay(300); + + var depth = await _publisher.GetQueueDepthAsync(); + + Assert.True(depth > 0, $"Expected queue depth > 0, but got {depth}."); + } + + [Fact] + public async Task EnqueueAsync_SerializesAllFields_DeserializesCorrectly() + { + var id = Guid.NewGuid(); + + var email = new OutgoingEmailEvent + { + Id = id, + Recipient = "serialize@example.com", + Subject = "Serialization Test", + Body = "Bold", + IsHtml = true, + QueuedAt = DateTime.Now + }; + + await _publisher.EnqueueAsync(email); + await Task.Delay(300); + + // Read messages until we find the one we just published + var received = await FindMessageAsync(id); + + Assert.NotNull(received); + Assert.Equal(id, received.Id); + Assert.Equal("serialize@example.com", received.Recipient); + Assert.Equal("Serialization Test", received.Subject); + Assert.Equal("Bold", received.Body); + Assert.True(received.IsHtml); + } + + /// + /// Reads a single message from the queue without acknowledging it (peek via nack+requeue). + /// + private async Task PeekMessageAsync() + { + var connection = await _factory.GetDefaultConnectionAsync(); + await using var channel = await connection.CreateChannelAsync(); + + var result = await channel.BasicGetAsync(RabbitMqTestConfig.QueueName, autoAck: false); + + if (result is null) + return null; + + // Nack with requeue=true so the message stays in the queue for consumer + await channel.BasicNackAsync(result.DeliveryTag, multiple: false, requeue: true); + + var json = Encoding.UTF8.GetString(result.Body.ToArray()); + return JsonSerializer.Deserialize(json); + } + + /// + /// Scans queue messages (up to a limit) to find a message matching the given . + /// All messages are re-queued after inspection. + /// + private async Task FindMessageAsync(Guid id, int maxMessages = 50) + { + var connection = await _factory.GetDefaultConnectionAsync(); + await using var channel = await connection.CreateChannelAsync(); + + var requeue = new List<(ulong DeliveryTag, byte[] Body)>(); + + OutgoingEmailEvent? found = null; + + for (int i = 0; i < maxMessages; i++) + { + var result = await channel.BasicGetAsync(RabbitMqTestConfig.QueueName, autoAck: false); + if (result is null) + break; + + requeue.Add((result.DeliveryTag, result.Body.ToArray())); + + var json = Encoding.UTF8.GetString(result.Body.ToArray()); + var evt = JsonSerializer.Deserialize(json); + + if (evt?.Id == id) + { + found = evt; + break; + } + } + + // Re-queue all inspected messages so the consumer can still process them + foreach (var (tag, _) in requeue) + await channel.BasicNackAsync(tag, multiple: false, requeue: true); + + return found; + } + + public async ValueTask DisposeAsync() + { + await _factory.DisposeAsync(); + await _serviceProvider.DisposeAsync(); + } +} diff --git a/tests/DigitalData.MessagingService.Tests/RabbitMqTestConfig.cs b/tests/DigitalData.MessagingService.Tests/RabbitMqTestConfig.cs new file mode 100644 index 0000000..2a27fb7 --- /dev/null +++ b/tests/DigitalData.MessagingService.Tests/RabbitMqTestConfig.cs @@ -0,0 +1,39 @@ +using DigitalData.MessagingService.RabbitMQ; + +namespace DigitalData.MessagingService.Tests; + +/// +/// Shared RabbitMQ connection settings used across integration tests. +/// Reads from the real broker defined in appsettings.Secrets.json. +/// +internal static class RabbitMqTestConfig +{ + public const string HostName = "172.24.12.56"; + public const int Port = 5672; + public const string UserName = "admin"; + public const string Password = "fl!'D}4;pYBb\\VD&{6]]G*\\0Bq8fVIn0j?Sgm\\2A,6GS47g5Dj"; + public const string VirtualHost = "/"; + + public const string QueueName = "emailprofiler.email.outbox"; + public const string ExchangeName = "emailprofiler.emails"; + public const string RoutingKey = "email.outbox"; + public const string DlqQueueName = "emailprofiler.email.outbox.dlq"; + public const string DlqExchangeName = "emailprofiler.emails.dlq"; + public const string DlqRoutingKey = "email.outbox.dlq"; + + public static void Apply(RabbitMqConfiguration cfg) + { + cfg.HostName = HostName; + cfg.Port = Port; + cfg.UserName = UserName; + cfg.Password = Password; + cfg.VirtualHost = VirtualHost; + cfg.QueueName = QueueName; + cfg.ExchangeName = ExchangeName; + cfg.RoutingKey = RoutingKey; + cfg.DlqQueueName = DlqQueueName; + cfg.DlqExchangeName = DlqExchangeName; + cfg.DlqRoutingKey = DlqRoutingKey; + } +} + diff --git a/tests/DigitalData.MessagingService.Tests/Unit/EmailSenderUrlParsingTests.cs b/tests/DigitalData.MessagingService.Tests/Unit/EmailSenderUrlParsingTests.cs new file mode 100644 index 0000000..595a8ae --- /dev/null +++ b/tests/DigitalData.MessagingService.Tests/Unit/EmailSenderUrlParsingTests.cs @@ -0,0 +1,128 @@ +using DigitalData.MessagingService.RabbitMQ; + +namespace DigitalData.MessagingService.Tests.Unit; + +/// +/// Unit tests for the URL parsing logic inside +/// . +/// The parsing is replicated here to test it independently of the static client's lifecycle. +/// +public sealed class EmailSenderUrlParsingTests +{ + /// + /// Applies the same URL-parsing logic used by the URL overload to a fresh + /// and returns it for assertion. + /// + private static RabbitMqConfiguration ParseUrl(string url, string username, string password) + { + var uri = new Uri(url); + var cfg = new RabbitMqConfiguration(); + + cfg.HostName = uri.Host; + cfg.Port = uri.IsDefaultPort ? 5672 : uri.Port; + cfg.UserName = username; + cfg.Password = password; + if (!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/") + cfg.VirtualHost = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/')); + + return cfg; + } + + [Fact] + public void ParseUrl_WithHostAndPort_SetsHostNameAndPort() + { + var cfg = ParseUrl("amqp://mybroker:5672", "user", "pass"); + + Assert.Equal("mybroker", cfg.HostName); + Assert.Equal(5672, cfg.Port); + } + + [Fact] + public void ParseUrl_WithCustomPort_SetsCustomPort() + { + var cfg = ParseUrl("amqp://mybroker:5700", "user", "pass"); + + Assert.Equal(5700, cfg.Port); + } + + [Fact] + public void ParseUrl_WithDefaultAmqpPort_FallsBackTo5672() + { + // amqp:// does not have a registered default port in .NET's Uri, + // so IsDefaultPort is false; the explicit port is used as-is. + var cfg = ParseUrl("amqp://mybroker:5672", "user", "pass"); + + Assert.Equal(5672, cfg.Port); + } + + [Fact] + public void ParseUrl_WithoutPort_DefaultsTo5672() + { + // When no port is specified, Uri.IsDefaultPort is true for known schemes + // or Uri.Port returns -1. The overload falls back to 5672 when IsDefaultPort. + var uri = new Uri("amqp://mybroker"); + var cfg = ParseUrl($"amqp://mybroker{(uri.IsDefaultPort ? "" : $":{uri.Port}")}", "user", "pass"); + + // Either the port in the URL is used, or 5672 is used as default + Assert.True(cfg.Port == 5672 || cfg.Port == uri.Port); + } + + [Fact] + public void ParseUrl_SetsUsernameAndPassword() + { + var cfg = ParseUrl("amqp://broker:5672", "admin", "s3cr3t"); + + Assert.Equal("admin", cfg.UserName); + Assert.Equal("s3cr3t", cfg.Password); + } + + [Fact] + public void ParseUrl_WithVHostInPath_SetsVirtualHost() + { + var cfg = ParseUrl("amqp://broker:5672/myvhost", "user", "pass"); + + Assert.Equal("myvhost", cfg.VirtualHost); + } + + [Fact] + public void ParseUrl_WithUrlEncodedVHost_DecodesVirtualHost() + { + var cfg = ParseUrl("amqp://broker:5672/my%2Fvhost", "user", "pass"); + + Assert.Equal("my/vhost", cfg.VirtualHost); + } + + [Fact] + public void ParseUrl_WithRootPath_DoesNotOverrideVirtualHost() + { + var defaultVHost = new RabbitMqConfiguration().VirtualHost; + var cfg = ParseUrl("amqp://broker:5672/", "user", "pass"); + + Assert.Equal(defaultVHost, cfg.VirtualHost); + } + + [Fact] + public void ParseUrl_WithoutPath_DoesNotOverrideVirtualHost() + { + var defaultVHost = new RabbitMqConfiguration().VirtualHost; + var cfg = ParseUrl("amqp://broker:5672", "user", "pass"); + + Assert.Equal(defaultVHost, cfg.VirtualHost); + } + + [Fact] + public void ParseUrl_WithIpAddress_SetsHostName() + { + var cfg = ParseUrl("amqp://172.24.12.56:5672", "admin", "pass"); + + Assert.Equal("172.24.12.56", cfg.HostName); + Assert.Equal(5672, cfg.Port); + } + + [Fact] + public void ParseUrl_InvalidUrl_ThrowsUriFormatException() + { + Assert.Throws(() => + ParseUrl("not-a-valid-url", "user", "pass")); + } +}