diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 642ce1a33..b3d6ae4cc 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -51,6 +51,7 @@ jobs: { name: "Testcontainers.DynamoDb", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Elasticsearch", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.EventStoreDb", runs-on: "ubuntu-22.04" }, + { name: "Testcontainers.EventHubs", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.FakeGcsServer", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.FirebirdSql", runs-on: "ubuntu-22.04" }, { name: "Testcontainers.Firestore", runs-on: "ubuntu-22.04" }, diff --git a/Directory.Packages.props b/Directory.Packages.props index 8c825fa6c..1701f5128 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,6 +33,7 @@ + diff --git a/Testcontainers.sln b/Testcontainers.sln index 444bdb1c3..d9f5b4d48 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.DynamoDb", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Elasticsearch", "src\Testcontainers.Elasticsearch\Testcontainers.Elasticsearch.csproj", "{641DDEA5-B6E0-41E6-BA11-7A28C0913127}" 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.EventStoreDb", "src\Testcontainers.EventStoreDb\Testcontainers.EventStoreDb.csproj", "{84D707E0-C9FA-4327-85DC-0AFEBEA73572}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.FakeGcsServer", "src\Testcontainers.FakeGcsServer\Testcontainers.FakeGcsServer.csproj", "{FF86B509-2F9E-4269-ABC2-912B3339DE29}" @@ -137,6 +139,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.DynamoDb.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Elasticsearch.Tests", "tests\Testcontainers.Elasticsearch.Tests\Testcontainers.Elasticsearch.Tests.csproj", "{DD5B3678-468F-4D73-AECE-705E3D66CD43}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.EventHubs.Tests", "tests\Testcontainers.EventHubs.Tests\Testcontainers.EventHubs.Tests.csproj", "{4A0C5523-CEB2-49C9-AE62-9187A01B016B}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.EventStoreDb.Tests", "tests\Testcontainers.EventStoreDb.Tests\Testcontainers.EventStoreDb.Tests.csproj", "{64F8E9B9-78FD-4E13-BDDF-0340E2D4E1D0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.FakeGcsServer.Tests", "tests\Testcontainers.FakeGcsServer.Tests\Testcontainers.FakeGcsServer.Tests.csproj", "{9F27AA1B-C25D-400C-BCB0-6B0BF1A1DCEA}" @@ -270,6 +274,10 @@ Global {641DDEA5-B6E0-41E6-BA11-7A28C0913127}.Debug|Any CPU.Build.0 = Debug|Any CPU {641DDEA5-B6E0-41E6-BA11-7A28C0913127}.Release|Any CPU.ActiveCfg = Release|Any CPU {641DDEA5-B6E0-41E6-BA11-7A28C0913127}.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 {84D707E0-C9FA-4327-85DC-0AFEBEA73572}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {84D707E0-C9FA-4327-85DC-0AFEBEA73572}.Debug|Any CPU.Build.0 = Debug|Any CPU {84D707E0-C9FA-4327-85DC-0AFEBEA73572}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -466,6 +474,10 @@ Global {DD5B3678-468F-4D73-AECE-705E3D66CD43}.Debug|Any CPU.Build.0 = Debug|Any CPU {DD5B3678-468F-4D73-AECE-705E3D66CD43}.Release|Any CPU.ActiveCfg = Release|Any CPU {DD5B3678-468F-4D73-AECE-705E3D66CD43}.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 {64F8E9B9-78FD-4E13-BDDF-0340E2D4E1D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {64F8E9B9-78FD-4E13-BDDF-0340E2D4E1D0}.Debug|Any CPU.Build.0 = Debug|Any CPU {64F8E9B9-78FD-4E13-BDDF-0340E2D4E1D0}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -625,6 +637,7 @@ Global {DCECB1F6-D9AA-431F-AE42-25D56B9E7DFC} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {2EAFA567-9F68-4C52-9DBC-8F3EC11BB2CE} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {641DDEA5-B6E0-41E6-BA11-7A28C0913127} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {0EF885E9-E973-47DC-AA9C-3A5E9175B0F3} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {84D707E0-C9FA-4327-85DC-0AFEBEA73572} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {FF86B509-2F9E-4269-ABC2-912B3339DE29} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {31BAF2C4-0608-4C0F-845A-14FE7C0A1670} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -674,6 +687,7 @@ Global {DA54916E-1128-4200-B6AE-9F5BF02D832D} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {101515E6-74C1-40F9-85C8-871F742A378D} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {DD5B3678-468F-4D73-AECE-705E3D66CD43} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {4A0C5523-CEB2-49C9-AE62-9187A01B016B} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {64F8E9B9-78FD-4E13-BDDF-0340E2D4E1D0} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {9F27AA1B-C25D-400C-BCB0-6B0BF1A1DCEA} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {E39095AC-9B34-4178-A486-04C902B6FD33} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} diff --git a/docs/modules/eventhubs.md b/docs/modules/eventhubs.md new file mode 100644 index 000000000..a13cd04eb --- /dev/null +++ b/docs/modules/eventhubs.md @@ -0,0 +1,45 @@ +# Azure EventHubs + +[Azure EventHubs](https://learn.microsoft.com/en-us/azure/event-hubs/event-hubs-about) is a native data-streaming service in the cloud that can stream millions of events per second, with low latency, from any source to any destination. Event Hubs is compatible with Apache Kafka. It enables you to run existing Kafka workloads without any code changes. +In this module, you will learn how to use Testcontainers to start an [Azure EventHubs emulator](https://learn.microsoft.com/en-us/azure/event-hubs/overview-emulator) container for testing. To be able to use the Azure EventHubs emulator, you need to accept the [Microsoft Event Hubs Emulator License](https://github.com/Azure/azure-event-hubs-emulator-installer/blob/main/EMULATOR_EULA.md). + +!!!Warning + + In the official documentation, there are **known limitations** to the Azure EventHubs emulator. You can find it [here](https://learn.microsoft.com/en-us/azure/event-hubs/overview-emulator#known-limitations). + +Add the following dependency to your project file: + +```shell title="NuGet" +dotnet add package Testcontainers.EventHubs +``` + +You can start a Azure EventHubs emulator instance from any .NET application. Here, we create different container instances and pass them to the base test class. This allows us to test different configurations. + +To create a container instance with minimal configuration, use the following code: + +=== "Create initial configuration JSON" +```csharp +--8<-- "tests/Testcontainers.EventHubs.Tests/EventHubsContainerTest.cs:MinimalConfigurationJson" +``` + +=== "Create Container Instance" +```csharp +--8<-- "tests/Testcontainers.EventHubs.Tests/EventHubsContainerTest.cs:MinimalConfigurationEventHubs" +``` + +This example uses xUnit.net's `IAsyncLifetime` interface to manage the lifecycle of the container. The container is started in the `InitializeAsync` method before the test method runs, ensuring that the environment is ready for testing. After the test completes, the container is removed in the `DisposeAsync` method. + +=== "Usage Example" +```csharp +--8<-- "tests/Testcontainers.EventHubs.Tests/EventHubsContainerTest.cs:EventHubsUsage" +``` + +=== "Kafka support" + +Azure Event Hubs is compatible with Apache Kafka. You can use the Azure Event Hubs Kafka endpoint to connect to the Event Hubs instance. +The following example demonstrates how to use the Azure Event Hubs Kafka endpoint with the Testcontainers library. Please, +keep in mind that only consumer and producer API is supported. More information about known limitations can be found [here](https://learn.microsoft.com/en-us/azure/event-hubs/overview-emulator#known-limitations). + +```csharp +--8<-- "tests/Testcontainers.EventHubs.Tests/EventHubsContainerTest.cs:EventHubsKafkaUsage" +``` diff --git a/docs/modules/index.md b/docs/modules/index.md index 48a75587b..dd1e92561 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -24,6 +24,7 @@ await moduleNameContainer.StartAsync(); | ActiveMQ Artemis | `apache/activemq-artemis:2.31.2` | [NuGet](https://www.nuget.org/packages/Testcontainers.ActiveMq) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.ActiveMq) | | ArangoDB | `arangodb:3.11.5` | [NuGet](https://www.nuget.org/packages/Testcontainers.ArangoDb) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.ArangoDb) | | Azure Cosmos DB | `mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator:latest` | [NuGet](https://www.nuget.org/packages/Testcontainers.CosmosDb) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.CosmosDb) | +| Azure Event Hubs | `mcr.microsoft.com/azure-messaging/eventhubs-emulator:latest` | [NuGet](https://www.nuget.org/packages/Testcontainers.EventHubs) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.EventHubs) | | Azure Service Bus | `mcr.microsoft.com/azure-messaging/servicebus-emulator:latest` | [NuGet](https://www.nuget.org/packages/Testcontainers.ServiceBus) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.ServiceBus) | | Azurite | `mcr.microsoft.com/azure-storage/azurite:3.24.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.Azurite) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Azurite) | | BigQuery | `ghcr.io/goccy/bigquery-emulator:0.4` | [NuGet](https://www.nuget.org/packages/Testcontainers.BigQuery) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.BigQuery) | diff --git a/mkdocs.yml b/mkdocs.yml index bf65d57f9..45efe9390 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -47,6 +47,7 @@ nav: - examples/aspnet.md - Modules: - modules/index.md + - modules/eventhubs.md - modules/elasticsearch.md - modules/mongodb.md - modules/mssql.md @@ -55,4 +56,4 @@ nav: - modules/pulsar.md - modules/rabbitmq.md - contributing.md - - contributing_docs.md \ No newline at end of file + - contributing_docs.md diff --git a/src/Testcontainers.Azurite/AzuriteContainer.cs b/src/Testcontainers.Azurite/AzuriteContainer.cs index e0cbd0d1b..b7b1023b7 100644 --- a/src/Testcontainers.Azurite/AzuriteContainer.cs +++ b/src/Testcontainers.Azurite/AzuriteContainer.cs @@ -32,30 +32,27 @@ public string GetConnectionString() /// /// Gets the blob endpoint /// - /// The azurite blob endpoint + /// The Azurite blob endpoint public string GetBlobEndpoint() { - return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(AzuriteBuilder.BlobPort), - AzuriteBuilder.AccountName).ToString(); + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(AzuriteBuilder.BlobPort), AzuriteBuilder.AccountName).ToString(); } /// /// Gets the queue endpoint /// - /// The azurite queue endpoint + /// The Azurite queue endpoint public string GetQueueEndpoint() { - return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(AzuriteBuilder.QueuePort), - AzuriteBuilder.AccountName).ToString(); + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(AzuriteBuilder.QueuePort), AzuriteBuilder.AccountName).ToString(); } /// /// Gets the table endpoint /// - /// The azurite table endpoint + /// The Azurite table endpoint public string GetTableEndpoint() { - return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(AzuriteBuilder.TablePort), - AzuriteBuilder.AccountName).ToString(); + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(AzuriteBuilder.TablePort), AzuriteBuilder.AccountName).ToString(); } } \ No newline at end of file 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/EventHubsBuilder.cs b/src/Testcontainers.EventHubs/EventHubsBuilder.cs new file mode 100644 index 000000000..b4f3fbaa0 --- /dev/null +++ b/src/Testcontainers.EventHubs/EventHubsBuilder.cs @@ -0,0 +1,180 @@ +namespace Testcontainers.EventHubs; + +/// +[PublicAPI] +public sealed class EventHubsBuilder : ContainerBuilder +{ + public const string EventHubsNetworkAlias = "eventhubs-container"; + + public const string AzuriteNetworkAlias = "azurite-container"; + + public const string EventHubsImage = "mcr.microsoft.com/azure-messaging/eventhubs-emulator:latest"; + + public const ushort EventHubsPort = 5672; + + public const ushort KafkaPort = 9092; + + private const string AcceptLicenseAgreementEnvVar = "ACCEPT_EULA"; + + private const string AcceptLicenseAgreement = "Y"; + + private const string DeclineLicenseAgreement = "N"; + + /// + /// 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; } + + /// + /// Accepts the license agreement. + /// + /// + /// When is set to true, the Azure Event Hubs Emulator license is accepted. + /// + /// A boolean value indicating whether the Azure Event Hubs Emulator license agreement is accepted. + /// A configured instance of . + public EventHubsBuilder WithAcceptLicenseAgreement(bool acceptLicenseAgreement) + { + var licenseAgreement = acceptLicenseAgreement ? AcceptLicenseAgreement : DeclineLicenseAgreement; + return WithEnvironment(AcceptLicenseAgreementEnvVar, licenseAgreement); + } + + /// + /// Sets the dependent Azurite container for the Azure Event Hubs Emulator. + /// + /// + /// This method allows an existing Azurite container to be attached to the Azure Event + /// Hubs Emulator. The containers must be on the same network to enable communication + /// between them. + /// + /// The network to connect the container to. + /// The Azure container. + /// The Azure container network alias. + /// A configured instance of . + public EventHubsBuilder WithAzuriteContainer( + INetwork network, + AzuriteContainer container, + string networkAlias) + { + return Merge(DockerResourceConfiguration, new EventHubsConfiguration(azuriteContainer: container)) + .DependsOn(container) + .WithNetwork(network) + .WithNetworkAliases(EventHubsNetworkAlias) + .WithEnvironment("BLOB_SERVER", networkAlias) + .WithEnvironment("METADATA_SERVER", networkAlias); + } + + /// + /// Sets the Azure Event Hubs Emulator configuration. + /// + /// The configuration. + /// A configured instance of . + public EventHubsBuilder WithConfigurationBuilder(EventHubsServiceConfiguration serviceConfiguration) + { + var resourceContent = Encoding.Default.GetBytes(serviceConfiguration.Build()); + return Merge(DockerResourceConfiguration, new EventHubsConfiguration(serviceConfiguration: serviceConfiguration)) + .WithResourceMapping(resourceContent, "Eventhubs_Emulator/ConfigFiles/Config.json"); + } + + /// + public override EventHubsContainer Build() + { + Validate(); + + if (DockerResourceConfiguration.AzuriteContainer != null) + { + return new EventHubsContainer(DockerResourceConfiguration); + } + + // If the user has not provided an existing Azurite container instance, + // we configure one. + var network = new NetworkBuilder() + .Build(); + + var container = new AzuriteBuilder() + .WithNetwork(network) + .WithNetworkAliases(AzuriteNetworkAlias) + .Build(); + + var eventHubsContainer = WithAzuriteContainer(network, container, AzuriteNetworkAlias); + return new EventHubsContainer(eventHubsContainer.DockerResourceConfiguration); + } + + /// + protected override void Validate() + { + const string message = "The image '{0}' requires you to accept a license agreement."; + + base.Validate(); + + Predicate licenseAgreementNotAccepted = value => + !value.Environments.TryGetValue(AcceptLicenseAgreementEnvVar, out var licenseAgreementValue) || !AcceptLicenseAgreement.Equals(licenseAgreementValue, StringComparison.Ordinal); + + _ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Image)) + .ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => throw new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name)); + + _ = Guard.Argument(DockerResourceConfiguration.ServiceConfiguration, nameof(DockerResourceConfiguration.ServiceConfiguration)) + .NotNull() + .ThrowIf(argument => !argument.Value.Validate(), _ => throw new ArgumentException("ConfigurationBuilder is invalid.")); + } + + /// + protected override EventHubsBuilder Init() + { + return base.Init() + .WithImage(EventHubsImage) + .WithPortBinding(EventHubsPort, true) + .WithPortBinding(KafkaPort, true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged("Emulator Service is Successfully Up!") + .AddCustomWaitStrategy(new WaitTwoSeconds())); + } + + /// + 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)); + } + + /// + private sealed class WaitTwoSeconds : IWaitUntil + { + /// + public async Task UntilAsync(IContainer container) + { + await Task.Delay(TimeSpan.FromSeconds(2)) + .ConfigureAwait(false); + + return true; + } + } +} \ 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..f42eb1573 --- /dev/null +++ b/src/Testcontainers.EventHubs/EventHubsConfiguration.cs @@ -0,0 +1,70 @@ +namespace Testcontainers.EventHubs; + +/// +[PublicAPI] +public sealed class EventHubsConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + /// The Azurite container. + /// The Azure Event Hubs Emulator configuration. + public EventHubsConfiguration(AzuriteContainer azuriteContainer = null, + EventHubsServiceConfiguration serviceConfiguration = null) + { + AzuriteContainer = azuriteContainer; + ServiceConfiguration = serviceConfiguration; + } + + /// + /// 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) + { + AzuriteContainer = BuildConfiguration.Combine(oldValue.AzuriteContainer, newValue.AzuriteContainer); + ServiceConfiguration = BuildConfiguration.Combine(oldValue.ServiceConfiguration, newValue.ServiceConfiguration); + } + + /// + /// Gets the Azurite container. + /// + public AzuriteContainer AzuriteContainer { get; } + + /// + /// Gets the Azure Event Hubs Emulator configuration. + /// + public EventHubsServiceConfiguration ServiceConfiguration { 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..f0f82ebe4 --- /dev/null +++ b/src/Testcontainers.EventHubs/EventHubsContainer.cs @@ -0,0 +1,39 @@ +namespace Testcontainers.EventHubs; + +/// +[PublicAPI] +public sealed class EventHubsContainer : DockerContainer +{ + private readonly EventHubsConfiguration _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + public EventHubsContainer(EventHubsConfiguration configuration) + : base(configuration) + { + _configuration = configuration; + } + + /// + /// Gets the Event Hubs connection string. + /// + /// The Event Hubs connection string. + public string GetConnectionString() + { + var properties = new Dictionary + { + { + "Endpoint", + new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(EventHubsBuilder.EventHubsPort)) + .ToString() + }, + { "DefaultEndpointsProtocol", Uri.UriSchemeHttp }, + { "SharedAccessKeyName", "RootManageSharedAccessKey" }, + { "SharedAccessKey", "SAS_KEY_VALUE" }, + { "UseDevelopmentEmulator", "true" }, + }; + return string.Join(";", properties.Select(property => string.Join("=", property.Key, property.Value))); + } +} \ No newline at end of file diff --git a/src/Testcontainers.EventHubs/EventHubsServiceConfiguration.cs b/src/Testcontainers.EventHubs/EventHubsServiceConfiguration.cs new file mode 100644 index 000000000..edb6399b6 --- /dev/null +++ b/src/Testcontainers.EventHubs/EventHubsServiceConfiguration.cs @@ -0,0 +1,99 @@ + +namespace Testcontainers.EventHubs; + +[PublicAPI] +public record RootConfiguration(UserConfig UserConfig) +{ + public UserConfig UserConfig { get; } = UserConfig; +} + +[PublicAPI] +public record UserConfig( + IReadOnlyList NamespaceConfig, + LoggingConfig LoggingConfig) +{ + public IReadOnlyList NamespaceConfig { get; } = NamespaceConfig; + public LoggingConfig LoggingConfig { get; } = LoggingConfig; +} + +[PublicAPI] +public record NamespaceConfig( + string Type, + string Name, + IReadOnlyList Entities) +{ + public string Type { get; } = Type; + public string Name { get; } = Name; + public IReadOnlyList Entities { get; } = Entities; +} + +[PublicAPI] +public record Entity( + string Name, + int PartitionCount, + IReadOnlyList ConsumerGroups) +{ + public string Name { get; } = Name; + public int PartitionCount { get; } = PartitionCount; + public IReadOnlyList ConsumerGroups { get; } = ConsumerGroups; +} + +[PublicAPI] +public record ConsumerGroup(string Name) +{ + public string Name { get; } = Name; +} + +[PublicAPI] +public record LoggingConfig(string Type) +{ + public string Type { get; } = Type; +} + +[PublicAPI] +public sealed class EventHubsServiceConfiguration +{ + private readonly NamespaceConfig _namespaceConfig; + + private EventHubsServiceConfiguration(NamespaceConfig namespaceConfig) + { + _namespaceConfig = namespaceConfig; + } + + public static EventHubsServiceConfiguration Create() + { + var namespaceConfig = new NamespaceConfig("EventHub", "ns-1", Array.Empty()); + return new EventHubsServiceConfiguration(namespaceConfig); + } + + public EventHubsServiceConfiguration WithEntity(string name, + int partitionCount, + params string[] consumerGroups) + { + return WithEntity(name, partitionCount, consumerGroups.ToImmutableList()); + } + + public EventHubsServiceConfiguration WithEntity(string name, + int partitionCount, + IEnumerable consumerGroups) + { + var entity = new Entity(name, partitionCount, + consumerGroups.Select(consumerGroup => new ConsumerGroup(consumerGroup)).ToImmutableList()); + var entities = _namespaceConfig.Entities.Append(entity).ToImmutableList(); + return new EventHubsServiceConfiguration(new NamespaceConfig(_namespaceConfig.Type, _namespaceConfig.Name, + entities)); + } + + public bool Validate() + { + return _namespaceConfig.Entities.All(entity => + entity.PartitionCount is > 0 and <= 32 && entity.ConsumerGroups.Count is > 0 and <= 20); + } + + public string Build() + { + var rootConfiguration = + new RootConfiguration(new UserConfig([_namespaceConfig], new LoggingConfig("file"))); + return JsonSerializer.Serialize(rootConfiguration); + } +} \ 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..140d6bb53 --- /dev/null +++ b/src/Testcontainers.EventHubs/Testcontainers.EventHubs.csproj @@ -0,0 +1,14 @@ + + + net8.0;net9.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..7a03c902f --- /dev/null +++ b/src/Testcontainers.EventHubs/Usings.cs @@ -0,0 +1,15 @@ +global using System; +global using System.Collections.Generic; +global using System.Collections.Immutable; +global using System.Linq; +global using System.Text; +global using System.Text.Json; +global using System.Threading.Tasks; +global using Docker.DotNet.Models; +global using DotNet.Testcontainers; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Configurations; +global using DotNet.Testcontainers.Containers; +global using DotNet.Testcontainers.Networks; +global using JetBrains.Annotations; +global using Testcontainers.Azurite; \ 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..033218e45 --- /dev/null +++ b/tests/Testcontainers.EventHubs.Tests/EventHubsContainerTest.cs @@ -0,0 +1,161 @@ +namespace Testcontainers.EventHubs; + +public abstract class EventHubsContainerTest : IAsyncLifetime +{ + // # --8<-- [start:MinimalConfigurationJson] + private const string EventHubsName = "eh-1"; + + private const string EventHubsConsumerGroupName = "cg-1"; + + private static readonly EventHubsServiceConfiguration ConfigurationBuilder = EventHubsServiceConfiguration + .Create() + .WithEntity(EventHubsName, 2, [EventHubsConsumerGroupName]); + // # --8<-- [end:MinimalConfigurationJson] + + private readonly EventHubsContainer _eventHubsContainer; + + private EventHubsContainerTest(EventHubsContainer eventHubsContainer) + { + _eventHubsContainer = eventHubsContainer; + } + + // # --8<-- [start:EventHubsUsage] + public Task InitializeAsync() + { + return _eventHubsContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _eventHubsContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task SendEventDataBatchShouldNotThrowException() + { + // Given + var message = Guid.NewGuid().ToString(); + + await using var client = new EventHubProducerClient(_eventHubsContainer.GetConnectionString(), EventHubsName); + + // When + var properties = await client.GetEventHubPropertiesAsync() + .ConfigureAwait(true); + + using var eventDataBatch = await client.CreateBatchAsync() + .ConfigureAwait(true); + + eventDataBatch.TryAdd(new EventData(message)); + + await client.SendAsync(eventDataBatch) + .ConfigureAwait(true); + + // Then + Assert.NotNull(properties); + } + // # --8<-- [end:EventHubsUsage] + + // # --8<-- [start:EventHubsKafkaUsage] + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task UsingKafkaShouldNotThrowException() + { + // Given + var kafkaPort = _eventHubsContainer.GetMappedPublicPort(EventHubsBuilder.KafkaPort); + var bootstrapServer = $"localhost:{kafkaPort}"; + var connectionString = _eventHubsContainer.GetConnectionString(); + + var producerConfig = new ProducerConfig + { + BootstrapServers = bootstrapServer, + SecurityProtocol = SecurityProtocol.SaslPlaintext, + SaslMechanism = SaslMechanism.Plain, + SaslUsername = "$ConnectionString", + SaslPassword = connectionString + }; + + var consumerConfig = new ConsumerConfig + { + BootstrapServers = bootstrapServer, + SecurityProtocol = SecurityProtocol.SaslPlaintext, + SaslMechanism = SaslMechanism.Plain, + SaslUsername = "$ConnectionString", + SaslPassword = connectionString, + GroupId = EventHubsConsumerGroupName, + AutoOffsetReset = AutoOffsetReset.Earliest, + }; + + var message = new Message + { + Value = Guid.NewGuid().ToString("D"), + }; + + // When + using var producer = new ProducerBuilder(producerConfig).Build(); + _ = await producer.ProduceAsync(EventHubsName, message) + .ConfigureAwait(true); + + using var consumer = new ConsumerBuilder(consumerConfig).Build(); + consumer.Subscribe(EventHubsName); + + var result = consumer.Consume(TimeSpan.FromSeconds(15)); + + // Then + Assert.NotNull(result); + Assert.Equal(message.Value, result.Message.Value); + } + + // # --8<-- [end:EventHubsKafkaUsage] + + // # --8<-- [start:MinimalConfigurationEventHubs] + [UsedImplicitly] + public sealed class EventHubsDefaultAzuriteConfiguration : EventHubsContainerTest + { + public EventHubsDefaultAzuriteConfiguration() + : base(new EventHubsBuilder() + .WithAcceptLicenseAgreement(true) + .WithConfigurationBuilder(ConfigurationBuilder) + .Build()) + { + } + } + // # --8<-- [end:MinimalConfigurationEventHubs] + + // # --8<-- [start:CustomConfigurationEventHubs] + [UsedImplicitly] + public sealed class EventHubsCustomAzuriteConfiguration : EventHubsContainerTest, IClassFixture + { + public EventHubsCustomAzuriteConfiguration(DatabaseFixture fixture) + : base(new EventHubsBuilder() + .WithAcceptLicenseAgreement(true) + .WithConfigurationBuilder(ConfigurationBuilder) + .WithAzuriteContainer(fixture.Network, fixture.Container, DatabaseFixture.AzuriteNetworkAlias) + .Build()) + { + } + } + + [UsedImplicitly] + public sealed class DatabaseFixture + { + public DatabaseFixture() + { + Network = new NetworkBuilder() + .Build(); + + Container = new AzuriteBuilder() + .WithNetwork(Network) + .WithNetworkAliases(AzuriteNetworkAlias) + .Build(); + } + + public static string AzuriteNetworkAlias => EventHubsBuilder.AzuriteNetworkAlias; + + public INetwork Network { get; } + + public AzuriteContainer Container { get; } + } + // # --8<-- [end:CustomConfigurationEventHubs] +} \ 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..724991785 --- /dev/null +++ b/tests/Testcontainers.EventHubs.Tests/Testcontainers.EventHubs.Tests.csproj @@ -0,0 +1,19 @@ + + + net9.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..6f2471440 --- /dev/null +++ b/tests/Testcontainers.EventHubs.Tests/Usings.cs @@ -0,0 +1,11 @@ +global using System; +global using System.Threading.Tasks; +global using Azure.Messaging.EventHubs; +global using Azure.Messaging.EventHubs.Producer; +global using Confluent.Kafka; +global using DotNet.Testcontainers.Builders; +global using DotNet.Testcontainers.Commons; +global using DotNet.Testcontainers.Networks; +global using JetBrains.Annotations; +global using Testcontainers.Azurite; +global using Xunit; \ No newline at end of file diff --git a/tests/Testcontainers.ServiceBus.Tests/ServiceBusContainerTest.cs b/tests/Testcontainers.ServiceBus.Tests/ServiceBusContainerTest.cs index 29fa40eba..5944fae3f 100644 --- a/tests/Testcontainers.ServiceBus.Tests/ServiceBusContainerTest.cs +++ b/tests/Testcontainers.ServiceBus.Tests/ServiceBusContainerTest.cs @@ -57,7 +57,9 @@ await sender.SendMessageAsync(message) public sealed class ServiceBusDefaultMsSqlConfiguration : ServiceBusContainerTest { public ServiceBusDefaultMsSqlConfiguration() - : base(new ServiceBusBuilder().WithAcceptLicenseAgreement(true).Build()) + : base(new ServiceBusBuilder() + .WithAcceptLicenseAgreement(true) + .Build()) { } } @@ -66,7 +68,10 @@ public ServiceBusDefaultMsSqlConfiguration() public sealed class ServiceBusCustomMsSqlConfiguration : ServiceBusContainerTest, IClassFixture { public ServiceBusCustomMsSqlConfiguration(DatabaseFixture fixture) - : base(new ServiceBusBuilder().WithAcceptLicenseAgreement(true).WithMsSqlContainer(fixture.Network, fixture.Container, fixture.DatabaseNetworkAlias).Build()) + : base(new ServiceBusBuilder() + .WithAcceptLicenseAgreement(true) + .WithMsSqlContainer(fixture.Network, fixture.Container, DatabaseFixture.DatabaseNetworkAlias) + .Build()) { } } @@ -85,7 +90,7 @@ public DatabaseFixture() .Build(); } - public string DatabaseNetworkAlias => ServiceBusBuilder.DatabaseNetworkAlias; + public static string DatabaseNetworkAlias => ServiceBusBuilder.DatabaseNetworkAlias; public INetwork Network { get; }