diff --git a/Directory.Packages.props b/Directory.Packages.props index a226bec96..48cdfdc21 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -29,6 +29,7 @@ + diff --git a/Testcontainers.sln b/Testcontainers.sln index 9595905ed..b1dff6af8 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -195,6 +195,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Tests", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.WebDriver.Tests", "tests\Testcontainers.WebDriver.Tests\Testcontainers.WebDriver.Tests.csproj", "{EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.EventHubs", "src\Testcontainers.EventHubs\Testcontainers.EventHubs.csproj", "{0EF885E9-E973-47DC-AA9C-3A5E9175B0F3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.EventHubs.Tests", "tests\Testcontainers.EventHubs.Tests\Testcontainers.EventHubs.Tests.csproj", "{4A0C5523-CEB2-49C9-AE62-9187A01B016B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -568,6 +572,14 @@ Global {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2}.Release|Any CPU.Build.0 = Release|Any CPU + {0EF885E9-E973-47DC-AA9C-3A5E9175B0F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EF885E9-E973-47DC-AA9C-3A5E9175B0F3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EF885E9-E973-47DC-AA9C-3A5E9175B0F3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EF885E9-E973-47DC-AA9C-3A5E9175B0F3}.Release|Any CPU.Build.0 = Release|Any CPU + {4A0C5523-CEB2-49C9-AE62-9187A01B016B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A0C5523-CEB2-49C9-AE62-9187A01B016B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A0C5523-CEB2-49C9-AE62-9187A01B016B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A0C5523-CEB2-49C9-AE62-9187A01B016B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {5365F780-0E6C-41F0-B1B9-7DC34368F80C} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -661,5 +673,7 @@ Global {1A1983E6-5297-435F-B467-E8E1F11277D6} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {27CDB869-A150-4593-958F-6F26E5391E7C} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {EBA72C3B-57D5-43FF-A5B4-3D55B3B6D4C2} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {0EF885E9-E973-47DC-AA9C-3A5E9175B0F3} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {4A0C5523-CEB2-49C9-AE62-9187A01B016B} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} EndGlobalSection EndGlobal diff --git a/src/Testcontainers.EventHubs/.editorconfig b/src/Testcontainers.EventHubs/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.EventHubs/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Configuration/ConfigurationBuilder.cs b/src/Testcontainers.EventHubs/Configuration/ConfigurationBuilder.cs new file mode 100644 index 000000000..7e6764e14 --- /dev/null +++ b/src/Testcontainers.EventHubs/Configuration/ConfigurationBuilder.cs @@ -0,0 +1,53 @@ +namespace Testcontainers.EventHubs.Configuration +{ + public class ConfigurationBuilder + { + private const string DefaultNamespace = "emulatorns1"; + + private readonly RootConfiguration _rootConfiguration = new RootConfiguration(); + + private ConfigurationBuilder() + { + _rootConfiguration.UserConfig = new UserConfig + { + NamespaceConfig = new List() + { + new NamespaceConfig + { + Type = "EventHub", + Name = DefaultNamespace + } + }, + LoggingConfig = new LoggingConfig() { Type = "File" } + }; + } + + public static ConfigurationBuilder Create() + { + return new ConfigurationBuilder(); + } + + public ConfigurationBuilder WithEventHub(string entityName, string partitionCount, IEnumerable consumerGroups) + { + var namespaceConfig = _rootConfiguration.UserConfig.NamespaceConfig.FirstOrDefault(x => x.Name == DefaultNamespace); + if (namespaceConfig == null) + { + throw new InvalidOperationException($"Default Namespace '{DefaultNamespace}' not found."); + } + + namespaceConfig.Entities.Add(new Entity + { + Name = entityName, + PartitionCount = partitionCount, + ConsumerGroups = consumerGroups.Select(consumerGroupName => new ConsumerGroup { Name = consumerGroupName }).ToList() + }); + + return this; + } + + public string Build() + { + return JsonSerializer.Serialize(_rootConfiguration); + } + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Configuration/ConsumerGroup.cs b/src/Testcontainers.EventHubs/Configuration/ConsumerGroup.cs new file mode 100644 index 000000000..c099a4e00 --- /dev/null +++ b/src/Testcontainers.EventHubs/Configuration/ConsumerGroup.cs @@ -0,0 +1,7 @@ +namespace Testcontainers.EventHubs.Configuration +{ + public record ConsumerGroup + { + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Configuration/Entity.cs b/src/Testcontainers.EventHubs/Configuration/Entity.cs new file mode 100644 index 000000000..ff3938a97 --- /dev/null +++ b/src/Testcontainers.EventHubs/Configuration/Entity.cs @@ -0,0 +1,9 @@ +namespace Testcontainers.EventHubs.Configuration +{ + public record Entity + { + public string Name { get; set; } + public string PartitionCount { get; set; } + public List ConsumerGroups { get; set; } = []; + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Configuration/LoggingConfig.cs b/src/Testcontainers.EventHubs/Configuration/LoggingConfig.cs new file mode 100644 index 000000000..8066dbe04 --- /dev/null +++ b/src/Testcontainers.EventHubs/Configuration/LoggingConfig.cs @@ -0,0 +1,7 @@ +namespace Testcontainers.EventHubs.Configuration +{ + public record LoggingConfig + { + public string Type { get; set; } + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Configuration/NamespaceConfig.cs b/src/Testcontainers.EventHubs/Configuration/NamespaceConfig.cs new file mode 100644 index 000000000..080acdf8d --- /dev/null +++ b/src/Testcontainers.EventHubs/Configuration/NamespaceConfig.cs @@ -0,0 +1,10 @@ +namespace Testcontainers.EventHubs.Configuration +{ + public record NamespaceConfig + { + public string Type { get; set; } + public string Name { get; set; } + + public List Entities { get; set; } = []; + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Configuration/RootConfiguration.cs b/src/Testcontainers.EventHubs/Configuration/RootConfiguration.cs new file mode 100644 index 000000000..24fd01f71 --- /dev/null +++ b/src/Testcontainers.EventHubs/Configuration/RootConfiguration.cs @@ -0,0 +1,7 @@ +namespace Testcontainers.EventHubs.Configuration +{ + public record RootConfiguration + { + public UserConfig UserConfig { get; set; } + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Configuration/UserConfig.cs b/src/Testcontainers.EventHubs/Configuration/UserConfig.cs new file mode 100644 index 000000000..18dec8f58 --- /dev/null +++ b/src/Testcontainers.EventHubs/Configuration/UserConfig.cs @@ -0,0 +1,8 @@ +namespace Testcontainers.EventHubs.Configuration +{ + public record UserConfig + { + public List NamespaceConfig { get; set; } = []; + public LoggingConfig LoggingConfig { get; set; } + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/EventHubsBuilder.cs b/src/Testcontainers.EventHubs/EventHubsBuilder.cs new file mode 100644 index 000000000..2ab796980 --- /dev/null +++ b/src/Testcontainers.EventHubs/EventHubsBuilder.cs @@ -0,0 +1,127 @@ +using DotNet.Testcontainers; + +namespace Testcontainers.EventHubs; + +/// +[PublicAPI] +public sealed class EventHubsBuilder : ContainerBuilder +{ + public const string EventHubsImage = "mcr.microsoft.com/azure-messaging/eventhubs-emulator:latest"; + + public const ushort EventHubsPort = 5672; + + /// + /// Initializes a new instance of the class. + /// + public EventHubsBuilder() + : this(new EventHubsConfiguration()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private EventHubsBuilder(EventHubsConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override EventHubsConfiguration DockerResourceConfiguration { get; } + + /// + /// Sets the event hub configuration + /// + /// + /// + public EventHubsBuilder WithConfigurationBuilder(ConfigurationBuilder configurationBuilder) + { + var configBytes = Encoding.UTF8.GetBytes(configurationBuilder.Build()); + + return Merge(DockerResourceConfiguration, new EventHubsConfiguration(configurationBuilder: configurationBuilder)) + .WithResourceMapping(configBytes, "Eventhubs_Emulator/ConfigFiles/Config.json"); + } + + /// + /// Sets the endpoint of the azurite blob service + /// + /// + /// + public EventHubsBuilder WithAzuriteBlobEndpoint(string azuriteBlobEndpoint) + { + return Merge(DockerResourceConfiguration, new EventHubsConfiguration(azuriteBlobEndpoint: azuriteBlobEndpoint)) + .WithEnvironment("BLOB_SERVER", azuriteBlobEndpoint); + } + + /// + /// Sets the endpoint of the azurite table service + /// + /// + /// + public EventHubsBuilder WithAzuriteTableEndpoint(string azuriteTableEndpoint) + { + return Merge(DockerResourceConfiguration, new EventHubsConfiguration(azuriteTableEndpoint: azuriteTableEndpoint)) + .WithEnvironment("METADATA_SERVER", azuriteTableEndpoint); + } + + /// + public override EventHubsContainer Build() + { + Validate(); + + var waitStrategy = Wait.ForUnixContainer().UntilMessageIsLogged("Emulator Service is Successfully Up!"); + + var eventHubsBuilder = DockerResourceConfiguration.WaitStrategies.Count() > 1 ? this : WithWaitStrategy(waitStrategy); + return new EventHubsContainer(eventHubsBuilder.DockerResourceConfiguration); + } + + /// + protected override void Validate() + { + base.Validate(); + + _ = Guard.Argument(DockerResourceConfiguration.ConfigurationBuilder, + nameof(DockerResourceConfiguration.ConfigurationBuilder)) + .NotNull(); + + _ = Guard.Argument(DockerResourceConfiguration.AzuriteBlobEndpoint, + nameof(DockerResourceConfiguration.AzuriteBlobEndpoint)) + .NotNull() + .NotEmpty(); + + _ = Guard.Argument(DockerResourceConfiguration.AzuriteTableEndpoint, + nameof(DockerResourceConfiguration.AzuriteTableEndpoint)) + .NotNull() + .NotEmpty(); + } + + /// + protected override EventHubsBuilder Init() + { + return base.Init() + .WithImage(EventHubsImage) + .WithEnvironment("ACCEPT_EULA", "Y") + .WithPortBinding(EventHubsPort, true); + } + + /// + protected override EventHubsBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new EventHubsConfiguration(resourceConfiguration)); + } + + /// + protected override EventHubsBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new EventHubsConfiguration(resourceConfiguration)); + } + + /// + protected override EventHubsBuilder Merge(EventHubsConfiguration oldValue, EventHubsConfiguration newValue) + { + return new EventHubsBuilder(new EventHubsConfiguration(oldValue, newValue)); + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/EventHubsConfiguration.cs b/src/Testcontainers.EventHubs/EventHubsConfiguration.cs new file mode 100644 index 000000000..ecdd82eed --- /dev/null +++ b/src/Testcontainers.EventHubs/EventHubsConfiguration.cs @@ -0,0 +1,80 @@ +namespace Testcontainers.EventHubs; + +/// +[PublicAPI] +public sealed class EventHubsConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + /// The configuration builder. + /// The Azurite blob endpoint. + /// The Azurite table endpoint. + public EventHubsConfiguration( + ConfigurationBuilder configurationBuilder = null, + string azuriteBlobEndpoint = null, + string azuriteTableEndpoint = null) + { + ConfigurationBuilder = configurationBuilder; + AzuriteBlobEndpoint = azuriteBlobEndpoint; + AzuriteTableEndpoint = azuriteTableEndpoint; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public EventHubsConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public EventHubsConfiguration(IContainerConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public EventHubsConfiguration(EventHubsConfiguration resourceConfiguration) + : this(new EventHubsConfiguration(), resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public EventHubsConfiguration(EventHubsConfiguration oldValue, EventHubsConfiguration newValue) + : base(oldValue, newValue) + { + ConfigurationBuilder = BuildConfiguration.Combine(oldValue.ConfigurationBuilder, newValue.ConfigurationBuilder); + AzuriteBlobEndpoint = BuildConfiguration.Combine(oldValue.AzuriteBlobEndpoint, newValue.AzuriteBlobEndpoint); + AzuriteTableEndpoint = BuildConfiguration.Combine(oldValue.AzuriteTableEndpoint, newValue.AzuriteTableEndpoint); + } + + /// + /// Gets the configuration builder + /// + public ConfigurationBuilder ConfigurationBuilder { get; } + + /// + /// Gets the Azurite blob endpoint + /// + public string AzuriteBlobEndpoint { get; } + + /// + /// Gets the Azurite table endpoint + /// + public string AzuriteTableEndpoint { get; } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/EventHubsContainer.cs b/src/Testcontainers.EventHubs/EventHubsContainer.cs new file mode 100644 index 000000000..a1c9c69b6 --- /dev/null +++ b/src/Testcontainers.EventHubs/EventHubsContainer.cs @@ -0,0 +1,36 @@ +namespace Testcontainers.EventHubs; + +/// +[PublicAPI] +public sealed class EventHubsContainer : DockerContainer +{ + private const string SharedAccessKeyName = "RootManageSharedAccessKey"; + + private const string SharedAccessKey = "SAS_KEY_VALUE"; + + private const string UseDevelopmentEmulator = "true"; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public EventHubsContainer(EventHubsConfiguration configuration) + : base(configuration) + { + } + + /// + /// Gets the event hub connection string. + /// + /// The event hub connection string. + public string GetConnectionString() + { + var properties = new Dictionary(); + properties.Add("Endpoint", new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(EventHubsBuilder.EventHubsPort)).ToString()); + properties.Add("DefaultEndpointsProtocol", Uri.UriSchemeHttp); + properties.Add("SharedAccessKeyName", SharedAccessKeyName); + properties.Add("SharedAccessKey", SharedAccessKey); + properties.Add("UseDevelopmentEmulator", UseDevelopmentEmulator); + return string.Join(";", properties.Select(property => string.Join("=", property.Key, property.Value))); + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Testcontainers.EventHubs.csproj b/src/Testcontainers.EventHubs/Testcontainers.EventHubs.csproj new file mode 100644 index 000000000..8b2ed72c6 --- /dev/null +++ b/src/Testcontainers.EventHubs/Testcontainers.EventHubs.csproj @@ -0,0 +1,12 @@ + + + net6.0;net8.0;netstandard2.0;netstandard2.1 + latest + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/Usings.cs b/src/Testcontainers.EventHubs/Usings.cs new file mode 100644 index 000000000..0836c1947 --- /dev/null +++ b/src/Testcontainers.EventHubs/Usings.cs @@ -0,0 +1,11 @@ +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Text; +global using System.Text.Json; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using JetBrains.Annotations; +global using Testcontainers.EventHubs.Configuration; \ No newline at end of file diff --git a/tests/Testcontainers.EventHubs.Tests/.editorconfig b/tests/Testcontainers.EventHubs.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.EventHubs.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.EventHubs.Tests/EventHubsContainerTest.cs b/tests/Testcontainers.EventHubs.Tests/EventHubsContainerTest.cs new file mode 100644 index 000000000..130f2ad99 --- /dev/null +++ b/tests/Testcontainers.EventHubs.Tests/EventHubsContainerTest.cs @@ -0,0 +1,71 @@ +namespace Testcontainers.EventHubs; + +public abstract class EventHubsContainerTest : IAsyncLifetime +{ + private readonly AzuriteContainer _azuriteContainer; + + private EventHubsContainer _eventHubsContainer; + + private readonly INetwork _network = new NetworkBuilder().WithName(NetworkName).Build(); + + private const string NetworkName = "eh-network"; + private const string AzuriteNetworkAlias = "azurite"; + + private const string EventHubName = "testeventhub"; + private const string EventHubConsumerGroupName = "testconsumergroup"; + + private EventHubsContainerTest() + { + _azuriteContainer = new AzuriteBuilder() + .WithNetwork(_network) + .WithNetworkAliases(AzuriteNetworkAlias) + .Build(); + } + + public async Task InitializeAsync() + { + await _azuriteContainer.StartAsync(); + + var configurationBuilder = ConfigurationBuilder + .Create() + .WithEventHub(EventHubName, "2", new[] { EventHubConsumerGroupName }); + + var builder = new EventHubsBuilder() + .WithNetwork(_network) + .WithConfigurationBuilder(configurationBuilder) + .WithAzuriteBlobEndpoint(AzuriteNetworkAlias) + .WithAzuriteTableEndpoint(AzuriteNetworkAlias); + + _eventHubsContainer = builder.Build(); + + await _eventHubsContainer.StartAsync(); + } + + public async Task DisposeAsync() + { + await _eventHubsContainer.DisposeAsync(); + + await _azuriteContainer.DisposeAsync(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task SendAndReceivesEvents() + { + // Give + var eventBody = Encoding.Default.GetBytes("test"); + var producerClient = new EventHubProducerClient(_eventHubsContainer.GetConnectionString(), EventHubName); + + // When + var eventDataBatch = await producerClient.CreateBatchAsync(); + eventDataBatch.TryAdd(new EventData(eventBody)); + + var thrownExceptionSend = await Record.ExceptionAsync(() => producerClient.SendAsync(eventDataBatch)); + + // Then + Assert.Null(thrownExceptionSend); + } + + [UsedImplicitly] + public sealed class EventHubsDefaultConfiguration : EventHubsContainerTest; +} \ No newline at end of file diff --git a/tests/Testcontainers.EventHubs.Tests/Testcontainers.EventHubs.Tests.csproj b/tests/Testcontainers.EventHubs.Tests/Testcontainers.EventHubs.Tests.csproj new file mode 100644 index 000000000..d91da6acb --- /dev/null +++ b/tests/Testcontainers.EventHubs.Tests/Testcontainers.EventHubs.Tests.csproj @@ -0,0 +1,22 @@ + + + net8.0 + false + false + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Testcontainers.EventHubs.Tests/Usings.cs b/tests/Testcontainers.EventHubs.Tests/Usings.cs new file mode 100644 index 000000000..447b8be46 --- /dev/null +++ b/tests/Testcontainers.EventHubs.Tests/Usings.cs @@ -0,0 +1,13 @@ +global using Azure.Messaging.EventHubs; +global using Azure.Messaging.EventHubs.Producer; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Commons; +global using DotNet.Testcontainers.Networks; +global using JetBrains.Annotations; +global using System.Text; +global using System.Threading.Tasks; +global using Testcontainers.Azurite; +global using Testcontainers.EventHubs.Configuration; +global using Xunit; + +