Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions SAMA.Tests.Integration/AssemblyHooks.cs
Original file line number Diff line number Diff line change
@@ -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<string>();
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();
}
Comment on lines +37 to +41
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DROP SCHEMA is built via string interpolation with an identifier coming from the database (pg_namespace). If a schema name ever contains characters that require quoting, this will fail; and interpolating identifiers directly is also avoidable risk. Consider quoting the identifier (e.g., via NpgsqlCommandBuilder.QuoteIdentifier) when constructing the DROP SCHEMA statement.

Copilot uses AI. Check for mistakes.
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to drop stale schema {schema}: {ex.Message}");
}
}
}

[AssemblyCleanup]
public static async Task CleanupAllSchemasAsync()
{
Comment thread
arktronic-sep marked this conversation as resolved.
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();
}
Comment on lines +56 to +60
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DROP SCHEMA is constructed with an unquoted schema identifier. Even though SchemaName is generated by the tests, quoting it makes the drop robust (and avoids edge-case failures if naming ever changes). Consider quoting the identifier when building the command text.

Copilot uses AI. Check for mistakes.
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to drop schema {state.SchemaName}: {ex.Message}");
}
finally
{
await state.DataSource.DisposeAsync();
}
Comment thread
arktronic-sep marked this conversation as resolved.
}
}

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;
Comment on lines +83 to +96
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryGetSchemaTimestamp only validates that the suffix is parseable as a GUID, but it doesn’t verify it’s actually UUIDv7 before interpreting the first 12 hex chars as a unix-ms timestamp. This can incorrectly classify non-v7 GUID suffixes as “stale” (by chance) and drop schemas that weren’t created by this test harness. Consider verifying the UUID version nibble is '7' (or equivalent check) before parsing/extracting the timestamp, and only then allow deletion.

Copilot uses AI. Check for mistakes.
}
}
119 changes: 72 additions & 47 deletions SAMA.Tests.Integration/IntegrationTestBase.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
Expand All @@ -16,10 +17,11 @@ namespace SAMA.Tests.Integration;

public abstract class IntegrationTestBase
{
private string _schemaName = null!;
private string _connectionString = null!;
private static readonly ConcurrentDictionary<Type, Lazy<ClassState>> _classStates = new();
internal static readonly ConcurrentBag<ClassState> AllClassStates = new();

private ClassState _classState = null!;
private ServiceProvider _serviceProvider = null!;
private NpgsqlDataSource _dataSource = null!;

protected SamaDbContext DbContext { get; private set; } = null!;

Expand All @@ -28,49 +30,60 @@ 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<ClassState>(() =>
{
var schemaName = $"test_{type.Name.ToLowerInvariant()}_{Guid.CreateVersion7():N}";
var connectionString = GetConnectionString(schemaName);
Comment thread
arktronic-sep marked this conversation as resolved.

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;
Comment thread
arktronic-sep marked this conversation as resolved.
}));

var isFirstTest = !lazy.IsValueCreated;
_classState = lazy.Value;

await InitializeServicesAsync();
await ApplyMigrationsAsync();

if (isFirstTest)
{
await CreateSchemaAsync();
await ApplyMigrationsAsync();
}
else
{
await TruncateAllTablesAsync();
}
}

[TestCleanup]
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();
}
}
}
Expand All @@ -94,39 +107,44 @@ 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";
var database = Environment.GetEnvironmentVariable("POSTGRES_DB") ?? "samadb";
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()
Expand All @@ -139,7 +157,7 @@ private Task InitializeServicesAsync()

services.AddDbContext<SamaDbContext>(options =>
{
options.UseNpgsql(_dataSource);
options.UseNpgsql(_classState.DataSource);
options.ConfigureWarnings(w => w.Throw(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.MultipleCollectionIncludeWarning));
});

Expand Down Expand Up @@ -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; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand All @@ -816,17 +816,17 @@ 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);

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<Workspace> CreateWorkspaceAsync(string name)
Expand Down
Loading