diff --git a/Testcontainers.sln b/Testcontainers.sln index e82a925db..0969d4f4e 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.MsSql", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.MySql", "src\Testcontainers.MySql\Testcontainers.MySql.csproj", "{9FDCFAEA-AE42-4C69-89EF-F1FF75E88CCC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Nats", "src\Testcontainers.Nats\Testcontainers.Nats.csproj", "{BF37BEA1-0816-4326-B1E0-E82290F8FCE0}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Neo4j", "src\Testcontainers.Neo4j\Testcontainers.Neo4j.csproj", "{ADC2372B-6FE0-421D-8277-BB628E8EFC22}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Oracle", "src\Testcontainers.Oracle\Testcontainers.Oracle.csproj", "{596EAFC1-0496-495C-B382-D57415FA456A}" @@ -115,6 +117,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.MsSql.Tests" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.MySql.Tests", "tests\Testcontainers.MySql.Tests\Testcontainers.MySql.Tests.csproj", "{E42DA1CE-698F-4E45-8D1F-5D5895893840}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Nats.Tests", "tests\Testcontainers.Nats.Tests\Testcontainers.Nats.Tests.csproj", "{87A3F137-6DC3-4CE5-91E6-01797D076086}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Neo4j.Tests", "tests\Testcontainers.Neo4j.Tests\Testcontainers.Neo4j.Tests.csproj", "{D3F63405-C0FA-4F83-8B79-E30BFF5FF5BF}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Oracle.Tests", "tests\Testcontainers.Oracle.Tests\Testcontainers.Oracle.Tests.csproj", "{4AC1088B-9965-4497-AC8E-570F1AD5631F}" @@ -230,6 +234,10 @@ Global {9FDCFAEA-AE42-4C69-89EF-F1FF75E88CCC}.Debug|Any CPU.Build.0 = Debug|Any CPU {9FDCFAEA-AE42-4C69-89EF-F1FF75E88CCC}.Release|Any CPU.ActiveCfg = Release|Any CPU {9FDCFAEA-AE42-4C69-89EF-F1FF75E88CCC}.Release|Any CPU.Build.0 = Release|Any CPU + {BF37BEA1-0816-4326-B1E0-E82290F8FCE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF37BEA1-0816-4326-B1E0-E82290F8FCE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF37BEA1-0816-4326-B1E0-E82290F8FCE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF37BEA1-0816-4326-B1E0-E82290F8FCE0}.Release|Any CPU.Build.0 = Release|Any CPU {ADC2372B-6FE0-421D-8277-BB628E8EFC22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ADC2372B-6FE0-421D-8277-BB628E8EFC22}.Debug|Any CPU.Build.0 = Debug|Any CPU {ADC2372B-6FE0-421D-8277-BB628E8EFC22}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -354,6 +362,10 @@ Global {E42DA1CE-698F-4E45-8D1F-5D5895893840}.Debug|Any CPU.Build.0 = Debug|Any CPU {E42DA1CE-698F-4E45-8D1F-5D5895893840}.Release|Any CPU.ActiveCfg = Release|Any CPU {E42DA1CE-698F-4E45-8D1F-5D5895893840}.Release|Any CPU.Build.0 = Release|Any CPU + {87A3F137-6DC3-4CE5-91E6-01797D076086}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87A3F137-6DC3-4CE5-91E6-01797D076086}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87A3F137-6DC3-4CE5-91E6-01797D076086}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87A3F137-6DC3-4CE5-91E6-01797D076086}.Release|Any CPU.Build.0 = Release|Any CPU {D3F63405-C0FA-4F83-8B79-E30BFF5FF5BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D3F63405-C0FA-4F83-8B79-E30BFF5FF5BF}.Debug|Any CPU.Build.0 = Debug|Any CPU {D3F63405-C0FA-4F83-8B79-E30BFF5FF5BF}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -428,6 +440,7 @@ Global {2613F146-6C66-4059-9D37-D48BA6B61515} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {121FB123-40D9-44D4-9AB7-AD57ED34F466} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {9FDCFAEA-AE42-4C69-89EF-F1FF75E88CCC} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {BF37BEA1-0816-4326-B1E0-E82290F8FCE0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {ADC2372B-6FE0-421D-8277-BB628E8EFC22} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {596EAFC1-0496-495C-B382-D57415FA456A} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} {8AB91636-9055-4900-A72A-7CFFACDFDBF0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -459,6 +472,7 @@ Global {82A7E7B8-3187-4CAE-845B-0BF43409B38A} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {25DBED78-99F4-433F-BBF5-1B4E9DEAE437} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {E42DA1CE-698F-4E45-8D1F-5D5895893840} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} + {87A3F137-6DC3-4CE5-91E6-01797D076086} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {D3F63405-C0FA-4F83-8B79-E30BFF5FF5BF} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {4AC1088B-9965-4497-AC8E-570F1AD5631F} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} {DA1D7ADE-452C-4369-83CC-56289176EACD} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} diff --git a/docs/modules/index.md b/docs/modules/index.md index b63fa53ef..33f861030 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -41,6 +41,7 @@ await moduleNameContainer.StartAsync(); | MinIO | `minio/minio:RELEASE.2023-01-31T02-24-19Z` | [NuGet](https://www.nuget.org/packages/Testcontainers.Minio) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Minio) | | MongoDB | `mongo:6.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.MongoDb) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MongoDb) | | MySQL | `mysql:8.0` | [NuGet](https://www.nuget.org/packages/Testcontainers.MySql) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.MySql) | +| NATS | `nats:2.9` | [NuGet](https://www.nuget.org/packages/Testcontainers.Nats) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Nats) | | Neo4j | `neo4j:5.4` | [NuGet](https://www.nuget.org/packages/Testcontainers.Neo4j) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Neo4j) | | Oracle | `gvenzl/oracle-xe:21.3.0-slim-faststart` | [NuGet](https://www.nuget.org/packages/Testcontainers.Oracle) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.Oracle) | | PostgreSQL | `postgres:15.1` | [NuGet](https://www.nuget.org/packages/Testcontainers.PostgreSql) | [Source](https://github.com/testcontainers/testcontainers-dotnet/tree/develop/src/Testcontainers.PostgreSql) | diff --git a/src/Testcontainers.MongoDb/MongoDbBuilder.cs b/src/Testcontainers.MongoDb/MongoDbBuilder.cs index ea0808826..e34666b95 100644 --- a/src/Testcontainers.MongoDb/MongoDbBuilder.cs +++ b/src/Testcontainers.MongoDb/MongoDbBuilder.cs @@ -96,7 +96,7 @@ protected override void Validate() .NotNull(); _ = Guard.Argument(DockerResourceConfiguration, "Credentials") - .ThrowIf(argument => 1.Equals(new[] { argument.Value.Username, argument.Value.Password }.Count(string.IsNullOrEmpty)), argument => new ArgumentException(message, argument.Name)); + .ThrowIf(argument => 1.Equals(new[] { argument.Value.Username, argument.Value.Password }.Count(string.IsNullOrWhiteSpace)), argument => new ArgumentException(message, argument.Name)); } /// diff --git a/src/Testcontainers.Nats/.editorconfig b/src/Testcontainers.Nats/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/src/Testcontainers.Nats/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/src/Testcontainers.Nats/NatsBuilder.cs b/src/Testcontainers.Nats/NatsBuilder.cs new file mode 100644 index 000000000..8de672756 --- /dev/null +++ b/src/Testcontainers.Nats/NatsBuilder.cs @@ -0,0 +1,117 @@ +namespace Testcontainers.Nats; + +/// +[PublicAPI] +public sealed class NatsBuilder : ContainerBuilder +{ + public const string NatsImage = "nats:2.9"; + + public const ushort NatsClientPort = 4222; + + public const ushort NatsClusterRoutingPort = 6222; + + public const ushort NatsHttpManagementPort = 8222; + + /// + /// Initializes a new instance of the class. + /// + public NatsBuilder() + : this(new NatsConfiguration()) + { + DockerResourceConfiguration = Init().DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private NatsBuilder(NatsConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + DockerResourceConfiguration = resourceConfiguration; + } + + /// + protected override NatsConfiguration DockerResourceConfiguration { get; } + + /// + /// Sets the Nats username. + /// + /// The Nats username. + /// A configured instance of . + public NatsBuilder WithUsername(string username) + { + return Merge(DockerResourceConfiguration, new NatsConfiguration(username: username)) + .WithCommand("--user", username); + } + + /// + /// Sets the Nats password. + /// + /// The Nats password. + /// A configured instance of . + public NatsBuilder WithPassword(string password) + { + return Merge(DockerResourceConfiguration, new NatsConfiguration(password: password)) + .WithCommand("--pass", password); + } + + /// + public override NatsContainer Build() + { + Validate(); + return new NatsContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + } + + /// + protected override NatsBuilder Init() + { + return base.Init() + .WithImage(NatsImage) + .WithPortBinding(NatsClientPort, true) + .WithPortBinding(NatsHttpManagementPort, true) + .WithPortBinding(NatsClusterRoutingPort, true) + .WithUsername(string.Empty) + .WithPassword(string.Empty) + .WithCommand("--http_port", NatsHttpManagementPort.ToString()) + .WithCommand("--jetstream") + .WithCommand("--debug") + .WithCommand("--trace") + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("Server is ready")); + } + + /// + protected override void Validate() + { + const string message = "Missing username or password. Both must be specified."; + + base.Validate(); + + _ = Guard.Argument(DockerResourceConfiguration.Username, nameof(DockerResourceConfiguration.Username)) + .NotNull(); + + _ = Guard.Argument(DockerResourceConfiguration.Password, nameof(DockerResourceConfiguration.Password)) + .NotNull(); + + _ = Guard.Argument(DockerResourceConfiguration, "Credentials") + .ThrowIf(argument => 1.Equals(new[] { argument.Value.Username, argument.Value.Password }.Count(string.IsNullOrWhiteSpace)), argument => new ArgumentException(message, argument.Name)); + } + + /// + protected override NatsBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new NatsConfiguration(resourceConfiguration)); + } + + /// + protected override NatsBuilder Clone(IContainerConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new NatsConfiguration(resourceConfiguration)); + } + + /// + protected override NatsBuilder Merge(NatsConfiguration oldValue, NatsConfiguration newValue) + { + return new NatsBuilder(new NatsConfiguration(oldValue, newValue)); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Nats/NatsConfiguration.cs b/src/Testcontainers.Nats/NatsConfiguration.cs new file mode 100644 index 000000000..4c410b773 --- /dev/null +++ b/src/Testcontainers.Nats/NatsConfiguration.cs @@ -0,0 +1,71 @@ +namespace Testcontainers.Nats; + +/// +[PublicAPI] +public sealed class NatsConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + /// The Nats username. + /// The Nats password. + public NatsConfiguration( + string username = null, + string password = null) + { + Username = username; + Password = password; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public NatsConfiguration(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 NatsConfiguration(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 NatsConfiguration(NatsConfiguration resourceConfiguration) + : this(new NatsConfiguration(), 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 NatsConfiguration(NatsConfiguration oldValue, NatsConfiguration newValue) + : base(oldValue, newValue) + { + Username = BuildConfiguration.Combine(oldValue.Username, newValue.Username); + Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password); + } + + /// + /// The Nats username. + /// + public string Username { get; } + + /// + /// The Nats password. + /// + public string Password { get; } +} \ No newline at end of file diff --git a/src/Testcontainers.Nats/NatsContainer.cs b/src/Testcontainers.Nats/NatsContainer.cs new file mode 100644 index 000000000..0800fe0b5 --- /dev/null +++ b/src/Testcontainers.Nats/NatsContainer.cs @@ -0,0 +1,43 @@ +namespace Testcontainers.Nats; + +/// +[PublicAPI] +public sealed class NatsContainer : DockerContainer +{ + private readonly NatsConfiguration _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public NatsContainer(NatsConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + _configuration = configuration; + } + + /// + /// Gets the Nats connection string. + /// + /// + /// If both username and password are set in the builder configuration, they will be included in the connection string. + /// + /// A Nats connection string in the format: nats://hostname:port. + public string GetConnectionString() + { + var endpoint = new UriBuilder("nats://", Hostname, GetMappedPublicPort(NatsBuilder.NatsClientPort)); + endpoint.UserName = Uri.EscapeDataString(_configuration.Username); + endpoint.Password = Uri.EscapeDataString(_configuration.Password); + return endpoint.ToString(); + } + + /// + /// Gets the Nats monitoring endpoint. + /// + /// An HTTP address in the format: http://hostname:port. + public string GetManagementEndpoint() + { + return new UriBuilder(Uri.UriSchemeHttp, Hostname, GetMappedPublicPort(NatsBuilder.NatsHttpManagementPort)).ToString(); + } +} \ No newline at end of file diff --git a/src/Testcontainers.Nats/Testcontainers.Nats.csproj b/src/Testcontainers.Nats/Testcontainers.Nats.csproj new file mode 100644 index 000000000..4c05d521f --- /dev/null +++ b/src/Testcontainers.Nats/Testcontainers.Nats.csproj @@ -0,0 +1,13 @@ + + + netstandard2.0;netstandard2.1 + latest + + + + + + + + + \ No newline at end of file diff --git a/src/Testcontainers.Nats/Usings.cs b/src/Testcontainers.Nats/Usings.cs new file mode 100644 index 000000000..346e012e3 --- /dev/null +++ b/src/Testcontainers.Nats/Usings.cs @@ -0,0 +1,9 @@ +global using System; +global using System.Linq; +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 JetBrains.Annotations; +global using Microsoft.Extensions.Logging; \ No newline at end of file diff --git a/tests/Testcontainers.Nats.Tests/.editorconfig b/tests/Testcontainers.Nats.Tests/.editorconfig new file mode 100644 index 000000000..6f066619d --- /dev/null +++ b/tests/Testcontainers.Nats.Tests/.editorconfig @@ -0,0 +1 @@ +root = true \ No newline at end of file diff --git a/tests/Testcontainers.Nats.Tests/NatsContainerTest.cs b/tests/Testcontainers.Nats.Tests/NatsContainerTest.cs new file mode 100644 index 000000000..0251f6b2a --- /dev/null +++ b/tests/Testcontainers.Nats.Tests/NatsContainerTest.cs @@ -0,0 +1,93 @@ +namespace Testcontainers.Nats; + +public abstract class NatsContainerTest : IAsyncLifetime +{ + private readonly NatsContainer _natsContainer; + + private NatsContainerTest(NatsContainer natsContainer) + { + _natsContainer = natsContainer; + } + + public Task InitializeAsync() + { + return _natsContainer.StartAsync(); + } + + public Task DisposeAsync() + { + return _natsContainer.DisposeAsync().AsTask(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task HealthcheckReturnsHttpStatusCodeOk() + { + // Given + using var client = new HttpClient(); + client.BaseAddress = new Uri(_natsContainer.GetManagementEndpoint()); + + // When + using var response = await client.GetAsync("/healthz") + .ConfigureAwait(false); + + var jsonStatusString = await response.Content.ReadAsStringAsync() + .ConfigureAwait(false); + + // Then + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("{\"status\":\"ok\"}", jsonStatusString); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public void GetStringReturnsPublishString() + { + // Given + var subject = Guid.NewGuid().ToString("D"); + + var message = Guid.NewGuid().ToString("D"); + + using var client = new ConnectionFactory().CreateConnection(_natsContainer.GetConnectionString()); + + using var subscription = client.SubscribeSync(subject); + + // When + client.Publish(subject, Encoding.Default.GetBytes(message)); + + var actualMessage = Encoding.Default.GetString(subscription.NextMessage().Data); + + // Then + Assert.Equal(message, actualMessage); + } + + [UsedImplicitly] + public sealed class NatsDefaultConfiguration : NatsContainerTest + { + public NatsDefaultConfiguration() + : base(new NatsBuilder().Build()) + { + } + } + + [UsedImplicitly] + public sealed class NatsAuthConfiguration : NatsContainerTest + { + public NatsAuthConfiguration() + : base(new NatsBuilder().WithUsername("%username!").WithPassword("?password&").Build()) + { + } + + [Fact] + public void ThrowsExceptionIfUsernameIsMissing() + { + Assert.Throws(() => new NatsBuilder().WithPassword("password").Build()); + } + + [Fact] + public void ThrowsExceptionIfPasswordIsMissing() + { + Assert.Throws(() => new NatsBuilder().WithUsername("username").Build()); + } + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Nats.Tests/Testcontainers.Nats.Tests.csproj b/tests/Testcontainers.Nats.Tests/Testcontainers.Nats.Tests.csproj new file mode 100644 index 000000000..4958edbaa --- /dev/null +++ b/tests/Testcontainers.Nats.Tests/Testcontainers.Nats.Tests.csproj @@ -0,0 +1,18 @@ + + + net6.0 + false + false + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Testcontainers.Nats.Tests/Usings.cs b/tests/Testcontainers.Nats.Tests/Usings.cs new file mode 100644 index 000000000..49a70954e --- /dev/null +++ b/tests/Testcontainers.Nats.Tests/Usings.cs @@ -0,0 +1,9 @@ +global using System; +global using System.Net; +global using System.Net.Http; +global using System.Text; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using JetBrains.Annotations; +global using NATS.Client; +global using Xunit; \ No newline at end of file