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("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($"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( 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($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}")); Assert.True(await ScalarAsync($"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()), '[]', ''); """); await _repository.RunMaintenanceAsync(new RetentionOptions(), DateTimeOffset.UtcNow); Assert.Equal(1, await ScalarAsync($"SELECT COUNT(*) FROM insight.MetricSamples WHERE InstanceId = {id}")); Assert.Equal(0, await ScalarAsync($"SELECT COUNT(*) FROM insight.MetricHourly WHERE InstanceId = {id} AND [Hour] < DATEADD(day, -400, SYSUTCDATETIME())")); Assert.Equal(0, await ScalarAsync($"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 = "", 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("", (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(id, "configuration"); Assert.Equal(8, stored!.Find("max degree of parallelism")!.ValueInUse); } private async Task ScalarAsync(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(); } }