diff --git a/CLAUDE.md b/CLAUDE.md index 8496a512..0f844243 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference - Module boundaries, dependencies, and data ownership - Communication patterns (contracts and events) - Endpoint, frontend, and authorization rules -- Compiler-enforced diagnostics (SM0001-SM0043) +- Compiler-enforced diagnostics (SM0001-SM0044) - Framework contributor guidelines ## Key Constraints diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index b87dcaad..61ede4e1 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -51,6 +51,31 @@ All hooks are optional. All have default no-op implementations. 8. **OnStopAsync** -- graceful shutdown cleanup 9. **CheckHealthAsync** -- report module health status (Healthy, Degraded, Unhealthy) +### Module Options + +Modules can expose configurable behavior via the `IModuleOptions` marker interface. The source generator auto-discovers these classes and generates typed `Configure{Module}()` extension methods on `SimpleModuleOptions`. + +```csharp +// Module defines options +public class ProductsModuleOptions : IModuleOptions +{ + public int DefaultPageSize { get; set; } = 10; + public int MaxPageSize { get; set; } = 100; +} + +// Host app configures them +builder.AddSimpleModule(o => +{ + o.ConfigureProducts(p => p.MaxPageSize = 50); +}); + +// Module reads them via IOptions +public class BrowseEndpoint(IOptions options) : IViewEndpoint { ... } +``` + +**Rules:** +- At most one `IModuleOptions` class per module (SM0044 warns on duplicates). +- Options classes may live in the module assembly or its Contracts assembly. ### What a Module Must Never Expose - Entity classes @@ -371,6 +396,7 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. ` | SM0002 | Warning | Module name must not be empty | | SM0040 | Error | No duplicate module names | | SM0043 | Warning | Module must override at least one IModule method | +| SM0044 | Warning | Multiple IModuleOptions for same module | ### Dependencies diff --git a/framework/SimpleModule.Core/IModuleOptions.cs b/framework/SimpleModule.Core/IModuleOptions.cs new file mode 100644 index 00000000..2af0b541 --- /dev/null +++ b/framework/SimpleModule.Core/IModuleOptions.cs @@ -0,0 +1,21 @@ +namespace SimpleModule.Core; + +/// +/// Marker interface for module options classes. Implementations are auto-discovered +/// by the source generator, registered with IOptions<T>, and exposed as +/// typed Configure{Module}() methods on SimpleModuleOptions. +/// +/// +/// Each module may define at most one options class implementing this interface. +/// The host application can then configure module behavior at startup: +/// +/// builder.AddSimpleModule(o => +/// { +/// o.ConfigureProducts(p => p.MaxPageSize = 50); +/// }); +/// +/// Module code reads configured values via IOptions<TOptions> injection. +/// +#pragma warning disable CA1040 // Avoid empty interfaces +public interface IModuleOptions; +#pragma warning restore CA1040 diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md index c5223b99..535d0cc3 100644 --- a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md @@ -30,3 +30,4 @@ SM0040 | SimpleModule.Generator | Error | Duplicate module name SM0041 | SimpleModule.Generator | Warning | View page name does not match module name prefix SM0042 | SimpleModule.Generator | Error | Module has view endpoints but no ViewPrefix SM0043 | SimpleModule.Generator | Warning | Module does not override any IModule methods +SM0044 | SimpleModule.Generator | Warning | Multiple IModuleOptions for same module diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index b6fba426..da542102 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -29,6 +29,7 @@ internal readonly record struct DiscoveryData( ImmutableArray PermissionClasses, ImmutableArray Interceptors, ImmutableArray VogenValueObjects, + ImmutableArray ModuleOptions, string HostAssemblyName ) { @@ -44,6 +45,7 @@ string HostAssemblyName ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, "" ); @@ -60,6 +62,7 @@ public bool Equals(DiscoveryData other) && PermissionClasses.SequenceEqual(other.PermissionClasses) && Interceptors.SequenceEqual(other.Interceptors) && VogenValueObjects.SequenceEqual(other.VogenValueObjects) + && ModuleOptions.SequenceEqual(other.ModuleOptions) && HostAssemblyName == other.HostAssemblyName; } @@ -77,6 +80,7 @@ public override int GetHashCode() hash = HashHelper.HashArray(hash, PermissionClasses); hash = HashHelper.HashArray(hash, Interceptors); hash = HashHelper.HashArray(hash, VogenValueObjects); + hash = HashHelper.HashArray(hash, ModuleOptions); hash = HashHelper.Combine(hash, (HostAssemblyName ?? "").GetHashCode()); return hash; } @@ -312,6 +316,29 @@ public override int GetHashCode() } } +internal readonly record struct ModuleOptionsRecord( + string FullyQualifiedName, + string ModuleName +) +{ + internal static Dictionary> GroupByModule( + ImmutableArray options + ) + { + var result = new Dictionary>(); + foreach (var opt in options) + { + if (!result.TryGetValue(opt.ModuleName, out var list)) + { + list = new List(); + result[opt.ModuleName] = list; + } + list.Add(opt); + } + return result; + } +} + internal readonly record struct VogenValueObjectRecord( string TypeFqn, string ConverterFqn, diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 8addf8db..29f8e2f0 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -395,6 +395,40 @@ is not IAssemblySymbol assemblySymbol } ); + // Step 3f: Find IModuleOptions implementors in module and contracts assemblies + var moduleOptionsList = new List(); + var moduleOptionsSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.IModuleOptions" + ); + if (moduleOptionsSymbol is not null) + { + ScanModuleAssemblies( + modules, + moduleSymbols, + (assembly, module) => + FindModuleOptionsClasses( + assembly.GlobalNamespace, + moduleOptionsSymbol, + module.ModuleName, + moduleOptionsList + ) + ); + + // Also scan contracts assemblies for module options classes + foreach (var kvp in contractsAssemblySymbols) + { + if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) + { + FindModuleOptionsClasses( + kvp.Value.GlobalNamespace, + moduleOptionsSymbol, + moduleName, + moduleOptionsList + ); + } + } + } + // Step 4: Detect dependencies and illegal references var dependencies = new List(); var illegalReferences = new List(); @@ -537,6 +571,7 @@ is not IAssemblySymbol assemblySymbol )) .ToImmutableArray(), vogenValueObjects.ToImmutableArray(), + moduleOptionsList.ToImmutableArray(), hostAssemblyName ); } @@ -1227,6 +1262,55 @@ m is IFieldSymbol field } } + private static void FindModuleOptionsClasses( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol moduleOptionsSymbol, + string moduleName, + List results + ) + { + FindConcreteClassesImplementing( + namespaceSymbol, + moduleOptionsSymbol, + typeSymbol => + results.Add( + new ModuleOptionsRecord( + typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + moduleName + ) + ) + ); + } + + /// + /// Recursively walks namespaces and invokes for each + /// concrete (non-abstract, non-static) class that implements the given interface. + /// + private static void FindConcreteClassesImplementing( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol interfaceSymbol, + Action onMatch + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNs) + { + FindConcreteClassesImplementing(childNs, interfaceSymbol, onMatch); + } + else if ( + member is INamedTypeSymbol typeSymbol + && typeSymbol.TypeKind == TypeKind.Class + && !typeSymbol.IsAbstract + && !typeSymbol.IsStatic + && ImplementsInterface(typeSymbol, interfaceSymbol) + ) + { + onMatch(typeSymbol); + } + } + } + private static void FindInterceptorTypes( INamespaceSymbol namespaceSymbol, INamedTypeSymbol saveChangesInterceptorSymbol, diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index a81ba663..53dd2d83 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -260,6 +260,15 @@ internal sealed class DiagnosticEmitter : IEmitter isEnabledByDefault: true ); + private static readonly DiagnosticDescriptor MultipleModuleOptions = new( + id: "SM0044", + title: "Multiple IModuleOptions for same module", + messageFormat: "Module '{0}' has multiple IModuleOptions implementations: '{1}' and '{2}'. Each module should have at most one options class. Only the first will be used.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + public void Emit(SourceProductionContext context, DiscoveryData data) { // SM0002: Empty module name @@ -850,6 +859,25 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } } + + // SM0044: Multiple IModuleOptions for same module + var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions); + + foreach (var kvp in optionsByModule) + { + if (kvp.Value.Count > 1) + { + context.ReportDiagnostic( + Diagnostic.Create( + MultipleModuleOptions, + Location.None, + kvp.Key, + Strip(kvp.Value[0].FullyQualifiedName), + Strip(kvp.Value[1].FullyQualifiedName) + ) + ); + } + } } private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); diff --git a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs index 7788d2ee..7d5c1676 100644 --- a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs @@ -47,10 +47,12 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine(" this WebApplicationBuilder builder,"); sb.AppendLine(" System.Action? configure = null)"); sb.AppendLine(" {"); + sb.AppendLine(" SimpleModuleOptions? smOptions = null;"); sb.AppendLine(" builder.AddSimpleModuleInfrastructure(o =>"); sb.AppendLine(" {"); sb.AppendLine(" o.ShellComponent ??= typeof(InertiaShell);"); sb.AppendLine(" configure?.Invoke(o);"); + sb.AppendLine(" smOptions = o;"); sb.AppendLine(" });"); sb.AppendLine(); sb.AppendLine(" // Source-generated module registration"); @@ -60,6 +62,9 @@ public void Emit(SourceProductionContext context, DiscoveryData data) " builder.Services.AddModuleDbContext(builder.Configuration, global::SimpleModule.Database.DatabaseConstants.HostModuleName);" ); sb.AppendLine(); + sb.AppendLine(" // Register module options (IModuleOptions auto-discovery)"); + sb.AppendLine(" smOptions?.ApplyModuleOptions(builder.Services, ModuleOptionsExtensions.RegisterModuleOptionsDefaults);"); + sb.AppendLine(); sb.AppendLine(" builder.Services.CollectModuleMenuItems();"); sb.AppendLine(" builder.Services.CollectModuleSettings();"); sb.AppendLine( diff --git a/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs new file mode 100644 index 00000000..91e861e8 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs @@ -0,0 +1,83 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +/// +/// Emits extension methods on SimpleModuleOptions for each discovered IModuleOptions class +/// (e.g., ConfigureProducts()) and a RegisterModuleOptionsDefaults() helper that registers +/// IOptions<T> for all discovered options types. +/// +internal sealed class ModuleOptionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + sb.AppendLine("using SimpleModule.Hosting;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine( + "/// " + ); + sb.AppendLine( + "/// Generated extension methods for configuring module options." + ); + sb.AppendLine("/// "); + sb.AppendLine("public static class ModuleOptionsExtensions"); + sb.AppendLine("{"); + + // Generate a Configure{ModuleName}() extension method for each unique module options class + foreach (var kvp in optionsByModule) + { + // Only emit for the first options class per module (diagnostic catches duplicates) + var opt = kvp.Value[0]; + var moduleName = opt.ModuleName; + + sb.AppendLine($" /// "); + sb.AppendLine($" /// Configures options for the {moduleName} module."); + sb.AppendLine($" /// "); + sb.AppendLine( + $" public static SimpleModuleOptions Configure{moduleName}(this SimpleModuleOptions options, System.Action<{opt.FullyQualifiedName}> configure)" + ); + sb.AppendLine(" {"); + sb.AppendLine(" return options.ConfigureModule(configure);"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + // Generate RegisterModuleOptionsDefaults method + sb.AppendLine(" /// "); + sb.AppendLine( + " /// Registers IOptions<T> defaults for all discovered module options." + ); + sb.AppendLine(" /// "); + sb.AppendLine( + " internal static void RegisterModuleOptionsDefaults(IServiceCollection services)" + ); + sb.AppendLine(" {"); + + foreach (var kvp in optionsByModule) + { + var opt = kvp.Value[0]; + sb.AppendLine( + $" services.AddOptions<{opt.FullyQualifiedName}>();" + ); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource( + "ModuleOptionsExtensions.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 6a7c0f04..8ec5a2bd 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -18,6 +18,7 @@ public class ModuleDiscovererGenerator : IIncrementalGenerator new JsonResolverEmitter(), new TypeScriptDefinitionsEmitter(), new HostingExtensionsEmitter(), + new ModuleOptionsEmitter(), new HostDbContextEmitter(), new ValueConverterConventionsEmitter(), new DbContextRegistryEmitter(), diff --git a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs index a71b2bc9..5fa9d6cc 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs @@ -1,7 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; + namespace SimpleModule.Hosting; public class SimpleModuleOptions { + private readonly List> _moduleOptionsActions = []; + public Type? ShellComponent { get; set; } public bool EnableSwagger { get; set; } = true; @@ -9,4 +14,30 @@ public class SimpleModuleOptions public bool EnableHealthChecks { get; set; } = true; public bool EnableDevTools { get; set; } = true; + + /// + /// Configures options for a module. Called by generated Configure{Module}() extension methods. + /// + public SimpleModuleOptions ConfigureModule(Action configure) + where TOptions : class, IModuleOptions + { + _moduleOptionsActions.Add(services => services.Configure(configure)); + return this; + } + + /// + /// Registers default options and applies user overrides. Called by generated code. + /// + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public void ApplyModuleOptions(IServiceCollection services, Action registerDefaults) + { + // Register IOptions defaults for all discovered options classes + registerDefaults(services); + + // Apply user-provided overrides + foreach (var action in _moduleOptionsActions) + { + action(services); + } + } } diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModuleOptions.cs b/modules/Admin/src/SimpleModule.Admin/AdminModuleOptions.cs new file mode 100644 index 00000000..94cc8482 --- /dev/null +++ b/modules/Admin/src/SimpleModule.Admin/AdminModuleOptions.cs @@ -0,0 +1,14 @@ +using SimpleModule.Core; + +namespace SimpleModule.Admin; + +/// +/// Configurable options for the Admin module. +/// +public class AdminModuleOptions : IModuleOptions +{ + /// + /// Number of users to display per page in the admin user list. Default: 20. + /// + public int UsersPageSize { get; set; } = 20; +} diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs index b4450968..329d696a 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; @@ -9,16 +10,20 @@ namespace SimpleModule.Admin.Views.Admin; [ViewPage("Admin/Admin/Users")] public class UsersEndpoint : IViewEndpoint { - private const int PageSize = 20; - public void Map(IEndpointRouteBuilder app) { app.MapGet( "/users", - async (IUserAdminContracts userAdmin, string? search, int page = 1) => + async ( + IUserAdminContracts userAdmin, + IOptions options, + string? search, + int page = 1 + ) => { - var result = await userAdmin.GetUsersPagedAsync(search, page, PageSize); - var totalPages = (int)Math.Ceiling((double)result.TotalCount / PageSize); + var pageSize = options.Value.UsersPageSize; + var result = await userAdmin.GetUsersPagedAsync(search, page, pageSize); + var totalPages = (int)Math.Ceiling((double)result.TotalCount / pageSize); return Inertia.Render( "Admin/Admin/Users", diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModuleOptions.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModuleOptions.cs new file mode 100644 index 00000000..193c84af --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModuleOptions.cs @@ -0,0 +1,29 @@ +using SimpleModule.Core; + +namespace SimpleModule.AuditLogs; + +/// +/// Configurable options for the AuditLogs module. +/// +public class AuditLogsModuleOptions : IModuleOptions +{ + /// + /// Maximum number of audit entries to batch before flushing to the database. Default: 100. + /// + public int WriterBatchSize { get; set; } = 100; + + /// + /// How long to wait before flushing an incomplete batch. Default: 2 seconds. + /// + public TimeSpan WriterFlushInterval { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// Default number of days to retain audit log entries before cleanup. Default: 90. + /// + public int RetentionDays { get; set; } = 90; + + /// + /// How often the retention cleanup job runs. Default: 24 hours. + /// + public TimeSpan RetentionCheckInterval { get; set; } = TimeSpan.FromHours(24); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs index a2e34acc..2673648a 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using SimpleModule.AuditLogs.Contracts; namespace SimpleModule.AuditLogs.Pipeline; @@ -8,16 +9,15 @@ namespace SimpleModule.AuditLogs.Pipeline; public sealed partial class AuditWriterService( AuditChannel channel, IServiceScopeFactory scopeFactory, + IOptions moduleOptions, ILogger logger ) : BackgroundService { - private const int BatchSize = 100; - private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(2); - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogStarted(logger); - var batch = new List(BatchSize); + var opts = moduleOptions.Value; + var batch = new List(opts.WriterBatchSize); while (!stoppingToken.IsCancellationRequested) { @@ -26,10 +26,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (await channel.Reader.WaitToReadAsync(stoppingToken)) { batch.Clear(); - var deadline = DateTimeOffset.UtcNow.Add(FlushInterval); + var deadline = DateTimeOffset.UtcNow.Add(opts.WriterFlushInterval); while ( - batch.Count < BatchSize + batch.Count < opts.WriterBatchSize && DateTimeOffset.UtcNow < deadline && channel.Reader.TryRead(out var entry) ) diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs index df6ae14e..a6d5c4e0 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using SimpleModule.AuditLogs.Contracts; using SimpleModule.Core.Settings; using SimpleModule.Settings.Contracts; @@ -9,15 +10,16 @@ namespace SimpleModule.AuditLogs.Retention; public sealed partial class AuditRetentionService( IServiceScopeFactory scopeFactory, + IOptions moduleOptions, ILogger logger ) : BackgroundService { - private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(24); - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + var checkInterval = moduleOptions.Value.RetentionCheckInterval; + while (!stoppingToken.IsCancellationRequested) { try @@ -35,7 +37,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) LogError(logger, ex); } - await Task.Delay(CheckInterval, stoppingToken); + await Task.Delay(checkInterval, stoppingToken); } } @@ -58,7 +60,7 @@ private async Task RunCleanupAsync(CancellationToken ct) } } - var retentionDays = 90; + var retentionDays = moduleOptions.Value.RetentionDays; if (settings is not null) { var days = await settings.GetSettingAsync( diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs index aa74c463..986532c3 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Menu; diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModuleOptions.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModuleOptions.cs new file mode 100644 index 00000000..77889411 --- /dev/null +++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModuleOptions.cs @@ -0,0 +1,21 @@ +using SimpleModule.Core; + +namespace SimpleModule.FileStorage; + +/// +/// Configurable options for the FileStorage module. +/// +public class FileStorageModuleOptions : IModuleOptions +{ + /// + /// Maximum file size allowed for uploads, in megabytes. Default: 50. + /// + public int MaxFileSizeMb { get; set; } = 50; + + /// + /// Comma-separated list of allowed file extensions for uploads. + /// Default: ".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.zip" + /// + public string AllowedExtensions { get; set; } = + ".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.zip"; +} diff --git a/modules/Orders/src/SimpleModule.Orders/OrdersModuleOptions.cs b/modules/Orders/src/SimpleModule.Orders/OrdersModuleOptions.cs new file mode 100644 index 00000000..26eaceb7 --- /dev/null +++ b/modules/Orders/src/SimpleModule.Orders/OrdersModuleOptions.cs @@ -0,0 +1,19 @@ +using SimpleModule.Core; + +namespace SimpleModule.Orders; + +/// +/// Configurable options for the Orders module. +/// +public class OrdersModuleOptions : IModuleOptions +{ + /// + /// Default number of recent orders to show in summary views. Default: 10. + /// + public int DefaultPageSize { get; set; } = 10; + + /// + /// Maximum allowed page size for order listing requests. Default: 100. + /// + public int MaxPageSize { get; set; } = 100; +} diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModuleOptions.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModuleOptions.cs new file mode 100644 index 00000000..cc50c131 --- /dev/null +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModuleOptions.cs @@ -0,0 +1,19 @@ +using SimpleModule.Core; + +namespace SimpleModule.PageBuilder; + +/// +/// Configurable options for the PageBuilder module. +/// +public class PageBuilderModuleOptions : IModuleOptions +{ + /// + /// Maximum length for page titles. Default: 200. + /// + public int MaxTitleLength { get; set; } = 200; + + /// + /// Maximum length for page slugs. Default: 200. + /// + public int MaxSlugLength { get; set; } = 200; +} diff --git a/modules/Products/src/SimpleModule.Products/ProductsModuleOptions.cs b/modules/Products/src/SimpleModule.Products/ProductsModuleOptions.cs new file mode 100644 index 00000000..e5a32720 --- /dev/null +++ b/modules/Products/src/SimpleModule.Products/ProductsModuleOptions.cs @@ -0,0 +1,26 @@ +using SimpleModule.Core; + +namespace SimpleModule.Products; + +/// +/// Configurable options for the Products module. +/// Override defaults from the host application: +/// +/// builder.AddSimpleModule(o => +/// { +/// o.ConfigureProducts(p => p.DefaultPageSize = 20); +/// }); +/// +/// +public class ProductsModuleOptions : IModuleOptions +{ + /// + /// Default number of products per page when browsing. Default: 10. + /// + public int DefaultPageSize { get; set; } = 10; + + /// + /// Maximum allowed page size for product listing requests. Default: 100. + /// + public int MaxPageSize { get; set; } = 100; +} diff --git a/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs b/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs index 47c081bf..0a01d17e 100644 --- a/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs +++ b/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Products.Contracts; @@ -14,10 +14,14 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( "/browse", - async (IProductContracts products) => + async (IProductContracts products, IOptions options) => Inertia.Render( "Products/Browse", - new { products = await products.GetAllProductsAsync() } + new + { + products = await products.GetAllProductsAsync(), + pageSize = options.Value.DefaultPageSize, + } ) ) .AllowAnonymous(); diff --git a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs index de5222fd..25cf35d4 100644 --- a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs +++ b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs @@ -1,16 +1,20 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using SimpleModule.Core.Menu; using SimpleModule.Settings.Contracts; using SimpleModule.Settings.Entities; namespace SimpleModule.Settings.Services; -public sealed class PublicMenuService(SettingsDbContext db, IMemoryCache cache) : IPublicMenuProvider +public sealed class PublicMenuService( + SettingsDbContext db, + IMemoryCache cache, + IOptions moduleOptions +) : IPublicMenuProvider { private const string MenuTreeCacheKey = "PublicMenu_Tree"; private const string HomePageCacheKey = "PublicMenu_Home"; - private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60); public async Task> GetMenuTreeAsync() { @@ -26,7 +30,7 @@ public async Task> GetMenuTreeAsync() .ToListAsync(); var tree = BuildPublicTree(entities, parentId: null); - cache.Set(MenuTreeCacheKey, tree, CacheDuration); + cache.Set(MenuTreeCacheKey, tree, moduleOptions.Value.CacheDuration); return tree; } @@ -45,7 +49,7 @@ public async Task> GetMenuTreeAsync() .FirstOrDefaultAsync(); var url = entity is not null ? (entity.Url ?? entity.PageRoute) : null; - cache.Set(HomePageCacheKey, url, CacheDuration); + cache.Set(HomePageCacheKey, url, moduleOptions.Value.CacheDuration); return url; } diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsModuleOptions.cs b/modules/Settings/src/SimpleModule.Settings/SettingsModuleOptions.cs new file mode 100644 index 00000000..c15f4d39 --- /dev/null +++ b/modules/Settings/src/SimpleModule.Settings/SettingsModuleOptions.cs @@ -0,0 +1,14 @@ +using SimpleModule.Core; + +namespace SimpleModule.Settings; + +/// +/// Configurable options for the Settings module. +/// +public class SettingsModuleOptions : IModuleOptions +{ + /// + /// How long settings values are cached in memory before being re-read from the database. Default: 60 seconds. + /// + public TimeSpan CacheDuration { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs index aed25070..99924749 100644 --- a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs +++ b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using SimpleModule.Core.Settings; using SimpleModule.Settings.Contracts; using SimpleModule.Settings.Entities; @@ -12,10 +13,10 @@ public sealed partial class SettingsService( SettingsDbContext db, ISettingsDefinitionRegistry definitions, IMemoryCache cache, + IOptions moduleOptions, ILogger logger ) : ISettingsContracts { - private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60); public async Task GetSettingAsync( string key, @@ -36,7 +37,7 @@ ILogger logger && (scope == SettingScope.User ? s.UserId == userId : s.UserId == null) ); - cache.Set(cacheKey, entity?.Value, CacheDuration); + cache.Set(cacheKey, entity?.Value, moduleOptions.Value.CacheDuration); return entity?.Value; } diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs index 9eee9499..38386bf7 100644 --- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs +++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs @@ -28,7 +28,7 @@ public PublicMenuServiceTests() _db.Database.EnsureCreated(); _cache = new MemoryCache(new MemoryCacheOptions()); - _service = new PublicMenuService(_db, _cache); + _service = new PublicMenuService(_db, _cache, Options.Create(new SettingsModuleOptions())); } [Fact] diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs index a2660629..00cfede9 100644 --- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs +++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs @@ -38,7 +38,13 @@ public SettingsServiceTests() ]); _cache = new MemoryCache(new MemoryCacheOptions()); - _service = new SettingsService(_db, registry, _cache, NullLogger.Instance); + _service = new SettingsService( + _db, + registry, + _cache, + Options.Create(new SettingsModuleOptions()), + NullLogger.Instance + ); } [Fact] diff --git a/modules/Users/src/SimpleModule.Users/ApplyUsersModuleOptions.cs b/modules/Users/src/SimpleModule.Users/ApplyUsersModuleOptions.cs new file mode 100644 index 00000000..dd04b246 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/ApplyUsersModuleOptions.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace SimpleModule.Users; + +/// +/// Bridges into ASP.NET Identity's , +/// allowing the host app to control password and lockout policies via the module options pattern. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1812:Avoid uninstantiated internal classes", + Justification = "Instantiated by DI" +)] +internal sealed class ApplyUsersModuleOptions(IOptions moduleOptions) + : IPostConfigureOptions +{ + public void PostConfigure(string? name, IdentityOptions options) + { + var opts = moduleOptions.Value; + + options.Password.RequiredLength = opts.PasswordMinLength; + options.Password.RequireDigit = opts.PasswordRequireDigit; + options.Password.RequireUppercase = opts.PasswordRequireUppercase; + options.Password.RequireLowercase = opts.PasswordRequireLowercase; + options.Password.RequireNonAlphanumeric = opts.PasswordRequireNonAlphanumeric; + options.Lockout.DefaultLockoutTimeSpan = opts.LockoutDuration; + options.Lockout.MaxFailedAccessAttempts = opts.MaxFailedAccessAttempts; + options.User.RequireUniqueEmail = true; + options.SignIn.RequireConfirmedEmail = false; + } +} diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index ef57ab43..beb80cfd 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Menu; using SimpleModule.Database; @@ -17,21 +18,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddModuleDbContext(configuration, UsersConstants.ModuleName); services - .AddIdentity(options => - { - options.Password.RequiredLength = 8; - options.Password.RequireDigit = true; - options.Password.RequireUppercase = true; - options.Password.RequireLowercase = true; - options.Password.RequireNonAlphanumeric = false; - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); - options.Lockout.MaxFailedAccessAttempts = 5; - options.User.RequireUniqueEmail = true; - options.SignIn.RequireConfirmedEmail = false; - }) + .AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + // Bridge UsersModuleOptions into ASP.NET Identity options + services.AddSingleton, ApplyUsersModuleOptions>(); + services.AddHostedService(); services.AddSingleton, ConsoleEmailSender>(); } diff --git a/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs b/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs new file mode 100644 index 00000000..81c30298 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs @@ -0,0 +1,44 @@ +using SimpleModule.Core; + +namespace SimpleModule.Users; + +/// +/// Configurable options for the Users module. +/// +public class UsersModuleOptions : IModuleOptions +{ + /// + /// Minimum required password length. Default: 8. + /// + public int PasswordMinLength { get; set; } = 8; + + /// + /// Whether passwords must contain at least one digit. Default: true. + /// + public bool PasswordRequireDigit { get; set; } = true; + + /// + /// Whether passwords must contain at least one uppercase letter. Default: true. + /// + public bool PasswordRequireUppercase { get; set; } = true; + + /// + /// Whether passwords must contain at least one lowercase letter. Default: true. + /// + public bool PasswordRequireLowercase { get; set; } = true; + + /// + /// Whether passwords must contain at least one non-alphanumeric character. Default: false. + /// + public bool PasswordRequireNonAlphanumeric { get; set; } + + /// + /// Maximum number of failed login attempts before account lockout. Default: 5. + /// + public int MaxFailedAccessAttempts { get; set; } = 5; + + /// + /// Duration of account lockout after exceeding max failed attempts. Default: 5 minutes. + /// + public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/tests/SimpleModule.Core.Tests/Infrastructure/ModuleOptionsTests.cs b/tests/SimpleModule.Core.Tests/Infrastructure/ModuleOptionsTests.cs new file mode 100644 index 00000000..66748f46 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Infrastructure/ModuleOptionsTests.cs @@ -0,0 +1,216 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SimpleModule.Admin; +using SimpleModule.AuditLogs; +using SimpleModule.FileStorage; +using SimpleModule.Orders; +using SimpleModule.PageBuilder; +using SimpleModule.Products; +using SimpleModule.Settings; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users; + +namespace SimpleModule.Core.Tests.Infrastructure; + +/// +/// Verifies that all module options are registered via IOptions<T> and can be +/// resolved with their default values. Also verifies that overrides from the host +/// app's Configure{Module}() calls are applied correctly. +/// +public class ModuleOptionsTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ModuleOptionsTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + // ── Default values ───────────────────────────────────────────────── + + [Fact] + public void ProductsModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.DefaultPageSize.Should().Be(10); + options.Value.MaxPageSize.Should().Be(100); + } + + [Fact] + public void AuditLogsModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.WriterBatchSize.Should().Be(100); + options.Value.WriterFlushInterval.Should().Be(TimeSpan.FromSeconds(2)); + options.Value.RetentionDays.Should().Be(90); + options.Value.RetentionCheckInterval.Should().Be(TimeSpan.FromHours(24)); + } + + [Fact] + public void AdminModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.UsersPageSize.Should().Be(20); + } + + [Fact] + public void FileStorageModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = + scope.ServiceProvider.GetRequiredService>(); + + options.Value.MaxFileSizeMb.Should().Be(50); + options.Value.AllowedExtensions.Should().Contain(".jpg"); + } + + [Fact] + public void PageBuilderModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = + scope.ServiceProvider.GetRequiredService>(); + + options.Value.MaxTitleLength.Should().Be(200); + options.Value.MaxSlugLength.Should().Be(200); + } + + [Fact] + public void UsersModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.PasswordMinLength.Should().Be(8); + options.Value.PasswordRequireDigit.Should().BeTrue(); + options.Value.PasswordRequireUppercase.Should().BeTrue(); + options.Value.PasswordRequireLowercase.Should().BeTrue(); + options.Value.PasswordRequireNonAlphanumeric.Should().BeFalse(); + options.Value.MaxFailedAccessAttempts.Should().Be(5); + options.Value.LockoutDuration.Should().Be(TimeSpan.FromMinutes(5)); + } + + [Fact] + public void SettingsModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.CacheDuration.Should().Be(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void OrdersModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.DefaultPageSize.Should().Be(10); + options.Value.MaxPageSize.Should().Be(100); + } + + // ── Override test ────────────────────────────────────────────────── + + [Fact] + public void ModuleOptions_CanBeOverridden_ViaServicesConfigure() + { + // Simulate what a host app would do: services.Configure(...) + // We test this by creating a custom factory with overrides + var services = new ServiceCollection(); + + // Register defaults (what the generator does) + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + + // Override (what the host app does via ConfigureProducts, etc.) + services.Configure(o => + { + o.DefaultPageSize = 25; + o.MaxPageSize = 50; + }); + services.Configure(o => + { + o.WriterBatchSize = 200; + o.RetentionDays = 30; + }); + services.Configure(o => o.UsersPageSize = 50); + services.Configure(o => o.DefaultPageSize = 20); + services.Configure(o => + { + o.PasswordMinLength = 12; + o.MaxFailedAccessAttempts = 3; + }); + services.Configure(o => + o.CacheDuration = TimeSpan.FromMinutes(5) + ); + services.Configure(o => + { + o.MaxFileSizeMb = 100; + o.AllowedExtensions = ".pdf,.zip"; + }); + services.Configure(o => + { + o.MaxTitleLength = 300; + o.MaxSlugLength = 300; + }); + + using var sp = services.BuildServiceProvider(); + + sp.GetRequiredService>() + .Value.DefaultPageSize.Should() + .Be(25); + sp.GetRequiredService>() + .Value.MaxPageSize.Should() + .Be(50); + + sp.GetRequiredService>() + .Value.WriterBatchSize.Should() + .Be(200); + sp.GetRequiredService>() + .Value.RetentionDays.Should() + .Be(30); + + sp.GetRequiredService>() + .Value.UsersPageSize.Should() + .Be(50); + + sp.GetRequiredService>() + .Value.DefaultPageSize.Should() + .Be(20); + + sp.GetRequiredService>() + .Value.PasswordMinLength.Should() + .Be(12); + sp.GetRequiredService>() + .Value.MaxFailedAccessAttempts.Should() + .Be(3); + + sp.GetRequiredService>() + .Value.CacheDuration.Should() + .Be(TimeSpan.FromMinutes(5)); + + sp.GetRequiredService>() + .Value.MaxFileSizeMb.Should() + .Be(100); + sp.GetRequiredService>() + .Value.AllowedExtensions.Should() + .Be(".pdf,.zip"); + + sp.GetRequiredService>() + .Value.MaxTitleLength.Should() + .Be(300); + sp.GetRequiredService>() + .Value.MaxSlugLength.Should() + .Be(300); + } +} diff --git a/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs new file mode 100644 index 00000000..4e2a5a33 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs @@ -0,0 +1,223 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class ModuleOptionsEmitterTests +{ + [Fact] + public void Module_WithOptionsClass_GeneratesConfigureExtensionMethod() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", RoutePrefix = "/api/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp + { + public class ProductsModuleOptions : IModuleOptions + { + public int MaxPageSize { get; set; } = 100; + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); + + generated + .Should() + .Contain("ConfigureProducts(this SimpleModuleOptions options"); + generated + .Should() + .Contain("global::TestApp.ProductsModuleOptions"); + generated + .Should() + .Contain("options.ConfigureModule(configure)"); + } + + [Fact] + public void Module_WithOptionsClass_RegistersDefaultsInRegisterMethod() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", RoutePrefix = "/api/products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int MaxPageSize { get; set; } = 100; + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); + + generated + .Should() + .Contain("RegisterModuleOptionsDefaults"); + generated + .Should() + .Contain("services.AddOptions()"); + } + + [Fact] + public void MultipleModules_InSameAssembly_GeneratesConfigureForDiscoveredOptions() + { + // In a single-assembly compilation, ScanModuleAssemblies deduplicates + // by assembly and discovers options for the first module in that assembly. + // In production, each module lives in a separate assembly. + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int PageSize { get; set; } = 10; + } + + [Module("Orders")] + public class OrdersModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); + + // Options class is discovered and a Configure method is generated + generated.Should().Contain("ConfigureProducts("); + generated.Should().Contain("ProductsModuleOptions"); + } + + [Fact] + public void Module_WithNoOptionsClass_GeneratesEmptyExtensions() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); + + generated.Should().Contain("ModuleOptionsExtensions"); + generated.Should().NotContain("ConfigureProducts("); + } + + [Fact] + public void SM0044_MultipleOptionsForSameModule_ReportsDiagnostic() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int PageSize { get; set; } + } + + public class ProductsExtraOptions : IModuleOptions + { + public bool Feature { get; set; } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0044"); + } + + [Fact] + public void SM0044_SingleOptionsPerModule_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int PageSize { get; set; } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0044"); + } + + [Fact] + public void HostingExtensions_CallsApplyModuleOptions() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int PageSize { get; set; } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "HostingExtensions.g.cs"); + + generated + .Should() + .Contain("ApplyModuleOptions"); + } + + private static string GetGeneratedSource( + Microsoft.CodeAnalysis.GeneratorDriverRunResult result, + string fileName + ) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs index d3c461f3..31277c9e 100644 --- a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs +++ b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs @@ -234,6 +234,7 @@ public void SortModules_WithDependencies_ReordersByDependency() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, "SimpleModule.Host" ); @@ -294,6 +295,7 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, "SimpleModule.Host" ); @@ -366,6 +368,7 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, "SimpleModule.Host" );