Skip to content

Commit 2e4f2e8

Browse files
committed
feat: add max children validation to enrollment check requests
1 parent ffa40ef commit 2e4f2e8

6 files changed

Lines changed: 76 additions & 60 deletions

File tree

src/SEBT.Portal.Api/Models/EnrollmentCheck/EnrollmentCheckApiRequest.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
using System.ComponentModel.DataAnnotations;
2+
13
namespace SEBT.Portal.Api.Models.EnrollmentCheck;
24

35
/// <summary>
46
/// Request model for checking enrollment status of one or more children.
57
/// </summary>
68
public class EnrollmentCheckApiRequest
79
{
10+
/// <summary>
11+
/// Maximum number of children that can be checked in a single request.
12+
/// </summary>
13+
public const int MaxChildren = 20;
14+
815
/// <summary>
916
/// The children to check enrollment for.
1017
/// </summary>
18+
[MaxLength(MaxChildren)]
1119
public IList<ChildCheckApiRequest> Children { get; set; } = new List<ChildCheckApiRequest>();
1220
}
1321

src/SEBT.Portal.Api/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@
9595
builder.Host.UseSerilog();
9696

9797
// Registers plugins and allows them to be constructor injected into ASP.NET controllers
98-
builder.Services.AddPlugins();
98+
builder.Services.AddPlugins(builder.Configuration);
9999

100100
// Add services to the container.
101101
builder.Services.AddControllers();

src/SEBT.Portal.Infrastructure/Dependencies.cs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -161,20 +161,9 @@ public static IServiceCollection AddPortalInfrastructureAppSettings(this IServic
161161
postConfig.PostConfigure(null, options);
162162
});
163163

164-
<<<<<<< feature/DC-172-backend-enrollment
165-
services.AddOptions<AppConfigFeatureFlagSettings>()
166-
.Bind(configuration.GetSection(AppConfigFeatureFlagSettings.SectionName))
167-
.PostConfigure<IConfiguration, ILogger<AppConfigFeatureFlagOptionsConfiguration>>((options, config, logger) =>
168-
{
169-
var postConfig = new AppConfigFeatureFlagOptionsConfiguration(config, logger);
170-
postConfig.PostConfigure(null, options);
171-
});
172-
173164
services.AddOptionsWithValidateOnStart<EnrollmentCheckRateLimitSettings>()
174165
.BindConfiguration(EnrollmentCheckRateLimitSettings.SectionName);
175166

176-
=======
177-
>>>>>>> feature/DC-172-design-system
178167
services.AddOptions<SeedingSettings>()
179168
.BindConfiguration(SeedingSettings.SectionName);
180169

src/SEBT.Portal.UseCases/EnrollmentCheck/CheckEnrollmentCommandHandler.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ public async Task<Result<EnrollmentCheckResult>> Handle(
2626
"Children", "At least one child is required.");
2727
}
2828

29+
const int maxChildren = 20;
30+
if (command.Children.Count > maxChildren)
31+
{
32+
return Result<EnrollmentCheckResult>.ValidationFailed(
33+
"Children", $"A maximum of {maxChildren} children can be checked per request.");
34+
}
35+
2936
var request = new EnrollmentCheckRequest
3037
{
3138
Children = command.Children.Select(c => new ChildCheckRequest
Lines changed: 38 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,66 @@
11
using Microsoft.AspNetCore.Hosting;
22
using Microsoft.AspNetCore.Mvc.Testing;
3-
using Microsoft.EntityFrameworkCore;
4-
using Microsoft.Extensions.Configuration;
53
using Microsoft.Extensions.DependencyInjection;
64
using NSubstitute;
75
using SEBT.Portal.Core.Services;
8-
using SEBT.Portal.Infrastructure.Data;
96
using SEBT.Portal.Infrastructure.Services;
10-
using SEBT.Portal.StatesPlugins.Interfaces;
117

128
namespace SEBT.Portal.Tests.Integration;
139

1410
/// <summary>
1511
/// Shared test factory for integration tests that spin up the real HTTP pipeline.
1612
/// Handles common concerns so individual test classes can focus on endpoint behavior:
1713
/// <list type="bullet">
18-
/// <item>Configures plugin assembly paths to a non-existent directory so no plugins load</item>
19-
/// <item>Replaces SQL Server with InMemory EF provider</item>
20-
/// <item>Replaces database migration/seeding with no-op mocks</item>
21-
/// <item>Mocks plugin service interfaces so tests don't depend on state plugins</item>
14+
/// <item>Redirects plugin assembly paths to prevent loading DLLs with missing transitive dependencies</item>
15+
/// <item>Replaces database services with no-op mocks (no SQL Server required)</item>
2216
/// </list>
2317
/// </summary>
2418
public class PortalWebApplicationFactory : WebApplicationFactory<Program>
2519
{
2620
protected override void ConfigureWebHost(IWebHostBuilder builder)
2721
{
28-
builder.UseEnvironment("Development");
22+
// Override plugin assembly paths via environment variables BEFORE the server starts.
23+
// WebApplicationFactory lazily starts the server, so env vars set here are visible
24+
// when Program.cs reads builder.Configuration during startup.
25+
// This prevents loading plugin DLLs (copied to test output by the API csproj)
26+
// that have unresolvable transitive dependencies in the test environment.
27+
Environment.SetEnvironmentVariable("PluginAssemblyPaths__0", "plugins-none");
28+
Environment.SetEnvironmentVariable("PluginAssemblyPaths__1", "plugins-none");
2929

30-
builder.ConfigureAppConfiguration((_, config) =>
31-
config.AddInMemoryCollection(new Dictionary<string, string?>
32-
{
33-
["PluginAssemblyPaths:0"] = "plugins-test",
34-
["JwtSettings:SecretKey"] =
35-
"integration-test-key-must-be-at-least-32-bytes-long",
36-
}));
30+
// Provide a dummy JWT secret so the JwtBearer handler can initialize.
31+
// The auth middleware runs on every request (including /health), and
32+
// PostConfigure reads JwtSettings:SecretKey to create a SymmetricSecurityKey.
33+
Environment.SetEnvironmentVariable("JwtSettings__SecretKey",
34+
"integration-test-secret-key-at-least-32-chars!");
3735

3836
builder.ConfigureServices(services =>
3937
{
40-
// Remove the real SQL Server DbContext registration
41-
var dbContextDescriptor = services.SingleOrDefault(
42-
d => d.ServiceType == typeof(DbContextOptions<PortalDbContext>));
43-
if (dbContextDescriptor != null)
44-
{
45-
services.Remove(dbContextDescriptor);
46-
}
47-
48-
// Add InMemory EF provider instead
49-
services.AddDbContext<PortalDbContext>(options =>
50-
options.UseInMemoryDatabase("IntegrationTests"));
38+
// Replace database services with no-op mocks so startup
39+
// doesn't require a real SQL Server instance.
40+
ReplaceWithMock<IDatabaseMigrator>(services);
41+
ReplaceWithMock<IDatabaseSeeder>(services);
42+
});
43+
}
5144

52-
// Replace database migrator and seeder with no-ops
53-
var migratorDescriptor = services.SingleOrDefault(
54-
d => d.ServiceType == typeof(IDatabaseMigrator));
55-
if (migratorDescriptor != null)
56-
{
57-
services.Remove(migratorDescriptor);
58-
}
59-
services.AddScoped(_ => Substitute.For<IDatabaseMigrator>());
45+
/// <summary>
46+
/// Replaces an existing service registration with a no-op NSubstitute mock.
47+
/// </summary>
48+
private static void ReplaceWithMock<TService>(IServiceCollection services) where TService : class
49+
{
50+
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(TService));
51+
if (descriptor != null)
52+
{
53+
services.Remove(descriptor);
54+
}
6055

61-
var seederDescriptor = services.SingleOrDefault(
62-
d => d.ServiceType == typeof(IDatabaseSeeder));
63-
if (seederDescriptor != null)
64-
{
65-
services.Remove(seederDescriptor);
66-
}
67-
services.AddScoped(_ => Substitute.For<IDatabaseSeeder>());
56+
services.AddScoped(_ => Substitute.For<TService>());
57+
}
6858

69-
// Override plugin service registrations with mocks.
70-
// These AddSingleton calls come after AddPlugins' TryAddSingleton defaults
71-
// and any MEF-loaded plugins, so they win — last registration wins in DI.
72-
services.AddSingleton(Substitute.For<ISummerEbtCaseService>());
73-
services.AddSingleton(Substitute.For<IEnrollmentCheckService>());
74-
});
59+
protected override void Dispose(bool disposing)
60+
{
61+
Environment.SetEnvironmentVariable("PluginAssemblyPaths__0", null);
62+
Environment.SetEnvironmentVariable("PluginAssemblyPaths__1", null);
63+
Environment.SetEnvironmentVariable("JwtSettings__SecretKey", null);
64+
base.Dispose(disposing);
7565
}
7666
}

test/SEBT.Portal.Tests/Unit/UseCases/EnrollmentCheck/CheckEnrollmentCommandHandlerTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@ public async Task Handle_WhenNoChildren_ReturnsValidationFailed()
3333
Assert.IsType<Kernel.Results.ValidationFailedResult<EnrollmentCheckResult>>(result);
3434
}
3535

36+
[Fact]
37+
public async Task Handle_WhenTooManyChildren_ReturnsValidationFailed()
38+
{
39+
var handler = CreateHandler();
40+
var children = Enumerable.Range(0, 21).Select(i => new CheckEnrollmentCommand.ChildInput
41+
{
42+
FirstName = $"Child{i}",
43+
LastName = "Doe",
44+
DateOfBirth = new DateOnly(2015, 1, 1)
45+
}).ToList();
46+
var command = new CheckEnrollmentCommand
47+
{
48+
Children = children,
49+
IpAddress = "127.0.0.1"
50+
};
51+
52+
var result = await handler.Handle(command);
53+
54+
Assert.False(result.IsSuccess);
55+
Assert.IsType<Kernel.Results.ValidationFailedResult<EnrollmentCheckResult>>(result);
56+
}
57+
3658
[Fact]
3759
public async Task Handle_WithValidChild_CallsPluginAndReturnsResults()
3860
{

0 commit comments

Comments
 (0)