diff --git a/Testcontainers.sln b/Testcontainers.sln index 4a415068d..5ba34f2bb 100644 --- a/Testcontainers.sln +++ b/Testcontainers.sln @@ -137,6 +137,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.Nats", "src\Testcontainers.Nats\Testcontainers.Nats.csproj", "{BF37BEA1-0816-4326-B1E0-E82290F8FCE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Testcontainers.Nats.Tests", "tests\Testcontainers.Nats.Tests\Testcontainers.Nats.Tests.csproj", "{87A3F137-6DC3-4CE5-91E6-01797D076086}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -394,6 +398,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 + {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 + {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 EndGlobalSection GlobalSection(NestedProjects) = preSolution {3F2E254F-C203-43FD-A078-DC3E2CBC0F9F} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} @@ -458,5 +470,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} + {BF37BEA1-0816-4326-B1E0-E82290F8FCE0} = {673F23AE-7694-4BB9-ABD4-136D6C13634E} + {87A3F137-6DC3-4CE5-91E6-01797D076086} = {7164F1FB-7F24-444A-ACD2-2C329C2B3CCF} EndGlobalSection EndGlobal 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..535d0682e --- /dev/null +++ b/src/Testcontainers.Nats/NatsBuilder.cs @@ -0,0 +1,108 @@ +namespace Testcontainers.Nats; + +/// +[PublicAPI] +public sealed class NatsBuilder : ContainerBuilder +{ + public const string NatsImage = "nats:2.9"; + + public const ushort ClientPort = 4222; + public const ushort RoutingPort = 6222; + public const ushort MonitoringPort = 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 Server password. + /// + /// The Nats Server password. + /// A configured instance of . + public NatsBuilder WithPassword(string password) + { + return Merge(DockerResourceConfiguration, new NatsConfiguration(password: password)) + .WithCommand("-pass", password); + } + + /// + /// Sets the Nats Server username. + /// + /// The Nats Server username. + /// A configured instance of . + public NatsBuilder WithUsername(string username) + { + return Merge(DockerResourceConfiguration, new NatsConfiguration(username: username)) + .WithCommand("-user", username); + } + + /// + /// Sets the Nats config. + /// + /// The Nats config. + /// A configured instance of . + public NatsBuilder WithNatsConfig(NatsConfiguration config) + { + // Extends the ContainerBuilder capabilities and holds a custom configuration in NatsConfiguration. + // In case of a module requires other properties to represent itself, extend ContainerConfiguration. + return Merge(DockerResourceConfiguration, new NatsConfiguration(config)); + } + + /// + public override NatsContainer Build() + { + Validate(); + return new NatsContainer(DockerResourceConfiguration, TestcontainersSettings.Logger); + } + + /// + protected override NatsBuilder Init() + { + return base.Init() + .WithImage(NatsImage) + .WithPortBinding(ClientPort, true) + .WithPortBinding(MonitoringPort, true) + .WithPortBinding(RoutingPort, true) + .WithCommand("-m", MonitoringPort.ToString()) // Enable monitoring endpoint. + .WithCommand("-js") // Enable JetStream functionality. + .WithCommand("-DV") // Enable both debug and protocol trace messages + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged("Listening for client connections on 0.0.0.0:4222")); + } + + /// + 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..4f87c389a --- /dev/null +++ b/src/Testcontainers.Nats/NatsConfiguration.cs @@ -0,0 +1,72 @@ +namespace Testcontainers.Nats; + +/// +[PublicAPI] +public sealed class NatsConfiguration : ContainerConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + /// The nats server user name. + /// The nats server 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) + { + // // Create an updated immutable copy of the module configuration. + Username = BuildConfiguration.Combine(oldValue.Username, newValue.Username); + Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password); + } + + /// + /// The nats server user name. + /// + public string Username { get; } + + /// + /// The nats server 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..59f667570 --- /dev/null +++ b/src/Testcontainers.Nats/NatsContainer.cs @@ -0,0 +1,44 @@ +namespace Testcontainers.Nats; + +/// +[PublicAPI] +public sealed class NatsContainer : DockerContainer +{ + private readonly NatsConfiguration _natsConfig; + + /// + /// Initializes a new instance of the class. + /// + /// The container configuration. + /// The logger. + public NatsContainer(NatsConfiguration configuration, ILogger logger) + : base(configuration, logger) + { + _natsConfig = configuration; + } + + /// + /// Gets the nats connection string + /// + /// A nats connection string in the form: nats://hostname:mappedPort/>. + /// + /// If either username or password is set, the connection string will contain the credentials. + /// + public string GetConnectionString() + { + return new UriBuilder("nats", Hostname, GetMappedPublicPort(NatsBuilder.ClientPort)) + { + UserName = _natsConfig.Username, + Password = _natsConfig.Password, + }.ToString(); + } + + /// + /// Gets the nats monitor url + /// + /// A url in the form: http://hostname:mappedPort/>. + public string GetMonitorUrl() + { + return new UriBuilder("http", Hostname, GetMappedPublicPort(NatsBuilder.MonitoringPort)).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..bf2829a65 --- /dev/null +++ b/src/Testcontainers.Nats/Usings.cs @@ -0,0 +1,7 @@ +global using System; +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 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..9aac0bd93 --- /dev/null +++ b/tests/Testcontainers.Nats.Tests/NatsContainerTest.cs @@ -0,0 +1,75 @@ +namespace Testcontainers.Nats; + +public sealed class NatsContainerTest : IAsyncLifetime +{ + private readonly NatsContainer _natsContainer = new NatsBuilder().Build(); + + 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 ContainerIsStartedWithCorrectParameters() + { + using var client = new ConnectionFactory() + .CreateConnection(_natsContainer.GetConnectionString()); + + Assert.Equal(ConnState.CONNECTED, client.State); + Assert.True(client.ServerInfo.JetStreamAvailable); + + using var monitorClient = new HttpClient() + { + BaseAddress = new Uri(_natsContainer.GetMonitorUrl()), + }; + + using var response = await monitorClient.GetAsync("/healthz"); + var s = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task PubSubSendsAndReturnsMessages() + { + using var client = new ConnectionFactory() + .CreateConnection(_natsContainer.GetConnectionString()); + + using ISyncSubscription subSync = client.SubscribeSync("greet.pam"); + client.Publish("greet.pam", Encoding.UTF8.GetBytes("hello pam 1")); + + var msg = subSync.NextMessage(1000); + var text = Encoding.UTF8.GetString(msg.Data); + + + Assert.Equal("hello pam 1", text); + await client.DrainAsync(); + } + + [Fact] + [Trait(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))] + public async Task BuilderShouldBuildWithUserNameAndPassword() + { + var builder = new NatsBuilder() + .WithUsername("test") + .WithPassword("testpass"); + await using var container = builder.Build(); + + await container.StartAsync(); + + var uri = new Uri(container.GetConnectionString()); + + Assert.Equal("test:testpass", uri.UserInfo); + + using var client = new ConnectionFactory() + .CreateConnection(_natsContainer.GetConnectionString()); + + Assert.Equal(ConnState.CONNECTED, client.State); + } +} \ 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..ad254a3d3 --- /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..9cf5647c7 --- /dev/null +++ b/tests/Testcontainers.Nats.Tests/Usings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Net.Http; +global using System.Text; +global using System.Threading.Tasks; +global using DotNet.Testcontainers.Commons; +global using NATS.Client; +global using Xunit; \ No newline at end of file