sqlinsight-pro/tests/SQLInsight.IntegrationTests/RepositoryTests.cs
Claude 9372c2e927
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Fase 1: local repository with finding tracking, time series and retention
- Versioned, embedded migrations serialized with sp_getapplock so the agent
  and web UI can both migrate at startup
- Findings tracked per rule and subject: first seen, last seen, resolved;
  a rule that could not run never resolves its findings
- Time series (waits, file I/O, CPU, counters), blocking chains and
  deadlock graphs; idempotent inserts (timestamps truncated to match
  datetime2(0)), hourly aggregation and batched retention deletes
- Read API for the web UI; integration tests against SQL Server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
2026-09-29 06:23:38 +00:00

250 lines
12 KiB
C#

using Microsoft.Data.SqlClient;
using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
using SQLInsight.Rules.Engine;
namespace SQLInsight.IntegrationTests;
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public class RepositoryTests(SqlServerFixture sql) : IAsyncLifetime
{
private readonly string _database = "SQLInsightRepo_" + Guid.NewGuid().ToString("N")[..8];
private InsightRepository _repository = null!;
private static readonly DateTimeOffset Now = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
private static readonly InstanceInfo Info = new()
{
ServerName = "SQL01",
Version = SqlServerVersion.Parse("16.0.4135.4"),
ProductLevel = "RTM",
ProductUpdateLevel = "CU14",
Edition = "Enterprise Edition",
EngineEdition = EngineEdition.Enterprise,
};
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
}
public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
[Fact]
public async Task Migrations_are_idempotent_and_safe_to_run_concurrently()
{
var applied = await Task.WhenAll(Enumerable.Range(0, 4).Select(_ => _repository.MigrateAsync()));
Assert.All(applied, count => Assert.Equal(0, count));
Assert.Equal(SchemaMigrator.Scripts().Count, await ScalarAsync<int>("SELECT COUNT(*) FROM insight.SchemaVersions"));
}
private static Finding Finding(string subject, Severity severity = Severity.High) => new()
{
RuleId = "SQLI-TEST-001",
Severity = severity,
Title = $"Problem med {subject}",
Subject = subject,
Explanation = "Forklaring",
Recommendation = "Anbefaling",
Evidence = [new Evidence("Måling", "42")],
References = [new Reference("Docs", new Uri("https://learn.microsoft.com/sql/"))],
FixScript = "SELECT 1;",
};
private static RuleResult Result(RuleOutcome outcome, params Finding[] findings) => new()
{
RuleId = "SQLI-TEST-001",
RuleTitle = "Testregel",
Outcome = outcome,
Findings = findings,
Reason = outcome == RuleOutcome.NotMeasurable ? "Kræver Query Store" : null,
};
[Fact]
public async Task Findings_are_tracked_across_evaluations()
{
var id = await _repository.UpsertInstanceAsync("sql01", Info, "SqlClient", false, Now);
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A"), Finding("database: B"))], Now);
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A", Severity.Critical))], Now.AddMinutes(15));
var active = await _repository.GetActiveFindingsAsync(id);
var a = Assert.Single(active);
Assert.Equal("database: A", a.Subject);
Assert.Equal(Severity.Critical, a.Severity);
Assert.Equal(Now.UtcDateTime, a.FirstSeenAt);
Assert.Equal(Now.AddMinutes(15).UtcDateTime, a.LastSeenAt);
Assert.Equal("42", Assert.Single(a.Evidence).Value);
Assert.Equal("SELECT 1;", a.FixScript);
var resolved = Assert.Single(await _repository.GetRecentlyResolvedAsync(id, 3650));
Assert.Equal("database: B", resolved.Subject);
}
[Fact]
public async Task Not_measurable_does_not_resolve_findings()
{
var id = await _repository.UpsertInstanceAsync("sql02", Info, "SqlClient", false, Now);
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("database: A"))], Now);
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.NotMeasurable)], Now.AddMinutes(15));
Assert.Single(await _repository.GetActiveFindingsAsync(id));
var state = Assert.Single(await _repository.GetRuleStatesAsync(id));
Assert.Equal(RuleOutcome.NotMeasurable, state.Outcome);
Assert.Equal("Kræver Query Store", state.Reason);
}
[Fact]
public async Task Returning_finding_starts_a_new_occurrence()
{
var id = await _repository.UpsertInstanceAsync("sql03", Info, "SqlClient", false, Now);
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("x"))], Now);
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Passed)], Now.AddHours(1));
await _repository.SaveRuleResultsAsync(id, [Result(RuleOutcome.Findings, Finding("x"))], Now.AddHours(2));
var active = Assert.Single(await _repository.GetActiveFindingsAsync(id));
Assert.Equal(Now.AddHours(2).UtcDateTime, active.FirstSeenAt);
Assert.Equal(2, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.Findings WHERE InstanceId = {id}"));
}
[Fact]
public async Task Overview_counts_findings_and_not_measurable_rules()
{
var id = await _repository.UpsertInstanceAsync("sql04", Info, "Odbc", true, Now);
await _repository.SaveRuleResultsAsync(id,
[
Result(RuleOutcome.Findings, Finding("a", Severity.Critical), Finding("b", Severity.Low)),
Result(RuleOutcome.NotMeasurable) with { RuleId = "SQLI-TEST-002" },
], Now);
var overview = Assert.Single(await _repository.GetInstancesAsync(), i => i.Name == "sql04");
Assert.Equal((1, 1, 1), (overview.Critical, overview.Low, overview.NotMeasurable));
Assert.Equal(Severity.Critical, overview.WorstSeverity);
Assert.True(overview.UsedLegacyFallback);
Assert.Equal("16.0.4135.4", overview.ProductVersion);
}
[Fact]
public async Task Instance_errors_are_recorded_and_shown_as_failing()
{
await _repository.UpsertInstanceAsync("sql05", Info, "SqlClient", false, Now);
await _repository.RecordInstanceErrorAsync("sql05", "Login failed", Now.AddMinutes(5));
var overview = Assert.Single(await _repository.GetInstancesAsync(), i => i.Name == "sql05");
Assert.True(overview.IsFailing);
Assert.Equal("Login failed", overview.LastError);
}
[Fact]
public async Task Time_series_are_stored_once_and_aggregated()
{
var id = await _repository.UpsertInstanceAsync("sql06", Info, "SqlClient", false, Now);
var recent = DateTimeOffset.UtcNow;
var cpu = new CpuUtilization
{
CapturedAt = recent,
Samples = Enumerable.Range(0, 10).Select(i => new CpuSample(recent.AddMinutes(-10 + i), 40 + i, 50)).ToList(),
};
var waits = new WaitStatsDelta { From = recent.AddMinutes(-5), To = recent, Waits = [new WaitStat("LCK_M_X", 5, 3000, 10), new WaitStat("WRITELOG", 100, 300, 5)] };
var waitSample = new CumulativeSample<WaitStatsSnapshot, WaitStatsDelta>(
new WaitStatsSnapshot { CapturedAt = recent, SqlServerStartTime = DateTime.UtcNow.AddDays(-1), Waits = [] }, waits);
await _repository.SaveCollectedAsync(id, "cpu", cpu);
await _repository.SaveCollectedAsync(id, "cpu", cpu); // the ring buffer overlaps between collections
await _repository.SaveCollectedAsync(id, "wait-stats", waitSample);
await _repository.SaveCollectedAsync(id, "wait-stats", waitSample);
await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow);
var series = await _repository.GetMetricAsync(id, Metrics.CpuSqlServer, recent.AddHours(-1));
Assert.Equal(10, series.Count);
Assert.Equal(49, series[^1].Value);
Assert.Equal(20, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}"));
Assert.True(await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricHourly WHERE InstanceId = {id}") >= 2);
var top = await _repository.GetTopWaitsAsync(id, recent.AddHours(-1));
Assert.Equal(["LCK_M_X", "WRITELOG"], top.Select(w => w.WaitType));
Assert.Equal(3000, top[0].WaitTimeMs);
}
[Fact]
public async Task Maintenance_deletes_data_past_retention()
{
var id = await _repository.UpsertInstanceAsync("sql07", Info, "SqlClient", false, Now);
await ExecuteAsync($"""
INSERT insight.MetricSamples (InstanceId, Metric, SampledAt, Value) VALUES ({id}, 'cpu.sqlserver', DATEADD(day, -20, SYSUTCDATETIME()), 1), ({id}, 'cpu.sqlserver', DATEADD(day, -1, SYSUTCDATETIME()), 2);
INSERT insight.MetricHourly (InstanceId, Metric, [Hour], AvgValue, MaxValue, Samples) VALUES ({id}, 'cpu.sqlserver', DATEADD(day, -500, SYSUTCDATETIME()), 1, 1, 1);
INSERT insight.Deadlocks (InstanceId, OccurredAt, ProcessesJson, GraphXml) VALUES ({id}, DATEADD(day, -100, SYSUTCDATETIME()), '[]', '<deadlock/>');
""");
await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow);
Assert.Equal(1, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}"));
Assert.Equal(0, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.MetricHourly WHERE InstanceId = {id} AND [Hour] < DATEADD(day, -400, SYSUTCDATETIME())"));
Assert.Equal(0, await ScalarAsync<int>($"SELECT COUNT(*) FROM insight.Deadlocks WHERE InstanceId = {id}"));
}
[Fact]
public async Task Events_and_snapshots_round_trip()
{
var id = await _repository.UpsertInstanceAsync("sql08", Info, "SqlClient", false, Now);
var at = DateTimeOffset.UtcNow;
var deadlocks = new DeadlockSnapshot
{
CapturedAt = at,
NewDeadlocks = [new DeadlockEvent { Time = at, GraphXml = "<deadlock/>", Processes = [new DeadlockProcess { Id = "p1", IsVictim = true, SessionId = 60 }] }],
};
var blocking = new BlockingSnapshot
{
CapturedAt = at,
Sessions =
[
new SessionInfo { SessionId = 51, Status = "sleeping", OpenTransactionCount = 1 },
new SessionInfo { SessionId = 60, BlockingSessionId = 51, WaitTimeMs = 45_000 },
new SessionInfo { SessionId = 70 },
new SessionInfo { SessionId = 71, BlockingSessionId = 70, WaitTimeMs = 1_000 }, // too short to store
],
};
var config = new ConfigurationSnapshot { CapturedAt = at, Options = [new ConfigurationOption("max degree of parallelism", 8, 8)] };
await _repository.SaveCollectedAsync(id, "deadlocks", deadlocks);
await _repository.SaveCollectedAsync(id, "blocking", blocking);
await _repository.SaveCollectedAsync(id, "configuration", config);
var deadlock = Assert.Single(await _repository.GetDeadlocksAsync(id, at.AddHours(-1)));
Assert.True(Assert.Single(deadlock.Processes).IsVictim);
Assert.Equal("<deadlock/>", (await _repository.GetDeadlockAsync(deadlock.DeadlockId))!.GraphXml);
var chain = Assert.Single(await _repository.GetBlockingAsync(id, at.AddHours(-1)));
Assert.Equal((51, 1, 45_000L), (chain.HeadSessionId, chain.BlockedCount, chain.MaxWaitMs));
var stored = await _repository.GetSnapshotAsync<ConfigurationSnapshot>(id, "configuration");
Assert.Equal(8, stored!.Find("max degree of parallelism")!.ValueInUse);
}
private async Task<T> ScalarAsync<T>(string query)
{
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = query;
return (T)Convert.ChangeType((await command.ExecuteScalarAsync())!, typeof(T), System.Globalization.CultureInfo.InvariantCulture);
}
private async Task ExecuteAsync(string query)
{
await using var connection = new SqlConnection(sql.DatabaseConnectionString(_database));
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = query;
await command.ExecuteNonQueryAsync();
}
}