sqlinsight-pro/tests/SQLInsight.Rules.Tests/DatabaseRuleTests.cs
Søren Eggert Lundsteen Olsen 244153e9e4 Rules the version does not support are "not applicable", not "not measurable"
On SQL Server 2005 twenty rules stood as "not measurable" (Query Store,
system_health, memory columns …) between the real gaps, and counted in the
overview's "regler ikke målbare". They are not gaps: the version simply
lacks what they look at.

- New RuleOutcome.NotApplicable (last in the enum: outcomes are stored as
  numbers, so existing rows keep their meaning).
- A rule or collector missing only capabilities inherent to the instance
  (version-gated, plan cache memory columns, or Azure-managed) is not
  applicable, with the version each needs: "Ikke relevant for SQL Server
  2005: kræver QueryStore (fra SQL Server 2016)". Missing settings or rights
  (HADR not enabled …), collector failures and permission errors stay
  "not measurable". CollectedData.MarkNotApplicable carries this from
  collectors to the rules that use their data; community rules below their
  minimumVersion are not applicable too.
- Counts are unchanged in shape: the overview, KPI, JSON API and Prometheus
  count outcomes 2 and 3 only. The instance page lists the rest in a
  collapsed "Ikke relevant for SQL Server 2005 (n)" (details/summary, no
  JS) after the real gaps. Report controls whose core rule does not apply
  count neither as met nor as not measured.

Tests: engine (version vs. setting vs. collected data), inherent
capabilities, the existing version tests now expect not applicable, report
control status, and the web page and overview count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:05:39 +02:00

272 lines
10 KiB
C#

using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Rules.Databases;
using SQLInsight.Rules.Engine;
using static SQLInsight.Rules.Tests.TestInstances;
namespace SQLInsight.Rules.Tests;
public class DatabaseRuleTests
{
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
private static RuleResult Run(IRule rule, params DatabaseInfo[] databases) => Run(rule, With(DatabaseList(databases)));
[Fact]
public void Healthy_database_passes_every_database_rule()
{
var rules = new IRule[]
{
new DatabaseStateRule(), new CompatibilityLevelRule(), new FullBackupRule(), new LogBackupRule(), new CheckDbRule(),
new VirtualLogFileRule(), new FileGrowthRule(), new FileMaxSizeRule(), new QueryStoreStateRule(),
}.Concat(SimpleDatabaseRules.Create());
foreach (var rule in rules)
{
Assert.Equal(RuleOutcome.Passed, Run(rule, Database()).Outcome);
}
}
[Theory]
[InlineData("SUSPECT", Severity.Critical)]
[InlineData("RECOVERY_PENDING", Severity.Critical)]
[InlineData("OFFLINE", Severity.Low)]
public void Unusable_database_state(string state, Severity severity)
{
var finding = Assert.Single(Run(new DatabaseStateRule(), Database(change: d => d with { State = state })).Findings);
Assert.Equal(severity, finding.Severity);
Assert.Equal("database: Sales", finding.Subject);
}
[Fact]
public void Settings_rules_skip_offline_databases()
{
var offline = Database(change: d => d with { State = "OFFLINE", IsAutoShrink = true });
Assert.Equal(RuleOutcome.Passed, Run(SimpleDatabaseRules.Create().Single(r => r.Id == "SQLI-DB-002"), offline).Outcome);
}
[Theory]
[InlineData("SQLI-DB-002")]
[InlineData("SQLI-DB-003")]
[InlineData("SQLI-DB-004")]
[InlineData("SQLI-DB-005")]
public void Simple_database_rules_find_and_script_the_fix(string id)
{
var bad = Database("My]Db", d => d with { IsAutoShrink = true, IsAutoClose = true, PageVerify = "TORN_PAGE_DETECTION", IsTrustworthy = true });
var finding = Assert.Single(Run(SimpleDatabaseRules.Create().Single(r => r.Id == id), bad).Findings);
Assert.StartsWith("ALTER DATABASE [My]]Db]", finding.FixScript, StringComparison.Ordinal);
}
[Fact]
public void Trustworthy_msdb_is_by_design()
{
var msdb = Database("msdb", d => d with { DatabaseId = 4, IsTrustworthy = true });
Assert.Equal(RuleOutcome.Passed, Run(SimpleDatabaseRules.Create().Single(r => r.Id == "SQLI-DB-005"), msdb).Outcome);
}
[Fact]
public void Old_compatibility_level_is_reported_for_user_databases_only()
{
var user = Database(change: d => d with { CompatibilityLevel = 130 });
var system = Database("master", d => d with { DatabaseId = 1, CompatibilityLevel = 130 });
Assert.Single(Run(new CompatibilityLevelRule(), user, system).Findings);
}
[Fact]
public void Never_backed_up_is_critical()
{
var finding = Assert.Single(Run(new FullBackupRule(), Database(change: d => d with { LastFullBackup = null })).Findings);
Assert.Equal(Severity.Critical, finding.Severity);
}
[Fact]
public void Old_backup_is_high_but_medium_in_an_availability_group()
{
var old = Database(change: d => d with { LastFullBackup = Now.UtcDateTime.AddDays(-9) });
Assert.Equal(Severity.High, Assert.Single(Run(new FullBackupRule(), old).Findings).Severity);
Assert.Equal(Severity.Medium, Assert.Single(Run(new FullBackupRule(), old with { IsInAvailabilityGroup = true }).Findings).Severity);
}
[Fact]
public void Backup_rule_skips_new_databases_model_and_secondaries()
{
var created = Database(change: d => d with { LastFullBackup = null, CreateDate = Now.UtcDateTime.AddHours(-2) });
var model = Database("model", d => d with { DatabaseId = 3, LastFullBackup = null });
var secondary = Database(change: d => d with { LastFullBackup = null, IsReplicaOrMirror = true });
Assert.Equal(RuleOutcome.Passed, Run(new FullBackupRule(), created, model, secondary).Outcome);
}
[Theory]
[InlineData("FULL", 30, RuleOutcome.Findings)]
[InlineData("FULL", 12, RuleOutcome.Passed)]
[InlineData("BULK_LOGGED", 30, RuleOutcome.Findings)]
[InlineData("SIMPLE", 30, RuleOutcome.Passed)]
public void Log_backups_in_full_recovery(string model, int hoursSinceLogBackup, RuleOutcome expected)
{
var db = Database(change: d => d with { RecoveryModel = model, LastLogBackup = Now.UtcDateTime.AddHours(-hoursSinceLogBackup) });
Assert.Equal(expected, Run(new LogBackupRule(), db).Outcome);
}
[Fact]
public void Full_recovery_without_any_full_backup_is_not_a_log_backup_problem()
{
var db = Database(change: d => d with { LastFullBackup = null, LastLogBackup = null });
Assert.Equal(RuleOutcome.Passed, Run(new LogBackupRule(), db).Outcome);
}
[Fact]
public void CheckDb_never_run_is_high()
{
var db = Database(change: d => d with { LastGoodCheckDb = new DateTime(1900, 1, 1) });
var finding = Assert.Single(Run(new CheckDbRule(), db).Findings);
Assert.Equal(Severity.High, finding.Severity);
Assert.Contains("aldrig", finding.Title, StringComparison.Ordinal);
}
[Theory]
[InlineData(10, Severity.Medium)]
[InlineData(40, Severity.High)]
public void CheckDb_age(int days, Severity severity)
{
var db = Database(change: d => d with { LastGoodCheckDb = Now.UtcDateTime.AddDays(-days) });
Assert.Equal(severity, Assert.Single(Run(new CheckDbRule(), db).Findings).Severity);
}
[Fact]
public void CheckDb_is_not_applicable_before_2016_sp2()
{
var result = Run(new CheckDbRule(), WithVersion("13.0.4001.0", DatabaseList(Database())));
Assert.Equal(RuleOutcome.NotApplicable, result.Outcome);
}
[Fact]
public void Suspect_pages_ignore_repaired_pages()
{
var pages = new SuspectPageSnapshot(Now,
[
new SuspectPage("Sales", 1, 1234, 2, 1, Now.UtcDateTime),
new SuspectPage("Sales", 1, 5678, 3, 2, Now.UtcDateTime),
new SuspectPage("Hr", 1, 42, 5, 1, Now.UtcDateTime), // repaired
]);
var finding = Assert.Single(Run(new SuspectPagesRule(), With(pages)).Findings);
Assert.Equal(Severity.Critical, finding.Severity);
Assert.Equal("database: Sales", finding.Subject);
Assert.Equal(2, finding.Evidence.Count);
}
[Theory]
[InlineData(999, RuleOutcome.Passed)]
[InlineData(1500, RuleOutcome.Findings)]
public void Vlf_threshold(int vlfs, RuleOutcome expected)
{
Assert.Equal(expected, Run(new VirtualLogFileRule(), Database(change: d => d with { VirtualLogFileCount = vlfs })).Outcome);
}
[Fact]
public void File_growth_flags_percent_and_tiny_fixed_growth_on_large_files()
{
static DatabaseFileInfo File(string name, long size, long growth, bool percent) => new()
{
LogicalName = name, Type = "ROWS", PhysicalName = name + ".mdf", SizeMb = size, Growth = growth, IsPercentGrowth = percent, MaxSizeMb = -1,
};
var db = Database(change: d => d with
{
Files =
[
File("percent", 50_000, 10, true),
File("tiny", 20_000, 1, false),
File("fine", 20_000, 512, false),
File("small-db", 100, 10, true), // too small to matter
File("fixed-size", 20_000, 0, false), // autogrowth off: deliberate
],
});
var findings = Run(new FileGrowthRule(), db).Findings;
Assert.Equal(["file: Sales/percent", "file: Sales/tiny"], findings.Select(f => f.Subject));
}
[Theory]
[InlineData(950, 1000, Severity.High)]
[InlineData(1000, 1000, Severity.Critical)]
public void File_near_max_size(long size, long max, Severity severity)
{
var db = Database(change: d => d with
{
Files = [new DatabaseFileInfo { LogicalName = "log", Type = "LOG", PhysicalName = "x", SizeMb = size, Growth = 64, IsPercentGrowth = false, MaxSizeMb = max }],
});
Assert.Equal(severity, Assert.Single(Run(new FileMaxSizeRule(), db).Findings).Severity);
}
[Fact]
public void Default_2tb_log_max_size_is_unlimited()
{
var db = Database(change: d => d with
{
Files = [new DatabaseFileInfo { LogicalName = "log", Type = "LOG", PhysicalName = "x", SizeMb = 2_000_000, Growth = 64, IsPercentGrowth = false, MaxSizeMb = 2_097_152 }],
});
Assert.Equal(RuleOutcome.Passed, Run(new FileMaxSizeRule(), db).Outcome);
}
[Fact]
public void Query_store_that_switched_to_read_only_because_full()
{
var db = Database(change: d => d with { QueryStore = new QueryStoreState("READ_WRITE", "READ_ONLY", 65536, 1000, 1000) });
var finding = Assert.Single(Run(new QueryStoreStateRule(), db).Findings);
Assert.Contains("fuld", finding.Title, StringComparison.Ordinal);
Assert.Contains("MAX_STORAGE_SIZE_MB", finding.Recommendation, StringComparison.Ordinal);
}
[Fact]
public void Query_store_deliberately_read_only_passes()
{
var db = Database(change: d => d with { QueryStore = new QueryStoreState("READ_ONLY", "READ_ONLY", 0, 10, 1000) });
Assert.Equal(RuleOutcome.Passed, Run(new QueryStoreStateRule(), db).Outcome);
}
[Fact]
public void Tempdb_with_one_file_on_many_cpus()
{
var snapshot = DatabaseList() with
{
TempdbFiles = [new DatabaseFileInfo { LogicalName = "tempdev", Type = "ROWS", PhysicalName = "t", SizeMb = 8, Growth = 64, IsPercentGrowth = false, MaxSizeMb = -1 }],
};
var finding = Assert.Single(Run(new TempdbFilesRule(), With(snapshot, Server(cpus: 16))).Findings);
Assert.Contains("Brug 8", finding.Recommendation, StringComparison.Ordinal);
}
[Fact]
public void Tempdb_files_of_different_size()
{
static DatabaseFileInfo File(string name, long size) => new() { LogicalName = name, Type = "ROWS", PhysicalName = name, SizeMb = size, Growth = 64, IsPercentGrowth = false, MaxSizeMb = -1 };
var snapshot = DatabaseList() with { TempdbFiles = [File("a", 1024), File("b", 1024), File("c", 1024), File("d", 4096)] };
var finding = Assert.Single(Run(new TempdbFilesRule(), With(snapshot, Server(cpus: 4))).Findings);
Assert.Equal(Severity.Low, finding.Severity);
}
}