From 8fbaac186e916f536927be50f7c29f177702437d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 5 Jun 2026 10:38:20 +0200 Subject: [PATCH 1/2] fix(admin): await users and roles sequentially on /admin/users (#242) GetUsersPagedAsync and GetAllRolesAsync were run via Task.WhenAll, but both contracts (IUserAdminContracts, IRoleAdminContracts) resolve the same scoped UsersDbContext. EF Core forbids concurrent operations on one context instance, so the parallel await intermittently threw and surfaced as HTTP 500. Await the two queries sequentially. The roles list is tiny, so the extra round-trip is negligible, and the shared-context concurrency is removed structurally. --- .../Pages/Admin/UsersEndpoint.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEndpoint.cs index 3e565764..c829ff99 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEndpoint.cs @@ -28,18 +28,21 @@ public void Map(IEndpointRouteBuilder app) ) => { var pageSize = options.Value.UsersPageSize; - var usersTask = userAdmin.GetUsersPagedAsync( + + // Await sequentially, not via Task.WhenAll: IUserAdminContracts and + // IRoleAdminContracts are both backed by the same scoped UsersDbContext, + // and EF Core forbids concurrent operations on one DbContext instance. + // Running them in parallel intermittently threw "A second operation was + // started on this context instance..." → HTTP 500 (#242). The roles list + // is tiny, so the extra round-trip is negligible. + var result = await userAdmin.GetUsersPagedAsync( search, page, pageSize, filterStatus, filterRole ); - var rolesTask = roleAdmin.GetAllRolesAsync(); - await Task.WhenAll(usersTask, rolesTask); - - var result = await usersTask; - var allRoles = await rolesTask; + var allRoles = await roleAdmin.GetAllRolesAsync(); var totalPages = (int)Math.Ceiling((double)result.TotalCount / pageSize); return Inertia.Render( From 9bc3403003a8b146901ebd0001e8267ae58d08fe Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Fri, 5 Jun 2026 10:38:32 +0200 Subject: [PATCH 2/2] fix(database): attribute navigation-only child entities to their owning module (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated HostDbContext (which physically creates the schema) prefixed only each module's explicit DbSet entities, then swept every remaining entity into the identity module's prefix. A child entity reachable only through a navigation — which EF's relationship-discovery convention adds to the model without a DbSet — fell into that catch-all (e.g. Users_RecurringLine), while the owning module's context queried Invoices_RecurringLine, producing a "no such table" error. Add ApplyHostModuleSchemas: seed module ownership from declared DbSets, propagate it across foreign keys to a fixed point so navigation-only children (and grandchildren) inherit their principal's module, then fall back to the identity module only for genuinely unowned tables (ASP.NET Identity). The host generator now emits an entity->module seed map and one call to this helper, replacing the brittle per-DbSet emission. All existing module entities have DbSets, so every table name is unchanged and no migration drift is introduced. --- .../ModuleModelBuilderExtensions.cs | 102 +++++++++++ .../Emitters/HostDbContextEmitter.cs | 114 +++--------- .../HostModuleSchemaTests.cs | 168 ++++++++++++++++++ .../DiagnosticTests.cs | 17 +- .../HostDbContextGenerationTests.cs | 93 +++++----- 5 files changed, 348 insertions(+), 146 deletions(-) create mode 100644 tests/SimpleModule.Database.Tests/HostModuleSchemaTests.cs diff --git a/framework/SimpleModule.Database/ModuleModelBuilderExtensions.cs b/framework/SimpleModule.Database/ModuleModelBuilderExtensions.cs index a78b5c13..eb4f6402 100644 --- a/framework/SimpleModule.Database/ModuleModelBuilderExtensions.cs +++ b/framework/SimpleModule.Database/ModuleModelBuilderExtensions.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using SimpleModule.Core.Entities; namespace SimpleModule.Database; @@ -50,6 +51,107 @@ tableName is not null ApplyEntityConventions(modelBuilder, provider); } + + /// + /// Applies schema isolation for the unified host that merges + /// entities from every module. Unlike — which owns a single + /// module's context and can safely prefix all entities — the host model contains + /// entities from many modules, so each entity must be attributed to its owning module. + /// + /// maps each module's declared DbSet<T> + /// entity CLR type to its module name. Entities that are only reachable through a navigation + /// (e.g. a child collection whose element type has a key but no DbSet) are attributed + /// to the same module as the principal they relate to, by following foreign keys. Any entity + /// that still has no owning module (e.g. ASP.NET Identity tables, which ship no module + /// DbSet) is assigned to when one is provided. + /// + /// + /// This is the fix for #229: previously a navigation-only child entity fell through the + /// per-DbSet pass and was swept into the identity module's schema (e.g. Users_RecurringLine) + /// instead of its real owner's (Invoices_RecurringLine), so the owning module's queries + /// hit a "no such table" error. + /// + /// + public static void ApplyHostModuleSchemas( + this ModelBuilder modelBuilder, + DatabaseOptions dbOptions, + IReadOnlyDictionary moduleByEntityType, + string? identityModuleName + ) + { + var provider = DatabaseProviderDetector.Detect( + dbOptions.DefaultConnection, + dbOptions.Provider + ); + + // 1. Seed ownership from the modules' declared DbSet entity types. + var moduleByEntity = new Dictionary(); + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + if (moduleByEntityType.TryGetValue(entity.ClrType, out var module)) + { + moduleByEntity[entity] = module; + } + } + + // 2. Propagate ownership across foreign keys so navigation-reachable child entities + // (which have no DbSet of their own) inherit their principal's module. Iterate to a + // fixed point so multi-level chains (a child of a child) are also covered. + bool changed; + do + { + changed = false; + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + if (moduleByEntity.ContainsKey(entity)) + { + continue; + } + + foreach (var foreignKey in entity.GetForeignKeys()) + { + if (moduleByEntity.TryGetValue(foreignKey.PrincipalEntityType, out var module)) + { + moduleByEntity[entity] = module; + changed = true; + break; + } + } + } + } while (changed); + + // 3. Apply the resolved module's schema/prefix; sweep anything still unowned into the + // identity module (whose own tables ship no module DbSet). + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + if (!moduleByEntity.TryGetValue(entity, out var module)) + { + if (identityModuleName is null) + { + continue; + } + + module = identityModuleName; + } + + if (provider == DatabaseProvider.Sqlite) + { + var prefix = $"{module}_"; + var tableName = entity.GetTableName(); + if ( + tableName is not null + && !tableName.StartsWith(prefix, StringComparison.Ordinal) + ) + { + entity.SetTableName($"{prefix}{tableName}"); + } + } + else + { + entity.SetSchema(module.ToLowerInvariant()); + } + } + } #pragma warning restore CA1308 /// diff --git a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs index e0960e08..d96b94d7 100644 --- a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs @@ -7,7 +7,6 @@ namespace SimpleModule.Generator; internal sealed class HostDbContextEmitter : IEmitter { -#pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server public void Emit(SourceProductionContext context, DiscoveryData data) { if (data.DbContexts.Length == 0) @@ -113,13 +112,6 @@ string ModuleName if (hasDuplicates) return; - // Build entity-to-module mapping for schema isolation - var entityToModule = new System.Collections.Generic.Dictionary(); - foreach (var entry in allDbSets) - { - entityToModule[entry.EntityFqn] = entry.ModuleName; - } - var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("#pragma warning disable"); @@ -191,100 +183,36 @@ string ModuleName sb.AppendLine(); } - // Schema isolation (non-SQLite only) - sb.AppendLine(" // Schema isolation (PostgreSQL/SQL Server only)"); - sb.AppendLine(" var provider = DatabaseProviderDetector.Detect("); - sb.AppendLine(" dbOptions.Value.DefaultConnection, dbOptions.Value.Provider);"); - sb.AppendLine(" if (provider != DatabaseProvider.Sqlite)"); + // Schema isolation. Each entity is attributed to its owning module — by table-name + // prefix on SQLite, by schema on PostgreSQL/SQL Server. The runtime helper resolves + // navigation-reachable child entities (which have no DbSet of their own) to the module + // of the principal they relate to, and sweeps any remaining entities (e.g. ASP.NET + // Identity tables) into the identity module. See ApplyHostModuleSchemas (#229). + sb.AppendLine( + " // Module schema isolation: prefix (SQLite) or schema (PostgreSQL/SQL Server)" + ); + sb.AppendLine( + " var moduleByEntityType = new global::System.Collections.Generic.Dictionary" + ); sb.AppendLine(" {"); - - // Group entities by module for schema assignment - var moduleNames = new System.Collections.Generic.HashSet(); - foreach (var entry in allDbSets) - { - moduleNames.Add(entry.ModuleName); - } - foreach (var entry in allDbSets) { - var schema = entry.ModuleName.ToLowerInvariant(); - sb.AppendLine($" builder.Entity<{entry.EntityFqn}>()"); - sb.AppendLine($" .Metadata.SetSchema(\"{schema}\");"); + sb.AppendLine($" [typeof({entry.EntityFqn})] = \"{entry.ModuleName}\","); } + sb.AppendLine(" };"); - // If there's an IdentityDbContext, assign remaining entities (Identity tables) - // to that module's schema - if (identityContext != null) - { - var identitySchema = identityContext.Value.ModuleName.ToLowerInvariant(); - sb.AppendLine(); - sb.AppendLine(" // Assign Identity tables to the identity module's schema"); - sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); - sb.AppendLine(" {"); - sb.AppendLine(" if (entityType.GetSchema() is null)"); - sb.AppendLine($" entityType.SetSchema(\"{identitySchema}\");"); - sb.AppendLine(" }"); - } - - sb.AppendLine(" }"); - - // SQLite table prefix isolation - sb.AppendLine(" else"); - sb.AppendLine(" {"); - - // For SQLite, apply table prefixes per module - foreach (var ctx in data.DbContexts) - { - var prefix = ctx.ModuleName + "_"; - foreach (var dbSet in ctx.DbSets) - { - if (!seenEntities.Contains(dbSet.EntityFqn)) - continue; - sb.AppendLine( - $" builder.Entity<{dbSet.EntityFqn}>().ToTable(\"{prefix}\" +" - ); - sb.AppendLine( - $" (builder.Entity<{dbSet.EntityFqn}>().Metadata.GetTableName() ?? \"{dbSet.PropertyName}\"));" - ); - } - } - - // For Identity tables in SQLite, prefix them too - if (identityContext != null) - { - var identityPrefix = identityContext.Value.ModuleName + "_"; - sb.AppendLine(); - sb.AppendLine(" // Prefix Identity tables for SQLite"); - sb.AppendLine(" foreach (var entityType in builder.Model.GetEntityTypes())"); - sb.AppendLine(" {"); - sb.AppendLine(" var tableName = entityType.GetTableName();"); - sb.AppendLine( - $" if (tableName is not null && !tableName.StartsWith(\"{identityPrefix}\", global::System.StringComparison.Ordinal))" - ); - - // Check it's not already prefixed by another module - sb.AppendLine(" {"); - sb.AppendLine(" var alreadyPrefixed = false;"); - foreach (var ctx2 in data.DbContexts) - { - if (ctx2.FullyQualifiedName == identityContext.Value.FullyQualifiedName) - continue; - sb.AppendLine( - $" if (tableName.StartsWith(\"{ctx2.ModuleName}_\", global::System.StringComparison.Ordinal)) alreadyPrefixed = true;" - ); - } - sb.AppendLine( - $" if (!alreadyPrefixed) entityType.SetTableName(\"{identityPrefix}\" + tableName);" - ); - sb.AppendLine(" }"); - sb.AppendLine(" }"); - } + var identityModuleArg = + identityContext != null ? $"\"{identityContext.Value.ModuleName}\"" : "null"; + sb.AppendLine( + " global::SimpleModule.Database.ModuleModelBuilderExtensions.ApplyHostModuleSchemas(" + ); + sb.AppendLine( + $" builder, dbOptions.Value, moduleByEntityType, {identityModuleArg});" + ); - sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine("}"); context.AddSource("HostDbContext.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); } -#pragma warning restore CA1308 } diff --git a/tests/SimpleModule.Database.Tests/HostModuleSchemaTests.cs b/tests/SimpleModule.Database.Tests/HostModuleSchemaTests.cs new file mode 100644 index 00000000..005b0ab8 --- /dev/null +++ b/tests/SimpleModule.Database.Tests/HostModuleSchemaTests.cs @@ -0,0 +1,168 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SimpleModule.Database; + +namespace SimpleModule.Database.Tests; + +/// +/// Regression tests for #229: a navigation-reachable child entity (a related type that has its +/// own key but no DbSet) must be attributed to its owning module's schema, not +/// swept into the identity module. Exercises +/// , the logic the generated +/// HostDbContext calls. +/// +public sealed class HostModuleSchemaTests +{ + private static HostLikeDbContext CreateContext(string? provider, string? identityModule) + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + // Each scenario varies provider/identity but reuses one context type; disable + // service-provider caching so EF builds a fresh model per test instead of serving a + // cached one (which would leak one test's schema choices into the next). + .EnableServiceProviderCaching(false) + .Options; + var dbOptions = new DatabaseOptions + { + // Looks like SQLite; an explicit Provider (when set) drives the schema strategy. + DefaultConnection = "Data Source=app.db", + Provider = provider, + }; + return new HostLikeDbContext(options, dbOptions, identityModule); + } + + [Fact] + public void Sqlite_NavigationOnlyChild_GetsOwningModulePrefix_NotIdentity() + { + using var db = CreateContext(provider: null, identityModule: "Users"); + var model = db.Model; + + model.FindEntityType(typeof(Invoice))!.GetTableName().Should().Be("Invoices_Invoices"); + + // The child is reachable only through Invoice.Lines (no DbSet of its own). Before the + // fix it landed in the identity module's prefix (Users_InvoiceLine). It must follow its + // principal's module instead. + model + .FindEntityType(typeof(InvoiceLine))! + .GetTableName() + .Should() + .Be("Invoices_InvoiceLine"); + + model.FindEntityType(typeof(Customer))!.GetTableName().Should().Be("Crm_Customers"); + + // An entity with no module DbSet and no relationship to one falls back to the identity + // module — this is what the fallback is actually for (e.g. ASP.NET Identity tables). + model.FindEntityType(typeof(LooseTable))!.GetTableName().Should().Be("Users_LooseTable"); + } + + [Fact] + public void Sqlite_GrandchildEntity_InheritsModuleTransitively() + { + using var db = CreateContext(provider: null, identityModule: "Users"); + var model = db.Model; + + // InvoiceLineNote is a child of InvoiceLine, which is itself only navigation-reachable. + // The fixed-point propagation must reach it (Invoice -> InvoiceLine -> InvoiceLineNote). + model + .FindEntityType(typeof(InvoiceLineNote))! + .GetTableName() + .Should() + .Be("Invoices_InvoiceLineNote"); + } + + [Fact] + public void Sqlite_NoIdentityModule_LeavesUnownedEntitiesUnprefixed() + { + using var db = CreateContext(provider: null, identityModule: null); + var model = db.Model; + + model.FindEntityType(typeof(Invoice))!.GetTableName().Should().Be("Invoices_Invoices"); + model + .FindEntityType(typeof(InvoiceLine))! + .GetTableName() + .Should() + .Be("Invoices_InvoiceLine"); + + // No identity module → nothing to sweep the unowned table into; it keeps its bare name. + model.FindEntityType(typeof(LooseTable))!.GetTableName().Should().Be("LooseTable"); + } + + [Fact] + public void Postgres_NavigationOnlyChild_GetsOwningModuleSchema_NotIdentity() + { + using var db = CreateContext(provider: "PostgreSql", identityModule: "Users"); + var model = db.Model; + + model.FindEntityType(typeof(Invoice))!.GetSchema().Should().Be("invoices"); + model.FindEntityType(typeof(InvoiceLine))!.GetSchema().Should().Be("invoices"); + model.FindEntityType(typeof(Customer))!.GetSchema().Should().Be("crm"); + model.FindEntityType(typeof(LooseTable))!.GetSchema().Should().Be("users"); + } +} + +#pragma warning disable CA1812 // Instantiated in tests +internal sealed class Invoice +{ + public int Id { get; set; } + public List Lines { get; } = []; +} + +internal sealed class InvoiceLine +{ + public int Id { get; set; } + public int InvoiceId { get; set; } + public List Notes { get; } = []; +} + +internal sealed class InvoiceLineNote +{ + public int Id { get; set; } + public int InvoiceLineId { get; set; } + public string Text { get; set; } = string.Empty; +} + +internal sealed class Customer +{ + public int Id { get; set; } +} + +internal sealed class LooseTable +{ + public int Id { get; set; } +} + +/// +/// Stands in for the generated HostDbContext: it owns DbSets from several modules and +/// has navigation-reachable child entities (no DbSet) plus an entity with no module at all. +/// +internal sealed class HostLikeDbContext( + DbContextOptions options, + DatabaseOptions dbOptions, + string? identityModule +) : DbContext(options) +{ + private readonly DatabaseOptions _dbOptions = dbOptions; + private readonly string? _identityModule = identityModule; + + public DbSet Invoices => Set(); // module "Invoices" + public DbSet Customers => Set(); // module "Crm" + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // InvoiceLine / InvoiceLineNote are intentionally NOT configured here: they are + // discovered by EF from the Invoice.Lines / InvoiceLine.Notes navigations, exactly as a + // module's child collection would be in the unified host model (#229). + + // An entity with no DbSet and no relationship to a module entity — represents an + // ASP.NET Identity table, which ships no module DbSet. + modelBuilder.Entity(); + + var moduleByEntityType = new Dictionary + { + [typeof(Invoice)] = "Invoices", + [typeof(Customer)] = "Crm", + }; + modelBuilder.ApplyHostModuleSchemas(_dbOptions, moduleByEntityType, _identityModule); + } +} +#pragma warning restore CA1812 diff --git a/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs b/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs index d7fcf4c7..a9a69bca 100644 --- a/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs +++ b/tests/SimpleModule.Generator.Tests/DiagnosticTests.cs @@ -675,9 +675,16 @@ public ProductInventoryDbContext(DbContextOptions opt hostDbContext.Should().Contain("Products"); hostDbContext.Should().Contain("InventoryItems"); - // Each module should get its own schema - hostDbContext.Should().Contain("SetSchema(\"product\")"); - hostDbContext.Should().Contain("SetSchema(\"productinventory\")"); + // Each entity should be attributed to its own module in the schema-isolation seed map + // (the helper lowercases these to schema names at runtime). + hostDbContext + .Should() + .Contain("[typeof(global::TestApp.Product.ProductEntity)] = \"Product\""); + hostDbContext + .Should() + .Contain( + "[typeof(global::TestApp.ProductInventory.InventoryItem)] = \"ProductInventory\"" + ); } #endregion @@ -823,7 +830,9 @@ public OrdersDbContext(DbContextOptions options, IOptions" + ); + source + .Should() + .Contain("[typeof(global::TestApp.Products.Contracts.Product)] = \"Products\""); + source.Should().Contain("[typeof(global::TestApp.Orders.Contracts.Order)] = \"Orders\""); + source + .Should() + .Contain("[typeof(global::TestApp.Orders.Contracts.OrderItem)] = \"Orders\""); } [Fact] - public void SchemaIsolation_Sqlite_PrefixesTables() + public void SchemaIsolation_InvokesApplyHostModuleSchemasHelper() { var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( ModuleWithDbContext, @@ -404,12 +419,16 @@ public void SchemaIsolation_Sqlite_PrefixesTables() var result = GeneratorTestHelper.RunGenerator(compilation); var source = GetHostDbContext(result); - source.Should().Contain(".ToTable(\"Products_\""); - source.Should().Contain(".ToTable(\"Orders_\""); + source + .Should() + .Contain( + "global::SimpleModule.Database.ModuleModelBuilderExtensions.ApplyHostModuleSchemas(" + ); + source.Should().Contain("builder, dbOptions.Value, moduleByEntityType,"); } [Fact] - public void IdentityFallbackSchema_AssignsRemainingEntitiesToIdentityModule() + public void SchemaIsolation_WithIdentity_PassesIdentityModuleName() { var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( ModuleWithDbContext, @@ -418,59 +437,33 @@ public void IdentityFallbackSchema_AssignsRemainingEntitiesToIdentityModule() var result = GeneratorTestHelper.RunGenerator(compilation); var source = GetHostDbContext(result); - // Identity tables (not explicitly assigned) should get the Users schema - source.Should().Contain("if (entityType.GetSchema() is null)"); - source.Should().Contain("entityType.SetSchema(\"users\")"); + // Remaining (Identity) tables fall back to the "Users" module inside the helper. + source.Should().Contain("moduleByEntityType, \"Users\");"); } [Fact] - public void NoIdentityDbContext_NoFallbackSchemaLoop() + public void SchemaIsolation_WithoutIdentity_PassesNullIdentityModule() { var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); var result = GeneratorTestHelper.RunGenerator(compilation); var source = GetHostDbContext(result); - source.Should().NotContain("if (entityType.GetSchema() is null)"); + source.Should().Contain("moduleByEntityType, null);"); } [Fact] - public void SchemaIsolation_UsesDatabaseProviderDetector() - { - var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); - var result = GeneratorTestHelper.RunGenerator(compilation); - - var source = GetHostDbContext(result); - source.Should().Contain("DatabaseProviderDetector.Detect("); - source.Should().Contain("dbOptions.Value.DefaultConnection"); - source.Should().Contain("dbOptions.Value.Provider"); - } - - [Fact] - public void Sqlite_IdentityTablesPrefixed() + public void IdentityDbContextOnly_NoDbSets_SeedMapEmpty_FallsBackToIdentity() { var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( - ModuleWithDbContext, ModuleWithIdentityDbContext ); var result = GeneratorTestHelper.RunGenerator(compilation); var source = GetHostDbContext(result); - source.Should().Contain("Prefix Identity tables for SQLite"); - source.Should().Contain("entityType.SetTableName(\"Users_\" + tableName)"); - } - - [Fact] - public void Sqlite_IdentityPrefixing_SkipsAlreadyPrefixedTables() - { - var compilation = GeneratorTestHelper.CreateCompilationWithEfCore( - ModuleWithDbContext, - ModuleWithIdentityDbContext - ); - var result = GeneratorTestHelper.RunGenerator(compilation); - - var source = GetHostDbContext(result); - // Should check for Products_ prefix before applying Users_ prefix - source.Should().Contain("tableName.StartsWith(\"Products_\""); + // No module declares a DbSet, so the seed map has no entries... + source.Should().NotContain("[typeof("); + // ...and every (Identity) entity is swept into the Users module by the helper. + source.Should().Contain("moduleByEntityType, \"Users\");"); } #endregion @@ -675,10 +668,12 @@ public void IdentityAndPlainModules_CombinedCorrectly() "ApplyConfiguration(new global::TestApp.Orders.EntityConfigurations.OrderItemConfiguration()" ); - // Should have schemas for all modules - source.Should().Contain("SetSchema(\"products\")"); - source.Should().Contain("SetSchema(\"orders\")"); - source.Should().Contain("SetSchema(\"users\")"); + // Should seed all module entities and fall back to the identity module ("Users") + source + .Should() + .Contain("[typeof(global::TestApp.Products.Contracts.Product)] = \"Products\""); + source.Should().Contain("[typeof(global::TestApp.Orders.Contracts.Order)] = \"Orders\""); + source.Should().Contain("moduleByEntityType, \"Users\");"); } #endregion @@ -883,8 +878,8 @@ public void IdentityDbContextOnly_NoExtraDbSets_EmitsHostDbContext() // Should have zero extra DbSet properties (no "=>" expression body for DbSet) hostSource.Should().NotContain("=> Set<"); - // Should still have schema isolation - hostSource.Should().Contain("SetSchema(\"users\")"); + // Should still have schema isolation — all (Identity) entities fall back to "Users". + hostSource.Should().Contain("moduleByEntityType, \"Users\");"); } [Fact]