From 5969ef43857e2bc29ee6ed9ce53f747b04d785c6 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Fri, 19 Jun 2026 11:14:08 +0100 Subject: [PATCH 01/14] add integration test project --- dsi-platform.sln | 15 +++++++ ...SignIn.InternalApi.IntegrationTests.csproj | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj diff --git a/dsi-platform.sln b/dsi-platform.sln index 87002b26..50fd02b5 100644 --- a/dsi-platform.sln +++ b/dsi-platform.sln @@ -119,6 +119,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dfe.SignIn.InternalApi.IntegrationTests", "tests\Dfe.SignIn.InternalApi.IntegrationTests\Dfe.SignIn.InternalApi.IntegrationTests.csproj", "{E92E522D-3383-4356-AD07-D1C491916E26}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -729,6 +731,18 @@ Global {5100CDC3-5C26-B655-51E7-C5738A1DCD44}.Release|x64.Build.0 = Release|Any CPU {5100CDC3-5C26-B655-51E7-C5738A1DCD44}.Release|x86.ActiveCfg = Release|Any CPU {5100CDC3-5C26-B655-51E7-C5738A1DCD44}.Release|x86.Build.0 = Release|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|x64.ActiveCfg = Debug|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|x64.Build.0 = Debug|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|x86.ActiveCfg = Debug|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Debug|x86.Build.0 = Debug|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Release|Any CPU.Build.0 = Release|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Release|x64.ActiveCfg = Release|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Release|x64.Build.0 = Release|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Release|x86.ActiveCfg = Release|Any CPU + {E92E522D-3383-4356-AD07-D1C491916E26}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -784,6 +798,7 @@ Global {282680D1-4103-4AC6-9238-0038A937DAA2} = {3F245DBD-544F-4DD4-9B56-06B116BB9CFE} {36F11936-D413-4D86-B5E7-0BF41EB2885A} = {3F245DBD-544F-4DD4-9B56-06B116BB9CFE} {5100CDC3-5C26-B655-51E7-C5738A1DCD44} = {3F245DBD-544F-4DD4-9B56-06B116BB9CFE} + {E92E522D-3383-4356-AD07-D1C491916E26} = {66605B0E-3683-4A9D-B8C6-73C7146BC2D6} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {86FC62DB-8432-46A5-BBDD-C58FDCB6403E} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj new file mode 100644 index 00000000..c1ad19b1 --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -0,0 +1,42 @@ + + + + {E92E522D-3383-4356-AD07-D1C491916E26} + net10.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From cce854435413be9526e9d5b47746fbb79a20be24 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Fri, 19 Jun 2026 11:35:03 +0100 Subject: [PATCH 02/14] added boilerplate --- Directory.Packages.props | 4 + src/Dfe.SignIn.InternalApi/Program.cs | 6 + .../Configuration/TestAuthHandler.cs | 32 ++++ ...SignIn.InternalApi.IntegrationTests.csproj | 5 +- .../Endpoints/GetOrganisationByIdTests.cs | 93 ++++++++++++ .../InternalApiWebApplicationFactory.cs | 139 ++++++++++++++++++ 6 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/Configuration/TestAuthHandler.cs create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 9f91492f..e6cae41e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -134,9 +134,13 @@ + + + + diff --git a/src/Dfe.SignIn.InternalApi/Program.cs b/src/Dfe.SignIn.InternalApi/Program.cs index 02489b02..41660131 100644 --- a/src/Dfe.SignIn.InternalApi/Program.cs +++ b/src/Dfe.SignIn.InternalApi/Program.cs @@ -125,3 +125,9 @@ app.UseUserEndpoints(); await app.RunAsync(); + +// Expose the Program class to the integration tests project +/// +/// The entry point class for the application. +/// +public partial class Program { } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Configuration/TestAuthHandler.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Configuration/TestAuthHandler.cs new file mode 100644 index 00000000..1dba899d --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Configuration/TestAuthHandler.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Dfe.SignIn.InternalApi.IntegrationTests.Configuration; + +public class TestAuthHandler : AuthenticationHandler +{ + public TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var claims = new[] + { + new Claim(ClaimTypes.Name, "TestUser"), + new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) + }; + var identity = new ClaimsIdentity(claims, "TestScheme"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "TestScheme"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj index c1ad19b1..3cbeff02 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -1,4 +1,4 @@ - + {E92E522D-3383-4356-AD07-D1C491916E26} @@ -19,9 +19,12 @@ + + + diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs new file mode 100644 index 00000000..514e80f6 --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using System.Net.Http.Json; +using Dfe.SignIn.Core.Contracts.Organisations; +using Dfe.SignIn.Core.Entities.Organisations; +using Dfe.SignIn.Gateways.EntityFramework; +using Dfe.SignIn.InternalApi.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; + +[TestClass] +public class GetOrganisationByIdTests +{ + private static InternalApiWebApplicationFactory _factory = null!; + private HttpClient _client = null!; + + [ClassInitialize] + public static async Task ClassInitialize(TestContext context) + { + _factory = new InternalApiWebApplicationFactory(); + await _factory.InitializeContainerAsync(); + } + + [ClassCleanup] + public static async Task ClassCleanup() + { + await _factory.DisposeAsync(); + } + + [TestInitialize] + public async Task TestInitialize() + { + // Clear database state between tests + await _factory.ResetDatabasesAsync(); + _client = _factory.CreateClient(); + } + + [TestMethod] + public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() + { + // Arrange: Seed an organisation + var orgId = Guid.NewGuid(); + var expectedName = "Test Academy Trust"; + + using (var scope = _factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Organisations.Add(new OrganisationEntity + { + Id = orgId, + Name = expectedName, + Status = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + await dbContext.SaveChangesAsync(); + } + + var request = new GetOrganisationByIdRequest + { + OrganisationId = orgId + }; + + // Act: POST to the endpoint + var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.IsNotNull(body); + Assert.IsNotNull(body.Data); + Assert.AreEqual(orgId, body.Data.Organisation.Id); + Assert.AreEqual(expectedName, body.Data.Organisation.Name); + } + + [TestMethod] + public async Task GetOrganisationById_Returns404_WhenDoesNotExist() + { + // Arrange + var missingOrgId = Guid.NewGuid(); + var request = new GetOrganisationByIdRequest + { + OrganisationId = missingOrgId + }; + + // Act + var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + + // Assert + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs new file mode 100644 index 00000000..8f9dcc14 --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -0,0 +1,139 @@ +using System.Data.Common; +using Dfe.SignIn.Gateways.EntityFramework; +using Dfe.SignIn.InternalApi.IntegrationTests.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Respawn; +using Testcontainers.MsSql; + +namespace Dfe.SignIn.InternalApi.IntegrationTests; + +public class InternalApiWebApplicationFactory : WebApplicationFactory +{ + private readonly MsSqlContainer _dbContainer = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .Build(); + + private Respawner? _directoriesRespawner; + private Respawner? _organisationsRespawner; + + private string? _directoriesConnectionString; + private string? _organisationsConnectionString; + + public async Task InitializeContainerAsync() + { + // Start the SQL Server container + await _dbContainer.StartAsync(); + + var containerConnectionString = _dbContainer.GetConnectionString(); + + // Build specific catalog connection strings + var directoriesBuilder = new SqlConnectionStringBuilder(containerConnectionString) + { + InitialCatalog = "dsi-directories-test" + }; + _directoriesConnectionString = directoriesBuilder.ConnectionString; + + var organisationsBuilder = new SqlConnectionStringBuilder(containerConnectionString) + { + InitialCatalog = "dsi-organisations-test" + }; + _organisationsConnectionString = organisationsBuilder.ConnectionString; + + // Initialize database schemas using EF Core EnsureCreatedAsync + using var scope = Services.CreateScope(); + + var directoriesContext = scope.ServiceProvider.GetRequiredService(); + await directoriesContext.Database.EnsureCreatedAsync(); + + var organisationsContext = scope.ServiceProvider.GetRequiredService(); + await organisationsContext.Database.EnsureCreatedAsync(); + + // Setup Respawner to clean database state between test runs + _directoriesRespawner = await Respawner.CreateAsync(_directoriesConnectionString, new RespawnerOptions + { + DbAdapter = DbAdapter.SqlServer + }); + + _organisationsRespawner = await Respawner.CreateAsync(_organisationsConnectionString, new RespawnerOptions + { + DbAdapter = DbAdapter.SqlServer + }); + } + + public async Task ResetDatabasesAsync() + { + if (_directoriesConnectionString != null && _directoriesRespawner != null) + { + await _directoriesRespawner.ResetAsync(_directoriesConnectionString); + } + if (_organisationsConnectionString != null && _organisationsRespawner != null) + { + await _organisationsRespawner.ResetAsync(_organisationsConnectionString); + } + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + // Set environment to Local to mock Service Bus, auditing, and other Azure endpoints + builder.UseEnvironment("Local"); + + builder.ConfigureAppConfiguration((context, config) => + { + var testConfiguration = new Dictionary + { + // Directories Db Settings + ["EntityFramework:Directories:Host"] = GetDataSource(_directoriesConnectionString), + ["EntityFramework:Directories:Name"] = "dsi-directories-test", + ["EntityFramework:Directories:Username"] = GetUserID(_directoriesConnectionString), + ["EntityFramework:Directories:Password"] = GetPassword(_directoriesConnectionString), + + // Organisations Db Settings + ["EntityFramework:Organisations:Host"] = GetDataSource(_organisationsConnectionString), + ["EntityFramework:Organisations:Name"] = "dsi-organisations-test", + ["EntityFramework:Organisations:Username"] = GetUserID(_organisationsConnectionString), + ["EntityFramework:Organisations:Password"] = GetPassword(_organisationsConnectionString), + + // Bypass Azure AD client credential throws + ["InternalApiClient:Tenant"] = Guid.Empty.ToString(), + ["InternalApiClient:ClientId"] = Guid.Empty.ToString(), + ["InternalApiClient:ClientSecret"] = "dummy-secret", + ["InternalApiClient:HostUrl"] = "https://localhost", + ["InternalApiClient:BaseUrl"] = "https://localhost" + }; + + config.AddInMemoryCollection(testConfiguration); + }); + + builder.ConfigureTestServices(services => + { + // Inject TestAuthHandler to bypass real JWT checks + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = "TestScheme"; + options.DefaultChallengeScheme = "TestScheme"; + }) + .AddScheme("TestScheme", options => { }); + }); + } + + private static string GetDataSource(string? connectionString) => + new SqlConnectionStringBuilder(connectionString).DataSource; + + private static string GetUserID(string? connectionString) => + new SqlConnectionStringBuilder(connectionString).UserID; + + private static string GetPassword(string? connectionString) => + new SqlConnectionStringBuilder(connectionString).Password; + + public override async ValueTask DisposeAsync() + { + await _dbContainer.DisposeAsync(); + await base.DisposeAsync(); + } +} From b30350a86d097152d2c116c54dd1c7716517b4bd Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 08:54:18 +0100 Subject: [PATCH 03/14] fixed tests --- ...SignIn.InternalApi.IntegrationTests.csproj | 6 + .../Endpoints/GetOrganisationByIdTests.cs | 49 ++++---- .../InternalApiWebApplicationFactory.cs | 114 +++++++++--------- 3 files changed, 92 insertions(+), 77 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj index 3cbeff02..bb3aae4c 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -42,4 +42,10 @@ + + + PreserveNewest + + + diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs index 514e80f6..3fefb6f3 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs @@ -1,3 +1,5 @@ +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; + using System.Net; using System.Net.Http.Json; using Dfe.SignIn.Core.Contracts.Organisations; @@ -6,8 +8,6 @@ using Dfe.SignIn.InternalApi.Contracts; using Microsoft.Extensions.DependencyInjection; -namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; - [TestClass] public class GetOrganisationByIdTests { @@ -15,16 +15,18 @@ public class GetOrganisationByIdTests private HttpClient _client = null!; [ClassInitialize] - public static async Task ClassInitialize(TestContext context) + public static void ClassInitialize(TestContext context) { _factory = new InternalApiWebApplicationFactory(); - await _factory.InitializeContainerAsync(); } [ClassCleanup] public static async Task ClassCleanup() { - await _factory.DisposeAsync(); + if (_factory is not null) + { + await _factory.DisposeAsync(); + } } [TestInitialize] @@ -32,7 +34,7 @@ public async Task TestInitialize() { // Clear database state between tests await _factory.ResetDatabasesAsync(); - _client = _factory.CreateClient(); + this._client = _factory.CreateClient(); } [TestMethod] @@ -41,23 +43,21 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() // Arrange: Seed an organisation var orgId = Guid.NewGuid(); var expectedName = "Test Academy Trust"; - - using (var scope = _factory.Services.CreateScope()) - { + + using (var scope = _factory.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Organisations.Add(new OrganisationEntity - { + dbContext.Organisations.Add( new OrganisationEntity { Id = orgId, Name = expectedName, + Category = "001", Status = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow - }); + } ); await dbContext.SaveChangesAsync(); } - var request = new GetOrganisationByIdRequest - { + var request = new GetOrganisationByIdRequest { OrganisationId = orgId }; @@ -65,13 +65,17 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); // Assert - Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorContent}"); + } var body = await response.Content.ReadFromJsonAsync>(); - Assert.IsNotNull(body); - Assert.IsNotNull(body.Data); - Assert.AreEqual(orgId, body.Data.Organisation.Id); - Assert.AreEqual(expectedName, body.Data.Organisation.Name); + Assert.IsNotNull( body ); + Assert.IsNotNull( body.Data ); + Assert.AreEqual( orgId, body.Data.Organisation.Id ); + Assert.AreEqual( expectedName, body.Data.Organisation.Name ); } [TestMethod] @@ -79,15 +83,14 @@ public async Task GetOrganisationById_Returns404_WhenDoesNotExist() { // Arrange var missingOrgId = Guid.NewGuid(); - var request = new GetOrganisationByIdRequest - { + var request = new GetOrganisationByIdRequest { OrganisationId = missingOrgId }; // Act - var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await this._client.PostAsJsonAsync( "interaction/Organisations.GetOrganisationById", request ); // Assert - Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + Assert.AreEqual( HttpStatusCode.NotFound, response.StatusCode ); } } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs index 8f9dcc14..92d7314e 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -19,20 +19,26 @@ public class InternalApiWebApplicationFactory : WebApplicationFactory .WithImage("mcr.microsoft.com/mssql/server:2022-latest") .Build(); - private Respawner? _directoriesRespawner; - private Respawner? _organisationsRespawner; + private readonly Respawner _directoriesRespawner; + private readonly Respawner _organisationsRespawner; - private string? _directoriesConnectionString; - private string? _organisationsConnectionString; + private readonly string _directoriesConnectionString; + private readonly string _organisationsConnectionString; - public async Task InitializeContainerAsync() + public InternalApiWebApplicationFactory() { - // Start the SQL Server container - await _dbContainer.StartAsync(); + // 1. Set environment to Local so Program.cs uses user secrets / local bypasses + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Local"); + + // 2. Load static JSON configurations and write them as environment variables (so they are visible immediately to builder.Configuration) + LoadStaticConfigurations(); + + // 3. Start the SQL Server container synchronously + _dbContainer.StartAsync().GetAwaiter().GetResult(); var containerConnectionString = _dbContainer.GetConnectionString(); - // Build specific catalog connection strings + // 4. Build specific catalog connection strings var directoriesBuilder = new SqlConnectionStringBuilder(containerConnectionString) { InitialCatalog = "dsi-directories-test" @@ -45,71 +51,71 @@ public async Task InitializeContainerAsync() }; _organisationsConnectionString = organisationsBuilder.ConnectionString; - // Initialize database schemas using EF Core EnsureCreatedAsync + // 5. Set dynamic connection variables so they are present in builder.Configuration immediately + SetDynamicConnectionEnvironmentVariables(); + + // 6. Initialize database schemas using EF Core EnsureCreated using var scope = Services.CreateScope(); var directoriesContext = scope.ServiceProvider.GetRequiredService(); - await directoriesContext.Database.EnsureCreatedAsync(); + directoriesContext.Database.EnsureCreated(); var organisationsContext = scope.ServiceProvider.GetRequiredService(); - await organisationsContext.Database.EnsureCreatedAsync(); + organisationsContext.Database.EnsureCreated(); - // Setup Respawner to clean database state between test runs - _directoriesRespawner = await Respawner.CreateAsync(_directoriesConnectionString, new RespawnerOptions + // 7. Setup Respawner to clean database state between test runs + _directoriesRespawner = Respawner.CreateAsync(_directoriesConnectionString, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer - }); + }).GetAwaiter().GetResult(); - _organisationsRespawner = await Respawner.CreateAsync(_organisationsConnectionString, new RespawnerOptions + _organisationsRespawner = Respawner.CreateAsync(_organisationsConnectionString, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer - }); + }).GetAwaiter().GetResult(); } public async Task ResetDatabasesAsync() { - if (_directoriesConnectionString != null && _directoriesRespawner != null) - { - await _directoriesRespawner.ResetAsync(_directoriesConnectionString); - } - if (_organisationsConnectionString != null && _organisationsRespawner != null) - { - await _organisationsRespawner.ResetAsync(_organisationsConnectionString); - } + await _directoriesRespawner.ResetAsync(_directoriesConnectionString); + await _organisationsRespawner.ResetAsync(_organisationsConnectionString); } - protected override void ConfigureWebHost(IWebHostBuilder builder) + private void LoadStaticConfigurations() { - // Set environment to Local to mock Service Bus, auditing, and other Azure endpoints - builder.UseEnvironment("Local"); + var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.IntegrationTests.json", optional: false) + .Build(); - builder.ConfigureAppConfiguration((context, config) => + foreach (var pair in config.AsEnumerable()) { - var testConfiguration = new Dictionary + if (pair.Value is not null) { - // Directories Db Settings - ["EntityFramework:Directories:Host"] = GetDataSource(_directoriesConnectionString), - ["EntityFramework:Directories:Name"] = "dsi-directories-test", - ["EntityFramework:Directories:Username"] = GetUserID(_directoriesConnectionString), - ["EntityFramework:Directories:Password"] = GetPassword(_directoriesConnectionString), - - // Organisations Db Settings - ["EntityFramework:Organisations:Host"] = GetDataSource(_organisationsConnectionString), - ["EntityFramework:Organisations:Name"] = "dsi-organisations-test", - ["EntityFramework:Organisations:Username"] = GetUserID(_organisationsConnectionString), - ["EntityFramework:Organisations:Password"] = GetPassword(_organisationsConnectionString), - - // Bypass Azure AD client credential throws - ["InternalApiClient:Tenant"] = Guid.Empty.ToString(), - ["InternalApiClient:ClientId"] = Guid.Empty.ToString(), - ["InternalApiClient:ClientSecret"] = "dummy-secret", - ["InternalApiClient:HostUrl"] = "https://localhost", - ["InternalApiClient:BaseUrl"] = "https://localhost" - }; - - config.AddInMemoryCollection(testConfiguration); - }); + // Translate C# hierarchy separator ':' to process environment separator '__' + var envKey = pair.Key.Replace(":", "__"); + Environment.SetEnvironmentVariable(envKey, pair.Value); + } + } + } + private void SetDynamicConnectionEnvironmentVariables() + { + // Directories Db Settings + Environment.SetEnvironmentVariable("EntityFramework__Directories__Host", GetDataSource(_directoriesConnectionString)); + Environment.SetEnvironmentVariable("EntityFramework__Directories__Name", "dsi-directories-test"); + Environment.SetEnvironmentVariable("EntityFramework__Directories__Username", GetUserID(_directoriesConnectionString)); + Environment.SetEnvironmentVariable("EntityFramework__Directories__Password", GetPassword(_directoriesConnectionString)); + + // Organisations Db Settings + Environment.SetEnvironmentVariable("EntityFramework__Organisations__Host", GetDataSource(_organisationsConnectionString)); + Environment.SetEnvironmentVariable("EntityFramework__Organisations__Name", "dsi-organisations-test"); + Environment.SetEnvironmentVariable("EntityFramework__Organisations__Username", GetUserID(_organisationsConnectionString)); + Environment.SetEnvironmentVariable("EntityFramework__Organisations__Password", GetPassword(_organisationsConnectionString)); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { builder.ConfigureTestServices(services => { // Inject TestAuthHandler to bypass real JWT checks @@ -122,13 +128,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } - private static string GetDataSource(string? connectionString) => + private static string GetDataSource(string connectionString) => new SqlConnectionStringBuilder(connectionString).DataSource; - private static string GetUserID(string? connectionString) => + private static string GetUserID(string connectionString) => new SqlConnectionStringBuilder(connectionString).UserID; - private static string GetPassword(string? connectionString) => + private static string GetPassword(string connectionString) => new SqlConnectionStringBuilder(connectionString).Password; public override async ValueTask DisposeAsync() From c5532373111a32ca10279442183014c6ca7726bd Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 11:30:21 +0100 Subject: [PATCH 04/14] added docs --- .../Endpoints/GetOrganisationByIdTests.cs | 14 +- .../readme.md | 224 ++++++++++++++++++ 2 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs index 3fefb6f3..583cd63c 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs @@ -15,7 +15,7 @@ public class GetOrganisationByIdTests private HttpClient _client = null!; [ClassInitialize] - public static void ClassInitialize(TestContext context) + public static void ClassInitialize( TestContext context ) { _factory = new InternalApiWebApplicationFactory(); } @@ -23,8 +23,7 @@ public static void ClassInitialize(TestContext context) [ClassCleanup] public static async Task ClassCleanup() { - if (_factory is not null) - { + if (_factory is not null) { await _factory.DisposeAsync(); } } @@ -62,15 +61,14 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() }; // Act: POST to the endpoint - var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await _client.PostAsJsonAsync( "interaction/Organisations.GetOrganisationById", request ); // Assert - if (response.StatusCode != HttpStatusCode.OK) - { + if (response.StatusCode != HttpStatusCode.OK) { var errorContent = await response.Content.ReadAsStringAsync(); - Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorContent}"); + Assert.Fail( $"Request failed with status {response.StatusCode}. Response: {errorContent}" ); } - + var body = await response.Content.ReadFromJsonAsync>(); Assert.IsNotNull( body ); Assert.IsNotNull( body.Data ); diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md b/tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md new file mode 100644 index 00000000..c3ecfe7d --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md @@ -0,0 +1,224 @@ +# DfE Sign-In Internal API Integration Testing Guide + +This guide explains the purpose, architecture, and implementation details of the integration testing framework in the `Dfe.SignIn.InternalApi.IntegrationTests` project. Use this document as a reference for writing new integration tests and troubleshooting test environments. + +--- + +## 1. Purpose of Integration Tests + +While unit tests validate business logic in isolation by mocking all external dependencies, **integration tests** verify that the entire application stack works together correctly. + +In the DfE Sign-In platform, integration tests ensure: +* **Database Schema Compatibility**: Entity Framework Core mappings align perfectly with real SQL Server database schemas. +* **API Route and Contract Validation**: HTTP endpoints are correctly mapped, accept the expected request payloads, and return correct response payloads. +* **Security & Interception**: Custom middlewares, filters, logging contexts, and exception handling blocks function correctly in a real HTTP lifecycle. +* **Resilience & DI Setup**: Dependency injection registrations resolve correctly without runtime exceptions during host startup. + +--- + +## 2. How the Testing Flow Works + +The framework spins up a lightweight, isolated SQL Server instance inside a Docker container, runs EF Core database schema creation, hosts the API in-memory, and executes tests against it. + +```mermaid +sequenceDiagram + participant TestRunner as MSTest Runner + participant Factory as WebApplicationFactory + participant Container as Docker (SQL Server) + participant API as In-Memory API Host + participant Db as Test Database + + Note over TestRunner,Db: Class Initialisation + TestRunner->>Factory: Instantiate Factory + Factory->>Container: Boot SQL Server container (Testcontainers) + Container-->>Factory: Connection string returned + Factory->>Factory: Map JSON settings to Env variables + Factory->>Db: EnsureCreated() (directories & organisations schemas) + Factory->>Factory: Initialise Respawner + + Note over TestRunner,Db: Test Initialization (Before Each Test) + TestRunner->>Factory: ResetDatabasesAsync() + Factory->>Db: Respawn wipes tables (FKs disabled/re-enabled) + TestRunner->>Factory: Create HttpClient + + Note over TestRunner,Db: Test Execution (Arrange, Act, Assert) + TestRunner->>Db: Seed entities (using scoped DbContext) + TestRunner->>API: HTTP POST request (using TestAuthHandler bypass) + API->>Db: Query/Update tables + Db-->>API: Data returned + API-->>TestRunner: HTTP Response (JSON) + TestRunner->>TestRunner: Assert status code and response payload +``` + +--- + +## 3. Key Libraries Used + +We utilize three main libraries to keep integration tests fast, isolated, and easy to maintain: + +### 1. `Microsoft.AspNetCore.Mvc.Testing` (`WebApplicationFactory`) +Hosts the API in-memory using a test-specific web server. +* **Why**: It allows sending real HTTP requests to your endpoints and testing the full middleware pipeline without running a slow, external IIS or Kestrel process. + +### 2. `Testcontainers.MsSql` +Provides lightweight, disposable SQL Server instances running in Docker. +* **Why**: It avoids the need for a shared, local database server that could contain stale data. Each test run gets a 100% fresh, isolated SQL Server instance. + +### 3. `Respawn` +Resets the state of database tables back to a blank schema. +* **Why**: Dropping and recreating database schemas for every test takes seconds. Respawn queries the database metadata, disables foreign keys, deletes all data using `DELETE`/`TRUNCATE` statements, and re-enables constraints in **under 15 milliseconds**, ensuring total test isolation without a speed penalty. + +--- + +## 4. How to Write a New Test + +Follow these steps to add integration tests for a new endpoint: + +### Step 1: Add a Test Class +Create a new test file under the `Endpoints/` folder. Use the following class template: + +```csharp +using System.Net; +using System.Net.Http.Json; +using Dfe.SignIn.Core.Contracts.Organisations; // Replace with your request/response contracts +using Dfe.SignIn.Core.Entities.Organisations; // Replace with your database entity models +using Dfe.SignIn.Gateways.EntityFramework; +using Dfe.SignIn.InternalApi.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; + +[TestClass] +public class GetOrganisationByIdTests +{ + private static InternalApiWebApplicationFactory _factory = null!; + private HttpClient _client = null!; + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { + _factory = new InternalApiWebApplicationFactory(); + } + + [ClassCleanup] + public static async Task ClassCleanup() + { + if (_factory is not null) + { + await _factory.DisposeAsync(); + } + } + + [TestInitialize] + public async Task TestInitialize() + { + await _factory.ResetDatabasesAsync(); + _client = _factory.CreateClient(); + } + + [TestMethod] + public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() + { + // 1. Arrange: Seed your data using the scoped DbContext + var orgId = Guid.NewGuid(); + var expectedName = "Test Academy Trust"; + + using (var scope = _factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Organisations.Add(new OrganisationEntity + { + Id = orgId, + Name = expectedName, + Category = "001", // Required field for DB mapping + Status = 1, // Required field for DB mapping + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + await dbContext.SaveChangesAsync(); + } + + var request = new GetOrganisationByIdRequest { OrganisationId = orgId }; + + // 2. Act: Send request to the endpoint + var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + + // 3. Assert: Verify response + if (response.StatusCode != HttpStatusCode.OK) + { + var errorBody = await response.Content.ReadAsStringAsync(); + Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorBody}"); + } + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.IsNotNull(body); + Assert.AreEqual(orgId, body.Data.Organisation.Id); + Assert.AreEqual(expectedName, body.Data.Organisation.Name); + } +} +``` + +### Step 2: Implement Common Seeding Helpers (Optional) +If you find yourself seeding the same entities (like a default user or organisation) across multiple test classes, create an extension class to reuse the code: + +```csharp +public static class SeedExtensions +{ + public static async Task SeedOrganisationAsync(this InternalApiWebApplicationFactory factory, string name) + { + var id = Guid.NewGuid(); + using var scope = factory.Services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + context.Organisations.Add(new OrganisationEntity + { + Id = id, + Name = name, + Category = "001", + Status = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + + await context.SaveChangesAsync(); + return id; + } +} +``` + +--- + +## 5. Configuration & Under-The-Hood Details + +### Minimal API Startup Limitation +In .NET 6+ Minimal APIs, using `ConfigureWebHost` to override configuration properties happens **after** `Program.cs` starts parsing `builder.Configuration`. Since the API project checks configuration keys immediately during bootstrap (`CreateBuilder(args)`), standard `WebApplicationFactory` configuration overrides are too late and cause "Section not found" exceptions. + +### Solution: JSON-to-Environment Variable Mapping +We solve this by loading static test settings from [appsettings.IntegrationTests.json](file:///c:/Work/Playground/dsi-workspace/dsi-platform/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json) in the `InternalApiWebApplicationFactory` constructor and dumping them as process environment variables. +The configuration binder translates double underscores (`__`) into colons (`:`), resulting in a perfect representation of configuration sections (e.g. `AzureAd__Audience` maps to `AzureAd:Audience`) which are processed in time by `Program.cs`. + +--- + +## 6. Troubleshooting & Tips for Developers + +### "An Application Control policy has blocked this file (0x800711C7)" +This occurs on corporate-managed laptops when Windows AppLocker blocks the execution/loading of DLLs in arbitrary folders (like `C:\Users\` or user sandbox folders). +* **Fix 1**: Move/clone your workspace folder to a whitelisted developer directory, such as `C:\git\` or `C:\source\`. +* **Fix 2**: Run tests inside **WSL 2** (Ubuntu/Linux terminal) where Windows AppLocker policies do not apply: + ```bash + dotnet test tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj + ``` + +### "Docker is either not running or misconfigured" +Testcontainers requires a running Docker daemon. +* **Fix**: Ensure **Docker Desktop** is running. If Docker is active and the error persists: + * Disable **"Resource Saver Mode"** in Docker Desktop settings (known to freeze background container boot requests). + * If using WSL 2 backend, ensure integration is enabled for your current distro. + +### Test execution is stuck at `StartAsync` / Taking too long +The first time integration tests run, Testcontainers has to pull the large Microsoft SQL Server image. Because it pulls the image silently over the Docker API, it looks like the test runner is frozen. +* **Fix**: Run a manual pull in your terminal once to download the image with progress indicators: + ```bash + docker pull mcr.microsoft.com/mssql/server:2022-latest + ``` + All subsequent runs will boot instantly since the image will be cached in your local Docker registry. From 6a22ae618be22b352d32bb14a983b9e188446345 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 12:08:33 +0100 Subject: [PATCH 05/14] refactor --- Directory.Packages.props | 1 + ...SignIn.InternalApi.IntegrationTests.csproj | 6 +- .../Endpoints/GetOrganisationByIdTests.cs | 3 +- .../InternalApiWebApplicationFactory.cs | 133 ++---------------- .../Dfe.SignIn.TestHelpers.csproj | 4 + .../Integration/DatabaseCatalog.cs | 15 ++ .../Integration/IntegrationTestFactory.cs | 85 +++++++++++ .../Integration/SqlContainerFixture.cs | 96 +++++++++++++ .../Integration}/TestAuthHandler.cs | 2 +- 9 files changed, 218 insertions(+), 127 deletions(-) create mode 100644 tests/Dfe.SignIn.TestHelpers/Integration/DatabaseCatalog.cs create mode 100644 tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs create mode 100644 tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs rename tests/{Dfe.SignIn.InternalApi.IntegrationTests/Configuration => Dfe.SignIn.TestHelpers/Integration}/TestAuthHandler.cs (93%) diff --git a/Directory.Packages.props b/Directory.Packages.props index e6cae41e..e0a772aa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -104,6 +104,7 @@ + diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj index bb3aae4c..ff3b6506 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -14,6 +14,10 @@ + + + + @@ -23,8 +27,6 @@ - - diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs index 583cd63c..2558997a 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs @@ -15,9 +15,10 @@ public class GetOrganisationByIdTests private HttpClient _client = null!; [ClassInitialize] - public static void ClassInitialize( TestContext context ) + public static async Task ClassInitialize( TestContext context ) { _factory = new InternalApiWebApplicationFactory(); + await _factory.InitialiseDatabasesAsync(); } [ClassCleanup] diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs index 92d7314e..06f9103f 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -1,145 +1,32 @@ -using System.Data.Common; using Dfe.SignIn.Gateways.EntityFramework; -using Dfe.SignIn.InternalApi.IntegrationTests.Configuration; +using Dfe.SignIn.TestHelpers.Integration; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; -using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Respawn; -using Testcontainers.MsSql; namespace Dfe.SignIn.InternalApi.IntegrationTests; -public class InternalApiWebApplicationFactory : WebApplicationFactory +public class InternalApiWebApplicationFactory : IntegrationTestFactory { - private readonly MsSqlContainer _dbContainer = new MsSqlBuilder() - .WithImage("mcr.microsoft.com/mssql/server:2022-latest") - .Build(); - - private readonly Respawner _directoriesRespawner; - private readonly Respawner _organisationsRespawner; - - private readonly string _directoriesConnectionString; - private readonly string _organisationsConnectionString; - - public InternalApiWebApplicationFactory() - { - // 1. Set environment to Local so Program.cs uses user secrets / local bypasses - Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Local"); - - // 2. Load static JSON configurations and write them as environment variables (so they are visible immediately to builder.Configuration) - LoadStaticConfigurations(); - - // 3. Start the SQL Server container synchronously - _dbContainer.StartAsync().GetAwaiter().GetResult(); - - var containerConnectionString = _dbContainer.GetConnectionString(); - - // 4. Build specific catalog connection strings - var directoriesBuilder = new SqlConnectionStringBuilder(containerConnectionString) - { - InitialCatalog = "dsi-directories-test" - }; - _directoriesConnectionString = directoriesBuilder.ConnectionString; - - var organisationsBuilder = new SqlConnectionStringBuilder(containerConnectionString) - { - InitialCatalog = "dsi-organisations-test" - }; - _organisationsConnectionString = organisationsBuilder.ConnectionString; - - // 5. Set dynamic connection variables so they are present in builder.Configuration immediately - SetDynamicConnectionEnvironmentVariables(); - - // 6. Initialize database schemas using EF Core EnsureCreated - using var scope = Services.CreateScope(); - - var directoriesContext = scope.ServiceProvider.GetRequiredService(); - directoriesContext.Database.EnsureCreated(); - - var organisationsContext = scope.ServiceProvider.GetRequiredService(); - organisationsContext.Database.EnsureCreated(); - - // 7. Setup Respawner to clean database state between test runs - _directoriesRespawner = Respawner.CreateAsync(_directoriesConnectionString, new RespawnerOptions - { - DbAdapter = DbAdapter.SqlServer - }).GetAwaiter().GetResult(); - - _organisationsRespawner = Respawner.CreateAsync(_organisationsConnectionString, new RespawnerOptions - { - DbAdapter = DbAdapter.SqlServer - }).GetAwaiter().GetResult(); - } - - public async Task ResetDatabasesAsync() - { - await _directoriesRespawner.ResetAsync(_directoriesConnectionString); - await _organisationsRespawner.ResetAsync(_organisationsConnectionString); - } - - private void LoadStaticConfigurations() - { - var config = new ConfigurationBuilder() - .SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.IntegrationTests.json", optional: false) - .Build(); - - foreach (var pair in config.AsEnumerable()) - { - if (pair.Value is not null) - { - // Translate C# hierarchy separator ':' to process environment separator '__' - var envKey = pair.Key.Replace(":", "__"); - Environment.SetEnvironmentVariable(envKey, pair.Value); - } - } - } - - private void SetDynamicConnectionEnvironmentVariables() - { - // Directories Db Settings - Environment.SetEnvironmentVariable("EntityFramework__Directories__Host", GetDataSource(_directoriesConnectionString)); - Environment.SetEnvironmentVariable("EntityFramework__Directories__Name", "dsi-directories-test"); - Environment.SetEnvironmentVariable("EntityFramework__Directories__Username", GetUserID(_directoriesConnectionString)); - Environment.SetEnvironmentVariable("EntityFramework__Directories__Password", GetPassword(_directoriesConnectionString)); - - // Organisations Db Settings - Environment.SetEnvironmentVariable("EntityFramework__Organisations__Host", GetDataSource(_organisationsConnectionString)); - Environment.SetEnvironmentVariable("EntityFramework__Organisations__Name", "dsi-organisations-test"); - Environment.SetEnvironmentVariable("EntityFramework__Organisations__Username", GetUserID(_organisationsConnectionString)); - Environment.SetEnvironmentVariable("EntityFramework__Organisations__Password", GetPassword(_organisationsConnectionString)); - } + protected override IReadOnlyList DatabaseCatalogs => + [ + new("dsi-directories-test", "Directories", typeof(DbDirectoriesContext)), + new("dsi-organisations-test", "Organisations", typeof(DbOrganisationsContext)) + ]; protected override void ConfigureWebHost(IWebHostBuilder builder) { + base.ConfigureWebHost(builder); + builder.ConfigureTestServices(services => { - // Inject TestAuthHandler to bypass real JWT checks services.AddAuthentication(options => { options.DefaultAuthenticateScheme = "TestScheme"; options.DefaultChallengeScheme = "TestScheme"; }) - .AddScheme("TestScheme", options => { }); + .AddScheme("TestScheme", _ => { }); }); } - - private static string GetDataSource(string connectionString) => - new SqlConnectionStringBuilder(connectionString).DataSource; - - private static string GetUserID(string connectionString) => - new SqlConnectionStringBuilder(connectionString).UserID; - - private static string GetPassword(string connectionString) => - new SqlConnectionStringBuilder(connectionString).Password; - - public override async ValueTask DisposeAsync() - { - await _dbContainer.DisposeAsync(); - await base.DisposeAsync(); - } } diff --git a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj index 27042e1f..b3d832a0 100644 --- a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj +++ b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj @@ -18,6 +18,10 @@ + + + + diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/DatabaseCatalog.cs b/tests/Dfe.SignIn.TestHelpers/Integration/DatabaseCatalog.cs new file mode 100644 index 00000000..02b3fbb8 --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/DatabaseCatalog.cs @@ -0,0 +1,15 @@ +namespace Dfe.SignIn.TestHelpers.Integration; + +/// +/// Describes a database catalog to provision inside the SQL container. +/// +/// The SQL catalog name (e.g., "dsi-directories-test"). +/// +/// The configuration key prefix used to set EntityFramework__[ConfigKey]__Host/Name/Username/Password +/// environment variables (e.g., "Directories"). +/// +/// The EF Core type to run EnsureCreated on. +public sealed record DatabaseCatalog( + string CatalogName, + string ConfigKey, + Type DbContextType); diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs new file mode 100644 index 00000000..608dd170 --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs @@ -0,0 +1,85 @@ +namespace Dfe.SignIn.TestHelpers.Integration; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +/// +/// A reusable base for integration tests. +/// Manages SQL container lifecycle, configuration bridging, and database resets. +/// +/// +/// The entry point class of the API under test (e.g., Program). +/// +public abstract class IntegrationTestFactory : WebApplicationFactory + where TProgram : class +{ + private readonly SqlContainerFixture _sqlFixture = new(); + + /// + /// Defines the database catalogs that this API requires. + /// Subclasses must override this to declare their catalogs. + /// + protected abstract IReadOnlyList DatabaseCatalogs { get; } + + protected IntegrationTestFactory() + { + // 1. Set environment before the host builds + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Local"); + + // 2. Load static config values from the test project's JSON file + LoadStaticConfigurations(); + + // 3. Start the SQL container + _sqlFixture.StartAsync(DatabaseCatalogs).GetAwaiter().GetResult(); + + // 4. Set connection env vars so the host can read them during build + _sqlFixture.SetConnectionEnvironmentVariables(); + } + + /// + /// Called after the host has been built and Services are available. + /// Creates database schemas and initialises Respawners. + /// Must be called explicitly from [ClassInitialize] or equivalent. + /// + public async Task InitialiseDatabasesAsync() + { + await _sqlFixture.InitialiseSchemasAsync(DatabaseCatalogs, Services); + } + + /// + /// Resets all databases to their empty schema state via Respawn. + /// Call from [TestInitialize] or equivalent. + /// + public async Task ResetDatabasesAsync() + { + await _sqlFixture.ResetAllAsync(); + } + + /// + /// Loads appsettings.IntegrationTests.json from the test project's output directory + /// and writes each key-value pair as an environment variable using the __ separator. + /// + private static void LoadStaticConfigurations() + { + var config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.IntegrationTests.json", optional: false) + .Build(); + + foreach (var pair in config.AsEnumerable()) + { + if (pair.Value is not null) + { + var envKey = pair.Key.Replace(":", "__"); + Environment.SetEnvironmentVariable(envKey, pair.Value); + } + } + } + + public override async ValueTask DisposeAsync() + { + await _sqlFixture.DisposeAsync(); + await base.DisposeAsync(); + } +} diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs b/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs new file mode 100644 index 00000000..3ddf9d54 --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs @@ -0,0 +1,96 @@ +namespace Dfe.SignIn.TestHelpers.Integration; + +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Respawn; +using Testcontainers.MsSql; + +/// +/// Manages a single SQL Server Testcontainer, creates database catalogs, +/// initialises schemas via EF Core, and provides Respawn-based state resets. +/// +public sealed class SqlContainerFixture : IAsyncDisposable +{ + private readonly MsSqlContainer _container = new MsSqlBuilder() + .WithImage("mcr.microsoft.com/mssql/server:2022-latest") + .Build(); + + private readonly Dictionary _connectionStrings = new(); + private readonly Dictionary _respawners = new(); + + /// + /// Starts the container and builds catalog connection strings. + /// + public async Task StartAsync(IReadOnlyList catalogs) + { + await _container.StartAsync(); + + var containerCs = _container.GetConnectionString(); + + foreach (var catalog in catalogs) + { + var csBuilder = new SqlConnectionStringBuilder(containerCs) + { + InitialCatalog = catalog.CatalogName + }; + _connectionStrings[catalog.ConfigKey] = csBuilder.ConnectionString; + } + } + + /// + /// Sets environment variables for each catalog so that the host's + /// EntityFramework:{ConfigKey}:Host/Name/Username/Password configuration + /// resolves correctly from the container's dynamic connection string. + /// + public void SetConnectionEnvironmentVariables() + { + foreach (var (configKey, cs) in _connectionStrings) + { + var csb = new SqlConnectionStringBuilder(cs); + Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Host", csb.DataSource); + Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Name", csb.InitialCatalog); + Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Username", csb.UserID); + Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Password", csb.Password); + } + } + + /// + /// Initialises database schemas via EF Core EnsureCreated and builds Respawners. + /// + public async Task InitialiseSchemasAsync(IReadOnlyList catalogs, IServiceProvider serviceProvider) + { + foreach (var catalog in catalogs) + { + var cs = _connectionStrings[catalog.ConfigKey]; + + using var scope = serviceProvider.CreateScope(); + var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(catalog.DbContextType); + await dbContext.Database.EnsureCreatedAsync(); + + var respawner = await Respawner.CreateAsync(cs, new RespawnerOptions + { + DbAdapter = DbAdapter.SqlServer + }); + + _respawners[catalog.ConfigKey] = respawner; + } + } + + /// + /// Resets all databases back to empty schemas using Respawn. + /// + public async Task ResetAllAsync() + { + foreach (var (configKey, respawner) in _respawners) + { + var cs = _connectionStrings[configKey]; + await respawner.ResetAsync(cs); + } + } + + public async ValueTask DisposeAsync() + { + await _container.DisposeAsync(); + } +} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Configuration/TestAuthHandler.cs b/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs similarity index 93% rename from tests/Dfe.SignIn.InternalApi.IntegrationTests/Configuration/TestAuthHandler.cs rename to tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs index 1dba899d..dea03f60 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Configuration/TestAuthHandler.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Dfe.SignIn.InternalApi.IntegrationTests.Configuration; +namespace Dfe.SignIn.TestHelpers.Integration; public class TestAuthHandler : AuthenticationHandler { From 9731ff906fd7ddab7011417b076395bb1b1142ae Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 12:33:56 +0100 Subject: [PATCH 06/14] appsetting --- ...e.SignIn.InternalApi.IntegrationTests.csproj | 2 +- .../appsettings.IntegrationTests.json | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj index ff3b6506..bff5a538 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -46,7 +46,7 @@ - PreserveNewest + Always diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json b/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json new file mode 100644 index 00000000..207e7035 --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json @@ -0,0 +1,17 @@ +{ + "AzureAd": { + "Audience": "dummy-audience", + "Instance": "https://login.microsoftonline.com", + "TenantId": "00000000-0000-0000-0000-000000000000" + }, + "InternalApiClient": { + "Tenant": "00000000-0000-0000-0000-000000000000", + "ClientId": "00000000-0000-0000-0000-000000000000", + "ClientSecret": "dummy-secret", + "HostUrl": "https://localhost", + "BaseUrl": "https://localhost" + }, + "PublicApiSecretEncryption": { + "Key": "some-encryption-key-for-test-32b" + } +} From 57d4c8cef4d9006950e50c61fbd12d8abc97592b Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 12:34:36 +0100 Subject: [PATCH 07/14] cleanup --- ...SignIn.InternalApi.IntegrationTests.csproj | 7 +++- .../Endpoints/GetOrganisationByIdTests.cs | 25 ++++++------ .../InternalApiWebApplicationFactory.cs | 12 +++--- .../Integration/IntegrationTestFactory.cs | 32 ++++++++-------- .../Integration/SqlContainerFixture.cs | 38 ++++++++----------- 5 files changed, 58 insertions(+), 56 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj index ff3b6506..7bf83a4b 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -10,6 +10,12 @@ true + + + + + + @@ -40,7 +46,6 @@ - diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs index 2558997a..321a24ac 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs @@ -1,4 +1,3 @@ -namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; using System.Net; using System.Net.Http.Json; @@ -8,6 +7,8 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; using Dfe.SignIn.InternalApi.Contracts; using Microsoft.Extensions.DependencyInjection; +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; + [TestClass] public class GetOrganisationByIdTests { @@ -15,7 +16,7 @@ public class GetOrganisationByIdTests private HttpClient _client = null!; [ClassInitialize] - public static async Task ClassInitialize( TestContext context ) + public static async Task ClassInitialize(TestContext context) { _factory = new InternalApiWebApplicationFactory(); await _factory.InitialiseDatabasesAsync(); @@ -46,14 +47,14 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() using (var scope = _factory.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Organisations.Add( new OrganisationEntity { + dbContext.Organisations.Add(new OrganisationEntity { Id = orgId, Name = expectedName, Category = "001", Status = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow - } ); + }); await dbContext.SaveChangesAsync(); } @@ -62,19 +63,19 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() }; // Act: POST to the endpoint - var response = await _client.PostAsJsonAsync( "interaction/Organisations.GetOrganisationById", request ); + var response = await this._client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); // Assert if (response.StatusCode != HttpStatusCode.OK) { var errorContent = await response.Content.ReadAsStringAsync(); - Assert.Fail( $"Request failed with status {response.StatusCode}. Response: {errorContent}" ); + Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorContent}"); } var body = await response.Content.ReadFromJsonAsync>(); - Assert.IsNotNull( body ); - Assert.IsNotNull( body.Data ); - Assert.AreEqual( orgId, body.Data.Organisation.Id ); - Assert.AreEqual( expectedName, body.Data.Organisation.Name ); + Assert.IsNotNull(body); + Assert.IsNotNull(body.Data); + Assert.AreEqual(orgId, body.Data.Organisation.Id); + Assert.AreEqual(expectedName, body.Data.Organisation.Name); } [TestMethod] @@ -87,9 +88,9 @@ public async Task GetOrganisationById_Returns404_WhenDoesNotExist() }; // Act - var response = await this._client.PostAsJsonAsync( "interaction/Organisations.GetOrganisationById", request ); + var response = await this._client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); // Assert - Assert.AreEqual( HttpStatusCode.NotFound, response.StatusCode ); + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); } } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs index 06f9103f..e93bc697 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -9,20 +9,20 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests; public class InternalApiWebApplicationFactory : IntegrationTestFactory { - protected override IReadOnlyList DatabaseCatalogs => - [ + protected override IReadOnlyList DatabaseCatalogs + => [ new("dsi-directories-test", "Directories", typeof(DbDirectoriesContext)), new("dsi-organisations-test", "Organisations", typeof(DbOrganisationsContext)) ]; + protected override string AppSettingsFileName => "appsettings.IntegrationTests.json"; + protected override void ConfigureWebHost(IWebHostBuilder builder) { base.ConfigureWebHost(builder); - builder.ConfigureTestServices(services => - { - services.AddAuthentication(options => - { + builder.ConfigureTestServices(services => { + services.AddAuthentication(options => { options.DefaultAuthenticateScheme = "TestScheme"; options.DefaultChallengeScheme = "TestScheme"; }) diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs index 608dd170..25792c63 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs @@ -1,9 +1,7 @@ -namespace Dfe.SignIn.TestHelpers.Integration; - -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; +namespace Dfe.SignIn.TestHelpers.Integration; /// /// A reusable base for integration tests. /// Manages SQL container lifecycle, configuration bridging, and database resets. @@ -22,19 +20,21 @@ public abstract class IntegrationTestFactory : WebApplicationFactory protected abstract IReadOnlyList DatabaseCatalogs { get; } + protected abstract string AppSettingsFileName { get; } + protected IntegrationTestFactory() { // 1. Set environment before the host builds Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Local"); // 2. Load static config values from the test project's JSON file - LoadStaticConfigurations(); + LoadStaticConfigurations(this.AppSettingsFileName); // 3. Start the SQL container - _sqlFixture.StartAsync(DatabaseCatalogs).GetAwaiter().GetResult(); + this._sqlFixture.StartAsync(this.DatabaseCatalogs).GetAwaiter().GetResult(); // 4. Set connection env vars so the host can read them during build - _sqlFixture.SetConnectionEnvironmentVariables(); + this._sqlFixture.SetConnectionEnvironmentVariables(); } /// @@ -44,7 +44,7 @@ protected IntegrationTestFactory() /// public async Task InitialiseDatabasesAsync() { - await _sqlFixture.InitialiseSchemasAsync(DatabaseCatalogs, Services); + await this._sqlFixture.InitialiseSchemasAsync(this.DatabaseCatalogs, this.Services); } /// @@ -53,24 +53,26 @@ public async Task InitialiseDatabasesAsync() /// public async Task ResetDatabasesAsync() { - await _sqlFixture.ResetAllAsync(); + await this._sqlFixture.ResetAllAsync(); } /// /// Loads appsettings.IntegrationTests.json from the test project's output directory /// and writes each key-value pair as an environment variable using the __ separator. /// - private static void LoadStaticConfigurations() + private static void LoadStaticConfigurations(string appSettingsFileName) { + if (string.IsNullOrEmpty(appSettingsFileName)) { + return; + } + var config = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.IntegrationTests.json", optional: false) + .AddJsonFile(appSettingsFileName, optional: false) .Build(); - foreach (var pair in config.AsEnumerable()) - { - if (pair.Value is not null) - { + foreach (var pair in config.AsEnumerable()) { + if (pair.Value is not null) { var envKey = pair.Key.Replace(":", "__"); Environment.SetEnvironmentVariable(envKey, pair.Value); } @@ -79,7 +81,7 @@ private static void LoadStaticConfigurations() public override async ValueTask DisposeAsync() { - await _sqlFixture.DisposeAsync(); + await this._sqlFixture.DisposeAsync(); await base.DisposeAsync(); } } diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs b/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs index 3ddf9d54..bd422618 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs @@ -1,4 +1,3 @@ -namespace Dfe.SignIn.TestHelpers.Integration; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; @@ -6,6 +5,7 @@ namespace Dfe.SignIn.TestHelpers.Integration; using Respawn; using Testcontainers.MsSql; +namespace Dfe.SignIn.TestHelpers.Integration; /// /// Manages a single SQL Server Testcontainer, creates database catalogs, /// initialises schemas via EF Core, and provides Respawn-based state resets. @@ -16,25 +16,23 @@ public sealed class SqlContainerFixture : IAsyncDisposable .WithImage("mcr.microsoft.com/mssql/server:2022-latest") .Build(); - private readonly Dictionary _connectionStrings = new(); - private readonly Dictionary _respawners = new(); + private readonly Dictionary _connectionStrings = []; + private readonly Dictionary _respawners = []; /// /// Starts the container and builds catalog connection strings. /// public async Task StartAsync(IReadOnlyList catalogs) { - await _container.StartAsync(); + await this._container.StartAsync(); - var containerCs = _container.GetConnectionString(); + var containerCs = this._container.GetConnectionString(); - foreach (var catalog in catalogs) - { - var csBuilder = new SqlConnectionStringBuilder(containerCs) - { + foreach (var catalog in catalogs) { + var csBuilder = new SqlConnectionStringBuilder(containerCs) { InitialCatalog = catalog.CatalogName }; - _connectionStrings[catalog.ConfigKey] = csBuilder.ConnectionString; + this._connectionStrings[catalog.ConfigKey] = csBuilder.ConnectionString; } } @@ -45,8 +43,7 @@ public async Task StartAsync(IReadOnlyList catalogs) /// public void SetConnectionEnvironmentVariables() { - foreach (var (configKey, cs) in _connectionStrings) - { + foreach (var (configKey, cs) in this._connectionStrings) { var csb = new SqlConnectionStringBuilder(cs); Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Host", csb.DataSource); Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Name", csb.InitialCatalog); @@ -60,20 +57,18 @@ public void SetConnectionEnvironmentVariables() /// public async Task InitialiseSchemasAsync(IReadOnlyList catalogs, IServiceProvider serviceProvider) { - foreach (var catalog in catalogs) - { - var cs = _connectionStrings[catalog.ConfigKey]; + foreach (var catalog in catalogs) { + var cs = this._connectionStrings[catalog.ConfigKey]; using var scope = serviceProvider.CreateScope(); var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(catalog.DbContextType); await dbContext.Database.EnsureCreatedAsync(); - var respawner = await Respawner.CreateAsync(cs, new RespawnerOptions - { + var respawner = await Respawner.CreateAsync(cs, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer }); - _respawners[catalog.ConfigKey] = respawner; + this._respawners[catalog.ConfigKey] = respawner; } } @@ -82,15 +77,14 @@ public async Task InitialiseSchemasAsync(IReadOnlyList catalogs /// public async Task ResetAllAsync() { - foreach (var (configKey, respawner) in _respawners) - { - var cs = _connectionStrings[configKey]; + foreach (var (configKey, respawner) in this._respawners) { + var cs = this._connectionStrings[configKey]; await respawner.ResetAsync(cs); } } public async ValueTask DisposeAsync() { - await _container.DisposeAsync(); + await this._container.DisposeAsync(); } } From 4916a01d56390f5fcc56e6b596cbb6b7d6b7519a Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 12:53:48 +0100 Subject: [PATCH 08/14] refactor --- ...e.SignIn.InternalApi.IntegrationTests.csproj | 2 +- .../InternalApiWebApplicationFactory.cs | 2 +- .../appsettings.IntegrationTests.json | 17 ----------------- .../Integration/IntegrationTestFactory.cs | 7 +------ 4 files changed, 3 insertions(+), 25 deletions(-) delete mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj index 48004b83..72a7adac 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -50,7 +50,7 @@ - + Always diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs index e93bc697..623a6fac 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -15,7 +15,7 @@ protected override IReadOnlyList DatabaseCatalogs new("dsi-organisations-test", "Organisations", typeof(DbOrganisationsContext)) ]; - protected override string AppSettingsFileName => "appsettings.IntegrationTests.json"; + protected override string AppSettingsFileName => "appsettings.Test.json"; protected override void ConfigureWebHost(IWebHostBuilder builder) { diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json b/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json deleted file mode 100644 index 207e7035..00000000 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "AzureAd": { - "Audience": "dummy-audience", - "Instance": "https://login.microsoftonline.com", - "TenantId": "00000000-0000-0000-0000-000000000000" - }, - "InternalApiClient": { - "Tenant": "00000000-0000-0000-0000-000000000000", - "ClientId": "00000000-0000-0000-0000-000000000000", - "ClientSecret": "dummy-secret", - "HostUrl": "https://localhost", - "BaseUrl": "https://localhost" - }, - "PublicApiSecretEncryption": { - "Key": "some-encryption-key-for-test-32b" - } -} diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs index 25792c63..efde0fbd 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs @@ -24,16 +24,11 @@ public abstract class IntegrationTestFactory : WebApplicationFactory Date: Mon, 22 Jun 2026 13:42:52 +0100 Subject: [PATCH 09/14] refactor --- Directory.Packages.props | 3 + ...SignIn.InternalApi.IntegrationTests.csproj | 13 ++- .../Endpoints/GetOrganisationByIdTests.cs | 85 +++++++++---------- .../IntegrationTestCollection.cs | 11 +++ .../InternalApiWebApplicationFactory.cs | 13 ++- .../Dfe.SignIn.TestHelpers.csproj | 4 +- .../Integration/IntegrationTestFactory.cs | 12 ++- .../Integration/SqlContainerFixture.cs | 1 + .../Integration/TestAuthHandler.cs | 4 +- 9 files changed, 87 insertions(+), 59 deletions(-) create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e0a772aa..0cef4ece 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -131,6 +131,9 @@ + + + diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj index 72a7adac..a08dfe62 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -10,11 +10,6 @@ true - - - - - @@ -27,16 +22,18 @@ - - + + + + - + diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs index 321a24ac..676cdd6e 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs @@ -1,3 +1,4 @@ +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; using System.Net; using System.Net.Http.Json; @@ -6,91 +7,85 @@ using Dfe.SignIn.Gateways.EntityFramework; using Dfe.SignIn.InternalApi.Contracts; using Microsoft.Extensions.DependencyInjection; +using Xunit; -namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; - -[TestClass] -public class GetOrganisationByIdTests +[Collection("IntegrationTestsCollection")] +[Trait("Category", "Integration")] +public class GetOrganisationByIdTests : IAsyncLifetime { - private static InternalApiWebApplicationFactory _factory = null!; - private HttpClient _client = null!; + private readonly InternalApiWebApplicationFactory _factory; + private readonly HttpClient _client; - [ClassInitialize] - public static async Task ClassInitialize(TestContext context) + public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) { - _factory = new InternalApiWebApplicationFactory(); - await _factory.InitialiseDatabasesAsync(); + _factory = factory; + _client = _factory.CreateClient(); } - [ClassCleanup] - public static async Task ClassCleanup() - { - if (_factory is not null) { - await _factory.DisposeAsync(); - } - } - - [TestInitialize] - public async Task TestInitialize() + public async Task InitializeAsync() { // Clear database state between tests await _factory.ResetDatabasesAsync(); - this._client = _factory.CreateClient(); } - [TestMethod] + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() { // Arrange: Seed an organisation var orgId = Guid.NewGuid(); var expectedName = "Test Academy Trust"; - using (var scope = _factory.Services.CreateScope()) { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Organisations.Add(new OrganisationEntity { - Id = orgId, - Name = expectedName, - Category = "001", - Status = 1, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }); - await dbContext.SaveChangesAsync(); - } + await using var scope = _factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Organisations.Add(new OrganisationEntity + { + Id = orgId, + Name = expectedName, + Category = "001", + Status = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + await dbContext.SaveChangesAsync(); - var request = new GetOrganisationByIdRequest { + var request = new GetOrganisationByIdRequest + { OrganisationId = orgId }; // Act: POST to the endpoint - var response = await this._client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); // Assert - if (response.StatusCode != HttpStatusCode.OK) { + if (response.StatusCode != HttpStatusCode.OK) + { var errorContent = await response.Content.ReadAsStringAsync(); Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorContent}"); } var body = await response.Content.ReadFromJsonAsync>(); - Assert.IsNotNull(body); - Assert.IsNotNull(body.Data); - Assert.AreEqual(orgId, body.Data.Organisation.Id); - Assert.AreEqual(expectedName, body.Data.Organisation.Name); + Assert.NotNull(body); + Assert.NotNull(body.Data); + Assert.Equal(orgId, body.Data.Organisation.Id); + Assert.Equal(expectedName, body.Data.Organisation.Name); } - [TestMethod] + [Fact] public async Task GetOrganisationById_Returns404_WhenDoesNotExist() { // Arrange var missingOrgId = Guid.NewGuid(); - var request = new GetOrganisationByIdRequest { + var request = new GetOrganisationByIdRequest + { OrganisationId = missingOrgId }; // Act - var response = await this._client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); // Assert - Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs new file mode 100644 index 00000000..a9270fb0 --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs @@ -0,0 +1,11 @@ +using Xunit; + +namespace Dfe.SignIn.InternalApi.IntegrationTests; + +[CollectionDefinition("IntegrationTestsCollection")] +public class IntegrationTestCollection : ICollectionFixture +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs index 623a6fac..4eacdca1 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -4,10 +4,11 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Xunit; namespace Dfe.SignIn.InternalApi.IntegrationTests; -public class InternalApiWebApplicationFactory : IntegrationTestFactory +public class InternalApiWebApplicationFactory : IntegrationTestFactory, IAsyncLifetime { protected override IReadOnlyList DatabaseCatalogs => [ @@ -29,4 +30,14 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) .AddScheme("TestScheme", _ => { }); }); } + + public async Task InitializeAsync() + { + await InitialiseDatabasesAsync(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await DisposeAsync(); + } } diff --git a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj index b3d832a0..fef45b8e 100644 --- a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj +++ b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj @@ -20,8 +20,8 @@ - - + + diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs index efde0fbd..3a5a0544 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; @@ -24,18 +25,25 @@ public abstract class IntegrationTestFactory : WebApplicationFactory /// Called after the host has been built and Services are available. /// Creates database schemas and initialises Respawners. - /// Must be called explicitly from [ClassInitialize] or equivalent. + /// This is called automatically by xUnit via . /// public async Task InitialiseDatabasesAsync() { diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs b/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs index bd422618..f4d5bc35 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs @@ -86,5 +86,6 @@ public async Task ResetAllAsync() public async ValueTask DisposeAsync() { await this._container.DisposeAsync(); + GC.SuppressFinalize(this); } } diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs b/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs index dea03f60..94a36fcb 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/TestAuthHandler.cs @@ -16,12 +16,14 @@ public TestAuthHandler( { } + private const string TestUserId = "00000000-0000-0000-0000-000000000001"; + protected override Task HandleAuthenticateAsync() { var claims = new[] { new Claim(ClaimTypes.Name, "TestUser"), - new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) + new Claim(ClaimTypes.NameIdentifier, TestUserId) }; var identity = new ClaimsIdentity(claims, "TestScheme"); var principal = new ClaimsPrincipal(identity); From 217897d4d3ef041d3a9ba9d06914e0186bd77091 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 14:33:17 +0100 Subject: [PATCH 10/14] update docs --- .../readme.md | 75 +++++++++---------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md b/tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md index c3ecfe7d..51e8c8cd 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md @@ -22,14 +22,14 @@ The framework spins up a lightweight, isolated SQL Server instance inside a Dock ```mermaid sequenceDiagram - participant TestRunner as MSTest Runner + participant TestRunner as xUnit Runner participant Factory as WebApplicationFactory participant Container as Docker (SQL Server) participant API as In-Memory API Host participant Db as Test Database - Note over TestRunner,Db: Class Initialisation - TestRunner->>Factory: Instantiate Factory + Note over TestRunner,Db: Class Initialisation (via collection fixture) + TestRunner->>Factory: Instantiate Factory (Starts container once) Factory->>Container: Boot SQL Server container (Testcontainers) Container-->>Factory: Connection string returned Factory->>Factory: Map JSON settings to Env variables @@ -85,58 +85,50 @@ using Dfe.SignIn.Core.Entities.Organisations; // Replace with your database ent using Dfe.SignIn.Gateways.EntityFramework; using Dfe.SignIn.InternalApi.Contracts; using Microsoft.Extensions.DependencyInjection; +using Xunit; namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; -[TestClass] -public class GetOrganisationByIdTests +[Collection("IntegrationTestsCollection")] +[Trait("Category", "Integration")] +public class GetOrganisationByIdTests : IAsyncLifetime { - private static InternalApiWebApplicationFactory _factory = null!; - private HttpClient _client = null!; + private readonly InternalApiWebApplicationFactory _factory; + private readonly HttpClient _client; - [ClassInitialize] - public static void ClassInitialize(TestContext context) + public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) { - _factory = new InternalApiWebApplicationFactory(); - } - - [ClassCleanup] - public static async Task ClassCleanup() - { - if (_factory is not null) - { - await _factory.DisposeAsync(); - } + _factory = factory; + _client = _factory.CreateClient(); } - [TestInitialize] - public async Task TestInitialize() + public async Task InitializeAsync() { + // Clear database state between tests await _factory.ResetDatabasesAsync(); - _client = _factory.CreateClient(); } - [TestMethod] + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() { // 1. Arrange: Seed your data using the scoped DbContext var orgId = Guid.NewGuid(); var expectedName = "Test Academy Trust"; - using (var scope = _factory.Services.CreateScope()) + await using var scope = _factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Organisations.Add(new OrganisationEntity { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Organisations.Add(new OrganisationEntity - { - Id = orgId, - Name = expectedName, - Category = "001", // Required field for DB mapping - Status = 1, // Required field for DB mapping - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }); - await dbContext.SaveChangesAsync(); - } + Id = orgId, + Name = expectedName, + Category = "001", // Required field for DB mapping + Status = 1, // Required field for DB mapping + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }); + await dbContext.SaveChangesAsync(); var request = new GetOrganisationByIdRequest { OrganisationId = orgId }; @@ -151,9 +143,10 @@ public class GetOrganisationByIdTests } var body = await response.Content.ReadFromJsonAsync>(); - Assert.IsNotNull(body); - Assert.AreEqual(orgId, body.Data.Organisation.Id); - Assert.AreEqual(expectedName, body.Data.Organisation.Name); + Assert.NotNull(body); + Assert.NotNull(body.Data); + Assert.Equal(orgId, body.Data.Organisation.Id); + Assert.Equal(expectedName, body.Data.Organisation.Name); } } ``` @@ -167,7 +160,7 @@ public static class SeedExtensions public static async Task SeedOrganisationAsync(this InternalApiWebApplicationFactory factory, string name) { var id = Guid.NewGuid(); - using var scope = factory.Services.CreateScope(); + await using var scope = factory.Services.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); context.Organisations.Add(new OrganisationEntity @@ -194,7 +187,7 @@ public static class SeedExtensions In .NET 6+ Minimal APIs, using `ConfigureWebHost` to override configuration properties happens **after** `Program.cs` starts parsing `builder.Configuration`. Since the API project checks configuration keys immediately during bootstrap (`CreateBuilder(args)`), standard `WebApplicationFactory` configuration overrides are too late and cause "Section not found" exceptions. ### Solution: JSON-to-Environment Variable Mapping -We solve this by loading static test settings from [appsettings.IntegrationTests.json](file:///c:/Work/Playground/dsi-workspace/dsi-platform/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json) in the `InternalApiWebApplicationFactory` constructor and dumping them as process environment variables. +We solve this by loading static test settings from [appsettings.IntegrationTests.json](file:///c:/Work/Playground/dsi-workspace/dsi-platform/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.IntegrationTests.json) (or custom test settings file defined by the factory) in the constructor and dumping them as process environment variables. The configuration binder translates double underscores (`__`) into colons (`:`), resulting in a perfect representation of configuration sections (e.g. `AzureAd__Audience` maps to `AzureAd:Audience`) which are processed in time by `Program.cs`. --- From 6110fc5ffff155eb891d84b4069699cd98787a4c Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 14:36:19 +0100 Subject: [PATCH 11/14] A few tweaks and updated docs --- .../testing/Integration-Tests.md | 0 .../Endpoints/GetOrganisationByIdTests.cs | 34 ++++++++----------- .../IntegrationTestCollection.cs | 2 -- .../InternalApiWebApplicationFactory.cs | 5 ++- 4 files changed, 17 insertions(+), 24 deletions(-) rename tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md => docs/testing/Integration-Tests.md (100%) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md b/docs/testing/Integration-Tests.md similarity index 100% rename from tests/Dfe.SignIn.InternalApi.IntegrationTests/readme.md rename to docs/testing/Integration-Tests.md diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs index 676cdd6e..00851147 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs @@ -1,5 +1,3 @@ -namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; - using System.Net; using System.Net.Http.Json; using Dfe.SignIn.Core.Contracts.Organisations; @@ -7,25 +5,27 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; using Dfe.SignIn.Gateways.EntityFramework; using Dfe.SignIn.InternalApi.Contracts; using Microsoft.Extensions.DependencyInjection; -using Xunit; +using Assert = Xunit.Assert; + +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; [Collection("IntegrationTestsCollection")] [Trait("Category", "Integration")] public class GetOrganisationByIdTests : IAsyncLifetime { - private readonly InternalApiWebApplicationFactory _factory; - private readonly HttpClient _client; + private readonly InternalApiWebApplicationFactory webAppfactory; + private readonly HttpClient httpClient; public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) { - _factory = factory; - _client = _factory.CreateClient(); + this.webAppfactory = factory; + this.httpClient = this.webAppfactory.CreateClient(); } public async Task InitializeAsync() { // Clear database state between tests - await _factory.ResetDatabasesAsync(); + await this.webAppfactory.ResetDatabasesAsync(); } public Task DisposeAsync() => Task.CompletedTask; @@ -37,10 +37,9 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() var orgId = Guid.NewGuid(); var expectedName = "Test Academy Trust"; - await using var scope = _factory.Services.CreateAsyncScope(); + await using var scope = this.webAppfactory.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Organisations.Add(new OrganisationEntity - { + dbContext.Organisations.Add(new OrganisationEntity { Id = orgId, Name = expectedName, Category = "001", @@ -50,17 +49,15 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() }); await dbContext.SaveChangesAsync(); - var request = new GetOrganisationByIdRequest - { + var request = new GetOrganisationByIdRequest { OrganisationId = orgId }; // Act: POST to the endpoint - var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await this.httpClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); // Assert - if (response.StatusCode != HttpStatusCode.OK) - { + if (response.StatusCode != HttpStatusCode.OK) { var errorContent = await response.Content.ReadAsStringAsync(); Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorContent}"); } @@ -77,13 +74,12 @@ public async Task GetOrganisationById_Returns404_WhenDoesNotExist() { // Arrange var missingOrgId = Guid.NewGuid(); - var request = new GetOrganisationByIdRequest - { + var request = new GetOrganisationByIdRequest { OrganisationId = missingOrgId }; // Act - var response = await _client.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await this.httpClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs index a9270fb0..690781a6 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs @@ -1,5 +1,3 @@ -using Xunit; - namespace Dfe.SignIn.InternalApi.IntegrationTests; [CollectionDefinition("IntegrationTestsCollection")] diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs index 4eacdca1..59c0db0c 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Xunit; namespace Dfe.SignIn.InternalApi.IntegrationTests; @@ -33,11 +32,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) public async Task InitializeAsync() { - await InitialiseDatabasesAsync(); + await this.InitialiseDatabasesAsync(); } async Task IAsyncLifetime.DisposeAsync() { - await DisposeAsync(); + await this.DisposeAsync(); } } From bb7417f20a659b582d2e2908815ef44bfbebf542 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Mon, 22 Jun 2026 15:04:51 +0100 Subject: [PATCH 12/14] test --- .../Integration/IntegrationTestFactory.cs | 12 ++++++------ ...{SqlContainerFixture.cs => SqlDatabaseManager.cs} | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) rename tests/Dfe.SignIn.TestHelpers/Integration/{SqlContainerFixture.cs => SqlDatabaseManager.cs} (98%) diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs index 3a5a0544..8eeafc3b 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs @@ -13,7 +13,7 @@ namespace Dfe.SignIn.TestHelpers.Integration; public abstract class IntegrationTestFactory : WebApplicationFactory where TProgram : class { - private readonly SqlContainerFixture _sqlFixture = new(); + private readonly SqlDatabaseManager _dbManager = new(); /// /// Defines the database catalogs that this API requires. @@ -31,8 +31,8 @@ protected IntegrationTestFactory() // The container must be started synchronously here because environment variables // need to be set before the WebApplicationFactory builds its host in ConfigureWebHost. - this._sqlFixture.StartAsync(this.DatabaseCatalogs).GetAwaiter().GetResult(); - this._sqlFixture.SetConnectionEnvironmentVariables(); + this._dbManager.StartAsync(this.DatabaseCatalogs).GetAwaiter().GetResult(); + this._dbManager.SetConnectionEnvironmentVariables(); } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -47,7 +47,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) /// public async Task InitialiseDatabasesAsync() { - await this._sqlFixture.InitialiseSchemasAsync(this.DatabaseCatalogs, this.Services); + await this._dbManager.InitialiseSchemasAsync(this.DatabaseCatalogs, this.Services); } /// @@ -56,7 +56,7 @@ public async Task InitialiseDatabasesAsync() /// public async Task ResetDatabasesAsync() { - await this._sqlFixture.ResetAllAsync(); + await this._dbManager.ResetAllAsync(); } /// @@ -84,7 +84,7 @@ private static void LoadStaticConfigurations(string appSettingsFileName) public override async ValueTask DisposeAsync() { - await this._sqlFixture.DisposeAsync(); + await this._dbManager.DisposeAsync(); await base.DisposeAsync(); } } diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs b/tests/Dfe.SignIn.TestHelpers/Integration/SqlDatabaseManager.cs similarity index 98% rename from tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs rename to tests/Dfe.SignIn.TestHelpers/Integration/SqlDatabaseManager.cs index f4d5bc35..89405801 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/SqlContainerFixture.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/SqlDatabaseManager.cs @@ -10,7 +10,7 @@ namespace Dfe.SignIn.TestHelpers.Integration; /// Manages a single SQL Server Testcontainer, creates database catalogs, /// initialises schemas via EF Core, and provides Respawn-based state resets. /// -public sealed class SqlContainerFixture : IAsyncDisposable +public sealed class SqlDatabaseManager : IAsyncDisposable { private readonly MsSqlContainer _container = new MsSqlBuilder() .WithImage("mcr.microsoft.com/mssql/server:2022-latest") From e6048b53ba2c3110b1e109dfb6eb36f23ff9efc2 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Tue, 23 Jun 2026 09:43:21 +0100 Subject: [PATCH 13/14] Fix vulnerability issues by upgrading repawn to latest version --- Directory.Packages.props | 6 ++- .../Dfe.SignIn.TestHelpers.csproj | 2 + .../Integration/DatabaseCatalog.cs | 15 ------- .../Integration/IntegrationTestFactory.cs | 2 +- .../Integration/SqlDatabaseManager.cs | 39 +++++++++++++------ 5 files changed, 35 insertions(+), 29 deletions(-) delete mode 100644 tests/Dfe.SignIn.TestHelpers/Integration/DatabaseCatalog.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 0cef4ece..04732bf5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,7 +21,7 @@ - + @@ -107,6 +107,8 @@ + + @@ -145,6 +147,6 @@ - + diff --git a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj index fef45b8e..a1eb3856 100644 --- a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj +++ b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj @@ -17,6 +17,8 @@ + + diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/DatabaseCatalog.cs b/tests/Dfe.SignIn.TestHelpers/Integration/DatabaseCatalog.cs deleted file mode 100644 index 02b3fbb8..00000000 --- a/tests/Dfe.SignIn.TestHelpers/Integration/DatabaseCatalog.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Dfe.SignIn.TestHelpers.Integration; - -/// -/// Describes a database catalog to provision inside the SQL container. -/// -/// The SQL catalog name (e.g., "dsi-directories-test"). -/// -/// The configuration key prefix used to set EntityFramework__[ConfigKey]__Host/Name/Username/Password -/// environment variables (e.g., "Directories"). -/// -/// The EF Core type to run EnsureCreated on. -public sealed record DatabaseCatalog( - string CatalogName, - string ConfigKey, - Type DbContextType); diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs index 8eeafc3b..ab194a0e 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/IntegrationTestFactory.cs @@ -25,7 +25,7 @@ public abstract class IntegrationTestFactory : WebApplicationFactory +/// Describes a database catalog to provision inside the SQL container. +/// +/// The SQL catalog name (e.g., "dsi-directories-test"). +/// +/// The configuration key prefix used to set EntityFramework__[ConfigKey]__Host/Name/Username/Password +/// environment variables (e.g., "Directories"). +/// +/// The EF Core type to run EnsureCreated on. +public sealed record DatabaseCatalog(string CatalogName, string ConfigKey, Type DbContextType); + /// /// Manages a single SQL Server Testcontainer, creates database catalogs, /// initialises schemas via EF Core, and provides Respawn-based state resets. @@ -26,10 +38,10 @@ public async Task StartAsync(IReadOnlyList catalogs) { await this._container.StartAsync(); - var containerCs = this._container.GetConnectionString(); + var containerConnectionString = this._container.GetConnectionString(); foreach (var catalog in catalogs) { - var csBuilder = new SqlConnectionStringBuilder(containerCs) { + var csBuilder = new SqlConnectionStringBuilder(containerConnectionString) { InitialCatalog = catalog.CatalogName }; this._connectionStrings[catalog.ConfigKey] = csBuilder.ConnectionString; @@ -44,11 +56,11 @@ public async Task StartAsync(IReadOnlyList catalogs) public void SetConnectionEnvironmentVariables() { foreach (var (configKey, cs) in this._connectionStrings) { - var csb = new SqlConnectionStringBuilder(cs); - Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Host", csb.DataSource); - Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Name", csb.InitialCatalog); - Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Username", csb.UserID); - Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Password", csb.Password); + var connectionStringBuilder = new SqlConnectionStringBuilder(cs); + Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Host", connectionStringBuilder.DataSource); + Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Name", connectionStringBuilder.InitialCatalog); + Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Username", connectionStringBuilder.UserID); + Environment.SetEnvironmentVariable($"EntityFramework__{configKey}__Password", connectionStringBuilder.Password); } } @@ -58,13 +70,16 @@ public void SetConnectionEnvironmentVariables() public async Task InitialiseSchemasAsync(IReadOnlyList catalogs, IServiceProvider serviceProvider) { foreach (var catalog in catalogs) { - var cs = this._connectionStrings[catalog.ConfigKey]; + var catalogConnectionString = this._connectionStrings[catalog.ConfigKey]; using var scope = serviceProvider.CreateScope(); var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(catalog.DbContextType); await dbContext.Database.EnsureCreatedAsync(); - var respawner = await Respawner.CreateAsync(cs, new RespawnerOptions { + using var connection = new SqlConnection(catalogConnectionString); + await connection.OpenAsync(); + + var respawner = await Respawner.CreateAsync(connection, new RespawnerOptions { DbAdapter = DbAdapter.SqlServer }); @@ -78,8 +93,10 @@ public async Task InitialiseSchemasAsync(IReadOnlyList catalogs public async Task ResetAllAsync() { foreach (var (configKey, respawner) in this._respawners) { - var cs = this._connectionStrings[configKey]; - await respawner.ResetAsync(cs); + var catalogConnectionString = this._connectionStrings[configKey]; + await using var connection = new SqlConnection(catalogConnectionString); + await connection.OpenAsync(); + await respawner.ResetAsync(connection); } } From 7619e5f57dde9faab558c13a1a1b21b78029c116 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Tue, 23 Jun 2026 10:19:59 +0100 Subject: [PATCH 14/14] Exclude integration tests from CI checks. Added try catch to handle exception assembly scanning Included appsettings.Test.json to the repo --- .github/workflows/dotnet-checks.yml | 1 + .../ExceptionReflectionHelpers.cs | 9 ++++++++- .../appsettings.Test.json | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.Test.json diff --git a/.github/workflows/dotnet-checks.yml b/.github/workflows/dotnet-checks.yml index dcc7bdbf..108bfe5e 100644 --- a/.github/workflows/dotnet-checks.yml +++ b/.github/workflows/dotnet-checks.yml @@ -80,6 +80,7 @@ jobs: run: | dotnet test ./build.sln ` --no-build ` + --filter "Category!=Integration" ` --collect "XPlat Code Coverage" ` --logger "trx;LogFileName=TestResults.trx" ` /p:AutomatedRun=true diff --git a/src/Dfe.SignIn.Base.Framework/ExceptionReflectionHelpers.cs b/src/Dfe.SignIn.Base.Framework/ExceptionReflectionHelpers.cs index 3b29f1d9..86bb3224 100644 --- a/src/Dfe.SignIn.Base.Framework/ExceptionReflectionHelpers.cs +++ b/src/Dfe.SignIn.Base.Framework/ExceptionReflectionHelpers.cs @@ -27,7 +27,14 @@ private static IReadOnlyDictionary ExceptionTypesByFullName { } exceptionTypesByFullName = AppDomain.CurrentDomain.GetAssemblies() - .SelectMany(assembly => assembly.GetTypes()) + .SelectMany(assembly => { + try { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) { + return e.Types.Where(t => t != null)!; + } + }) .Where(type => typeof(Exception).IsAssignableFrom(type)) .GroupBy(type => type.FullName) .ToDictionary( diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.Test.json b/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.Test.json new file mode 100644 index 00000000..b8c212cc --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/appsettings.Test.json @@ -0,0 +1,20 @@ +{ + "AzureAd": { + "Audience": "dummy-audience", + "Instance": "https://login.microsoftonline.com", + "TenantId": "00000000-0000-0000-0000-000000000000" + }, + "InternalApiClient": { + "Tenant": "00000000-0000-0000-0000-000000000000", + "ClientId": "00000000-0000-0000-0000-000000000000", + "ClientSecret": "dummy-secret", + "HostUrl": "https://localhost", + "BaseUrl": "https://localhost" + }, + "PublicApiSecretEncryption": { + "Key": "some-encryption-key-for-test-32b" + }, + "ServiceBus": { + "AuditTopic": "dummy-audit-topic" + } +}