Adds SlpModularCms.Api.Slave for local master/slave dev testing (Unit 1)

Relocates ModuleOrchestrator, ServiceCollectionExtensions, and
ApiPrefixConvention from SlpModularCms.Api into SlpModularCms.Core.Hosting
so a new Master-less SlpModularCms.Api.Slave host project (ports 5285/7222)
can share the same bootstrap code without duplicating it. This lets a
developer run a master instance and a slave instance side by side locally
to test the master/slave connection, without touching the existing
master/slave protocol itself.

Relocates the two orchestrator/convention test files from
Modules.Identity.Tests to Core.Tests, dropping an incidental
ProjectReference to SlpModularCms.Api that existed only for those tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 02:07:35 +02:00
co-authored by Claude Sonnet 5
parent 8072025e0b
commit 274946dbff
36 changed files with 1380 additions and 14 deletions
@@ -0,0 +1,76 @@
using System.Reflection;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using SlpModularCms.Core.Hosting;
namespace SlpModularCms.Core.Tests.Hosting;
public class ApiPrefixConventionTests
{
[Fact]
public void Apply_PrependsPrefixToExistingAttributeRoute()
{
var convention = new ApiPrefixConvention("api/v1");
var application = BuildApplicationModel("[controller]");
convention.Apply(application);
var template = application.Controllers[0].Selectors[0].AttributeRouteModel!.Template;
template.Should().Be("api/v1/[controller]");
}
[Fact]
public void Apply_SetsPrefixAsRoute_WhenNoExistingAttributeRoute()
{
var convention = new ApiPrefixConvention("api/v1");
var application = BuildApplicationModelWithoutRoute();
convention.Apply(application);
var template = application.Controllers[0].Selectors[0].AttributeRouteModel!.Template;
template.Should().Be("api/v1");
}
[Fact]
public void Apply_HandlesMulitpleControllers()
{
var convention = new ApiPrefixConvention("api/v1");
var application = new ApplicationModel();
application.Controllers.Add(BuildController("[controller]"));
application.Controllers.Add(BuildController("other"));
convention.Apply(application);
application.Controllers[0].Selectors[0].AttributeRouteModel!.Template.Should().Be("api/v1/[controller]");
application.Controllers[1].Selectors[0].AttributeRouteModel!.Template.Should().Be("api/v1/other");
}
private static ApplicationModel BuildApplicationModel(string routeTemplate)
{
var app = new ApplicationModel();
app.Controllers.Add(BuildController(routeTemplate));
return app;
}
private static ApplicationModel BuildApplicationModelWithoutRoute()
{
var app = new ApplicationModel();
var controller = new ControllerModel(typeof(object).GetConstructors()[0].DeclaringType!.GetTypeInfo(), []);
var selector = new SelectorModel { AttributeRouteModel = null };
controller.Selectors.Add(selector);
app.Controllers.Add(controller);
return app;
}
private static ControllerModel BuildController(string routeTemplate)
{
var controller = new ControllerModel(typeof(object).GetConstructors()[0].DeclaringType!.GetTypeInfo(), []);
var selector = new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel(new RouteAttribute(routeTemplate))
};
controller.Selectors.Add(selector);
return controller;
}
}
@@ -0,0 +1,92 @@
using FluentAssertions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using SlpModularCms.Core.Hosting;
using SlpModularCms.Core.Modules;
namespace SlpModularCms.Core.Tests.Hosting;
public class ModuleOrchestratorTests
{
private readonly ModuleOrchestrator _orchestrator =
new(NullLogger<ModuleOrchestrator>.Instance);
[Fact]
public void RegisterModuleServices_CallsRegisterServicesOnDiscoveredModules()
{
var module = Substitute.For<IModule>();
var services = new ServiceCollection();
InjectModules(_orchestrator, [module]);
_orchestrator.RegisterModuleServices(services);
module.Received(1).RegisterServices(services);
}
[Fact]
public void UseModules_CallsUseModuleOnDiscoveredModules()
{
var module = Substitute.For<IModule>();
var app = Substitute.For<IApplicationBuilder>();
app.ApplicationServices.Returns(new ServiceCollection().BuildServiceProvider());
InjectModules(_orchestrator, [module]);
_orchestrator.UseModules(app);
module.Received(1).UseModule(app);
}
[Fact]
public void RegisterModuleServices_DoesNotThrow_WhenModuleListIsEmpty()
{
var services = new ServiceCollection();
var act = () => _orchestrator.RegisterModuleServices(services);
act.Should().NotThrow();
}
[Fact]
public void UseModules_DoesNotThrow_WhenModuleListIsEmpty()
{
var app = Substitute.For<IApplicationBuilder>();
app.ApplicationServices.Returns(new ServiceCollection().BuildServiceProvider());
var act = () => _orchestrator.UseModules(app);
act.Should().NotThrow();
}
[Fact]
public void DiscoverModules_RunsWithoutThrowing_InTestEnvironment()
{
var act = () => _orchestrator.DiscoverModules();
act.Should().NotThrow();
}
[Fact]
public void RegisterModuleServices_ContinuesOnModuleThatThrows()
{
var badModule = Substitute.For<IModule>();
badModule.When(m => m.RegisterServices(Arg.Any<IServiceCollection>()))
.Do(_ => throw new InvalidOperationException("boom"));
var goodModule = Substitute.For<IModule>();
var services = new ServiceCollection();
InjectModules(_orchestrator, [badModule, goodModule]);
var act = () => _orchestrator.RegisterModuleServices(services);
act.Should().NotThrow();
goodModule.Received(1).RegisterServices(services);
}
// Inject modules via reflection — ModuleOrchestrator stores them in a private List<IModule>
private static void InjectModules(ModuleOrchestrator orchestrator, IModule[] modules)
{
var field = typeof(ModuleOrchestrator)
.GetField("_modules", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
var list = (List<IModule>)field.GetValue(orchestrator)!;
list.AddRange(modules);
}
}