diff --git a/SAMA.Tests.Integration/AssemblyHooks.cs b/SAMA.Tests.Integration/AssemblyHooks.cs new file mode 100644 index 0000000..8202c7e --- /dev/null +++ b/SAMA.Tests.Integration/AssemblyHooks.cs @@ -0,0 +1,98 @@ +using Npgsql; + +namespace SAMA.Tests.Integration; + +[TestClass] +public static class AssemblyHooks +{ + [AssemblyInitialize] + public static async Task CleanupStaleTestSchemasAsync(TestContext _) + { + var connString = IntegrationTestBase.GetAdminConnectionString(); + await using var dataSource = NpgsqlDataSource.Create(connString); + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + + // Find test schemas older than 1 hour based on GUIDv7 timestamp in name + cmd.CommandText = "SELECT nspname FROM pg_namespace WHERE nspname LIKE 'test_%'"; + await using var reader = await cmd.ExecuteReaderAsync(); + + var staleSchemas = new List(); + var cutoff = DateTimeOffset.UtcNow.AddHours(-1); + + while (await reader.ReadAsync()) + { + var schemaName = reader.GetString(0); + if (TryGetSchemaTimestamp(schemaName, out var timestamp) && timestamp < cutoff) + { + staleSchemas.Add(schemaName); + } + } + + await reader.CloseAsync(); + + foreach (var schema in staleSchemas) + { + try + { + await using var dropCmd = conn.CreateCommand(); + dropCmd.CommandText = $"DROP SCHEMA IF EXISTS {schema} CASCADE"; + await dropCmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to drop stale schema {schema}: {ex.Message}"); + } + } + } + + [AssemblyCleanup] + public static async Task CleanupAllSchemasAsync() + { + foreach (var state in IntegrationTestBase.AllClassStates) + { + try + { + await using var conn = await state.DataSource.OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"DROP SCHEMA IF EXISTS {state.SchemaName} CASCADE"; + await cmd.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Failed to drop schema {state.SchemaName}: {ex.Message}"); + } + finally + { + await state.DataSource.DisposeAsync(); + } + } + } + + private static bool TryGetSchemaTimestamp(string schemaName, out DateTimeOffset timestamp) + { + // Schema names are: test_{classname}_{guidv7hex} + // GUIDv7 has the unix timestamp in ms in the first 48 bits + timestamp = default; + var lastUnderscore = schemaName.LastIndexOf('_'); + if (lastUnderscore < 0 || lastUnderscore + 1 >= schemaName.Length) + { + return false; + } + + var guidHex = schemaName[(lastUnderscore + 1)..]; + if (guidHex.Length != 32 || !Guid.TryParse(guidHex, out _)) + { + return false; + } + + // First 12 hex chars = 48 bits = unix timestamp in milliseconds + if (!long.TryParse(guidHex[..12], System.Globalization.NumberStyles.HexNumber, null, out var unixMs)) + { + return false; + } + + timestamp = DateTimeOffset.FromUnixTimeMilliseconds(unixMs); + return timestamp.Year is >= 2024 and <= 2100; + } +} diff --git a/SAMA.Tests.Integration/IntegrationTestBase.cs b/SAMA.Tests.Integration/IntegrationTestBase.cs index 3230bc7..65bd6c0 100644 --- a/SAMA.Tests.Integration/IntegrationTestBase.cs +++ b/SAMA.Tests.Integration/IntegrationTestBase.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -16,10 +17,11 @@ namespace SAMA.Tests.Integration; public abstract class IntegrationTestBase { - private string _schemaName = null!; - private string _connectionString = null!; + private static readonly ConcurrentDictionary> _classStates = new(); + internal static readonly ConcurrentBag AllClassStates = new(); + + private ClassState _classState = null!; private ServiceProvider _serviceProvider = null!; - private NpgsqlDataSource _dataSource = null!; protected SamaDbContext DbContext { get; private set; } = null!; @@ -28,20 +30,42 @@ public abstract class IntegrationTestBase [TestInitialize] public virtual async Task InitializeTestAsync() { - _schemaName = $"test_{GetType().Name.ToLowerInvariant()}_{Guid.CreateVersion7():N}"; - _connectionString = GetConnectionString(); - - // Create a dedicated data source for this test class with limited pooling - var dataSourceBuilder = new NpgsqlDataSourceBuilder(_connectionString); - dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = 3; - dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = 0; - dataSourceBuilder.ConnectionStringBuilder.ConnectionLifetime = 30; - dataSourceBuilder.ConnectionStringBuilder.Timeout = 30; - _dataSource = dataSourceBuilder.Build(); - - await CreateSchemaAsync(); + var type = GetType(); + + var lazy = _classStates.GetOrAdd(type, _ => new Lazy(() => + { + var schemaName = $"test_{type.Name.ToLowerInvariant()}_{Guid.CreateVersion7():N}"; + var connectionString = GetConnectionString(schemaName); + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); + dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = 3; + dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = 0; + dataSourceBuilder.ConnectionStringBuilder.ConnectionLifetime = 30; + dataSourceBuilder.ConnectionStringBuilder.Timeout = 30; + + var state = new ClassState + { + SchemaName = schemaName, + DataSource = dataSourceBuilder.Build() + }; + AllClassStates.Add(state); + return state; + })); + + var isFirstTest = !lazy.IsValueCreated; + _classState = lazy.Value; + await InitializeServicesAsync(); - await ApplyMigrationsAsync(); + + if (isFirstTest) + { + await CreateSchemaAsync(); + await ApplyMigrationsAsync(); + } + else + { + await TruncateAllTablesAsync(); + } } [TestCleanup] @@ -49,28 +73,17 @@ public virtual async Task CleanupTestAsync() { try { - // Explicitly close all DbContext connections if (DbContext != null) { await DbContext.Database.CloseConnectionAsync(); await DbContext.DisposeAsync(); } - - // Dispose service provider (releases pooled connections) - if (_serviceProvider != null) - { - await _serviceProvider.DisposeAsync(); - } - - // Drop schema - await DropSchemaAsync(); } finally { - // Always dispose data source to release all pooled connections - if (_dataSource != null) + if (_serviceProvider != null) { - await _dataSource.DisposeAsync(); + await _serviceProvider.DisposeAsync(); } } } @@ -94,7 +107,7 @@ protected void ConfigurePageModel(PageModel pageModel) pageModel.MetadataProvider = modelMetadataProvider; } - private string GetConnectionString() + internal static string GetAdminConnectionString() { var host = Environment.GetEnvironmentVariable("POSTGRES_HOST") ?? "localhost"; var port = Environment.GetEnvironmentVariable("POSTGRES_PORT") ?? "5432"; @@ -102,31 +115,36 @@ private string GetConnectionString() var username = Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "sama"; var password = Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? "sama-dev-pw"; - return $"Host={host};Port={port};Database={database};Username={username};Password={password};Search Path={_schemaName}"; + return $"Host={host};Port={port};Database={database};Username={username};Password={password}"; + } + + private static string GetConnectionString(string schemaName) + { + return $"{GetAdminConnectionString()};Search Path={schemaName};Options=-c synchronous_commit=off"; } private async Task CreateSchemaAsync() { - await using var connection = await _dataSource.OpenConnectionAsync(); + await using var connection = await _classState.DataSource.OpenConnectionAsync(); await using var command = connection.CreateCommand(); - command.CommandText = $"CREATE SCHEMA {_schemaName}"; + command.CommandText = $"CREATE SCHEMA {_classState.SchemaName}"; await command.ExecuteNonQueryAsync(); } - private async Task DropSchemaAsync() + private async Task TruncateAllTablesAsync() { - try - { - await using var connection = await _dataSource.OpenConnectionAsync(); - await using var command = connection.CreateCommand(); - command.CommandText = $"DROP SCHEMA IF EXISTS {_schemaName} CASCADE"; - await command.ExecuteNonQueryAsync(); - } - catch (Exception ex) - { - // Log but don't throw - we're in cleanup - System.Diagnostics.Debug.WriteLine($"Failed to drop schema {_schemaName}: {ex.Message}"); - } + await using var connection = await _classState.DataSource.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $""" + DO $$ DECLARE r RECORD; + BEGIN + FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = '{_classState.SchemaName}' AND tablename != '__EFMigrationsHistory' + LOOP + EXECUTE format('TRUNCATE TABLE %I.%I RESTART IDENTITY CASCADE', '{_classState.SchemaName}', r.tablename); + END LOOP; + END $$; + """; + await command.ExecuteNonQueryAsync(); } private Task InitializeServicesAsync() @@ -139,7 +157,7 @@ private Task InitializeServicesAsync() services.AddDbContext(options => { - options.UseNpgsql(_dataSource); + options.UseNpgsql(_classState.DataSource); options.ConfigureWarnings(w => w.Throw(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.MultipleCollectionIncludeWarning)); }); @@ -181,4 +199,11 @@ private async Task ApplyMigrationsAsync() await DbContext.Database.MigrateAsync(); await DbContext.Database.CloseConnectionAsync(); } + + internal class ClassState + { + public required string SchemaName { get; init; } + + public required NpgsqlDataSource DataSource { get; init; } + } } diff --git a/SAMA.Tests.Integration/Web/Services/Queries/CheckQueryServiceTests.cs b/SAMA.Tests.Integration/Web/Services/Queries/CheckQueryServiceTests.cs index 0d1f3c9..eeab72d 100644 --- a/SAMA.Tests.Integration/Web/Services/Queries/CheckQueryServiceTests.cs +++ b/SAMA.Tests.Integration/Web/Services/Queries/CheckQueryServiceTests.cs @@ -796,7 +796,7 @@ public async Task GetWorkspaceIncidentTimelineAsyncShouldExcludeDisabledCheckWit var enabledCheck = await CreateCheckAsync("Enabled Check", CheckTypes.Http, "60", true); await CreateCheckAsync("Disabled No Results", CheckTypes.Http, "60", false); - var recentTime = DateTimeOffset.UtcNow.AddSeconds(-1); + var recentTime = DateTimeOffset.UtcNow.AddMinutes(-3); await CreateCheckResultAsync(enabledCheck.Id, CheckStatuses.Up, recentTime); var result = await _service.GetWorkspaceIncidentTimelineAsync(_workspace.Id, 1); @@ -805,9 +805,9 @@ public async Task GetWorkspaceIncidentTimelineAsyncShouldExcludeDisabledCheckWit Assert.IsNotEmpty(result.Increments); // Disabled check with no results should not inflate TotalChecks - var lastIncrement = result.Increments.Last(); - Assert.AreEqual(1, lastIncrement.TotalChecks); - Assert.AreEqual(1, lastIncrement.UpCount); + var increment = result.Increments.First(i => i.StartTime <= recentTime && recentTime < i.EndTime); + Assert.AreEqual(1, increment.TotalChecks); + Assert.AreEqual(1, increment.UpCount); } [TestMethod] @@ -816,7 +816,7 @@ public async Task GetWorkspaceIncidentTimelineAsyncShouldHandleChecksWithoutResu var checkWithResults = await CreateCheckAsync("Check With Results", CheckTypes.Http, "60", true); var checkWithoutResults = await CreateCheckAsync("Check Without Results", CheckTypes.Http, "60", true); - var recentTime = DateTimeOffset.UtcNow.AddSeconds(-1); + var recentTime = DateTimeOffset.UtcNow.AddMinutes(-3); await CreateCheckResultAsync(checkWithResults.Id, CheckStatuses.Up, recentTime); var result = await _service.GetWorkspaceIncidentTimelineAsync(_workspace.Id, 1); @@ -824,9 +824,9 @@ public async Task GetWorkspaceIncidentTimelineAsyncShouldHandleChecksWithoutResu Assert.IsNotNull(result); Assert.IsNotEmpty(result.Increments); - var lastIncrement = result.Increments.Last(); - Assert.AreEqual(2, lastIncrement.TotalChecks); - Assert.AreEqual(1, lastIncrement.UpCount); + var increment = result.Increments.First(i => i.StartTime <= recentTime && recentTime < i.EndTime); + Assert.AreEqual(2, increment.TotalChecks); + Assert.AreEqual(1, increment.UpCount); } private async Task CreateWorkspaceAsync(string name)