diff --git a/server.core/Data/AppDbContext.cs b/server.core/Data/AppDbContext.cs index 078bab5..2db0191 100644 --- a/server.core/Data/AppDbContext.cs +++ b/server.core/Data/AppDbContext.cs @@ -6,19 +6,11 @@ namespace Server.Core.Data; public class AppDbContext(DbContextOptions options) : DbContext(options) { public const string AppSchema = "app"; - public const string DataSchema = "data"; public const string MigrationsHistoryTable = "__EFMigrationsHistory"; public DbSet ImportLogs => Set(); public DbSet Users => Set(); - public DbSet ChartStringSegments => Set(); - - public DbSet DepartmentHierarchies => Set(); - public DbSet AccountHierarchies => Set(); - public DbSet FundHierarchies => Set(); - public DbSet ActivityHierarchies => Set(); - protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -26,44 +18,5 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.HasDefaultSchema(AppSchema); modelBuilder.Entity().ToTable("ImportLog", AppSchema); modelBuilder.Entity().ToTable("Users", AppSchema); - - modelBuilder.Entity(entity => - { - entity.ToTable("ChartStringSegments", DataSchema, table => table.ExcludeFromMigrations()); - entity.HasKey(segment => new { segment.SegmentType, segment.Code }); - entity.Property(segment => segment.SegmentType).HasConversion().HasMaxLength(20); - entity.Property(segment => segment.Code).HasMaxLength(50); - entity.Property(segment => segment.Description).HasMaxLength(300); - entity.Property(segment => segment.Sfn).HasMaxLength(10); - }); - - ConfigureHierarchy(modelBuilder, "DepartmentHierarchy"); - ConfigureHierarchy(modelBuilder, "AccountHierarchy"); - ConfigureHierarchy(modelBuilder, "FundHierarchy"); - ConfigureHierarchy(modelBuilder, "ActivityHierarchy"); - } - - private static void ConfigureHierarchy(ModelBuilder modelBuilder, string table) - where T : class, ISegmentHierarchy - { - modelBuilder.Entity(entity => - { - entity.ToTable(table, DataSchema, t => t.ExcludeFromMigrations()); - entity.HasKey(e => e.Code); - - foreach (var property in entity.Metadata.GetProperties() - .Where(p => p.ClrType == typeof(string))) - { - var maxLength = property.Name switch - { - // Match ChartStringSegment.Code so joins/lookups on Code stay aligned. - nameof(ISegmentHierarchy.Code) => 50, - "Description" => 1000, - _ when property.Name.EndsWith("Name") => 1000, - _ => 20, - }; - property.SetMaxLength(maxLength); - } - }); } } diff --git a/server.core/Data/ChartStringSegmentSeed.cs b/server.core/Data/ChartStringSegmentSeed.cs index 381e09b..471e6ee 100644 --- a/server.core/Data/ChartStringSegmentSeed.cs +++ b/server.core/Data/ChartStringSegmentSeed.cs @@ -21,7 +21,7 @@ public static class ChartStringSegmentSeed private static readonly string[] FundSfnPool = ["201", "202", "203", "205", "220", "221", "223", "Multiple"]; - public static async Task EnsureSeededAsync(AppDbContext db, CancellationToken ct = default) + public static async Task EnsureSeededAsync(DataDbContext db, CancellationToken ct = default) { if (await db.ChartStringSegments.AnyAsync(ct)) { diff --git a/server.core/Data/DataDbConnection.cs b/server.core/Data/DataDbConnection.cs index e47d1ea..e187e3f 100644 --- a/server.core/Data/DataDbConnection.cs +++ b/server.core/Data/DataDbConnection.cs @@ -6,6 +6,7 @@ public static class DataDbConnection { public const string EnvironmentVariableName = "DATA_DB_CONNECTION"; public const string ConnectionStringName = "DataConnection"; + public const int ImportCommandTimeoutSeconds = 600; public static string Resolve(IConfiguration configuration, string? fallbackConnectionString) { diff --git a/server.core/Data/DataDbContext.cs b/server.core/Data/DataDbContext.cs new file mode 100644 index 0000000..275aea3 --- /dev/null +++ b/server.core/Data/DataDbContext.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using Server.Core.Domain; + +namespace Server.Core.Data; + +public class DataDbContext(DbContextOptions options) : DbContext(options) +{ + public const string DataSchema = "data"; + + public DbSet ChartStringSegments => Set(); + + public DbSet DepartmentHierarchies => Set(); + public DbSet AccountHierarchies => Set(); + public DbSet FundHierarchies => Set(); + public DbSet ActivityHierarchies => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.HasDefaultSchema(DataSchema); + + modelBuilder.Entity(entity => + { + entity.ToTable("ChartStringSegments", DataSchema, table => table.ExcludeFromMigrations()); + entity.HasKey(segment => new { segment.SegmentType, segment.Code }); + entity.Property(segment => segment.SegmentType).HasConversion().HasMaxLength(20); + entity.Property(segment => segment.Code).HasMaxLength(50); + entity.Property(segment => segment.Description).HasMaxLength(300); + entity.Property(segment => segment.Sfn).HasMaxLength(10); + }); + + ConfigureHierarchy(modelBuilder, "DepartmentHierarchy"); + ConfigureHierarchy(modelBuilder, "AccountHierarchy"); + ConfigureHierarchy(modelBuilder, "FundHierarchy"); + ConfigureHierarchy(modelBuilder, "ActivityHierarchy"); + } + + private static void ConfigureHierarchy(ModelBuilder modelBuilder, string table) + where T : class, ISegmentHierarchy + { + modelBuilder.Entity(entity => + { + entity.ToTable(table, DataSchema, mapping => mapping.ExcludeFromMigrations()); + entity.HasKey(e => e.Code); + + foreach (var property in entity.Metadata.GetProperties() + .Where(p => p.ClrType == typeof(string))) + { + var maxLength = property.Name switch + { + // Match ChartStringSegment.Code so joins/lookups on Code stay aligned. + nameof(ISegmentHierarchy.Code) => 50, + "Description" => 1000, + _ when property.Name.EndsWith("Name") => 1000, + _ => 20, + }; + property.SetMaxLength(maxLength); + } + }); + } +} diff --git a/server.core/Data/DbInitializer.cs b/server.core/Data/DbInitializer.cs index 042421a..a57a13e 100644 --- a/server.core/Data/DbInitializer.cs +++ b/server.core/Data/DbInitializer.cs @@ -10,11 +10,13 @@ public interface IDbInitializer public class DbInitializer : IDbInitializer { private readonly AppDbContext _db; + private readonly DataDbContext _dataDb; private readonly ILogger _logger; - public DbInitializer(AppDbContext db, ILogger logger) + public DbInitializer(AppDbContext db, DataDbContext dataDb, ILogger logger) { _db = db; + _dataDb = dataDb; _logger = logger; } @@ -37,8 +39,8 @@ public async Task InitializeAsync(bool includeDevSeed, CancellationToken cancell private async Task SeedDevelopmentAsync(CancellationToken ct) { // Hierarchy first: chart-string segments are derived from the hierarchy tables. - await HierarchySeed.EnsureSeededAsync(_db, ct); - await ChartStringSegmentSeed.EnsureSeededAsync(_db, ct); + await HierarchySeed.EnsureSeededAsync(_dataDb, ct); + await ChartStringSegmentSeed.EnsureSeededAsync(_dataDb, ct); } // just a placeholder for any production-safe seeding diff --git a/server.core/Data/HierarchySeed.cs b/server.core/Data/HierarchySeed.cs index 841bf8b..d82f065 100644 --- a/server.core/Data/HierarchySeed.cs +++ b/server.core/Data/HierarchySeed.cs @@ -12,7 +12,7 @@ namespace Server.Core.Data; /// public static class HierarchySeed { - public static async Task EnsureSeededAsync(AppDbContext db, CancellationToken ct = default) + public static async Task EnsureSeededAsync(DataDbContext db, CancellationToken ct = default) { if (!await db.AccountHierarchies.AnyAsync(ct)) { diff --git a/server.core/Import/PgmProjectsImportService.cs b/server.core/Import/PgmProjectsImportService.cs index 4d0db1d..15b00f6 100644 --- a/server.core/Import/PgmProjectsImportService.cs +++ b/server.core/Import/PgmProjectsImportService.cs @@ -13,7 +13,7 @@ public sealed class PgmProjectsImportService : IPgmProjectsImportService public const string RemoteLinkedServer = "AE_Redshift_PROD"; - private const int CommandTimeoutSeconds = 600; + private const int CommandTimeoutSeconds = DataDbConnection.ImportCommandTimeoutSeconds; private const string DestinationTable = "[data].[PGMProjects]"; // source reader column -> destination table column. @@ -58,16 +58,16 @@ private static readonly (string Source, string Destination)[] ColumnMappings = ("contract_admins", "ContractAdmins"), ]; - private readonly AppDbContext _dbContext; + private readonly DataDbContext _dataDbContext; private readonly IConfiguration _configuration; private readonly ILogger _logger; public PgmProjectsImportService( - AppDbContext dbContext, + DataDbContext dataDbContext, IConfiguration configuration, ILogger logger) { - _dbContext = dbContext; + _dataDbContext = dataDbContext; _configuration = configuration; _logger = logger; } @@ -86,7 +86,7 @@ public async Task ImportAsync(DateOnly reportDate, Canc var destinationConnectionString = DataDbConnection.Resolve( _configuration, - _dbContext.Database.GetConnectionString()); + _dataDbContext.Database.GetConnectionString()); _logger.LogInformation("Importing PGM projects for report date {ReportDate}", reportDate); diff --git a/server.core/createMigration.sh b/server.core/createMigration.sh index 75d985d..1f4abe1 100755 --- a/server.core/createMigration.sh +++ b/server.core/createMigration.sh @@ -12,6 +12,6 @@ MIGRATION_NAME=$1 ROOT_DIR="$(dirname "$0")/.." echo "📦 Creating migration '$MIGRATION_NAME'..." -dotnet ef migrations add "$MIGRATION_NAME" -p "$ROOT_DIR/server.core" -s "$ROOT_DIR/server" +dotnet ef migrations add "$MIGRATION_NAME" --context AppDbContext -p "$ROOT_DIR/server.core" -s "$ROOT_DIR/server" echo "✅ Migration created successfully." diff --git a/server/Controllers/ChartStringSegmentsController.cs b/server/Controllers/ChartStringSegmentsController.cs index 03eb6dd..8c8272c 100644 --- a/server/Controllers/ChartStringSegmentsController.cs +++ b/server/Controllers/ChartStringSegmentsController.cs @@ -8,9 +8,9 @@ namespace Server.Controllers; public class ChartStringSegmentsController : ApiControllerBase { - private readonly AppDbContext _db; + private readonly DataDbContext _db; - public ChartStringSegmentsController(AppDbContext db) + public ChartStringSegmentsController(DataDbContext db) { _db = db; } diff --git a/server/Import/FlatFileImportService.cs b/server/Import/FlatFileImportService.cs index ce344f3..fc6539e 100644 --- a/server/Import/FlatFileImportService.cs +++ b/server/Import/FlatFileImportService.cs @@ -34,10 +34,12 @@ public sealed record ImportValidationFailed(ImportValidationResponse Response) : public sealed class FlatFileImportService( AppDbContext dbContext, + DataDbContext dataDbContext, IFlatFileImportRegistry registry, IConfiguration configuration, ILogger logger) : IFlatFileImportService { + private const int DatabaseCommandTimeoutSeconds = DataDbConnection.ImportCommandTimeoutSeconds; private const string TempTableName = "#FlatFileImportRows"; private const string StatusSucceeded = "Succeeded"; private const string StatusValidationFailed = "ValidationFailed"; @@ -767,7 +769,7 @@ private async Task ReplaceTargetTableAsync( { var connectionString = DataDbConnection.Resolve( configuration, - dbContext.Database.GetConnectionString()); + dataDbContext.Database.GetConnectionString()); await using var connection = new SqlConnection(connectionString); await connection.OpenAsync(cancellationToken); @@ -778,6 +780,7 @@ private async Task ReplaceTargetTableAsync( await connection.ExecuteAsync(new CommandDefinition( CreateTempTableSql(definition), transaction: transaction, + commandTimeout: DatabaseCommandTimeoutSeconds, cancellationToken: cancellationToken)); var table = CreateDataTable(definition, rows); @@ -785,6 +788,7 @@ await connection.ExecuteAsync(new CommandDefinition( { bulkCopy.DestinationTableName = TempTableName; bulkCopy.BatchSize = Math.Max(rows.Count, 1); + bulkCopy.BulkCopyTimeout = DatabaseCommandTimeoutSeconds; bulkCopy.ColumnMappings.Add("ImportRowNumber", "ImportRowNumber"); foreach (var column in definition.Columns) @@ -800,6 +804,7 @@ await connection.ExecuteAsync(new CommandDefinition( await connection.ExecuteAsync(new CommandDefinition( ReplaceTableSql(definition), transaction: transaction, + commandTimeout: DatabaseCommandTimeoutSeconds, cancellationToken: cancellationToken)); await transaction.CommitAsync(cancellationToken); @@ -813,6 +818,7 @@ private static Task DropTempTableIfExistsAsync( return connection.ExecuteAsync(new CommandDefinition( $"DROP TABLE IF EXISTS {TempTableName};", transaction: transaction, + commandTimeout: DatabaseCommandTimeoutSeconds, cancellationToken: cancellationToken)); } @@ -830,6 +836,7 @@ private static async Task RunStagingValidationAsync( var duplicateRowNumbers = await connection.QueryAsync(new CommandDefinition( DuplicateKeyValidationSql(uniqueKey, definition.Columns), transaction: transaction, + commandTimeout: DatabaseCommandTimeoutSeconds, cancellationToken: cancellationToken)); foreach (var rowNumber in duplicateRowNumbers) diff --git a/server/Program.cs b/server/Program.cs index aa59929..5b698f2 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -75,9 +75,13 @@ .MigrationsAssembly("server.core") .MigrationsHistoryTable(AppDbContext.MigrationsHistoryTable, AppDbContext.AppSchema))); +var dataConn = DataDbConnection.Resolve(builder.Configuration, conn); +builder.Services.AddDbContextPool(o => o.UseSqlServer(dataConn)); + builder.Services .AddHealthChecks() - .AddDbContextCheck(); + .AddDbContextCheck() + .AddDbContextCheck("data_db"); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs index 7b3b1d9..45d0ee7 100644 --- a/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentMappingTests.cs @@ -10,7 +10,7 @@ public class ChartStringSegmentMappingTests [Fact] public void Maps_to_data_schema_with_composite_key() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); var entityType = db.Model.FindEntityType(typeof(ChartStringSegment)); @@ -24,7 +24,7 @@ public void Maps_to_data_schema_with_composite_key() [Fact] public void Stores_segment_type_as_string() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); var property = db.Model.FindEntityType(typeof(ChartStringSegment))! .FindProperty(nameof(ChartStringSegment.SegmentType)); @@ -35,7 +35,7 @@ public void Stores_segment_type_as_string() [Fact] public void Sfn_column_allows_ten_characters() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); var property = db.Model.FindEntityType(typeof(ChartStringSegment))! .FindProperty(nameof(ChartStringSegment.Sfn)); diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs index 4db68c0..b328296 100644 --- a/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentSeedTests.cs @@ -10,7 +10,7 @@ public class ChartStringSegmentSeedTests [Fact] public async Task EnsureSeeded_derives_one_segment_per_hierarchy_row() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); await HierarchySeed.EnsureSeededAsync(db); await ChartStringSegmentSeed.EnsureSeededAsync(db); @@ -27,7 +27,7 @@ await db.DepartmentHierarchies.CountAsync() + [Fact] public async Task EnsureSeeded_loads_ern_codes_from_csv() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); await HierarchySeed.EnsureSeededAsync(db); await ChartStringSegmentSeed.EnsureSeededAsync(db); @@ -52,7 +52,7 @@ public async Task EnsureSeeded_loads_ern_codes_from_csv() [Fact] public async Task EnsureSeeded_maps_segment_types_to_their_hierarchy() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); await HierarchySeed.EnsureSeededAsync(db); await ChartStringSegmentSeed.EnsureSeededAsync(db); @@ -67,7 +67,7 @@ public async Task EnsureSeeded_maps_segment_types_to_their_hierarchy() [Fact] public async Task EnsureSeeded_is_idempotent() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); await HierarchySeed.EnsureSeededAsync(db); await ChartStringSegmentSeed.EnsureSeededAsync(db); diff --git a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs b/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs index 5046a72..129561d 100644 --- a/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs +++ b/tests/server.tests/ChartStringSegments/ChartStringSegmentsControllerTests.cs @@ -11,7 +11,7 @@ public class ChartStringSegmentsControllerTests [Fact] public async Task Get_returns_all_segments() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.AddRange( new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "45530", Description = "AES", IncludeInReport = true, Sfn = "220" }, new ChartStringSegment { SegmentType = SegmentType.Account, Code = "500000", Description = "S and E", IncludeInReport = null }); @@ -28,7 +28,7 @@ public async Task Get_returns_all_segments() [Fact] public async Task Get_includes_hierarchy_for_segment_with_matching_code() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "45530", IncludeInReport = true, Sfn = "220" }); db.FundHierarchies.Add(new FundHierarchy { @@ -56,7 +56,7 @@ public async Task Get_includes_hierarchy_for_segment_with_matching_code() public async Task Get_reads_hierarchy_from_the_matching_segment_types_own_table( SegmentType segmentType, string expectedLevel) { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); const string code = "12345"; db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = segmentType, Code = code, IncludeInReport = null }); @@ -101,7 +101,7 @@ public async Task Get_reads_hierarchy_from_the_matching_segment_types_own_table( [Fact] public async Task Get_returns_empty_hierarchy_when_no_matching_row() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Account, Code = "500000", IncludeInReport = null }); await db.SaveChangesAsync(); var controller = new ChartStringSegmentsController(db); @@ -116,7 +116,7 @@ public async Task Get_returns_empty_hierarchy_when_no_matching_row() [Fact] public async Task Patch_updates_include_flag() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null, Sfn = "219" }); await db.SaveChangesAsync(); var controller = new ChartStringSegmentsController(db); @@ -133,7 +133,7 @@ public async Task Patch_updates_include_flag() [Fact] public async Task Patch_returns_not_found_for_missing_segment() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); var controller = new ChartStringSegmentsController(db); var result = await controller.UpdateClassification( @@ -145,7 +145,7 @@ public async Task Patch_returns_not_found_for_missing_segment() [Fact] public async Task Patch_returns_bad_request_for_unknown_segment_type() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); var controller = new ChartStringSegmentsController(db); var result = await controller.UpdateClassification( @@ -157,7 +157,7 @@ public async Task Patch_returns_bad_request_for_unknown_segment_type() [Fact] public async Task Patch_sets_sfn_for_included_fund() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null }); await db.SaveChangesAsync(); var controller = new ChartStringSegmentsController(db); @@ -174,7 +174,7 @@ public async Task Patch_sets_sfn_for_included_fund() [Fact] public async Task Patch_clears_sfn_when_fund_excluded() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "45530", IncludeInReport = true, Sfn = "220" }); await db.SaveChangesAsync(); var controller = new ChartStringSegmentsController(db); @@ -191,7 +191,7 @@ public async Task Patch_clears_sfn_when_fund_excluded() [Fact] public async Task Patch_rejects_invalid_sfn_for_included_fund() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null }); await db.SaveChangesAsync(); var controller = new ChartStringSegmentsController(db); @@ -205,7 +205,7 @@ public async Task Patch_rejects_invalid_sfn_for_included_fund() [Fact] public async Task Patch_rejects_sfn_on_non_fund_segment() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Account, Code = "500000", IncludeInReport = null }); await db.SaveChangesAsync(); var controller = new ChartStringSegmentsController(db); @@ -219,7 +219,7 @@ public async Task Patch_rejects_sfn_on_non_fund_segment() [Fact] public async Task Patch_accepts_multiple_marker_for_included_fund() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); db.ChartStringSegments.Add(new ChartStringSegment { SegmentType = SegmentType.Fund, Code = "70575", IncludeInReport = null }); await db.SaveChangesAsync(); var controller = new ChartStringSegmentsController(db); diff --git a/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs b/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs index 9e52b9d..722499b 100644 --- a/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs +++ b/tests/server.tests/ChartStringSegments/HierarchyMappingTests.cs @@ -13,7 +13,7 @@ public class HierarchyMappingTests [InlineData(typeof(ActivityHierarchy), "ActivityHierarchy")] public void Maps_to_data_schema_keyed_by_code(Type clr, string table) { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); var entityType = db.Model.FindEntityType(clr); @@ -42,7 +42,7 @@ public void Department_levels_return_ordered_non_null_pairs() [Fact] public void Fund_level_properties_have_max_lengths() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); var entity = db.Model.FindEntityType(typeof(FundHierarchy))!; entity.FindProperty("ParentLevel0Code")!.GetMaxLength().Should().Be(20); diff --git a/tests/server.tests/ChartStringSegments/HierarchySeedTests.cs b/tests/server.tests/ChartStringSegments/HierarchySeedTests.cs index 86d549f..90e3bfb 100644 --- a/tests/server.tests/ChartStringSegments/HierarchySeedTests.cs +++ b/tests/server.tests/ChartStringSegments/HierarchySeedTests.cs @@ -10,7 +10,7 @@ public class HierarchySeedTests [Fact] public async Task EnsureSeeded_loads_rows_from_the_embedded_csvs() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); await HierarchySeed.EnsureSeededAsync(db); @@ -25,7 +25,7 @@ public async Task EnsureSeeded_loads_rows_from_the_embedded_csvs() [Fact] public async Task EnsureSeeded_synthesizes_department_levels_a_through_g() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); await HierarchySeed.EnsureSeededAsync(db); @@ -41,7 +41,7 @@ public async Task EnsureSeeded_synthesizes_department_levels_a_through_g() [Fact] public async Task EnsureSeeded_is_idempotent() { - using var db = TestDbContextFactory.CreateInMemory(); + using var db = TestDbContextFactory.CreateDataInMemory(); await HierarchySeed.EnsureSeededAsync(db); var count = await db.FundHierarchies.CountAsync(); diff --git a/tests/server.tests/Import/FlatFileImportServiceTests.cs b/tests/server.tests/Import/FlatFileImportServiceTests.cs index 496d87e..fdeff08 100644 --- a/tests/server.tests/Import/FlatFileImportServiceTests.cs +++ b/tests/server.tests/Import/FlatFileImportServiceTests.cs @@ -66,7 +66,8 @@ public void Dataset_definition_allows_normalized_aliases_for_the_same_column() public async Task Import_rejects_unsupported_file_types() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateTextFile("projects.txt", "not,a,supported,type"); var result = await service.ImportAsync("all-projects", file, null, CancellationToken.None); @@ -85,7 +86,8 @@ public async Task Import_rejects_unsupported_file_types() public async Task Import_reports_malformed_workbooks_as_validation_failures() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateTextFile("active-projects.xlsx", "not a workbook"); var result = await service.ImportAsync("active-projects", file, null, CancellationToken.None); @@ -104,7 +106,8 @@ public async Task Import_reports_malformed_workbooks_as_validation_failures() public async Task Import_reports_missing_required_headers() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateWorkbook("all-projects.xlsx", ["Accession Number"], [["A123"]]); @@ -120,7 +123,8 @@ public async Task Import_reports_missing_required_headers() public async Task Import_rejects_flat_files_without_data_rows() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateWorkbook("active-projects.xlsx", [ "Project Number", @@ -144,7 +148,8 @@ public async Task Import_rejects_flat_files_without_data_rows() public async Task Import_collects_csv_required_type_length_and_duplicate_errors_before_persistence() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateCsvFile("active-projects.csv", [ "Project Number", @@ -188,7 +193,8 @@ public async Task Import_collects_csv_required_type_length_and_duplicate_errors_ public async Task Import_all_projects_allows_duplicate_accession_numbers_and_project_numbers() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateCsvFile("all-projects.csv", AllProjectsHeaders(), [ @@ -209,7 +215,8 @@ public async Task Import_all_projects_allows_duplicate_accession_numbers_and_pro public async Task Import_all_projects_still_rejects_duplicate_source_and_proposal_numbers() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateCsvFile("all-projects.csv", AllProjectsHeaders(), [ @@ -232,7 +239,8 @@ public async Task Import_all_projects_still_rejects_duplicate_source_and_proposa public async Task Import_assistance_listing_numbers_allows_loosened_program_number_and_text_values() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var longValue = new string('X', 501); var file = CreateCsvFile("assistance-listing-numbers.csv", [ @@ -268,7 +276,8 @@ public async Task Import_assistance_listing_numbers_allows_loosened_program_numb public async Task Import_all_projects_still_rejects_overlong_strings() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateCsvFile("all-projects.csv", AllProjectsHeaders(), [ @@ -288,7 +297,8 @@ public async Task Import_all_projects_still_rejects_overlong_strings() public async Task Import_preserves_flat_file_column_order_in_validation_values() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateCsvFile("active-projects.csv", [ "PD Email Address", @@ -322,7 +332,8 @@ public async Task Import_preserves_flat_file_column_order_in_validation_values() public async Task Import_reports_duplicate_headers_before_row_parsing() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateWorkbook("active-projects.xlsx", [ "Project Number", @@ -350,7 +361,8 @@ public async Task Import_reports_duplicate_headers_before_row_parsing() public async Task Import_logs_persistence_failures_without_marking_success() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var file = CreateWorkbook("active-projects.xlsx", [ "Project Number", @@ -385,7 +397,8 @@ public async Task Import_logs_persistence_failures_without_marking_success() public async Task Import_logs_all_validation_errors_without_row_values() { await using var db = TestDbContextFactory.CreateInMemory(); - var service = CreateService(db); + await using var dataDb = TestDbContextFactory.CreateDataInMemory(); + var service = CreateService(db, dataDb); var rows = Enumerable.Range(1, 105) .Select(index => new[] { @@ -448,10 +461,13 @@ private static string[] AllProjectsHeaders() ]; } - private static FlatFileImportService CreateService(Server.Core.Data.AppDbContext db) + private static FlatFileImportService CreateService( + Server.Core.Data.AppDbContext db, + Server.Core.Data.DataDbContext dataDb) { return new FlatFileImportService( db, + dataDb, new FlatFileImportRegistry(), new ConfigurationBuilder().Build(), NullLogger.Instance); diff --git a/tests/server.tests/TestDbContextFactory.cs b/tests/server.tests/TestDbContextFactory.cs index c78ee05..408e494 100644 --- a/tests/server.tests/TestDbContextFactory.cs +++ b/tests/server.tests/TestDbContextFactory.cs @@ -20,4 +20,20 @@ public static AppDbContext CreateInMemory() ctx.Database.EnsureCreated(); return ctx; } + + /// + /// Creates a fresh DataDbContext using EFCore InMemory with a unique database name, + /// so each test starts clean. + /// + public static DataDbContext CreateDataInMemory() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"TestDataDb_{Guid.NewGuid():N}") + .EnableSensitiveDataLogging() + .Options; + + var ctx = new DataDbContext(options); + ctx.Database.EnsureCreated(); + return ctx; + } }