From a5de7a56406d3ba6d9fb154405e66db954385732 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Wed, 15 Jul 2026 13:23:28 +0100 Subject: [PATCH 01/13] Happy path test working --- ...SignIn.InternalApi.IntegrationTests.csproj | 4 - .../GetOrganisationByIdTests.cs | 2 +- .../Endpoints/Users/ChangeJobTitleTests.cs | 151 ++++++++++++++++++ .../InternalApiWebApplicationFactory.cs | 4 + 4 files changed, 156 insertions(+), 5 deletions(-) rename tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/{ => Organisations}/GetOrganisationByIdTests.cs (97%) create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs 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 a08dfe62..638e4a87 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -42,10 +42,6 @@ - - - - Always diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs similarity index 97% rename from tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs rename to tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs index e151beac..35a02df9 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Assert = Xunit.Assert; -namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Organisations; [Trait("Category", "Integration")] public class GetOrganisationByIdTests : IntegrationEndpointTestBase diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs new file mode 100644 index 00000000..8c5af7dc --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs @@ -0,0 +1,151 @@ +using System.Net; +using System.Net.Http.Json; +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Audit; +using Dfe.SignIn.Core.Contracts.Users; +using Dfe.SignIn.Core.Entities.Directories; +using Dfe.SignIn.Gateways.EntityFramework; +using Dfe.SignIn.InternalApi.Contracts; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Dfe.SignIn.TestHelpers.Integration; +using Assert = Xunit.Assert; + +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Users; + +[Trait("Category", "Integration")] +public class ChangeJobTitleTests : IntegrationEndpointTestBase +{ + public ChangeJobTitleTests(InternalApiWebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task ChangeJobTitle_ReturnsSuccess_WhenUserExists() + { + var capturedAudit = new CapturingWriteToAuditInteractor(); + using var customisedFactory = this.WebAppFactory.WithWebHostBuilder(builder => { + builder.ConfigureTestServices(services => { + services.RemoveAll>(); + services.AddSingleton>(capturedAudit); + }); + }); + + var authenticatedClient = customisedFactory.CreateClient(); + authenticatedClient.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); + + var userId = Guid.NewGuid(); + var expectedJobTitle = "Senior Software Developer"; + + await using var scope = this.WebAppFactory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Users.Add(new UserEntity { + Sub = userId, + Email = "alex.johnson@example.com", + FirstName = "Alex", + LastName = "Johnson", + Password = "", + Salt = "", + Status = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + JobTitle = "Software Developer" + }); + await dbContext.SaveChangesAsync(); + + var request = new ChangeJobTitleRequest { + UserId = userId, + NewJobTitle = expectedJobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + + 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.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = customisedFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == userId); + Assert.Equal(expectedJobTitle, updatedUser.JobTitle); + + var auditRequest = capturedAudit.CapturedRequest; + Assert.NotNull(auditRequest); + Assert.Equal(AuditEventCategoryNames.ChangeJobTitle, auditRequest.EventCategory); + Assert.Equal($"Successfully changed job title to {expectedJobTitle}", auditRequest.Message); + Assert.Equal(userId, auditRequest.UserId); + } + + [Fact(Skip = "TODO: implement missing-user assertion")] + public async Task ChangeJobTitle_Returns404_WhenUserDoesNotExist() + { + await Task.CompletedTask; + } + + [Fact(Skip = "TODO: implement auth assertion")] + public async Task ChangeJobTitle_Returns401_WhenUnauthenticated() + { + await Task.CompletedTask; + } + + [Fact(Skip = "TODO: verify database update")] + public async Task ChangeJobTitle_UpdatesJobTitleInDatabase() + { + await Task.CompletedTask; + } + + [Fact(Skip = "TODO: verify audit event")] + public async Task ChangeJobTitle_WritesAuditEvent_OnSuccessfulUpdate() + { + await Task.CompletedTask; + } + + [Fact(Skip = "TODO: verify unchanged-title branch")] + public async Task ChangeJobTitle_DoesNotWriteAuditEvent_WhenTitleIsUnchanged() + { + await Task.CompletedTask; + } + + [Fact(Skip = "TODO: verify whitespace normalisation")] + public async Task ChangeJobTitle_NormalisesWhitespaceBeforeSaving() + { + await Task.CompletedTask; + } + + [Fact(Skip = "TODO: verify other users are untouched")] + public async Task ChangeJobTitle_LeavesOtherUsersUnchanged() + { + await Task.CompletedTask; + } + + [Fact(Skip = "TODO: verify validation failure")] + public async Task ChangeJobTitle_Returns400_WhenNewJobTitleIsInvalid() + { + await Task.CompletedTask; + } + + [Fact(Skip = "TODO: verify max-length validation failure")] + public async Task ChangeJobTitle_Returns400_WhenNewJobTitleExceedsMaxLength() + { + await Task.CompletedTask; + } + + private sealed class CapturingWriteToAuditInteractor : IInteractor + { + public WriteToAuditRequest? CapturedRequest { get; private set; } + + public Task InvokeAsync(InteractionContext context, CancellationToken cancellationToken = default) + { + this.CapturedRequest = context.Request; + return Task.FromResult(new WriteToAuditResponse()); + } + } +} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs index 27f1dac3..6c759658 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -9,6 +9,8 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests; public class InternalApiWebApplicationFactory : IntegrationTestFactory, IAsyncLifetime { + public Action? ConfigureAdditionalTestServices { get; set; } + protected override IReadOnlyList DatabaseCatalogs => [ new("dsi-directories-test", "Directories", typeof(DbDirectoriesContext)), @@ -25,6 +27,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) options.DefaultChallengeScheme = "TestScheme"; }) .AddScheme("TestScheme", _ => { }); + + this.ConfigureAdditionalTestServices?.Invoke(services); }); } From 9058323e6ce4a42d95f4c139fd2534021aac39e6 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Wed, 15 Jul 2026 13:38:43 +0100 Subject: [PATCH 02/13] additional test cases added --- .../Endpoints/Users/ChangeJobTitleTests.cs | 72 +++++++++++++++++-- 1 file changed, 66 insertions(+), 6 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs index 8c5af7dc..8b0dbaec 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs @@ -84,22 +84,82 @@ public async Task ChangeJobTitle_ReturnsSuccess_WhenUserExists() Assert.Equal(userId, auditRequest.UserId); } - [Fact(Skip = "TODO: implement missing-user assertion")] + [Fact] public async Task ChangeJobTitle_Returns404_WhenUserDoesNotExist() { - await Task.CompletedTask; + var authenticatedClient = this.CreateAuthenticatedClient(); + + var request = new ChangeJobTitleRequest { + UserId = Guid.NewGuid(), + NewJobTitle = "Senior Software Developer" + }; + + var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - [Fact(Skip = "TODO: implement auth assertion")] + [Fact] public async Task ChangeJobTitle_Returns401_WhenUnauthenticated() { - await Task.CompletedTask; + var anonymousClient = this.CreateAnonymousClient(); + + var request = new ChangeJobTitleRequest { + UserId = Guid.NewGuid(), + NewJobTitle = "Senior Software Developer" + }; + + var response = await anonymousClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } - [Fact(Skip = "TODO: verify database update")] + [Fact] public async Task ChangeJobTitle_UpdatesJobTitleInDatabase() { - await Task.CompletedTask; + var capturedAudit = new CapturingWriteToAuditInteractor(); + using var customisedFactory = this.WebAppFactory.WithWebHostBuilder(builder => { + builder.ConfigureTestServices(services => { + services.RemoveAll>(); + services.AddSingleton>(capturedAudit); + }); + }); + + var authenticatedClient = customisedFactory.CreateClient(); + authenticatedClient.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); + + var userId = Guid.NewGuid(); + var expectedJobTitle = "Principal Software Engineer"; + + await using var scope = this.WebAppFactory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Users.Add(new UserEntity { + Sub = userId, + Email = "alex.johnson@example.com", + FirstName = "Alex", + LastName = "Johnson", + Password = "", + Salt = "", + Status = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + JobTitle = "Software Developer" + }); + await dbContext.SaveChangesAsync(); + + var request = new ChangeJobTitleRequest { + UserId = userId, + NewJobTitle = expectedJobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + await using var assertionScope = customisedFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == userId); + Assert.Equal(expectedJobTitle, updatedUser.JobTitle); } [Fact(Skip = "TODO: verify audit event")] From 94ff78ae07f5b1046e81bdcc9b6f78df8b0d6e31 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 12:34:26 +0100 Subject: [PATCH 03/13] Updates to tests --- Directory.Packages.props | 1 + ...SignIn.InternalApi.IntegrationTests.csproj | 2 + .../Endpoints/IntegrationEndpointTestBase.cs | 27 ---- .../Organisations/GetOrganisationByIdTests.cs | 9 +- .../Endpoints/Users/ChangeJobTitleTests.cs | 139 +++++------------- .../InternalApiIntegrationEndpointTestBase.cs | 89 +++++++++++ ...> InternalApiIntegrationTestCollection.cs} | 4 +- .../InternalApiWebApplicationFactory.cs | 4 - .../Dfe.SignIn.TestHelpers.csproj | 4 + .../Integration/Data/EntityFaker.cs | 19 +++ .../Extensions/HttpClientExtensions.cs | 23 +++ .../Mocks/CapturingWriteToAuditInteractor.cs | 17 +++ 12 files changed, 200 insertions(+), 138 deletions(-) delete mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/IntegrationEndpointTestBase.cs create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs rename tests/Dfe.SignIn.InternalApi.IntegrationTests/{IntegrationTestCollection.cs => InternalApiIntegrationTestCollection.cs} (58%) create mode 100644 tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs create mode 100644 tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs create mode 100644 tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 04732bf5..cb041197 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,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 638e4a87..210c234c 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Dfe.SignIn.InternalApi.IntegrationTests.csproj @@ -40,6 +40,8 @@ + + diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/IntegrationEndpointTestBase.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/IntegrationEndpointTestBase.cs deleted file mode 100644 index cb357e74..00000000 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/IntegrationEndpointTestBase.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints; - -[Collection("IntegrationTestsCollection")] -public abstract class IntegrationEndpointTestBase(InternalApiWebApplicationFactory webAppFactory) : IAsyncLifetime -{ - protected InternalApiWebApplicationFactory WebAppFactory { get; } = webAppFactory; - - public async Task InitializeAsync() - { - await this.WebAppFactory.ResetDatabasesAsync(); - } - - public Task DisposeAsync() - { - return Task.CompletedTask; - } - - protected HttpClient CreateAuthenticatedClient(string? userId = null, string? userName = null, params string[] roles) - { - return this.WebAppFactory.CreateAuthenticatedClient(userId, userName, roles); - } - - protected HttpClient CreateAnonymousClient() - { - return this.WebAppFactory.CreateAnonymousClient(); - } -} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs index 35a02df9..58e4548c 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs @@ -4,13 +4,14 @@ using Dfe.SignIn.Core.Entities.Organisations; using Dfe.SignIn.Gateways.EntityFramework; using Dfe.SignIn.InternalApi.Contracts; +using Dfe.SignIn.TestHelpers.Integration.Extensions; using Microsoft.Extensions.DependencyInjection; using Assert = Xunit.Assert; namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Organisations; [Trait("Category", "Integration")] -public class GetOrganisationByIdTests : IntegrationEndpointTestBase +public class GetOrganisationByIdTests : InternalApiIntegrationEndpointTestBase { public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) : base(factory) @@ -20,7 +21,7 @@ public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) [Fact] public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() { - var authenticatedClient = this.CreateAuthenticatedClient(); + var authenticatedClient = this.CreateClient().Authenticate(); // Arrange: Seed an organisation var orgId = Guid.NewGuid(); @@ -61,7 +62,7 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() [Fact] public async Task GetOrganisationById_Returns404_WhenDoesNotExist() { - var authenticatedClient = this.CreateAuthenticatedClient(); + var authenticatedClient = this.CreateClient().Authenticate(); // Arrange var missingOrgId = Guid.NewGuid(); @@ -79,7 +80,7 @@ public async Task GetOrganisationById_Returns404_WhenDoesNotExist() [Fact] public async Task GetOrganisationById_Returns401_WhenUnauthenticated() { - var anonymousClient = this.CreateAnonymousClient(); + var anonymousClient = this.CreateClient(); var request = new GetOrganisationByIdRequest { OrganisationId = Guid.NewGuid() diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs index 8b0dbaec..b90408b3 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs @@ -1,22 +1,20 @@ using System.Net; using System.Net.Http.Json; -using Dfe.SignIn.Base.Framework; using Dfe.SignIn.Core.Contracts.Audit; using Dfe.SignIn.Core.Contracts.Users; using Dfe.SignIn.Core.Entities.Directories; using Dfe.SignIn.Gateways.EntityFramework; using Dfe.SignIn.InternalApi.Contracts; +using Dfe.SignIn.TestHelpers.Integration.Data; +using Dfe.SignIn.TestHelpers.Integration.Extensions; using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Dfe.SignIn.TestHelpers.Integration; using Assert = Xunit.Assert; namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Users; [Trait("Category", "Integration")] -public class ChangeJobTitleTests : IntegrationEndpointTestBase +public class ChangeJobTitleTests : InternalApiIntegrationEndpointTestBase { public ChangeJobTitleTests(InternalApiWebApplicationFactory factory) : base(factory) @@ -24,70 +22,46 @@ public ChangeJobTitleTests(InternalApiWebApplicationFactory factory) } [Fact] - public async Task ChangeJobTitle_ReturnsSuccess_WhenUserExists() + public async Task ChangeJobTitle_ReturnsSuccess_UpdatesDb_AndWritesAudit_WhenUserExists() { - var capturedAudit = new CapturingWriteToAuditInteractor(); - using var customisedFactory = this.WebAppFactory.WithWebHostBuilder(builder => { - builder.ConfigureTestServices(services => { - services.RemoveAll>(); - services.AddSingleton>(capturedAudit); - }); - }); - - var authenticatedClient = customisedFactory.CreateClient(); - authenticatedClient.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); - - var userId = Guid.NewGuid(); + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + var expectedJobTitle = "Senior Software Developer"; + var user = EntityFaker.User + .RuleFor(x => x.JobTitle, (_, _) => "Old Title") + .Generate(); - await using var scope = this.WebAppFactory.Services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Users.Add(new UserEntity { - Sub = userId, - Email = "alex.johnson@example.com", - FirstName = "Alex", - LastName = "Johnson", - Password = "", - Salt = "", - Status = 1, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - JobTitle = "Software Developer" - }); - await dbContext.SaveChangesAsync(); + await this.InsertEntityAsync(user); var request = new ChangeJobTitleRequest { - UserId = userId, + UserId = user.Sub, NewJobTitle = expectedJobTitle }; var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); - if (response.StatusCode != HttpStatusCode.OK) { - var errorContent = await response.Content.ReadAsStringAsync(); - Assert.Fail($"Request failed with status {response.StatusCode}. Response: {errorContent}"); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); var body = await response.Content.ReadFromJsonAsync>(); Assert.NotNull(body); Assert.NotNull(body.Data); - await using var assertionScope = customisedFactory.Services.CreateAsyncScope(); + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); - var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == userId); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); Assert.Equal(expectedJobTitle, updatedUser.JobTitle); - var auditRequest = capturedAudit.CapturedRequest; + var auditRequest = auditMock.CapturedRequest; Assert.NotNull(auditRequest); Assert.Equal(AuditEventCategoryNames.ChangeJobTitle, auditRequest.EventCategory); Assert.Equal($"Successfully changed job title to {expectedJobTitle}", auditRequest.Message); - Assert.Equal(userId, auditRequest.UserId); + Assert.Equal(user.Sub, auditRequest.UserId); } [Fact] public async Task ChangeJobTitle_Returns404_WhenUserDoesNotExist() { - var authenticatedClient = this.CreateAuthenticatedClient(); + var authenticatedClient = this.CreateClient().Authenticate(); var request = new ChangeJobTitleRequest { UserId = Guid.NewGuid(), @@ -102,7 +76,7 @@ public async Task ChangeJobTitle_Returns404_WhenUserDoesNotExist() [Fact] public async Task ChangeJobTitle_Returns401_WhenUnauthenticated() { - var anonymousClient = this.CreateAnonymousClient(); + var anonymousClient = this.CreateClient(); var request = new ChangeJobTitleRequest { UserId = Guid.NewGuid(), @@ -115,63 +89,37 @@ public async Task ChangeJobTitle_Returns401_WhenUnauthenticated() } [Fact] - public async Task ChangeJobTitle_UpdatesJobTitleInDatabase() + public async Task ChangeJobTitle_DoesNotWriteAuditEvent_WhenTitleIsUnchanged() { - var capturedAudit = new CapturingWriteToAuditInteractor(); - using var customisedFactory = this.WebAppFactory.WithWebHostBuilder(builder => { - builder.ConfigureTestServices(services => { - services.RemoveAll>(); - services.AddSingleton>(capturedAudit); - }); - }); - - var authenticatedClient = customisedFactory.CreateClient(); - authenticatedClient.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); - - var userId = Guid.NewGuid(); - var expectedJobTitle = "Principal Software Engineer"; - - await using var scope = this.WebAppFactory.Services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Users.Add(new UserEntity { - Sub = userId, - Email = "alex.johnson@example.com", - FirstName = "Alex", - LastName = "Johnson", - Password = "", - Salt = "", - Status = 1, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - JobTitle = "Software Developer" - }); - await dbContext.SaveChangesAsync(); + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var jobTitle = "Senior Software Developer"; + var user = EntityFaker.User + .RuleFor(x => x.JobTitle, (_, _) => jobTitle) + .Generate(); + + await this.InsertEntityAsync(user); var request = new ChangeJobTitleRequest { - UserId = userId, - NewJobTitle = expectedJobTitle + UserId = user.Sub, + NewJobTitle = jobTitle }; var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - await using var assertionScope = customisedFactory.Services.CreateAsyncScope(); - var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); - var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == userId); - Assert.Equal(expectedJobTitle, updatedUser.JobTitle); - } + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); - [Fact(Skip = "TODO: verify audit event")] - public async Task ChangeJobTitle_WritesAuditEvent_OnSuccessfulUpdate() - { - await Task.CompletedTask; - } + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); + Assert.Equal(jobTitle, updatedUser.JobTitle); - [Fact(Skip = "TODO: verify unchanged-title branch")] - public async Task ChangeJobTitle_DoesNotWriteAuditEvent_WhenTitleIsUnchanged() - { - await Task.CompletedTask; + var auditRequest = auditMock.CapturedRequest; + Assert.Null(auditRequest); } [Fact(Skip = "TODO: verify whitespace normalisation")] @@ -197,15 +145,4 @@ public async Task ChangeJobTitle_Returns400_WhenNewJobTitleExceedsMaxLength() { await Task.CompletedTask; } - - private sealed class CapturingWriteToAuditInteractor : IInteractor - { - public WriteToAuditRequest? CapturedRequest { get; private set; } - - public Task InvokeAsync(InteractionContext context, CancellationToken cancellationToken = default) - { - this.CapturedRequest = context.Request; - return Task.FromResult(new WriteToAuditResponse()); - } - } } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs new file mode 100644 index 00000000..7c32b222 --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs @@ -0,0 +1,89 @@ +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Audit; +using Dfe.SignIn.TestHelpers.Integration; +using Dfe.SignIn.TestHelpers.Integration.Mocks; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Dfe.SignIn.InternalApi.IntegrationTests; + +[Collection("InternalApiIntegrationTestCollection")] +public abstract class InternalApiIntegrationEndpointTestBase(InternalApiWebApplicationFactory webAppFactory) : IAsyncLifetime +{ + private readonly List createdFactories = []; + + protected InternalApiWebApplicationFactory WebAppFactory { get; } = webAppFactory; + + public async Task InitializeAsync() + { + await this.WebAppFactory.ResetDatabasesAsync(); + } + + public Task DisposeAsync() + { + return this.DisposeCreatedFactoriesAsync(); + } + + protected (HttpClient Client, CapturingWriteToAuditInteractor AuditMock) CreateClientWithAuditMock() + { + var auditMock = new CapturingWriteToAuditInteractor(); + + var customisedFactory = this.WebAppFactory.WithWebHostBuilder(builder => { + builder.ConfigureTestServices(services => { + services.RemoveAll>(); + services.AddSingleton>(auditMock); + }); + }); + + this.createdFactories.Add(customisedFactory); + + var client = customisedFactory.CreateClient(); + client.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); + + return (client, auditMock); + } + + protected async Task InsertEntityAsync(TEntity entity) + where TContext : DbContext + where TEntity : class + { + await this.InsertEntitiesAsync([entity]); + } + + protected async Task InsertEntitiesAsync(params TEntity[] entities) + where TContext : DbContext + where TEntity : class + { + await using var scope = this.WebAppFactory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.AddRange(entities); + await dbContext.SaveChangesAsync(); + } + + private async Task DisposeCreatedFactoriesAsync() + { + foreach (var createdFactory in this.createdFactories) { + await createdFactory.DisposeAsync(); + } + + this.createdFactories.Clear(); + } + + protected HttpClient CreateClient() + { + return this.WebAppFactory.CreateClient(); + } + + //protected HttpClient CreateAuthenticatedClient(string? userId = null, string? userName = null, params string[] roles) + //{ + // return this.WebAppFactory.CreateAuthenticatedClient(userId, userName, roles); + //} + + //protected HttpClient CreateAnonymousClient() + //{ + // return this.WebAppFactory.CreateAnonymousClient(); + //} +} diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationTestCollection.cs similarity index 58% rename from tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs rename to tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationTestCollection.cs index 690781a6..ce551dab 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/IntegrationTestCollection.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationTestCollection.cs @@ -1,7 +1,7 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests; -[CollectionDefinition("IntegrationTestsCollection")] -public class IntegrationTestCollection : ICollectionFixture +[CollectionDefinition("InternalApiIntegrationTestCollection")] +public class InternalApiIntegrationTestCollection : ICollectionFixture { // This class has no code, and is never created. Its purpose is simply // to be the place to apply [CollectionDefinition] and all the diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs index 6c759658..27f1dac3 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiWebApplicationFactory.cs @@ -9,8 +9,6 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests; public class InternalApiWebApplicationFactory : IntegrationTestFactory, IAsyncLifetime { - public Action? ConfigureAdditionalTestServices { get; set; } - protected override IReadOnlyList DatabaseCatalogs => [ new("dsi-directories-test", "Directories", typeof(DbDirectoriesContext)), @@ -27,8 +25,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) options.DefaultChallengeScheme = "TestScheme"; }) .AddScheme("TestScheme", _ => { }); - - this.ConfigureAdditionalTestServices?.Invoke(services); }); } diff --git a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj index a1eb3856..02c182a0 100644 --- a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj +++ b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj @@ -22,6 +22,8 @@ + + @@ -37,6 +39,8 @@ + + diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs new file mode 100644 index 00000000..fda0efff --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs @@ -0,0 +1,19 @@ +using Bogus; +using Dfe.SignIn.Core.Entities.Directories; + +namespace Dfe.SignIn.TestHelpers.Integration.Data; + +public static class EntityFaker +{ + public static Faker User => new Faker() + .RuleFor(x => x.Sub, f => f.Random.Guid()) + .RuleFor(x => x.Email, f => f.Internet.Email()) + .RuleFor(x => x.FirstName, f => f.Name.FirstName()) + .RuleFor(x => x.LastName, f => f.Name.LastName()) + .RuleFor(x => x.Password, f => f.Internet.Password()) + .RuleFor(x => x.Salt, f => f.Random.AlphaNumeric(32)) + .RuleFor(x => x.Status, _ => (short)1) + .RuleFor(x => x.CreatedAt, f => f.Date.Past(2)) + .RuleFor(x => x.UpdatedAt, (f, user) => f.Date.Between(user.CreatedAt, DateTime.UtcNow)) + .RuleFor(x => x.JobTitle, (_, _) => "Old Title"); +} \ No newline at end of file diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs new file mode 100644 index 00000000..70033c91 --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs @@ -0,0 +1,23 @@ +namespace Dfe.SignIn.TestHelpers.Integration.Extensions; + +public static class HttpClientExtensions +{ + public static HttpClient Authenticate(this HttpClient client, string? userId = null, string? userName = null, params string[] roles) + { + client.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); + + if (!string.IsNullOrWhiteSpace(userId)) { + client.DefaultRequestHeaders.Add(TestAuthHandler.UserIdHeaderName, userId); + } + + if (!string.IsNullOrWhiteSpace(userName)) { + client.DefaultRequestHeaders.Add(TestAuthHandler.UserNameHeaderName, userName); + } + + foreach (var role in roles.Where(static r => !string.IsNullOrWhiteSpace(r))) { + client.DefaultRequestHeaders.Add(TestAuthHandler.RoleHeaderName, role); + } + + return client; + } +} diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs new file mode 100644 index 00000000..15bd3ceb --- /dev/null +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs @@ -0,0 +1,17 @@ +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Audit; + +namespace Dfe.SignIn.TestHelpers.Integration.Mocks; + +public sealed class CapturingWriteToAuditInteractor : IInteractor +{ + public WriteToAuditRequest? CapturedRequest { get; private set; } + + public Task InvokeAsync( + InteractionContext context, + CancellationToken cancellationToken = default) + { + this.CapturedRequest = context.Request; + return Task.FromResult(new WriteToAuditResponse()); + } +} \ No newline at end of file From d7df6bb6abd37b33ea94c86b1d450c76d6de21b8 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 12:34:45 +0100 Subject: [PATCH 04/13] Added some final test in --- docs/testing/Integration-Tests.md | 84 ++++++++++---- .../Endpoints/Users/ChangeJobTitleTests.cs | 106 ++++++++++++++++-- .../InternalApiIntegrationEndpointTestBase.cs | 19 ++-- 3 files changed, 166 insertions(+), 43 deletions(-) diff --git a/docs/testing/Integration-Tests.md b/docs/testing/Integration-Tests.md index 51e8c8cd..41053a91 100644 --- a/docs/testing/Integration-Tests.md +++ b/docs/testing/Integration-Tests.md @@ -9,10 +9,11 @@ This guide explains the purpose, architecture, and implementation details of the 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. + +- **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. --- @@ -50,6 +51,25 @@ sequenceDiagram TestRunner->>TestRunner: Assert status code and response payload ``` +### 2.1 How test authentication works + +Integration tests do not use a real identity provider. Instead, they use a test authentication scheme so the full HTTP pipeline can still enforce authorisation rules. + +Approach: + +1. The test host configures a custom scheme (`TestScheme`) as the default authenticate/challenge scheme. +2. Tests call `Authenticate()` on `HttpClient` to add `X-Test-Auth: true`. +3. A custom auth handler checks this header and builds a `ClaimsPrincipal` for the request. +4. If no user details are supplied, the handler uses default test values for user ID and username. +5. Endpoints still require authorisation (`RequireAuthorization()`), so requests without test auth headers return `401`. + +Key references to apply when writing or troubleshooting tests: + +- `InternalApiWebApplicationFactory.ConfigureWebHost` (registers the test auth scheme/handler) +- `HttpClientExtensions.Authenticate` (adds test auth headers to requests) +- `TestAuthHandler.HandleAuthenticateAsync` (creates claims and fallback defaults) +- `InteractionHandlerExtensions.Map` with `RequireAuthorization()` (enforces auth on interaction endpoints) + --- ## 3. Key Libraries Used @@ -57,16 +77,22 @@ sequenceDiagram 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. + +- **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. + +- **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. + +- **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. --- @@ -75,6 +101,7 @@ Resets the state of database tables back to a blank schema. 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 @@ -116,7 +143,7 @@ public class GetOrganisationByIdTests : IAsyncLifetime // 1. Arrange: Seed your data using the scoped DbContext var orgId = Guid.NewGuid(); var expectedName = "Test Academy Trust"; - + await using var scope = _factory.Services.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Organisations.Add(new OrganisationEntity @@ -152,6 +179,7 @@ public class GetOrganisationByIdTests : IAsyncLifetime ``` ### 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 @@ -162,7 +190,7 @@ public static class SeedExtensions var id = Guid.NewGuid(); await using var scope = factory.Services.CreateAsyncScope(); var context = scope.ServiceProvider.GetRequiredService(); - + context.Organisations.Add(new OrganisationEntity { Id = id, @@ -172,7 +200,7 @@ public static class SeedExtensions CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }); - + await context.SaveChangesAsync(); return id; } @@ -184,10 +212,12 @@ public static class SeedExtensions ## 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) (or custom test settings file defined by the factory) in the 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`. --- @@ -195,23 +225,29 @@ The configuration binder translates double underscores (`__`) into colons (`:`), ## 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 - ``` + +- **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. + +- **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. + +- **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. diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs index b90408b3..ec5046b9 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs @@ -122,27 +122,117 @@ public async Task ChangeJobTitle_DoesNotWriteAuditEvent_WhenTitleIsUnchanged() Assert.Null(auditRequest); } - [Fact(Skip = "TODO: verify whitespace normalisation")] + [Fact] public async Task ChangeJobTitle_NormalisesWhitespaceBeforeSaving() { - await Task.CompletedTask; + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var newjobTitle = "Senior Software Developer"; + var expectedJobTitle = "Senior Software Developer"; + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeJobTitleRequest { + UserId = user.Sub, + NewJobTitle = newjobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); + Assert.Equal(expectedJobTitle, updatedUser.JobTitle); } - [Fact(Skip = "TODO: verify other users are untouched")] + [Fact] public async Task ChangeJobTitle_LeavesOtherUsersUnchanged() { - await Task.CompletedTask; + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var newJobTitle = "Senior Software Developer"; + var users = EntityFaker.User.Generate(3); + + await this.InsertEntitiesAsync(users); + + var userToUpdate = users[1]; + + var request = new ChangeJobTitleRequest { + UserId = userToUpdate.Sub, + NewJobTitle = newJobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + + var userSubs = users.Select(u => u.Sub).ToList(); + var assertionUsers = await assertionDbContext.Users + .Where(x => userSubs.Contains(x.Sub)) + .ToListAsync(); + + var updatedUser = assertionUsers.Single(x => x.Sub == userToUpdate.Sub); + Assert.Equal(newJobTitle, updatedUser.JobTitle); + + var unchangedUsers = assertionUsers.Where(x => x.Sub != userToUpdate.Sub).ToList(); + foreach (var unchangedUser in unchangedUsers) { + Assert.NotEqual(newJobTitle, unchangedUser.JobTitle); + } } - [Fact(Skip = "TODO: verify validation failure")] + [Fact] public async Task ChangeJobTitle_Returns400_WhenNewJobTitleIsInvalid() { - await Task.CompletedTask; + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var users = EntityFaker.User.Generate(3); + + await this.InsertEntitiesAsync(users); + + var userToUpdate = users[1]; + + var request = new ChangeJobTitleRequest { + UserId = userToUpdate.Sub, + NewJobTitle = "Senior Software Engineer!!!" // Invalid characters in job title + }; + + var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - [Fact(Skip = "TODO: verify max-length validation failure")] + [Fact] public async Task ChangeJobTitle_Returns400_WhenNewJobTitleExceedsMaxLength() { - await Task.CompletedTask; + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var longJobTitle = "Vice President of Global Human Capital Management and Organizational Culture Development"; + var user = EntityFaker.User + .Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeJobTitleRequest { + UserId = user.Sub, + NewJobTitle = longJobTitle + }; + + var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs index 7c32b222..b3320cab 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/InternalApiIntegrationEndpointTestBase.cs @@ -26,6 +26,13 @@ public Task DisposeAsync() return this.DisposeCreatedFactoriesAsync(); } + /// + /// This method creates a new HttpClient instance with a mock implementation of the IInteractor interface. + /// The mock implementation captures the WriteToAuditRequest passed to it, allowing for verification of audit logging behavior during integration tests. + /// NOTE: This has only been added to support the interator that writes to audit, so that we can verify that the correct audit events are being written during integration tests. + /// NOTE: When we move to a simpler IAuditorService implementation, this method can be removed and the tests can be updated to use the real implementation of IAuditorService. + /// + /// protected (HttpClient Client, CapturingWriteToAuditInteractor AuditMock) CreateClientWithAuditMock() { var auditMock = new CapturingWriteToAuditInteractor(); @@ -52,7 +59,7 @@ protected async Task InsertEntityAsync(TEntity entity) await this.InsertEntitiesAsync([entity]); } - protected async Task InsertEntitiesAsync(params TEntity[] entities) + protected async Task InsertEntitiesAsync(IEnumerable entities) where TContext : DbContext where TEntity : class { @@ -76,14 +83,4 @@ protected HttpClient CreateClient() { return this.WebAppFactory.CreateClient(); } - - //protected HttpClient CreateAuthenticatedClient(string? userId = null, string? userName = null, params string[] roles) - //{ - // return this.WebAppFactory.CreateAuthenticatedClient(userId, userName, roles); - //} - - //protected HttpClient CreateAnonymousClient() - //{ - // return this.WebAppFactory.CreateAnonymousClient(); - //} } From 19a26f12a361f30e5573c3164ba005af4a69f4a9 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 12:37:41 +0100 Subject: [PATCH 05/13] renamed --- .../Endpoints/Organisations/GetOrganisationByIdTests.cs | 4 ++-- .../Endpoints/Users/ChangeJobTitleTests.cs | 2 +- .../Integration/Extensions/HttpClientExtensions.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs index 58e4548c..744cc075 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs @@ -21,7 +21,7 @@ public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) [Fact] public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() { - var authenticatedClient = this.CreateClient().Authenticate(); + var authenticatedClient = this.CreateClient().WithAuthenticate(); // Arrange: Seed an organisation var orgId = Guid.NewGuid(); @@ -62,7 +62,7 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() [Fact] public async Task GetOrganisationById_Returns404_WhenDoesNotExist() { - var authenticatedClient = this.CreateClient().Authenticate(); + var authenticatedClient = this.CreateClient().WithAuthenticate(); // Arrange var missingOrgId = Guid.NewGuid(); diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs index ec5046b9..2e543498 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs @@ -61,7 +61,7 @@ public async Task ChangeJobTitle_ReturnsSuccess_UpdatesDb_AndWritesAudit_WhenUse [Fact] public async Task ChangeJobTitle_Returns404_WhenUserDoesNotExist() { - var authenticatedClient = this.CreateClient().Authenticate(); + var authenticatedClient = this.CreateClient().WithAuthenticate(); var request = new ChangeJobTitleRequest { UserId = Guid.NewGuid(), diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs index 70033c91..1171d86f 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs @@ -2,7 +2,7 @@ namespace Dfe.SignIn.TestHelpers.Integration.Extensions; public static class HttpClientExtensions { - public static HttpClient Authenticate(this HttpClient client, string? userId = null, string? userName = null, params string[] roles) + public static HttpClient WithAuthenticate(this HttpClient client, string? userId = null, string? userName = null, params string[] roles) { client.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); From 615b6e243189a366385755146a9868bdd8f189fa Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 12:44:41 +0100 Subject: [PATCH 06/13] A bit more refactor --- .../Organisations/GetOrganisationByIdTests.cs | 8 +++-- .../Endpoints/Users/ChangeJobTitleTests.cs | 31 +++++++++---------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs index 744cc075..77bf5701 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs @@ -13,6 +13,8 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Organisations; [Trait("Category", "Integration")] public class GetOrganisationByIdTests : InternalApiIntegrationEndpointTestBase { + private const string endpoint = "interaction/Organisations.GetOrganisationById"; + public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) : base(factory) { @@ -44,7 +46,7 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() }; // Act: POST to the endpoint - var response = await authenticatedClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); // Assert if (response.StatusCode != HttpStatusCode.OK) { @@ -71,7 +73,7 @@ public async Task GetOrganisationById_Returns404_WhenDoesNotExist() }; // Act - var response = await authenticatedClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); // Assert Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); @@ -86,7 +88,7 @@ public async Task GetOrganisationById_Returns401_WhenUnauthenticated() OrganisationId = Guid.NewGuid() }; - var response = await anonymousClient.PostAsJsonAsync("interaction/Organisations.GetOrganisationById", request); + var response = await anonymousClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs index 2e543498..13649b89 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs @@ -16,6 +16,8 @@ namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Users; [Trait("Category", "Integration")] public class ChangeJobTitleTests : InternalApiIntegrationEndpointTestBase { + private const string endpoint = "interaction/Users.ChangeJobTitle"; + public ChangeJobTitleTests(InternalApiWebApplicationFactory factory) : base(factory) { @@ -38,7 +40,7 @@ public async Task ChangeJobTitle_ReturnsSuccess_UpdatesDb_AndWritesAudit_WhenUse NewJobTitle = expectedJobTitle }; - var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -68,7 +70,7 @@ public async Task ChangeJobTitle_Returns404_WhenUserDoesNotExist() NewJobTitle = "Senior Software Developer" }; - var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -83,7 +85,7 @@ public async Task ChangeJobTitle_Returns401_WhenUnauthenticated() NewJobTitle = "Senior Software Developer" }; - var response = await anonymousClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + var response = await anonymousClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -105,7 +107,7 @@ public async Task ChangeJobTitle_DoesNotWriteAuditEvent_WhenTitleIsUnchanged() NewJobTitle = jobTitle }; - var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -127,7 +129,7 @@ public async Task ChangeJobTitle_NormalisesWhitespaceBeforeSaving() { var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); - var newjobTitle = "Senior Software Developer"; + var newjobTitle = "Senior Software Developer"; // Intentionally includes multiple spaces var expectedJobTitle = "Senior Software Developer"; var user = EntityFaker.User.Generate(); @@ -138,7 +140,7 @@ public async Task ChangeJobTitle_NormalisesWhitespaceBeforeSaving() NewJobTitle = newjobTitle }; - var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -169,7 +171,7 @@ public async Task ChangeJobTitle_LeavesOtherUsersUnchanged() NewJobTitle = newJobTitle }; - var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -199,18 +201,16 @@ public async Task ChangeJobTitle_Returns400_WhenNewJobTitleIsInvalid() { var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); - var users = EntityFaker.User.Generate(3); - - await this.InsertEntitiesAsync(users); + var user = EntityFaker.User.Generate(); - var userToUpdate = users[1]; + await this.InsertEntityAsync(user); var request = new ChangeJobTitleRequest { - UserId = userToUpdate.Sub, + UserId = user.Sub, NewJobTitle = "Senior Software Engineer!!!" // Invalid characters in job title }; - var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -221,8 +221,7 @@ public async Task ChangeJobTitle_Returns400_WhenNewJobTitleExceedsMaxLength() var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); var longJobTitle = "Vice President of Global Human Capital Management and Organizational Culture Development"; - var user = EntityFaker.User - .Generate(); + var user = EntityFaker.User.Generate(); await this.InsertEntityAsync(user); @@ -231,7 +230,7 @@ public async Task ChangeJobTitle_Returns400_WhenNewJobTitleExceedsMaxLength() NewJobTitle = longJobTitle }; - var response = await authenticatedClient.PostAsJsonAsync("interaction/Users.ChangeJobTitle", request); + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } From d0c0a36c309de57a89ebca9274d96194b458473f Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 13:02:47 +0100 Subject: [PATCH 07/13] fix --- tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs | 2 +- .../Integration/Mocks/CapturingWriteToAuditInteractor.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs index fda0efff..c8eb49f0 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Data/EntityFaker.cs @@ -16,4 +16,4 @@ public static class EntityFaker .RuleFor(x => x.CreatedAt, f => f.Date.Past(2)) .RuleFor(x => x.UpdatedAt, (f, user) => f.Date.Between(user.CreatedAt, DateTime.UtcNow)) .RuleFor(x => x.JobTitle, (_, _) => "Old Title"); -} \ No newline at end of file +} diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs index 15bd3ceb..59fada94 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Mocks/CapturingWriteToAuditInteractor.cs @@ -14,4 +14,4 @@ public Task InvokeAsync( this.CapturedRequest = context.Request; return Task.FromResult(new WriteToAuditResponse()); } -} \ No newline at end of file +} From 1c7e66d82ea78d3113be3d9ceef8a9904167c46f Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 13:09:58 +0100 Subject: [PATCH 08/13] Set TestHelpers as not test project --- tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj index 02c182a0..8f453188 100644 --- a/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj +++ b/tests/Dfe.SignIn.TestHelpers/Dfe.SignIn.TestHelpers.csproj @@ -6,8 +6,9 @@ enable enable false + false - + From 0105d7342e57644d3df755a12a8ba7c4bd7cf6c1 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 13:22:36 +0100 Subject: [PATCH 09/13] Fixed typo --- .../Endpoints/Organisations/GetOrganisationByIdTests.cs | 4 ++-- .../Endpoints/Users/ChangeJobTitleTests.cs | 2 +- .../Integration/Extensions/HttpClientExtensions.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs index 77bf5701..989d6f7e 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Organisations/GetOrganisationByIdTests.cs @@ -23,7 +23,7 @@ public GetOrganisationByIdTests(InternalApiWebApplicationFactory factory) [Fact] public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() { - var authenticatedClient = this.CreateClient().WithAuthenticate(); + var authenticatedClient = this.CreateClient().WithAuthentication(); // Arrange: Seed an organisation var orgId = Guid.NewGuid(); @@ -64,7 +64,7 @@ public async Task GetOrganisationById_ReturnsOrganisation_WhenExists() [Fact] public async Task GetOrganisationById_Returns404_WhenDoesNotExist() { - var authenticatedClient = this.CreateClient().WithAuthenticate(); + var authenticatedClient = this.CreateClient().WithAuthentication(); // Arrange var missingOrgId = Guid.NewGuid(); diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs index 13649b89..d513b31c 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeJobTitleTests.cs @@ -63,7 +63,7 @@ public async Task ChangeJobTitle_ReturnsSuccess_UpdatesDb_AndWritesAudit_WhenUse [Fact] public async Task ChangeJobTitle_Returns404_WhenUserDoesNotExist() { - var authenticatedClient = this.CreateClient().WithAuthenticate(); + var authenticatedClient = this.CreateClient().WithAuthentication(); var request = new ChangeJobTitleRequest { UserId = Guid.NewGuid(), diff --git a/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs b/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs index 1171d86f..873c859c 100644 --- a/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs +++ b/tests/Dfe.SignIn.TestHelpers/Integration/Extensions/HttpClientExtensions.cs @@ -2,7 +2,7 @@ namespace Dfe.SignIn.TestHelpers.Integration.Extensions; public static class HttpClientExtensions { - public static HttpClient WithAuthenticate(this HttpClient client, string? userId = null, string? userName = null, params string[] roles) + public static HttpClient WithAuthentication(this HttpClient client, string? userId = null, string? userName = null, params string[] roles) { client.DefaultRequestHeaders.Add(TestAuthHandler.EnableAuthHeaderName, bool.TrueString); From e5ef0e3f27dddf7ed5e2e584cdebce8ee54a528f Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 14:15:26 +0100 Subject: [PATCH 10/13] initial tests for change name --- .../Users/ChangeNameUseCase.cs | 2 +- .../Endpoints/Users/ChangeNameTests.cs | 338 ++++++++++++++++++ 2 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs diff --git a/src/Dfe.SignIn.Core.UseCases/Users/ChangeNameUseCase.cs b/src/Dfe.SignIn.Core.UseCases/Users/ChangeNameUseCase.cs index 4e6d3281..c747bcae 100644 --- a/src/Dfe.SignIn.Core.UseCases/Users/ChangeNameUseCase.cs +++ b/src/Dfe.SignIn.Core.UseCases/Users/ChangeNameUseCase.cs @@ -44,7 +44,7 @@ public override async Task InvokeAsync( await interaction.DispatchAsync( new WriteToAuditRequest { - EventCategory = AuditEventCategoryNames.ChangeJobTitle, + EventCategory = AuditEventCategoryNames.ChangeName, Message = $"Successfully changed users name to {user.FirstName} {user.LastName}", UserId = context.Request.UserId, } diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs new file mode 100644 index 00000000..67fab3b5 --- /dev/null +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs @@ -0,0 +1,338 @@ +using System.Net; +using System.Net.Http.Json; +using Dfe.SignIn.Core.Contracts.Audit; +using Dfe.SignIn.Core.Contracts.Users; +using Dfe.SignIn.Core.Entities.Directories; +using Dfe.SignIn.Gateways.EntityFramework; +using Dfe.SignIn.InternalApi.Contracts; +using Dfe.SignIn.TestHelpers.Integration.Data; +using Dfe.SignIn.TestHelpers.Integration.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Assert = Xunit.Assert; + +namespace Dfe.SignIn.InternalApi.IntegrationTests.Endpoints.Users; + +[Trait("Category", "Integration")] +public class ChangeNameTests : InternalApiIntegrationEndpointTestBase +{ + private const string endpoint = "interaction/Users.ChangeName"; + + public ChangeNameTests(InternalApiWebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task ChangeName_ReturnsSuccess_UpdatesDb_AndWritesAudit_WhenUserExists() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var expectedFirstName = "Jane"; + var expectedLastName = "Smith"; + var user = EntityFaker.User + .RuleFor(x => x.FirstName, (_, _) => "John") + .RuleFor(x => x.LastName, (_, _) => "Doe") + .Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = expectedFirstName, + LastName = expectedLastName + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); + Assert.Equal(expectedFirstName, updatedUser.FirstName); + Assert.Equal(expectedLastName, updatedUser.LastName); + + var auditRequest = auditMock.CapturedRequest; + Assert.NotNull(auditRequest); + Assert.Equal(AuditEventCategoryNames.ChangeName, auditRequest.EventCategory); + Assert.Equal($"Successfully changed users name to {expectedFirstName} {expectedLastName}", auditRequest.Message); + Assert.Equal(user.Sub, auditRequest.UserId); + } + + [Fact] + public async Task ChangeName_Returns404_WhenUserDoesNotExist() + { + var authenticatedClient = this.CreateClient().WithAuthentication(); + + var request = new ChangeNameRequest { + UserId = Guid.NewGuid(), + FirstName = "Jane", + LastName = "Smith" + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task ChangeName_Returns401_WhenUnauthenticated() + { + var anonymousClient = this.CreateClient(); + + var request = new ChangeNameRequest { + UserId = Guid.NewGuid(), + FirstName = "Jane", + LastName = "Smith" + }; + + var response = await anonymousClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task ChangeName_DoesNotWriteAuditEvent_WhenNameIsUnchanged() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var firstName = "John"; + var lastName = "Doe"; + var user = EntityFaker.User + .RuleFor(x => x.FirstName, (_, _) => firstName) + .RuleFor(x => x.LastName, (_, _) => lastName) + .Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = firstName, + LastName = lastName + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); + Assert.Equal(firstName, updatedUser.FirstName); + Assert.Equal(lastName, updatedUser.LastName); + + var auditRequest = auditMock.CapturedRequest; + Assert.Null(auditRequest); + } + + [Fact] + public async Task ChangeName_NormalisesWhitespaceBeforeSaving() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var newFirstName = "Jane Mary"; + var expectedFirstName = "Jane Mary"; + var newLastName = "Smith Jones"; + var expectedLastName = "Smith Jones"; + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = newFirstName, + LastName = newLastName + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + var updatedUser = await assertionDbContext.Users.SingleAsync(x => x.Sub == user.Sub); + Assert.Equal(expectedFirstName, updatedUser.FirstName); + Assert.Equal(expectedLastName, updatedUser.LastName); + } + + [Fact] + public async Task ChangeName_LeavesOtherUsersUnchanged() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var newFirstName = "Jane"; + var newLastName = "Smith"; + var users = EntityFaker.User.Generate(3); + + await this.InsertEntitiesAsync(users); + + var userToUpdate = users[1]; + + var request = new ChangeNameRequest { + UserId = userToUpdate.Sub, + FirstName = newFirstName, + LastName = newLastName + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadFromJsonAsync>(); + Assert.NotNull(body); + Assert.NotNull(body.Data); + + await using var assertionScope = this.WebAppFactory.Services.CreateAsyncScope(); + var assertionDbContext = assertionScope.ServiceProvider.GetRequiredService(); + + var userSubs = users.Select(u => u.Sub).ToList(); + var assertionUsers = await assertionDbContext.Users + .Where(x => userSubs.Contains(x.Sub)) + .ToListAsync(); + + var updatedUser = assertionUsers.Single(x => x.Sub == userToUpdate.Sub); + Assert.Equal(newFirstName, updatedUser.FirstName); + Assert.Equal(newLastName, updatedUser.LastName); + + var unchangedUsers = assertionUsers.Where(x => x.Sub != userToUpdate.Sub).ToList(); + foreach (var unchangedUser in unchangedUsers) { + Assert.NotEqual(newFirstName, unchangedUser.FirstName); + Assert.NotEqual(newLastName, unchangedUser.LastName); + } + } + + [Fact] + public async Task ChangeName_Returns400_WhenFirstNameIsEmpty() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = "", + LastName = "Smith" + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ChangeName_Returns400_WhenLastNameIsEmpty() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = "Jane", + LastName = "" + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ChangeName_Returns400_WhenFirstNameIsInvalid() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = "Jane!!!", // Invalid character + LastName = "Smith" + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ChangeName_Returns400_WhenLastNameIsInvalid() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = "Jane", + LastName = "Smith!!!" // Invalid character + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ChangeName_Returns400_WhenFirstNameExceedsMaxLength() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var longFirstName = new string('A', 61); + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = longFirstName, + LastName = "Smith" + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task ChangeName_Returns400_WhenLastNameExceedsMaxLength() + { + var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); + + var longLastName = new string('A', 61); + var user = EntityFaker.User.Generate(); + + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = "Jane", + LastName = longLastName + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } +} From 5ba091d03e34eb24053172a2638fb36d4426639f Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Thu, 16 Jul 2026 14:34:05 +0100 Subject: [PATCH 11/13] added comments --- .../Endpoints/Users/ChangeNameTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs index 67fab3b5..da86f389 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs @@ -139,9 +139,9 @@ public async Task ChangeName_NormalisesWhitespaceBeforeSaving() { var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); - var newFirstName = "Jane Mary"; + var newFirstName = "Jane Mary"; // Intentional extra spaces var expectedFirstName = "Jane Mary"; - var newLastName = "Smith Jones"; + var newLastName = "Smith Jones"; // Intentional extra spaces var expectedLastName = "Smith Jones"; var user = EntityFaker.User.Generate(); From af74957df325108514172b5c9f499e96d087980e Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Fri, 17 Jul 2026 09:03:27 +0100 Subject: [PATCH 12/13] Consolidated validation tests --- .../Endpoints/Users/ChangeNameTests.cs | 72 +++---------------- 1 file changed, 8 insertions(+), 64 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs index da86f389..cdb572d0 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs @@ -214,48 +214,12 @@ public async Task ChangeName_LeavesOtherUsersUnchanged() } } - [Fact] - public async Task ChangeName_Returns400_WhenFirstNameIsEmpty() - { - var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); - - var user = EntityFaker.User.Generate(); - - await this.InsertEntityAsync(user); - - var request = new ChangeNameRequest { - UserId = user.Sub, - FirstName = "", - LastName = "Smith" - }; - - var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task ChangeName_Returns400_WhenLastNameIsEmpty() - { - var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); - - var user = EntityFaker.User.Generate(); - - await this.InsertEntityAsync(user); - - var request = new ChangeNameRequest { - UserId = user.Sub, - FirstName = "Jane", - LastName = "" - }; - - var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task ChangeName_Returns400_WhenFirstNameIsInvalid() + [Theory] + [InlineData("", "Smith")] // Invalid FirstName: Empty + [InlineData("Jane!!!", "Smith")] // Invalid FirstName: Special chars + [InlineData("Jane", "")] // Invalid LastName: Empty + [InlineData("Jane", "Smith!!!")] // Invalid LastName: Special chars + public async Task ChangeName_Returns400_WhenNameIsInvalid(string firstName, string lastName) { var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); @@ -265,28 +229,8 @@ public async Task ChangeName_Returns400_WhenFirstNameIsInvalid() var request = new ChangeNameRequest { UserId = user.Sub, - FirstName = "Jane!!!", // Invalid character - LastName = "Smith" - }; - - var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task ChangeName_Returns400_WhenLastNameIsInvalid() - { - var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); - - var user = EntityFaker.User.Generate(); - - await this.InsertEntityAsync(user); - - var request = new ChangeNameRequest { - UserId = user.Sub, - FirstName = "Jane", - LastName = "Smith!!!" // Invalid character + FirstName = firstName, + LastName = lastName }; var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); From 23c7e74ee51fdb803ff721d44fa88018c72f26e1 Mon Sep 17 00:00:00 2001 From: Amjad Mahmood Date: Fri, 17 Jul 2026 09:32:44 +0100 Subject: [PATCH 13/13] Split to firstname and lastname test --- .../Endpoints/Users/ChangeNameTests.cs | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs index cdb572d0..cfad933d 100644 --- a/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs +++ b/tests/Dfe.SignIn.InternalApi.IntegrationTests/Endpoints/Users/ChangeNameTests.cs @@ -215,26 +215,40 @@ public async Task ChangeName_LeavesOtherUsersUnchanged() } [Theory] - [InlineData("", "Smith")] // Invalid FirstName: Empty - [InlineData("Jane!!!", "Smith")] // Invalid FirstName: Special chars - [InlineData("Jane", "")] // Invalid LastName: Empty - [InlineData("Jane", "Smith!!!")] // Invalid LastName: Special chars - public async Task ChangeName_Returns400_WhenNameIsInvalid(string firstName, string lastName) + [InlineData("")] + [InlineData("Jane!!!")] + public async Task ChangeName_Returns400_WhenFirstNameIsInvalid(string invalidFirstName) { - var (authenticatedClient, auditMock) = this.CreateClientWithAuditMock(); - + var (authenticatedClient, _) = this.CreateClientWithAuditMock(); var user = EntityFaker.User.Generate(); - await this.InsertEntityAsync(user); var request = new ChangeNameRequest { UserId = user.Sub, - FirstName = firstName, - LastName = lastName + FirstName = invalidFirstName, + LastName = "Smith" }; var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + [Theory] + [InlineData("")] + [InlineData("Smith!!!")] + public async Task ChangeName_Returns400_WhenLastNameIsInvalid(string invalidLastName) + { + var (authenticatedClient, _) = this.CreateClientWithAuditMock(); + var user = EntityFaker.User.Generate(); + await this.InsertEntityAsync(user); + + var request = new ChangeNameRequest { + UserId = user.Sub, + FirstName = "Jane", + LastName = invalidLastName + }; + + var response = await authenticatedClient.PostAsJsonAsync(endpoint, request); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); }