sqlinsight-pro/tests/SQLInsight.Rules.Tests/AvailabilityRuleTests.cs
Claude 15907401a9
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
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 2: Always On availability groups and SQL Agent jobs
- Availability group collector (replica and database replica states,
  queues, commit times) and rules: SQLI-AG-001 unhealthy/disconnected
  replica or suspended data movement (scripts HADR RESUME), SQLI-AG-002
  secondary behind (estimated data loss and redo time)
- SQL Agent collector using only granted msdb tables (no EXECUTE on msdb
  functions): SQLI-JOB-001 failed job with the failing step's message
  (backup/CHECKDB jobs are High), SQLI-JOB-002 SQL Agent not running
- Integration tests create a real availability group (CLUSTER_TYPE NONE)
  and a failing job in the test container (HADR and Agent enabled)

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

117 lines
5.1 KiB
C#

using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Rules.Availability;
using SQLInsight.Rules.Engine;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class AvailabilityRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static RuleContext Hadr(AvailabilityGroupSnapshot snapshot)
{
var info = new InstanceInfo { ServerName = "n1", Version = SqlServerVersion.Parse("16.0.4295.3"), Edition = "Enterprise", EngineEdition = EngineEdition.Enterprise, IsHadrEnabled = true };
var context = new RuleContext(info, InstanceCapabilities.From(info), Now);
context.Data.Set(snapshot);
return context;
}
private static DatabaseReplicaState Db(string replica, bool primary, DateTime? commit = null, long redoKb = 0, long redoRate = 1000, bool suspended = false, string health = "HEALTHY") => new()
{
Group = "ag1", Database = "Sales", Replica = replica, IsPrimaryReplica = primary, LastCommitTime = commit,
RedoQueueKb = redoKb, RedoRateKbPerSecond = redoRate, IsSuspended = suspended, SuspendReason = suspended ? "SUSPEND_FROM_USER" : null,
SynchronizationHealth = health, SynchronizationState = "SYNCHRONIZED", LogSendQueueKb = 0,
};
[Fact]
public void Not_measurable_without_hadr()
{
Assert.Equal(RuleOutcome.NotMeasurable, Run(new AvailabilityGroupHealthRule(), Context("16.0.4295.3")).Outcome);
}
[Fact]
public void Healthy_group_passes()
{
var snapshot = new AvailabilityGroupSnapshot
{
CapturedAt = Now,
Replicas = [new ReplicaState { Group = "ag1", Replica = "n1", Role = "PRIMARY", ConnectedState = "CONNECTED", SynchronizationHealth = "HEALTHY" }],
Databases = [Db("n1", true, Now.UtcDateTime), Db("n2", false, Now.UtcDateTime)],
};
Assert.Equal(RuleOutcome.Passed, Run(new AvailabilityGroupHealthRule(), Hadr(snapshot)).Outcome);
Assert.Equal(RuleOutcome.Passed, Run(new AvailabilityGroupLagRule(), Hadr(snapshot)).Outcome);
}
[Fact]
public void Disconnected_replica_and_suspended_database_are_high()
{
var snapshot = new AvailabilityGroupSnapshot
{
CapturedAt = Now,
Replicas = [new ReplicaState { Group = "ag1", Replica = "n2", Role = "SECONDARY", ConnectedState = "DISCONNECTED", SynchronizationHealth = "NOT_HEALTHY" }],
Databases = [Db("n2", false, suspended: true, health: "NOT_HEALTHY")],
};
var findings = Run(new AvailabilityGroupHealthRule(), Hadr(snapshot)).Findings;
Assert.Equal(2, findings.Count);
Assert.All(findings, f => Assert.Equal(Severity.High, f.Severity));
Assert.Contains(findings, f => f.FixScript == "ALTER DATABASE [Sales] SET HADR RESUME;");
}
[Fact]
public void Lag_reports_data_loss_and_redo_time()
{
var snapshot = new AvailabilityGroupSnapshot
{
CapturedAt = Now,
Replicas = [],
Databases = [Db("n1", true, Now.UtcDateTime), Db("n2", false, Now.UtcDateTime.AddMinutes(-5), redoKb: 2_000_000, redoRate: 1000)],
};
var finding = Assert.Single(Run(new AvailabilityGroupLagRule(), Hadr(snapshot)).Findings);
Assert.Contains("300 s datatab", finding.Title, StringComparison.Ordinal);
Assert.Contains("redo ca. 33 min", finding.Title, StringComparison.Ordinal);
}
private static AgentJobSnapshot Jobs(bool running, params AgentJob[] jobs) => new() { CapturedAt = Now, IsAgentRunning = running, Jobs = jobs };
[Fact]
public void Failed_backup_job_is_high_with_step_message()
{
var job = new AgentJob { Name = "DatabaseBackup - USER_DATABASES - FULL", IsEnabled = true, LastRunStatus = 0, LastFailedStepName = "Backup", LastFailedStepMessage = "Cannot open backup device 'X:\\bak'" };
var finding = Assert.Single(Run(new FailedJobRule(), With(Jobs(true, job))).Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.Contains(finding.Evidence, e => e.Value.Contains("Cannot open backup device", StringComparison.Ordinal));
}
[Fact]
public void Disabled_or_succeeding_jobs_pass()
{
var jobs = Jobs(true,
new AgentJob { Name = "Old", IsEnabled = false, LastRunStatus = 0 },
new AgentJob { Name = "Fine", IsEnabled = true, LastRunStatus = 1 },
new AgentJob { Name = "Never run", IsEnabled = true });
Assert.Equal(RuleOutcome.Passed, Run(new FailedJobRule(), With(jobs)).Outcome);
}
[Fact]
public void Stopped_agent_is_high_when_jobs_exist_but_not_on_express()
{
var jobs = Jobs(false, new AgentJob { Name = "Backup", IsEnabled = true });
Assert.Equal(Severity.High, Assert.Single(Run(new AgentStoppedRule(), With(jobs)).Findings).Severity);
var express = Context("16.0.4295.3", edition: EngineEdition.Express);
express.Data.Set(jobs);
Assert.Equal(RuleOutcome.Passed, Run(new AgentStoppedRule(), express).Outcome);
}
}