diff --git a/.github/workflows/codecoverage.yml b/.github/workflows/codecoverage.yml index 7be7317..09600ec 100644 --- a/.github/workflows/codecoverage.yml +++ b/.github/workflows/codecoverage.yml @@ -27,23 +27,23 @@ jobs: - name: Run Unit Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - dotnet test "Shared.Tests\Shared.Tests.csproj" \ - /p:CollectCoverage=true \ - /p:Exclude="[xunit*]*" \ - /p:ExcludeByAttribute="Obsolete" \ - /p:ExcludeByAttribute="GeneratedCodeAttribute" \ - /p:ExcludeByAttribute="CompilerGeneratedAttribute" \ - /p:ExcludeByAttribute="ExcludeFromCodeCoverageAttribute" \ - /p:CoverletOutput="../lcov1.info" /maxcpucount:1 \ - /p:CoverletOutputFormat="lcov" - - dotnet test "Shared.EventStore.Tests\Shared.EventStore.Tests.csproj" \ - /p:CollectCoverage=true /p:Exclude="[xunit*]*" \ - /p:ExcludeByAttribute="Obsolete" \ - /p:ExcludeByAttribute="GeneratedCodeAttribute" \ - /p:ExcludeByAttribute="CompilerGeneratedAttribute" \ - /p:ExcludeByAttribute="ExcludeFromCodeCoverageAttribute" \ - /p:CoverletOutput="../lcov2.info" /maxcpucount:1 /p:CoverletOutputFormat="lcov" + dotnet test "Shared.Tests/Shared.Tests.csproj" \ + --results-directory "coverage/Shared.Tests" \ + --coverlet --coverlet-output-format lcov --coverlet-file-prefix Shared.Tests \ + --coverlet-exclude "[xunit*]*" \ + --coverlet-exclude-by-attribute Obsolete \ + --coverlet-exclude-by-attribute GeneratedCodeAttribute \ + --coverlet-exclude-by-attribute CompilerGeneratedAttribute \ + --coverlet-exclude-by-attribute ExcludeFromCodeCoverageAttribute + + dotnet test "Shared.EventStore.Tests/Shared.EventStore.Tests.csproj" \ + --results-directory "coverage/Shared.EventStore.Tests" \ + --coverlet --coverlet-output-format lcov --coverlet-file-prefix Shared.EventStore.Tests \ + --coverlet-exclude "[xunit*]*" \ + --coverlet-exclude-by-attribute Obsolete \ + --coverlet-exclude-by-attribute GeneratedCodeAttribute \ + --coverlet-exclude-by-attribute CompilerGeneratedAttribute \ + --coverlet-exclude-by-attribute ExcludeFromCodeCoverageAttribute - name: Install LCOV merger run: npm install -g lcov-result-merger @@ -51,7 +51,7 @@ jobs: - name: Merge LCOV reports run: | mkdir -p coverage - lcov-result-merger "*.info" > lcov.info + lcov-result-merger "coverage/**/*.lcov" > lcov.info - name: Upload merged coverage to Codacy uses: codacy/codacy-coverage-reporter-action@v1 diff --git a/.github/workflows/nightlybuild.yml b/.github/workflows/nightlybuild.yml index dc25577..df94184 100644 --- a/.github/workflows/nightlybuild.yml +++ b/.github/workflows/nightlybuild.yml @@ -37,18 +37,18 @@ jobs: - name: Run Unit Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - dotnet test "Shared.Tests\Shared.Tests.csproj" + dotnet test "Shared.Tests/Shared.Tests.csproj" - name: Run Event Store Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - dotnet test "Shared.EventStore.Tests\Shared.EventStore.Tests.csproj" - dotnet test "Shared.EventStoreContext.Tests\Shared.EventStoreContext.Tests.csproj" + dotnet test "Shared.EventStore.Tests/Shared.EventStore.Tests.csproj" + dotnet test "Shared.EventStoreContext.Tests/Shared.EventStoreContext.Tests.csproj" - name: Run Integration Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - dotnet test "Shared.IntegrationTesting.Tests\Shared.IntegrationTesting.Tests.csproj" + dotnet test "Shared.IntegrationTesting.Tests/Shared.IntegrationTesting.Tests.csproj" - uses: actions/upload-artifact@v4.4.3 with: diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index a17c807..8afcd34 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -30,18 +30,18 @@ jobs: - name: Run Unit Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - dotnet test "Shared.Tests\Shared.Tests.csproj" + dotnet test "Shared.Tests/Shared.Tests.csproj" - name: Run Event Store Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - dotnet test "Shared.EventStore.Tests\Shared.EventStore.Tests.csproj" - dotnet test "Shared.EventStoreContext.Tests\Shared.EventStoreContext.Tests.csproj" + dotnet test "Shared.EventStore.Tests/Shared.EventStore.Tests.csproj" + dotnet test "Shared.EventStoreContext.Tests/Shared.EventStoreContext.Tests.csproj" - name: Run Integration Tests run: | echo "ASPNETCORE_ENVIRONMENT are > ${ASPNETCORE_ENVIRONMENT}" - dotnet test "Shared.IntegrationTesting.Tests\Shared.IntegrationTesting.Tests.csproj" + dotnet test "Shared.IntegrationTesting.Tests/Shared.IntegrationTesting.Tests.csproj" - uses: actions/upload-artifact@v4.4.3 with: diff --git a/Directory.Packages.props b/Directory.Packages.props index 210567d..8f90f5a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,40 +4,38 @@ - - - - - - - - + + + + + + + - - - + + + + - - - - - - + + + + + + - + - - + + - - - + - + - \ No newline at end of file + diff --git a/Driver/Driver.csproj b/Driver/Driver.csproj index e6e0035..7852aa6 100644 --- a/Driver/Driver.csproj +++ b/Driver/Driver.csproj @@ -11,6 +11,7 @@ + diff --git a/README.md b/README.md index 57f7627..e3ffc07 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Other packages can be installed independently depending on the functionality req Run the test suite with: ```bash -dotnet test Shared.sln +dotnet test --solution Shared.sln ``` Docker-backed integration tests require a compatible Linux Docker engine. diff --git a/Shared.EventStore.Tests/AssemblyInfo.cs b/Shared.EventStore.Tests/AssemblyInfo.cs index 2171200..61e212b 100644 --- a/Shared.EventStore.Tests/AssemblyInfo.cs +++ b/Shared.EventStore.Tests/AssemblyInfo.cs @@ -1,3 +1,4 @@ -using Xunit; +using Xunit.Sdk; +using Xunit.v3; -[assembly: CollectionBehavior(DisableTestParallelization = true)] +[assembly: Parallelization(Mode = ParallelMode.None)] diff --git a/Shared.EventStore.Tests/Shared.EventStore.Tests.csproj b/Shared.EventStore.Tests/Shared.EventStore.Tests.csproj index 32e8910..a13e49e 100644 --- a/Shared.EventStore.Tests/Shared.EventStore.Tests.csproj +++ b/Shared.EventStore.Tests/Shared.EventStore.Tests.csproj @@ -13,16 +13,16 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + all + + all diff --git a/Shared.EventStore/Shared.EventStore.csproj b/Shared.EventStore/Shared.EventStore.csproj index b7c39b9..9fe6060 100644 --- a/Shared.EventStore/Shared.EventStore.csproj +++ b/Shared.EventStore/Shared.EventStore.csproj @@ -16,6 +16,7 @@ + diff --git a/Shared.EventStoreContext.Tests/EventStoreContextRetryIntegrationTests.cs b/Shared.EventStoreContext.Tests/EventStoreContextRetryIntegrationTests.cs index 0b68b4b..36b0b5e 100644 --- a/Shared.EventStoreContext.Tests/EventStoreContextRetryIntegrationTests.cs +++ b/Shared.EventStoreContext.Tests/EventStoreContextRetryIntegrationTests.cs @@ -60,10 +60,10 @@ public async Task EventStoreContext_InsertEvents_RetriesAfterDockerOutage() Task restartTask = Task.Run(async () => { await Task.Delay(2000); - await this.EventStoreDockerHelper.UnpauseEventStoreContainer(); + await this.EventStoreDockerHelper.UnpauseKurrentDbContainer(); }); - await this.EventStoreDockerHelper.PauseEventStoreContainer(); + await this.EventStoreDockerHelper.PauseKurrentDbContainer(); try { diff --git a/Shared.EventStoreContext.Tests/EventStoreDockerHelper.cs b/Shared.EventStoreContext.Tests/EventStoreDockerHelper.cs index e2acc3c..5f96799 100644 --- a/Shared.EventStoreContext.Tests/EventStoreDockerHelper.cs +++ b/Shared.EventStoreContext.Tests/EventStoreDockerHelper.cs @@ -18,7 +18,7 @@ public async Task StartContainers( { this.SetHostTraceFolder(testName); this.ScenarioName = testName; - await this.StartContainersForScenarioRun(testName, DockerServices.EventStore, cancellationToken); + await this.StartContainersForScenarioRun(testName, DockerServices.KurrentDb, cancellationToken); } public override async Task StopContainersForScenarioRun(DockerServices sharedDockerServices) @@ -66,34 +66,34 @@ public override async Task StartContainersForScenarioRun( ContainerBuilder SetupSecureEventStoreContainerLocal() { - this.IsSecureEventStore = true; + this.IsSecureKurrentDb = true; - this.EventStoreContainerName = "UnitTestEventStore_Secure"; + this.KurrentDbContainerName = "UnitTestKurrentDb_Secure"; - return this.SetupEventStoreContainer().WithReuse(true); + return this.SetupKurrentDbContainer().WithReuse(true); } ContainerBuilder SetupInsecureEventStoreContainerLocal() { - this.IsSecureEventStore = false; + this.IsSecureKurrentDb = false; - this.EventStoreContainerName = "UnitTestEventStore_Insecure"; + this.KurrentDbContainerName = "UnitTestKurrentDb_Insecure"; - return this.SetupEventStoreContainer().WithReuse(true); + return this.SetupKurrentDbContainer().WithReuse(true); } await this.StartContainer2(SetupSecureEventStoreContainerLocal, new List { networkService }, - DockerServices.EventStore, + DockerServices.KurrentDb, cancellationToken); await this.StartContainer2(SetupInsecureEventStoreContainerLocal, new List { networkService }, - DockerServices.EventStore, + DockerServices.KurrentDb, cancellationToken); } @@ -101,8 +101,8 @@ public KurrentDBClientSettings CreateEventStoreClientSettings(Boolean secureEven { String connectionString = secureEventStore switch { - true => $"esdb://{userName}:{password}@127.0.0.1:{this.EventStoreSecureHttpPort}?tls=true&tlsVerifyCert=false", - _ => $"esdb://{userName}:{password}@127.0.0.1:{this.EventStoreHttpPort}?tls=false" + true => $"esdb://{userName}:{password}@127.0.0.1:{this.KurrentDbSecureHttpPort}?tls=true&tlsVerifyCert=false", + _ => $"esdb://{userName}:{password}@127.0.0.1:{this.KurrentDbHttpPort}?tls=false" }; KurrentDBClientSettings settings = KurrentDBClientSettings.Create(connectionString); @@ -127,24 +127,24 @@ public KurrentDBClientSettings CreateEventStoreClientSettings(Boolean secureEven return settings; } - public Task PauseEventStoreContainer() + public Task PauseKurrentDbContainer() { - return this.RunDockerCommand($"pause {this.EventStoreContainerName}"); + return this.RunDockerCommand($"pause {this.KurrentDbContainerName}"); } - public Task UnpauseEventStoreContainer() + public Task UnpauseKurrentDbContainer() { - return this.RunDockerCommand($"unpause {this.EventStoreContainerName}"); + return this.RunDockerCommand($"unpause {this.KurrentDbContainerName}"); } public Task StopEventStoreContainer() { - return this.RunDockerCommand($"stop -t 0 {this.EventStoreContainerName}"); + return this.RunDockerCommand($"stop -t 0 {this.KurrentDbContainerName}"); } - public Task StartEventStoreContainer() + public Task StartKurrentDbContainer() { - return this.RunDockerCommand($"start {this.EventStoreContainerName}"); + return this.RunDockerCommand($"start {this.KurrentDbContainerName}"); } private async Task RunDockerCommand(String arguments) diff --git a/Shared.EventStoreContext.Tests/Shared.EventStoreContext.Tests.csproj b/Shared.EventStoreContext.Tests/Shared.EventStoreContext.Tests.csproj index 90aa53c..668eb02 100644 --- a/Shared.EventStoreContext.Tests/Shared.EventStoreContext.Tests.csproj +++ b/Shared.EventStoreContext.Tests/Shared.EventStoreContext.Tests.csproj @@ -2,6 +2,8 @@ net10.0 + true + Exe enable enable Full @@ -16,11 +18,8 @@ - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Shared.IntegrationTesting.Tests/AssemblyInfo.cs b/Shared.IntegrationTesting.Tests/AssemblyInfo.cs index 1f8a70e..ea172eb 100644 --- a/Shared.IntegrationTesting.Tests/AssemblyInfo.cs +++ b/Shared.IntegrationTesting.Tests/AssemblyInfo.cs @@ -16,5 +16,6 @@ // The following GUID is for the ID of the typelib if this project is exposed to COM. [assembly: Guid("36b1817d-245b-424f-8a32-a924b23f98ba")] +// Scenarios remain parallelizable because shared SQL connections use per-scenario database names. [assembly: LevelOfParallelism(2)] -[assembly: Parallelizable(ParallelScope.Fixtures)] \ No newline at end of file +[assembly: Parallelizable(ParallelScope.Fixtures)] diff --git a/Shared.IntegrationTesting.Tests/DockerHelperStartupPlanTests.cs b/Shared.IntegrationTesting.Tests/DockerHelperStartupPlanTests.cs index 81f8576..b55cd1c 100644 --- a/Shared.IntegrationTesting.Tests/DockerHelperStartupPlanTests.cs +++ b/Shared.IntegrationTesting.Tests/DockerHelperStartupPlanTests.cs @@ -1,18 +1,19 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using EventStore.Client; +using KurrentDB.Client; using NLog; using NUnit.Framework; -using Shared.Logger; using Shared.IntegrationTesting; using Shared.IntegrationTesting.TestContainers; +using Shared.Logger; using Shouldly; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; namespace Shared.IntegrationTesting.Tests; @@ -20,14 +21,43 @@ public class DockerHelperStartupPlanTests { private sealed class InspectableDockerHelper : TestDockerHelper { + public string GetConnectionStringForTesting(string databaseName) + { + return this.SetConnectionString(databaseName); + } + + public static string GetScenarioDatabaseNameForTesting( + string databaseName, + Guid testId, + bool sharedSqlServer) + { + return BaseDockerHelper.GetScenarioDatabaseName(databaseName, testId, sharedSqlServer); + } + + public static DockerServices GetServicesForStartupForTesting( + DockerServices requiredServices, + bool hasSharedSqlServer) + { + return BaseDockerHelper.GetServicesForStartup(requiredServices, hasSharedSqlServer); + } + + public static string GetHostTraceFolderPathForTesting( + string scenarioName, + DateTimeOffset runStartedAt, + bool isCI, + bool isWindows) + { + return BuildHostTraceFolderPath(scenarioName, runStartedAt, isCI, isWindows); + } + public IReadOnlyList> GetStartupGroupsForTesting() { return base.GetStartupGroups(); } - public HttpClient GetEventStoreHealthCheckClientForTesting() + public HttpClient GetKurrentDbHealthCheckClientForTesting() { - return BaseDockerHelper.EventStoreHealthCheckClient; + return BaseDockerHelper.KurrentDbHealthCheckClient; } public Task CaptureContainerLogsForTesting(IContainer container) @@ -73,7 +103,7 @@ protected override List GetRequiredProjections() } protected override Task CreateContinuousProjection( - EventStoreProjectionManagementClient projectionClient, + KurrentDBProjectionManagementClient projectionClient, string projectionName, string projection, CancellationToken cancellationToken) @@ -91,7 +121,7 @@ public void StartupGroups_place_foundation_services_first_and_estate_management_ startupGroups.Select(group => group.ToArray()).ShouldBe([ [DockerServices.SqlServer], - [DockerServices.EventStore], + [DockerServices.KurrentDb], [ DockerServices.MessagingService, DockerServices.SecurityService, @@ -113,7 +143,7 @@ public void Shared_containers_are_retained_for_later_cleanup() List<(DockerServices service, IContainer container)> containers = [ (DockerServices.SqlServer, null!), - (DockerServices.EventStore, null!) + (DockerServices.KurrentDb, null!) ]; IReadOnlyList<(DockerServices service, IContainer container)> remaining = @@ -122,13 +152,125 @@ public void Shared_containers_are_retained_for_later_cleanup() remaining.Select(container => container.service).ShouldBe([DockerServices.SqlServer]); } + [Test] + public void Shared_SQL_server_is_not_started_by_a_scenario_helper() + { + DockerServices services = InspectableDockerHelper.GetServicesForStartupForTesting( + DockerServices.SqlServer | DockerServices.KurrentDb, + hasSharedSqlServer: true); + + services.ShouldBe(DockerServices.KurrentDb); + } + + [Test] + public void Scenario_helper_still_starts_SQL_server_without_a_shared_fixture() + { + DockerServices services = InspectableDockerHelper.GetServicesForStartupForTesting( + DockerServices.SqlServer | DockerServices.KurrentDb, + hasSharedSqlServer: false); + + services.ShouldBe(DockerServices.SqlServer | DockerServices.KurrentDb); + } + + [Test] + public void Scenario_database_names_include_the_scenario_id() + { + Guid firstTestId = Guid.NewGuid(); + Guid secondTestId = Guid.NewGuid(); + + string firstDatabaseName = InspectableDockerHelper.GetScenarioDatabaseNameForTesting( + "TransactionProcessorReadModel", + firstTestId, + sharedSqlServer: true); + string secondDatabaseName = InspectableDockerHelper.GetScenarioDatabaseNameForTesting( + "TransactionProcessorReadModel", + secondTestId, + sharedSqlServer: true); + + firstDatabaseName.ShouldBe($"TransactionProcessorReadModel-{firstTestId:N}"); + secondDatabaseName.ShouldBe($"TransactionProcessorReadModel-{secondTestId:N}"); + firstDatabaseName.ShouldNotBe(secondDatabaseName); + } + + [Test] + public void Existing_scenario_scoped_database_names_are_not_suffixed_twice() + { + Guid testId = Guid.NewGuid(); + + string databaseName = InspectableDockerHelper.GetScenarioDatabaseNameForTesting( + $"PersistedGrantStore-{testId}", + testId, + sharedSqlServer: true); + + databaseName.ShouldBe($"PersistedGrantStore-{testId:N}"); + } + + [Test] + public void Master_database_name_is_not_scenario_scoped() + { + InspectableDockerHelper helper = new() { TestId = Guid.NewGuid() }; + + helper.GetConnectionStringForTesting("master").ShouldContain("database=master;"); + } + + [Test] + public void Host_trace_folder_contains_the_test_run_timestamp() + { + DateTimeOffset firstRunStartedAt = new(2026, 10, 19, 16, 37, 0, TimeSpan.Zero); + DateTimeOffset secondRunStartedAt = new(2026, 10, 19, 16, 38, 0, TimeSpan.Zero); + string firstRunPath = InspectableDockerHelper.GetHostTraceFolderPathForTesting( + "MyScenario", + firstRunStartedAt, + isCI: false, + isWindows: true); + string secondRunPath = InspectableDockerHelper.GetHostTraceFolderPathForTesting( + "MyScenario", + secondRunStartedAt, + isCI: false, + isWindows: true); + + firstRunPath.ShouldEndWith(Path.Combine("MyScenario", "20261019", "1637")); + secondRunPath.ShouldEndWith(Path.Combine("MyScenario", "20261019", "1638")); + firstRunPath.ShouldNotBe(secondRunPath); + } + + [Test] + public void Local_host_trace_folder_uses_the_integration_tests_root() + { + string windowsLocalPath = InspectableDockerHelper.GetHostTraceFolderPathForTesting( + "MyScenario", + new DateTimeOffset(2026, 10, 19, 16, 37, 0, TimeSpan.Zero), + isCI: false, + isWindows: true); + string linuxLocalPath = InspectableDockerHelper.GetHostTraceFolderPathForTesting( + "MyScenario", + new DateTimeOffset(2026, 10, 19, 16, 37, 0, TimeSpan.Zero), + isCI: false, + isWindows: false); + string windowsCiPath = InspectableDockerHelper.GetHostTraceFolderPathForTesting( + "MyScenario", + new DateTimeOffset(2026, 10, 19, 16, 37, 0, TimeSpan.Zero), + isCI: true, + isWindows: true); + string linuxCiPath = InspectableDockerHelper.GetHostTraceFolderPathForTesting( + "MyScenario", + new DateTimeOffset(2026, 10, 19, 16, 37, 0, TimeSpan.Zero), + isCI: true, + isWindows: false); + + windowsLocalPath.ShouldBe(Path.Combine(@"C:\home\integrationtests", "MyScenario", "20261019", "1637")); + linuxLocalPath.Replace('\\', '/').ShouldBe("/home/integrationtests/MyScenario/20261019/1637"); + windowsCiPath.ShouldBe(Path.Combine(@"C:\Users\runneradmin\trace", "MyScenario", "20261019", "1637")); + linuxCiPath.Replace('\\', '/').ShouldBe("/home/runner/trace/MyScenario/20261019/1637"); + } + [Test] public void EventStore_health_checks_reuse_their_http_client() { InspectableDockerHelper helper = new(); - HttpClient firstClient = helper.GetEventStoreHealthCheckClientForTesting(); - HttpClient secondClient = helper.GetEventStoreHealthCheckClientForTesting(); + HttpClient firstClient = helper.GetKurrentDbHealthCheckClientForTesting(); + HttpClient secondClient = helper.GetKurrentDbHealthCheckClientForTesting(); secondClient.ShouldBeSameAs(firstClient); } diff --git a/Shared.IntegrationTesting.Tests/DockerHelperTest.feature b/Shared.IntegrationTesting.Tests/DockerHelperTest.feature index 7a07e07..4263d6d 100644 --- a/Shared.IntegrationTesting.Tests/DockerHelperTest.feature +++ b/Shared.IntegrationTesting.Tests/DockerHelperTest.feature @@ -2,3 +2,5 @@ Feature: DockerHelperTest Scenario: Test Container Startup Sequence + +Scenario: Test Container Startup Sequence 2 diff --git a/Shared.IntegrationTesting.Tests/DockerHelperTest.feature.cs b/Shared.IntegrationTesting.Tests/DockerHelperTest.feature.cs index 96aa205..efdb9d7 100644 --- a/Shared.IntegrationTesting.Tests/DockerHelperTest.feature.cs +++ b/Shared.IntegrationTesting.Tests/DockerHelperTest.feature.cs @@ -107,7 +107,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa private static global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages InitializeCucumberMessages() { - return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("DockerHelperTest.feature.ndjson", 3); + return new global::Reqnroll.Formatters.RuntimeSupport.FeatureLevelCucumberMessages("DockerHelperTest.feature.ndjson", 4); } [global::NUnit.Framework.TestAttribute()] @@ -122,6 +122,30 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa global::Reqnroll.RuleInfo ruleInfo = null; #line 4 this.ScenarioInitialize(scenarioInfo, ruleInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + await testRunner.SkipScenarioAsync(); + } + else + { + await this.ScenarioStartAsync(); + } + await this.ScenarioCleanupAsync(); + } + + [global::NUnit.Framework.TestAttribute()] + [global::NUnit.Framework.DescriptionAttribute("Test Container Startup Sequence 2")] + public async global::System.Threading.Tasks.Task TestContainerStartupSequence2() + { + string[] tagsOfScenario = ((string[])(null)); + global::System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new global::System.Collections.Specialized.OrderedDictionary(); + string pickleIndex = "1"; + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Test Container Startup Sequence 2", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); + string[] tagsOfRule = ((string[])(null)); + global::Reqnroll.RuleInfo ruleInfo = null; +#line 6 +this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) { diff --git a/Shared.IntegrationTesting.Tests/GenericSteps.cs b/Shared.IntegrationTesting.Tests/GenericSteps.cs index c606e35..6c7c554 100644 --- a/Shared.IntegrationTesting.Tests/GenericSteps.cs +++ b/Shared.IntegrationTesting.Tests/GenericSteps.cs @@ -41,7 +41,7 @@ public async Task StartSystem() { this.TestingContext.DockerHelper = new TestDockerHelper(); this.TestingContext.DockerHelper.Logger = logger; - DockerServices services = DockerServices.SqlServer | DockerServices.EventStore | DockerServices.MessagingService | DockerServices.SecurityService | + DockerServices services = DockerServices.SqlServer | DockerServices.KurrentDb | DockerServices.MessagingService | DockerServices.SecurityService | DockerServices.CallbackHandler | DockerServices.FileProcessor | DockerServices.TestHost | DockerServices.TransactionProcessor | DockerServices.TransactionProcessorAcl | DockerServices.ConfigurationHost | @@ -50,10 +50,24 @@ public async Task StartSystem() { this.TestingContext.Logger = logger; this.TestingContext.Logger.LogInformation("About to Start Containers for Scenario Run"); this.TestingContext.DockerHelper.ScenarioName = scenarioName; - await this.TestingContext.DockerHelper.StartContainersForScenarioRun( - scenarioName, - services, - CancellationToken.None).ConfigureAwait(false); + System.Diagnostics.Stopwatch startupTimer = System.Diagnostics.Stopwatch.StartNew(); + Setup.RecordInfrastructureStartupStarted(); + try + { + this.TestingContext.DockerHelper.UseSharedSqlServer( + await Setup.GetSqlServerFixtureAsync().ConfigureAwait(false)); + await this.TestingContext.DockerHelper.StartContainersForScenarioRun( + scenarioName, + services, + CancellationToken.None).ConfigureAwait(false); + } + finally + { + startupTimer.Stop(); + Setup.RecordInfrastructureStartupCompleted(); + this.TestingContext.Logger.LogInformation( + $"Scenario infrastructure startup completed in {startupTimer.Elapsed.TotalSeconds:N1}s"); + } this.TestingContext.Logger.LogInformation("Containers for Scenario Run Started"); } diff --git a/Shared.IntegrationTesting.Tests/Setup.cs b/Shared.IntegrationTesting.Tests/Setup.cs index 673487e..fdad5fa 100644 --- a/Shared.IntegrationTesting.Tests/Setup.cs +++ b/Shared.IntegrationTesting.Tests/Setup.cs @@ -12,9 +12,94 @@ namespace Shared.IntegrationTesting.Tests; [Binding] public class Setup { + private static readonly SemaphoreSlim SqlServerFixtureLock = new(1, 1); + private static Shared.IntegrationTesting.TestContainers.SqlServerTestRunFixture? sqlServerFixture; + private static long InfrastructureStartupStartedTimestamp; + private static long InfrastructureStartupCompletedTimestamp; + + public static async Task GetSqlServerFixtureAsync() + { + await SqlServerFixtureLock.WaitAsync().ConfigureAwait(false); + try + { + if (sqlServerFixture is null) + { + System.Diagnostics.Stopwatch startupTimer = System.Diagnostics.Stopwatch.StartNew(); + try + { + sqlServerFixture = await Shared.IntegrationTesting.TestContainers.SqlServerTestRunFixture + .StartAsync().ConfigureAwait(false); + } + finally + { + startupTimer.Stop(); + LogManager.GetLogger(nameof(Setup)).Info( + "Shared SQL Server fixture startup completed in {0:N1}s", + startupTimer.Elapsed.TotalSeconds); + } + } + + return sqlServerFixture; + } + finally + { + SqlServerFixtureLock.Release(); + } + } + [BeforeTestRun] - protected static Task GlobalSetup(){ + public static Task GlobalSetup() + { ShouldlyConfiguration.DefaultTaskTimeout = TimeSpan.FromMinutes(1); return Task.CompletedTask; } + + [AfterTestRun] + public static async Task GlobalTeardown() + { + try + { + if (sqlServerFixture is not null) + { + await sqlServerFixture.DisposeAsync().ConfigureAwait(false); + sqlServerFixture = null; + } + } + finally + { + long startedTimestamp = Interlocked.Read(ref InfrastructureStartupStartedTimestamp); + long completedTimestamp = Interlocked.Read(ref InfrastructureStartupCompletedTimestamp); + TimeSpan totalStartup = startedTimestamp == 0 || completedTimestamp < startedTimestamp + ? TimeSpan.Zero + : TimeSpan.FromSeconds( + (completedTimestamp - startedTimestamp) / (double)System.Diagnostics.Stopwatch.Frequency); + LogManager.GetLogger(nameof(Setup)).Info( + "Integration-test infrastructure startup wall time (first scenario request to last scenario ready): {0:N1}s", + totalStartup.TotalSeconds); + } + } + + internal static void RecordInfrastructureStartupStarted() + { + long timestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + Interlocked.CompareExchange(ref InfrastructureStartupStartedTimestamp, timestamp, 0); + } + + internal static void RecordInfrastructureStartupCompleted() + { + long timestamp = System.Diagnostics.Stopwatch.GetTimestamp(); + long previousTimestamp; + do + { + previousTimestamp = Interlocked.Read(ref InfrastructureStartupCompletedTimestamp); + if (previousTimestamp >= timestamp) + { + return; + } + } + while (Interlocked.CompareExchange( + ref InfrastructureStartupCompletedTimestamp, + timestamp, + previousTimestamp) != previousTimestamp); + } } diff --git a/Shared.IntegrationTesting.Tests/Shared.IntegrationTesting.Tests.csproj b/Shared.IntegrationTesting.Tests/Shared.IntegrationTesting.Tests.csproj index 3817c0e..3ec91af 100644 --- a/Shared.IntegrationTesting.Tests/Shared.IntegrationTesting.Tests.csproj +++ b/Shared.IntegrationTesting.Tests/Shared.IntegrationTesting.Tests.csproj @@ -2,6 +2,8 @@ net10.0 + true + Exe enable enable Full @@ -20,11 +22,8 @@ - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - + diff --git a/Shared.IntegrationTesting.UnitTests/Shared.IntegrationTesting.UnitTests.csproj b/Shared.IntegrationTesting.UnitTests/Shared.IntegrationTesting.UnitTests.csproj index 5d8bfb9..8ff89d1 100644 --- a/Shared.IntegrationTesting.UnitTests/Shared.IntegrationTesting.UnitTests.csproj +++ b/Shared.IntegrationTesting.UnitTests/Shared.IntegrationTesting.UnitTests.csproj @@ -16,15 +16,12 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - diff --git a/Shared.IntegrationTesting/ContainerType.cs b/Shared.IntegrationTesting/ContainerType.cs index 9adf8a9..21875e0 100644 --- a/Shared.IntegrationTesting/ContainerType.cs +++ b/Shared.IntegrationTesting/ContainerType.cs @@ -5,7 +5,7 @@ namespace Shared.IntegrationTesting; public enum ContainerType { SqlServer, - EventStore, + KurrentDb, MessagingService, SecurityService, CallbackHandler, @@ -24,7 +24,7 @@ public enum DockerServices { None = 0, SqlServer = 1, - EventStore = 2, + KurrentDb = 2, MessagingService = 4, SecurityService = 8, CallbackHandler = 16, diff --git a/Shared.IntegrationTesting/DockerPorts.cs b/Shared.IntegrationTesting/DockerPorts.cs index 806dd11..151689e 100644 --- a/Shared.IntegrationTesting/DockerPorts.cs +++ b/Shared.IntegrationTesting/DockerPorts.cs @@ -8,9 +8,9 @@ public static class DockerPorts public static readonly Int32 CallbackHandlerDockerPort = 5010; - public static readonly Int32 EventStoreHttpDockerPort = 2113; + public static readonly Int32 KurrentDbHttpDockerPort = 2113; - public static readonly Int32 EventStoreTcpDockerPort = 1113; + //public static readonly Int32 EventStoreTcpDockerPort = 1113; public static readonly Int32 MessagingServiceDockerPort = 5006; diff --git a/Shared.IntegrationTesting/Shared.IntegrationTesting.csproj b/Shared.IntegrationTesting/Shared.IntegrationTesting.csproj index 652d867..4c4b376 100644 --- a/Shared.IntegrationTesting/Shared.IntegrationTesting.csproj +++ b/Shared.IntegrationTesting/Shared.IntegrationTesting.csproj @@ -12,9 +12,9 @@ - - + + diff --git a/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs b/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs index 6f76c74..368607b 100644 --- a/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs +++ b/Shared.IntegrationTesting/TestContainers/BaseDockerHelper.cs @@ -12,6 +12,7 @@ namespace Shared.IntegrationTesting.TestContainers; using Docker.DotNet; using EventStore.Client; using HealthChecks; +using KurrentDB.Client; using Logger; using Microsoft.Data.SqlClient; using Shouldly; @@ -58,12 +59,12 @@ public abstract class BaseDockerHelper{ protected readonly Object ContainerStateLock = new(); - protected String EventStoreContainerName; + protected String KurrentDbContainerName; public String ConfigHostContainerName; public Int32 ConfigHostPort; - protected Int32 EventStoreHttpPort; - protected Int32 EventStoreSecureHttpPort; + protected Int32 KurrentDbHttpPort; + protected Int32 KurrentDbSecureHttpPort; protected String FileProcessorContainerName; @@ -71,7 +72,7 @@ public abstract class BaseDockerHelper{ protected readonly IHealthCheckClient HealthCheckClient; - protected static readonly HttpClient EventStoreHealthCheckClient = CreateEventStoreHealthCheckClient(); + protected static readonly HttpClient KurrentDbHealthCheckClient = CreateKurrentDbHealthCheckClient(); protected Dictionary HostPorts = new(); @@ -107,6 +108,8 @@ public abstract class BaseDockerHelper{ protected Boolean UseSecureSqlServerDatabase; + protected SqlServerTestRunFixture SharedSqlServerFixture; + private String sqlTestConnString; @@ -125,7 +128,7 @@ protected BaseDockerHelper(Boolean skipHealthChecks=false) { })); this.ImageDetails.Add(ContainerType.SqlServer, ("mcr.microsoft.com/mssql/server:2025-latest", true)); - this.ImageDetails.Add(ContainerType.EventStore, ("kurrentplatform/kurrentdb:26.2.2", true)); + this.ImageDetails.Add(ContainerType.KurrentDb, ("kurrentplatform/kurrentdb:26.2.2", true)); this.ImageDetails.Add(ContainerType.MessagingService, ("stuartferguson/messagingservice:master", true)); this.ImageDetails.Add(ContainerType.SecurityService, ("stuartferguson/securityservice:master", true)); this.ImageDetails.Add(ContainerType.CallbackHandler, ("stuartferguson/callbackhandler:master", true)); @@ -142,7 +145,7 @@ protected BaseDockerHelper(Boolean skipHealthChecks=false) { - public Boolean IsSecureEventStore{ get; protected set; } + public Boolean IsSecureKurrentDb { get; protected set; } @@ -167,7 +170,7 @@ public virtual Dictionary GetCommonEnvironmentVariables(){ Int32 securityServicePort = this.GetSecurityServicePort(); return new Dictionary() { - {"EventStoreSettings:ConnectionString", this.GenerateEventStoreConnectionString()}, + {"EventStoreSettings:ConnectionString", this.GenerateKurrentDbConnectionString()}, {"AppSettings:PersistentSubscriptionPollingInSeconds", this.PersistentSubscriptionSettings.pollingInterval.ToString()}, {"AppSettings:InternalSubscriptionServiceCacheDuration", this.PersistentSubscriptionSettings.cacheDuration.ToString()}, {"AppSettings:SubscriptionConfiguration:PersistentSubscriptionPollingInSeconds", this.PersistentSubscriptionSettings.pollingInterval.ToString()}, @@ -180,7 +183,7 @@ public virtual Dictionary GetCommonEnvironmentVariables(){ {"AppSettings:TransactionProcessorApi", $"http://{this.TransactionProcessorContainerName}:{DockerPorts.TransactionProcessorDockerPort}"}, {"AppSettings:FileProcessorApi", $"http://{this.FileProcessorContainerName}:{DockerPorts.FileProcessorDockerPort}"}, {"ConnectionStrings:HealthCheck", this.SetConnectionString("master", this.UseSecureSqlServerDatabase)}, - {"\"EventStoreSettings:Insecure", this.IsSecureEventStore.ToString()} + {"EventStoreSettings:Insecure", this.IsSecureKurrentDb.ToString()} }; } @@ -189,7 +192,7 @@ protected virtual IReadOnlyList> GetStartupGroups( { List> startupGroups = [ [DockerServices.SqlServer], - [DockerServices.EventStore], + [DockerServices.KurrentDb], [ DockerServices.MessagingService, DockerServices.SecurityService, @@ -218,8 +221,7 @@ public static DockerClient GetDockerHost() { public Int32? GetHostPort(ContainerType key) => key switch { - ContainerType.CallbackHandler => this.CallbackHandlerPort, - //ContainerType.EventStore => this.EventStoreHttpPort, + ContainerType.CallbackHandler => this.CallbackHandlerPort, ContainerType.ConfigurationHost => this.ConfigHostPort, ContainerType.FileProcessor => this.FileProcessorPort, ContainerType.MessagingService => this.MessagingServicePort, @@ -230,8 +232,8 @@ public static DockerClient GetDockerHost() { ContainerType.SqlServer => this.SqlServerPort, ContainerType.EstateManagementUI => this.EstateManagementUiPort, ContainerType.EstateReporting => this.EstateReportingPort, - _ when key == ContainerType.EventStore && this.IsSecureEventStore => this.EventStoreSecureHttpPort, - _ when key == ContainerType.EventStore && this.IsSecureEventStore == false => this.EventStoreHttpPort, + _ when key == ContainerType.KurrentDb && this.IsSecureKurrentDb => this.KurrentDbSecureHttpPort, + _ when key == ContainerType.KurrentDb && this.IsSecureKurrentDb == false => this.KurrentDbHttpPort, _ => null }; @@ -291,7 +293,7 @@ public virtual ContainerBuilder SetupCallbackHandlerContainer(){ public virtual void SetupContainerNames(){ // Setup the container names this.SqlServerContainerName= $"sqlserver{this.TestId:N}"; - this.EventStoreContainerName = $"eventstore{this.TestId:N}"; + this.KurrentDbContainerName = $"kurrentdb{this.TestId:N}"; this.SecurityServiceContainerName = $"securityservice{this.TestId:N}"; this.TestHostContainerName = $"testhosts{this.TestId:N}"; this.CallbackHandlerContainerName = $"callbackhandler{this.TestId:N}"; @@ -307,10 +309,18 @@ public virtual void SetupContainerNames(){ protected Int32 EstateManagementUiPort; protected Int32 SqlServerPort; + public void UseSharedSqlServer(SqlServerTestRunFixture fixture) + { + this.SharedSqlServerFixture = fixture ?? throw new ArgumentNullException(nameof(fixture)); + this.SqlServerContainerName = fixture.ContainerName; + this.SqlServerPort = fixture.MappedPort; + this.SqlCredentials = (fixture.UserName, fixture.Password); + } + protected String EstateManagementUiContainerName; - public virtual ContainerBuilder SetupEventStoreContainer(){ - this.Trace("About to Start Event Store Container"); + public virtual ContainerBuilder SetupKurrentDbContainer(){ + this.Trace("About to Start Kurrent Db Container"); Dictionary environmentVariables = new(){ {"EVENTSTORE_RUN_PROJECTIONS","all"}, @@ -318,18 +328,18 @@ public virtual ContainerBuilder SetupEventStoreContainer(){ {"EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP","true"}, {"EVENTSTORE_PROJECTION_EXECUTION_TIMEOUT","5000"} }; - ContainerBuilder eventStoreContainer = new ContainerBuilder(); + ContainerBuilder kurrentDbContainer = new(); - if (!this.IsSecureEventStore){ + if (!this.IsSecureKurrentDb){ environmentVariables.Add("EVENTSTORE_INSECURE","true"); } else{ - String certsPath = "/etc/eventstore/certs"; + String certsPath = "/etc/kurrentdb/certs"; // Copy these to the container String path = Path.Combine(Directory.GetCurrentDirectory(), "certs"); - eventStoreContainer = eventStoreContainer.MountHostFolder(path, certsPath); + kurrentDbContainer = kurrentDbContainer.MountHostFolder(path, certsPath); // Certificates configuration environmentVariables.Add("EVENTSTORE_CertificateFile",$"{certsPath}/node1/node.crt"); @@ -339,19 +349,19 @@ public virtual ContainerBuilder SetupEventStoreContainer(){ } String logfilePath = "/var/log/kurrentdb"; - eventStoreContainer = eventStoreContainer.MountHostFolder(this.HostTraceFolder, logfilePath); + kurrentDbContainer = kurrentDbContainer.MountHostFolder(this.HostTraceFolder, logfilePath); + + (String imageName, Boolean useLatest) imageDetails = this.GetImageDetails(ContainerType.KurrentDb).Data; + this.Trace($"About to Start Kurrent Db Container using image [{imageDetails.imageName}]"); - (String imageName, Boolean useLatest) imageDetails = this.GetImageDetails(ContainerType.EventStore).Data; - this.Trace($"About to Start Event Store Container using image [{imageDetails.imageName}]"); - - eventStoreContainer = eventStoreContainer.WithName(this.EventStoreContainerName) // similar to WithName() + kurrentDbContainer = kurrentDbContainer.WithName(this.KurrentDbContainerName) // similar to WithName() .WithImage(imageDetails.imageName) .WithEnvironment(environmentVariables) - .WithPortBinding(DockerPorts.EventStoreHttpDockerPort, true); + .WithPortBinding(DockerPorts.KurrentDbHttpDockerPort, true); - return eventStoreContainer; + return kurrentDbContainer; } public virtual ContainerBuilder SetupFileProcessorContainer(){ @@ -747,20 +757,20 @@ protected void CheckSqlConnection(IContainer databaseServerContainer){ } } - protected virtual EventStoreClientSettings ConfigureEventStoreSettings(String username = "admin", String password="changeit"){ - String connectionString = $"esdb://{username}:{password}@127.0.0.1:{this.EventStoreHttpPort}"; + protected virtual KurrentDBClientSettings ConfigureKurrentDbSettings(String username = "admin", String password="changeit"){ + String connectionString = $"esdb://{username}:{password}@127.0.0.1:{this.KurrentDbHttpPort}"; - connectionString = this.IsSecureEventStore switch{ + connectionString = this.IsSecureKurrentDb switch{ true => $"{connectionString}?tls=true&tlsVerifyCert=false", _ => $"{connectionString}?tls=false&tlsVerifyCert=false" }; - return EventStoreClientSettings.Create(connectionString); + return KurrentDBClientSettings.Create(connectionString); } protected virtual async Task CreatePersistentSubscription((String streamName, String groupName, Int32 maxRetryCount) subscription){ - EventStorePersistentSubscriptionsClient client = new(this.ConfigureEventStoreSettings()); - + KurrentDBPersistentSubscriptionsClient client = new(this.ConfigureKurrentDbSettings()); + PersistentSubscriptionSettings settings = new(resolveLinkTos:true, StreamPosition.Start, maxRetryCount:subscription.maxRetryCount); this.Trace($"Creating persistent subscription Group [{subscription.groupName}] Stream [{subscription.streamName}] Retry Count [{subscription.maxRetryCount}]"); await client.CreateToStreamAsync(subscription.streamName, subscription.groupName, settings); @@ -803,7 +813,7 @@ protected async Task DoSqlServerHealthCheck( } } protected async Task DoEventStoreHealthCheck(CancellationToken cancellationToken){ - String scheme = this.IsSecureEventStore switch + String scheme = this.IsSecureKurrentDb switch { true => "https", _ => "http" @@ -812,9 +822,9 @@ protected async Task DoEventStoreHealthCheck(CancellationToken cancellationToken await Retry.For(async cancellationToken => { cancellationToken.ThrowIfCancellationRequested(); - String url = $"{scheme}://127.0.0.1:{this.EventStoreHttpPort}/ping"; + String url = $"{scheme}://127.0.0.1:{this.KurrentDbHttpPort}/ping"; - using HttpResponseMessage pingResponse = await EventStoreHealthCheckClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + using HttpResponseMessage pingResponse = await KurrentDbHealthCheckClient.GetAsync(url, cancellationToken).ConfigureAwait(false); pingResponse.StatusCode.ShouldBe(HttpStatusCode.OK); }, cancellationToken, @@ -826,9 +836,9 @@ await Retry.For(async cancellationToken => await Retry.For(async cancellationToken => { cancellationToken.ThrowIfCancellationRequested(); - String url = $"{scheme}://127.0.0.1:{this.EventStoreHttpPort}/info"; + String url = $"{scheme}://127.0.0.1:{this.KurrentDbHttpPort}/info"; - using HttpResponseMessage infoResponse = await EventStoreHealthCheckClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + using HttpResponseMessage infoResponse = await KurrentDbHealthCheckClient.GetAsync(url, cancellationToken).ConfigureAwait(false); infoResponse.StatusCode.ShouldBe(HttpStatusCode.OK); String infoData = await infoResponse.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -837,7 +847,7 @@ await Retry.For(async cancellationToken => }, cancellationToken); } - private static HttpClient CreateEventStoreHealthCheckClient() + private static HttpClient CreateKurrentDbHealthCheckClient() { HttpClientHandler httpClientHandler = new() { @@ -898,8 +908,8 @@ protected void Error(String message, Exception ex){ } } - protected virtual String GenerateEventStoreConnectionString(String userName = "admin", String password = "changeit"){ - String eventStoreAddress = $"esdb://{userName}:{password}@{this.EventStoreContainerName}:{DockerPorts.EventStoreHttpDockerPort}?tls=false"; + protected virtual String GenerateKurrentDbConnectionString(String userName = "admin", String password = "changeit"){ + String eventStoreAddress = $"esdb://{userName}:{password}@{this.KurrentDbContainerName}:{DockerPorts.KurrentDbHttpDockerPort}?tls=false"; return eventStoreAddress; } @@ -930,7 +940,7 @@ protected virtual async Task LoadEventStoreProjections(CancellationToken cancell { IReadOnlyList files = this.GetProjectionFiles(); var requiredProjections = this.GetRequiredProjections(); - EventStoreProjectionManagementClient projectionClient = new(this.ConfigureEventStoreSettings()); + KurrentDBProjectionManagementClient projectionClient = new(this.ConfigureKurrentDbSettings()); List projectionNames = new(); foreach (FileInfo file in files) @@ -988,7 +998,7 @@ protected virtual IReadOnlyList GetProjectionFiles() } protected virtual Task CreateContinuousProjection( - EventStoreProjectionManagementClient projectionClient, + KurrentDBProjectionManagementClient projectionClient, String projectionName, String projection, CancellationToken cancellationToken) @@ -1001,7 +1011,7 @@ protected virtual Task CreateContinuousProjection( } protected virtual Task GetProjectionStatus( - EventStoreProjectionManagementClient projectionClient, + KurrentDBProjectionManagementClient projectionClient, String projectionName, CancellationToken cancellationToken) { @@ -1037,13 +1047,49 @@ protected virtual String SetConnectionString(String databaseName, encryptValue = ";Encrypt=False"; } + String scenarioDatabaseName = GetScenarioDatabaseName( + databaseName, + this.TestId, + this.SharedSqlServerFixture is not null); + String connectionString = - $"server={this.SqlServerContainerName},1433;user id={this.SqlCredentials.usename};password={this.SqlCredentials.password};database={databaseName}{encryptValue}"; + $"server={this.SqlServerContainerName},1433;user id={this.SqlCredentials.usename};password={this.SqlCredentials.password};database={scenarioDatabaseName}{encryptValue}"; return connectionString; } + protected static String GetScenarioDatabaseName( + String databaseName, + Guid testId, + Boolean sharedSqlServer) + { + if (sharedSqlServer == false || databaseName == "master") + { + return databaseName; + } + + String compactSuffix = $"-{testId:N}"; + if (databaseName.EndsWith(compactSuffix, StringComparison.OrdinalIgnoreCase)) + { + return databaseName; + } + + String dashedSuffix = $"-{testId:D}"; + return databaseName.EndsWith(dashedSuffix, StringComparison.OrdinalIgnoreCase) + ? $"{databaseName[..^dashedSuffix.Length]}{compactSuffix}" + : $"{databaseName}{compactSuffix}"; + } + + protected static DockerServices GetServicesForStartup( + DockerServices requiredServices, + Boolean hasSharedSqlServer) + { + return hasSharedSqlServer + ? requiredServices & ~DockerServices.SqlServer + : requiredServices; + } + protected async Task StartContainer2( Func buildContainerFunc, List networkServices, @@ -1098,7 +1144,7 @@ await Retry.For(async cancellationToken => DockerServices.TestHost => ContainerType.TestHost, DockerServices.TransactionProcessor => ContainerType.TransactionProcessor, DockerServices.TransactionProcessorAcl => ContainerType.TransactionProcessorAcl, - DockerServices.EventStore=> ContainerType.EventStore, + DockerServices.KurrentDb => ContainerType.KurrentDb, DockerServices.SqlServer => ContainerType.SqlServer, DockerServices.ConfigurationHost => ContainerType.ConfigurationHost, DockerServices.EstateManagementUI => ContainerType.EstateManagementUI, @@ -1119,7 +1165,7 @@ await Retry.For(async cancellationToken => switch (type) { - case ContainerType.EventStore: + case ContainerType.KurrentDb: await DoEventStoreHealthCheck(cancellationToken); break; case ContainerType.SqlServer: @@ -1179,11 +1225,11 @@ UInt16 GetPort(Int32 dockerPort) => startedContainer.GetMappedPublicPort(dockerPort); switch (type) { - case ContainerType.EventStore: - Int32 port = GetPort(DockerPorts.EventStoreHttpDockerPort); - EventStoreHttpPort = port; - if (IsSecureEventStore) - EventStoreSecureHttpPort = port; + case ContainerType.KurrentDb: + Int32 port = GetPort(DockerPorts.KurrentDbHttpDockerPort); + KurrentDbHttpPort = port; + if (IsSecureKurrentDb) + KurrentDbSecureHttpPort = port; break; case ContainerType.MessagingService: diff --git a/Shared.IntegrationTesting/TestContainers/DockerHelper.cs b/Shared.IntegrationTesting/TestContainers/DockerHelper.cs index b17bb64..7c62047 100644 --- a/Shared.IntegrationTesting/TestContainers/DockerHelper.cs +++ b/Shared.IntegrationTesting/TestContainers/DockerHelper.cs @@ -9,6 +9,7 @@ namespace Shared.IntegrationTesting.TestContainers; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Runtime.InteropServices; @@ -20,14 +21,13 @@ protected DockerHelper(Boolean skipHealthChecks=false) :base(skipHealthChecks){ StringSerialiser.Initialise(new SystemTextJsonSerializer(new System.Text.Json.JsonSerializerOptions())); } - protected virtual void SetHostTraceFolder(String scenarioName) { + protected virtual void SetHostTraceFolder(String scenarioName, DateTimeOffset? runStartedAt = null) { String ciEnvVar = Environment.GetEnvironmentVariable("CI"); Boolean isCI = (!String.IsNullOrEmpty(ciEnvVar) && String.Compare(ciEnvVar, Boolean.TrueString, StringComparison.InvariantCultureIgnoreCase) == 0); + Boolean isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - this.HostTraceFolder = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? (isCI ? $@"C:\Users\runneradmin\trace\{scenarioName}" : $@"C:\home\txnproc\trace\{scenarioName}") - : (isCI ? $"/home/runner/trace/{scenarioName}" : $"/home/txnproc/trace/{scenarioName}"); + this.HostTraceFolder = BuildHostTraceFolderPath(scenarioName, runStartedAt, isCI, isWindows); if (Directory.Exists(this.HostTraceFolder) == false){ this.Trace($"[{this.HostTraceFolder}] does not exist"); @@ -41,6 +41,25 @@ protected virtual void SetHostTraceFolder(String scenarioName) { this.Trace($"HostTraceFolder is [{this.HostTraceFolder}]"); } + protected static String BuildHostTraceFolderPath( + String scenarioName, + DateTimeOffset? runStartedAt, + Boolean isCI, + Boolean isWindows) + { + String rootFolder = isWindows + ? (isCI ? @"C:\Users\runneradmin\trace" : @"C:\home\integrationtests") + : (isCI ? "/home/runner/trace" : "/home/integrationtests"); + + return runStartedAt is null + ? Path.Combine(rootFolder, scenarioName) + : Path.Combine( + rootFolder, + scenarioName, + runStartedAt.Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture), + runStartedAt.Value.ToString("HHmm", CultureInfo.InvariantCulture)); + } + public override async Task StartContainersForScenarioRun( String scenarioName, @@ -53,25 +72,37 @@ public override async Task StartContainersForScenarioRun( { this.RequiredDockerServices = dockerServices; - this.IsSecureEventStore = Environment.GetEnvironmentVariable("IsSecureEventStore") switch + this.IsSecureKurrentDb = Environment.GetEnvironmentVariable("IsSecureEventStore") switch { null => false, { Length: 0 } => false, "false" => false, _ => true }; - this.SetHostTraceFolder(scenarioName); + this.SetHostTraceFolder(scenarioName, this.SharedSqlServerFixture?.StartedAt); this.TestId = Guid.NewGuid(); this.Trace($"Test Id is {this.TestId}"); this.SetupContainerNames(); + if (this.SharedSqlServerFixture is not null) + { + this.SqlServerContainerName = this.SharedSqlServerFixture.ContainerName; + } this.ClientDetails = ("serviceClient", "Secret1"); - INetwork testNetwork = await this.SetupTestNetwork(); - this.TestNetworks.Add(testNetwork); + INetwork testNetwork; + if (this.SharedSqlServerFixture is null) + { + testNetwork = await this.SetupTestNetwork(); + this.TestNetworks.Add(testNetwork); + } + else + { + testNetwork = this.SharedSqlServerFixture.Network; + } List networks = [ testNetwork, @@ -89,7 +120,7 @@ async Task StartWithTrace(Func Func> setupContainerForService = service => service switch { DockerServices.SqlServer => this.ConfigureSqlContainer, - DockerServices.EventStore => this.SetupEventStoreContainer, + DockerServices.KurrentDb => this.SetupKurrentDbContainer, DockerServices.MessagingService => this.SetupMessagingServiceContainer, DockerServices.SecurityService => this.SetupSecurityServiceContainer, DockerServices.CallbackHandler => this.SetupCallbackHandlerContainer, @@ -103,10 +134,16 @@ async Task StartWithTrace(Func _ => throw new InvalidOperationException($"No startup plan exists for docker service [{service}]") }; + DockerServices servicesForStartup = GetServicesForStartup( + this.RequiredDockerServices, + this.SharedSqlServerFixture is not null); + foreach (IReadOnlyList startupGroup in this.GetStartupGroups()) { cancellationToken.ThrowIfCancellationRequested(); - await Task.WhenAll(startupGroup.Select(service => StartWithTrace(setupContainerForService(service), service))); + await Task.WhenAll(startupGroup + .Where(service => (servicesForStartup & service) == service) + .Select(service => StartWithTrace(setupContainerForService(service), service))); } await this.LoadEventStoreProjections(cancellationToken); @@ -114,8 +151,21 @@ async Task StartWithTrace(Func cancellationToken.ThrowIfCancellationRequested(); await this.CreateSubscriptions(cancellationToken); } - catch + catch (Exception startupException) { + try + { + await this.StopContainersForScenarioRun(DockerServices.None).ConfigureAwait(false); + } + catch (Exception cleanupException) + { + this.DisposeIntegrationTestCertificate(); + throw new AggregateException( + "Scenario container startup failed and cleanup also failed.", + startupException, + cleanupException); + } + this.DisposeIntegrationTestCertificate(); throw; } diff --git a/Shared.IntegrationTesting/TestContainers/SqlServerTestRunFixture.cs b/Shared.IntegrationTesting/TestContainers/SqlServerTestRunFixture.cs new file mode 100644 index 0000000..8a4e71d --- /dev/null +++ b/Shared.IntegrationTesting/TestContainers/SqlServerTestRunFixture.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using DotNet.Testcontainers.Networks; +using Microsoft.Data.SqlClient; + +namespace Shared.IntegrationTesting.TestContainers; + +public sealed class SqlServerTestRunFixture : IAsyncDisposable +{ + private const String SqlServerImage = "mcr.microsoft.com/mssql/server:2025-latest"; + private const String SqlServerPassword = "thisisalongpassword123!"; + private const Int32 HealthCheckAttempts = 10; + + private readonly IContainer container; + private readonly INetwork network; + private Boolean IsDisposed; + + private SqlServerTestRunFixture( + String runId, + DateTimeOffset startedAt, + String containerName, + IContainer container, + INetwork network) + { + this.RunId = runId; + this.StartedAt = startedAt; + this.ContainerName = containerName; + this.container = container; + this.network = network; + this.Network = network; + this.UserName = "sa"; + this.Password = SqlServerPassword; + } + + public String ContainerName { get; } + + public String RunId { get; } + + public DateTimeOffset StartedAt { get; } + + public UInt16 MappedPort { get; private set; } + + public INetwork Network { get; } + + public String UserName { get; } + + public String Password { get; } + + public static async Task StartAsync(CancellationToken cancellationToken = default) + { + String runId = Guid.NewGuid().ToString("N"); + DateTimeOffset startedAt = DateTimeOffset.Now; + String containerName = $"shared-sqlserver-{runId}"; + INetwork network = new NetworkBuilder() + .WithName($"shared-test-run-{runId}") + .WithLabel("test-run-id", runId) + .Build(); + IContainer container = new ContainerBuilder(SqlServerImage) + .WithName(containerName) + .WithNetwork(network) + .WithNetworkAliases(containerName) + .WithEnvironment("ACCEPT_EULA", "Y") + .WithEnvironment("SA_PASSWORD", SqlServerPassword) + .WithPortBinding(DockerPorts.SqlServerDockerPort, true) + .Build(); + SqlServerTestRunFixture fixture = new(runId, startedAt, containerName, container, network); + + try + { + await container.StartAsync(cancellationToken).ConfigureAwait(false); + fixture.MappedPort = container.GetMappedPublicPort(DockerPorts.SqlServerDockerPort); + await fixture.WaitForReadiness(cancellationToken).ConfigureAwait(false); + return fixture; + } + catch (Exception startupException) + { + try + { + await fixture.DisposeAsync().ConfigureAwait(false); + } + catch (Exception cleanupException) + { + throw new AggregateException( + "SQL Server test-run fixture startup failed and cleanup also failed.", + startupException, + cleanupException); + } + + throw; + } + } + + public async ValueTask DisposeAsync() + { + if (this.IsDisposed) + { + return; + } + + this.IsDisposed = true; + List cleanupErrors = []; + + try + { + await this.container.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + cleanupErrors.Add(exception); + } + + try + { + await this.network.DeleteAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + { + cleanupErrors.Add(exception); + } + + try + { + await this.network.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + cleanupErrors.Add(exception); + } + + if (cleanupErrors.Count > 0) + { + throw new AggregateException("Failed to clean up SQL Server test-run resources.", cleanupErrors); + } + } + + private async Task WaitForReadiness(CancellationToken cancellationToken) + { + String connectionString = new SqlConnectionStringBuilder + { + DataSource = $"127.0.0.1,{this.MappedPort}", + InitialCatalog = "master", + UserID = "sa", + Password = this.Password, + Encrypt = false, + ConnectTimeout = 5 + }.ConnectionString; + + SqlException lastException = null; + for (Int32 attempt = 1; attempt <= HealthCheckAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await using SqlConnection connection = new(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using SqlCommand command = new("SELECT 1", connection); + await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + return; + } + catch (SqlException exception) when (attempt < HealthCheckAttempts) + { + lastException = exception; + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken).ConfigureAwait(false); + } + catch (SqlException exception) + { + lastException = exception; + } + } + + throw new InvalidOperationException( + $"SQL Server did not become ready after {HealthCheckAttempts} health checks.", + lastException); + } +} diff --git a/Shared.Tests/Shared.Tests.csproj b/Shared.Tests/Shared.Tests.csproj index e88e766..38d3558 100644 --- a/Shared.Tests/Shared.Tests.csproj +++ b/Shared.Tests/Shared.Tests.csproj @@ -8,9 +8,8 @@ - + all - runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,6 +18,7 @@ + all diff --git a/Shared/Shared.csproj b/Shared/Shared.csproj index 1d1bcec..6707c2c 100644 --- a/Shared/Shared.csproj +++ b/Shared/Shared.csproj @@ -23,6 +23,7 @@ + diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +}