Cascorp's BATCH table: every STATUS 4 row has an end time and no exception code (finished), STATUS 1 rows have no end time and starts long passed (waiting). So AX 3.0 uses the same order as AX 4.0+: 0 Tilbageholdt, 1 Venter, 2 Udfører, 3 Fejl, 4 Afsluttet. The old assumption (0 Venter ... 4 Tilbageholdt) treated finished jobs as held and waiting ones as running. SQLI-AX-003 skips waiting jobs planned more than BatchAbandonedDays (30) ago: the customer has 25 such rows from 2007-2018 that no batch server serves; they are dead rows, not a live alarm. Adds docs/dynamics-ax/verificer-ax30-batchstatus.sql (read-only) to repeat the check at other AX 3.0 customers. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
358 lines
19 KiB
C#
358 lines
19 KiB
C#
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.DynamicsAx;
|
|
using SQLInsight.Core.Findings;
|
|
using SQLInsight.Rules.DynamicsAx;
|
|
using SQLInsight.Rules.Engine;
|
|
using static SQLInsight.Rules.Tests.TestInstances;
|
|
|
|
namespace SQLInsight.Rules.Tests;
|
|
|
|
/// <summary>
|
|
/// The Dynamics AX rules on realistic data: AX 3.0 rows as the real customer schema stores them (bmssa, local date +
|
|
/// seconds since midnight, 32-bit RecId), interpreted by the AX 3.0 adapter, and AX 2012 rows by the 2012 adapter.
|
|
/// </summary>
|
|
public class DynamicsAxRuleTests
|
|
{
|
|
private static readonly DateTime Utc = Now.UtcDateTime;
|
|
private static readonly AxThresholds Thresholds = new();
|
|
|
|
private static RuleResult Run(IRule rule, RuleContext context) => Assert.Single(new RuleEngine([rule]).Evaluate(context));
|
|
|
|
private static AxDatabase Ax30(bool readable = true) =>
|
|
AxDetection.Recognise("AX30", AxSchemaFile.Find(Path.Combine(AppContext.BaseDirectory, "DynamicsAx"), "schema-ax30")!.DetectionRows(readable), ["AX30"])!
|
|
with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
|
|
|
|
/// <summary>A BATCH row as AX 3.0 stores it: STARTDATE (local date) + STARTTIME (seconds since midnight), server at UTC+2.</summary>
|
|
private static AxBatchJobRow Batch30(long recId, int status, DateTime plannedUtc, int classNumber = 4711) =>
|
|
new(recId, classNumber, null, "dat", "NAT", status, plannedUtc.AddHours(2).Date, (int)plannedUtc.AddHours(2).TimeOfDay.TotalSeconds,
|
|
null, null, null, null, -120);
|
|
|
|
private static AxBatchSnapshot Batch(params AxBatchJob[] jobs) => new()
|
|
{
|
|
CapturedAt = Now, Thresholds = Thresholds, Databases = [new AxBatchDatabase { Database = "AX30", Version = AxVersion.Axapta30, Jobs = jobs }],
|
|
};
|
|
|
|
private static AxBatchJob Job30(AxBatchJobRow row, AxBatchHistory? history = null, DateTime? firstSeen = null) =>
|
|
AxSchemaAdapter.Ax30.Interpret(row, history ?? new AxBatchHistory(), "AX30", firstSeen);
|
|
|
|
private static AxBatchHistory History(string jobKey, int runs, TimeSpan duration, string database = "AX30")
|
|
{
|
|
var history = new AxBatchHistory();
|
|
for (var i = 1; i <= runs; i++)
|
|
{
|
|
history.Add(database, new AxBatchRun(jobKey, "R" + i, "Lagerafslutning", "dat", Utc.AddDays(-i), Utc.AddDays(-i) + duration, AxBatchStatus.Finished));
|
|
}
|
|
|
|
return history;
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_ax_databases_the_ax_rules_are_not_relevant()
|
|
{
|
|
var empty = new AxBatchSnapshot { CapturedAt = Now, Thresholds = Thresholds, Databases = [] };
|
|
var sequences = new AxSequenceSnapshot { CapturedAt = Now, Thresholds = Thresholds, Databases = [] };
|
|
var ax = new AxDatabaseSnapshot { CapturedAt = Now, Databases = [] };
|
|
var rules = BuiltInRules.Create().OfType<DynamicsAxRule>().ToList();
|
|
|
|
Assert.All(rules, rule =>
|
|
Assert.Equal(RuleOutcome.NotApplicable, Run(rule, With(empty, sequences, ax, new BlockingSnapshot { CapturedAt = Now, Sessions = [] })).Outcome));
|
|
Assert.Equal(10, rules.Count);
|
|
Assert.All(rules, r => Assert.StartsWith("SQLI-AX-", r.Id, StringComparison.Ordinal));
|
|
Assert.All(rules, r => Assert.True(Core.Configuration.DynamicsAxFindings.IsDynamicsAxRule(r.Id)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_the_read_grant_the_batch_rules_are_not_measurable_with_the_exact_grant()
|
|
{
|
|
var reason = AxSchemaAdapter.Ax30.BatchJobs(Ax30(readable: false)).NotMeasurable!;
|
|
var snapshot = new AxBatchSnapshot
|
|
{
|
|
CapturedAt = Now, Thresholds = Thresholds, Databases = [new AxBatchDatabase { Database = "AX30", Version = AxVersion.Axapta30, NotMeasurable = reason }],
|
|
};
|
|
|
|
var result = Run(new AxBatchFailedRule(), With(snapshot));
|
|
|
|
Assert.Equal(RuleOutcome.NotMeasurable, result.Outcome);
|
|
Assert.Contains(@"GRANT SELECT ON [bmssa].[BATCH] TO [KUNDE\svc-sqlinsight$];", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax001_a_batch_job_in_error_is_high_and_an_old_error_is_not_reported()
|
|
{
|
|
var failed = Job30(Batch30(10, 3, Utc.AddHours(-3)));
|
|
var old = Job30(Batch30(11, 3, Utc.AddDays(-3), classNumber: 99));
|
|
|
|
var finding = Assert.Single(Run(new AxBatchFailedRule(), With(Batch(failed, old))).Findings);
|
|
|
|
Assert.Equal(Severity.High, finding.Severity);
|
|
Assert.Equal("database: AX30 · batch: 4711|dat|NAT", finding.Subject);
|
|
Assert.Contains("Fejl (STATUS = 3)", finding.Evidence.Single(e => e.Name == "Status i AX").Value, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchFailedRule(), With(Batch(Job30(Batch30(12, 1, Utc.AddMinutes(10)))))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax001_a_recurring_job_whose_last_run_failed_is_reported_while_it_waits_for_the_next_run()
|
|
{
|
|
var row = new AxBatchJobRow(5_000_000_001, null, "Integration til webshop", "dk01", null, 1, Utc.AddHours(1), null, null, null, null, null, 0);
|
|
var history = new AxBatchHistory();
|
|
history.Add("AX2012", new AxBatchRun("5000000001", "H1", "Integration til webshop", "dk01", Utc.AddHours(-2), Utc.AddHours(-1), AxBatchStatus.Error));
|
|
var job = AxSchemaAdapter.Ax2012.Interpret(row, history, "AX2012", null);
|
|
var snapshot = new AxBatchSnapshot
|
|
{
|
|
CapturedAt = Now, Thresholds = Thresholds, Databases = [new AxBatchDatabase { Database = "AX2012", Version = AxVersion.Ax2012R2, Jobs = [job] }],
|
|
};
|
|
|
|
var finding = Assert.Single(Run(new AxBatchFailedRule(), With(snapshot)).Findings);
|
|
|
|
Assert.Contains("Integration til webshop", finding.Title, StringComparison.Ordinal);
|
|
Assert.Contains("ikke verificeret mod kundedata", finding.Evidence.Single(e => e.Name == "AX-version").Value, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(25, null)]
|
|
[InlineData(45, Severity.Medium)]
|
|
[InlineData(100, Severity.High)]
|
|
public void Ax002_a_job_running_much_longer_than_its_own_history(int runningMinutes, Severity? expected)
|
|
{
|
|
var row = Batch30(20, 2, Utc.AddHours(-5));
|
|
var job = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20)), firstSeen: Utc.AddMinutes(-runningMinutes));
|
|
|
|
var result = Run(new AxBatchLongRunningRule(), With(Batch(job)));
|
|
|
|
Assert.Equal(expected, result.Findings.SingleOrDefault()?.Severity);
|
|
|
|
// Too little history to compare with.
|
|
var young = Job30(row, History("4711|dat|NAT", 3, TimeSpan.FromMinutes(20)), Utc.AddHours(-3));
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLongRunningRule(), With(Batch(young))).Outcome);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(20, null)]
|
|
[InlineData(45, Severity.Medium)]
|
|
[InlineData(60 * 30, Severity.High)]
|
|
public void Ax003_a_waiting_job_past_its_planned_start_is_late_and_after_a_day_never_started(int minutesLate, Severity? expected)
|
|
{
|
|
var job = Job30(Batch30(30, 1, Utc.AddMinutes(-minutesLate)));
|
|
|
|
var finding = Run(new AxBatchLateRule(), With(Batch(job))).Findings.SingleOrDefault();
|
|
|
|
Assert.Equal(expected, finding?.Severity);
|
|
if (expected == Severity.High)
|
|
{
|
|
Assert.Contains("aldrig startet", finding!.Title, StringComparison.Ordinal);
|
|
Assert.Contains("lokal dato + sekunder efter midnat", finding.Evidence.Single(e => e.Name == "Tid").Value, StringComparison.Ordinal);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax003_a_job_waiting_for_years_is_abandoned_not_late()
|
|
{
|
|
// Seen at a customer: 25 waiting AX 3.0 jobs with an empty batch group, planned 2007-2018.
|
|
var abandoned = Job30(Batch30(32, 1, Utc.AddDays(-Thresholds.BatchAbandonedDays - 1)));
|
|
var recent = Job30(Batch30(33, 1, Utc.AddDays(-Thresholds.BatchAbandonedDays + 1)));
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLateRule(), With(Batch(abandoned))).Outcome);
|
|
Assert.Single(Run(new AxBatchLateRule(), With(Batch(recent))).Findings);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax003_held_jobs_are_not_late()
|
|
{
|
|
var held = Job30(Batch30(31, 0, Utc.AddDays(-2)));
|
|
|
|
Assert.Equal(AxBatchStatus.Hold, held.Status);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLateRule(), With(Batch(held))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax004_a_job_executing_for_hours_is_stuck_unless_it_normally_runs_that_long()
|
|
{
|
|
var row = Batch30(40, 2, Utc.AddHours(-9));
|
|
var stuck = Job30(row, firstSeen: Utc.AddHours(-7));
|
|
var normallyLong = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromHours(6)), Utc.AddHours(-7));
|
|
|
|
var finding = Assert.Single(Run(new AxBatchStuckRule(), With(Batch(stuck))).Findings);
|
|
|
|
Assert.Equal(Severity.High, finding.Severity);
|
|
Assert.Contains("Målt fra første gang SQLInsight så jobbet køre", finding.Evidence.Single(e => e.Name == "Bemærk").Value, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchStuckRule(), With(Batch(normallyLong))).Outcome);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchStuckRule(), With(Batch(Job30(row, firstSeen: Utc.AddHours(-1))))).Outcome);
|
|
}
|
|
|
|
private static AxSequenceSnapshot Sequences(IReadOnlyList<AxNumberSequence>? numbers = null, IReadOnlyList<AxRecIdSequence>? recIds = null,
|
|
AxVersion version = AxVersion.Axapta30) => new()
|
|
{
|
|
CapturedAt = Now, Thresholds = Thresholds,
|
|
Databases = [new AxSequenceDatabase { Database = "AX30", Version = version, NumberSequences = numbers ?? [], RecIds = recIds ?? [] }],
|
|
};
|
|
|
|
/// <summary>A NUMBERSEQUENCETABLE row as AX 3.0 stores it (int LOWEST/HIGHEST/NEXTREC per DATAAREAID, local cleanup date + seconds).</summary>
|
|
private static AxNumberSequence Number30(string code, long next, long highest = 999_999, string format = "######", bool blocked = false, bool continuous = false,
|
|
bool? referenced = true, DateTime? lastCleanLocal = null, params AxSample[] samples) =>
|
|
AxSchemaAdapter.Ax30.Interpret(new AxNumberSequenceRow(1, code, "dat", "Tekst", 1, highest, next, format, blocked, continuous, false, false, referenced,
|
|
lastCleanLocal?.Date ?? new DateTime(1900, 1, 1), (int?)lastCleanLocal?.TimeOfDay.TotalSeconds ?? 0, 24, -120), samples, Utc, 30);
|
|
|
|
[Theory]
|
|
[InlineData(500_000, null)]
|
|
[InlineData(850_000, Severity.Medium)]
|
|
[InlineData(960_000, Severity.High)]
|
|
[InlineData(1_000_000, Severity.Critical)]
|
|
public void Ax005_number_sequence_usage(long next, Severity? expected) =>
|
|
Assert.Equal(expected, Run(new AxNumberSequenceExhaustionRule(), With(Sequences([Number30("Debitor", next)]))).Findings.SingleOrDefault()?.Severity);
|
|
|
|
[Fact]
|
|
public void Ax005_forecast_from_sqlinsights_own_samples()
|
|
{
|
|
// 50 % used, but 10.000 numbers a day: 50 days left.
|
|
var sequence = Number30("Ordre", 500_001, samples: [new AxSample(Utc.AddDays(-10), 400_001)]);
|
|
|
|
var finding = Assert.Single(Run(new AxNumberSequenceExhaustionRule(), With(Sequences([sequence]))).Findings);
|
|
|
|
Assert.Equal(Severity.Medium, finding.Severity);
|
|
Assert.Contains("løber tør om ca. 50 dage", finding.Title, StringComparison.Ordinal);
|
|
Assert.Equal("database: AX30 · nummerserie: dat/Ordre", finding.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax006_format_shorter_than_highest()
|
|
{
|
|
var tooShort = Number30("Faktura", 12_000, highest: 99_999_999, format: "FA-####");
|
|
var fits = Number30("Faktura", 12_000, highest: 99_999, format: "FA-#####");
|
|
|
|
Assert.Equal(Severity.High, Assert.Single(Run(new AxNumberSequenceFormatRule(), With(Sequences([tooShort]))).Findings).Severity);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxNumberSequenceFormatRule(), With(Sequences([fits]))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax007_blocked_but_still_referenced()
|
|
{
|
|
Assert.Single(Run(new AxNumberSequenceBlockedRule(), With(Sequences([Number30("Gammel", 10, blocked: true)]))).Findings);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxNumberSequenceBlockedRule(), With(Sequences([Number30("Gammel", 10, blocked: true, referenced: false)]))).Outcome);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxNumberSequenceBlockedRule(), With(Sequences([Number30("Ukendt", 10, blocked: true, referenced: null)]))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax008_continuous_sequence_never_or_long_ago_cleaned_up()
|
|
{
|
|
var never = Number30("Kont", 100, continuous: true);
|
|
var recent = Number30("Kont", 100, continuous: true, lastCleanLocal: Utc.AddDays(-1).AddHours(2));
|
|
var old = Number30("Kont", 100, continuous: true, lastCleanLocal: Utc.AddDays(-30).AddHours(2));
|
|
|
|
Assert.Contains("aldrig ryddet op", Assert.Single(Run(new AxNumberSequenceContinuousRule(), With(Sequences([never]))).Findings).Title, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxNumberSequenceContinuousRule(), With(Sequences([recent]))).Outcome);
|
|
Assert.Equal(Severity.Low, Assert.Single(Run(new AxNumberSequenceContinuousRule(), With(Sequences([old]))).Findings).Severity);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1_000_000_000, null)]
|
|
[InlineData(1_200_000_000, Severity.High)]
|
|
[InlineData(1_700_000_000, Severity.Critical)]
|
|
public void Ax009_ax30_recid_is_flagged_early_and_critical_from_75_percent(long next, Severity? expected)
|
|
{
|
|
var recId = AxSchemaAdapter.Interpret(new AxRecIdRow(-1, "dat", next, 0, null), nextValIsInt: true, [], Utc, 30);
|
|
|
|
Assert.Equal(expected, Run(new AxRecIdExhaustionRule(), With(Sequences(recIds: [recId]))).Findings.SingleOrDefault()?.Severity);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax009_64_bit_recid_only_matters_with_a_lowered_maxval_or_a_fast_forecast()
|
|
{
|
|
var huge = AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 5_000_000_000, null, "CUSTTABLE"), nextValIsInt: false, [], Utc, 30);
|
|
var lowered = AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 900, 1_000, "CUSTTABLE"), nextValIsInt: false, [], Utc, 30);
|
|
|
|
Assert.Null(AxRecIdExhaustionRule.Assess(huge, Thresholds));
|
|
Assert.Equal(Severity.High, AxRecIdExhaustionRule.Assess(lowered, Thresholds));
|
|
var finding = Assert.Single(Run(new AxRecIdExhaustionRule(), With(Sequences(recIds: [lowered], version: AxVersion.Ax2012R2))).Findings);
|
|
Assert.Contains("CUSTTABLE", finding.Title, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static AxDatabaseSnapshot AxDatabases(AxVersion version) => new()
|
|
{
|
|
CapturedAt = Now, Databases = [new AxDatabase { Database = "AX", Schema = version == AxVersion.Axapta30 ? "bmssa" : "dbo", Version = version }],
|
|
};
|
|
|
|
private static BlockingSnapshot Blocking(string? contextInfo, long waitMs = 120_000) => new()
|
|
{
|
|
CapturedAt = Now,
|
|
Sessions =
|
|
[
|
|
new SessionInfo
|
|
{
|
|
SessionId = 61, Status = "sleeping", DatabaseName = "AX", LoginName = "AXSERVICE", HostName = "AOS01", ProgramName = "Microsoft Dynamics AX",
|
|
OpenTransactionCount = 1, ContextInfo = contextInfo, SqlText = "UPDATE INVENTSUM SET …",
|
|
},
|
|
new SessionInfo { SessionId = 62, BlockingSessionId = 61, DatabaseName = "AX", WaitTimeMs = waitMs, WaitType = "LCK_M_U" },
|
|
],
|
|
};
|
|
|
|
[Fact]
|
|
public void Ax010_ax2012_names_the_ax_user_from_context_info_as_sensitive_evidence()
|
|
{
|
|
var context = Convert.ToHexString(System.Text.Encoding.ASCII.GetBytes("anne 17"));
|
|
|
|
var finding = Assert.Single(Run(new AxBlockingUserRule(), With(AxDatabases(AxVersion.Ax2012R2), Blocking(context))).Findings);
|
|
|
|
var user = finding.Evidence.Single(e => e.Name == "AX-bruger");
|
|
Assert.Equal(("anne", true), (user.Value, user.Sensitive));
|
|
Assert.DoesNotContain("anne", finding.Title + finding.Subject, StringComparison.Ordinal);
|
|
Assert.Equal("database: AX · blokering: session 61", finding.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax010_ax30_says_honestly_that_the_ax_user_cannot_be_seen()
|
|
{
|
|
var finding = Assert.Single(Run(new AxBlockingUserRule(), With(AxDatabases(AxVersion.Axapta30), Blocking(null))).Findings);
|
|
|
|
Assert.Equal("ukendt", finding.Evidence.Single(e => e.Name == "AX-bruger").Value);
|
|
Assert.Contains("sætter ikke CONTEXT_INFO", finding.Explanation, StringComparison.Ordinal);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBlockingUserRule(), With(AxDatabases(AxVersion.Axapta30), Blocking(null, waitMs: 5_000))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax010_ax30_keeps_host_and_program_out_of_the_explanation_which_may_reach_the_hub()
|
|
{
|
|
var finding = Assert.Single(Run(new AxBlockingUserRule(), With(AxDatabases(AxVersion.Axapta30), Blocking(null))).Findings);
|
|
|
|
Assert.DoesNotContain("AOS01", finding.Explanation + finding.Title + finding.Subject, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("Microsoft Dynamics AX", finding.Explanation, StringComparison.Ordinal);
|
|
Assert.True(finding.Evidence.Single(e => e.Name == "Vært / program").Sensitive);
|
|
Assert.True(finding.Evidence.Single(e => e.Name == "CONTEXT_INFO (rå)").Sensitive);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax001_ax30_rows_of_one_recurring_job_give_one_finding_with_the_highest_severity()
|
|
{
|
|
// AX 3.0 keeps a BATCH row per recurrence: the failed run and the next waiting one share the job key.
|
|
var history = new AxBatchHistory();
|
|
history.Add("AX30", new AxBatchRun("4711|dat|NAT", "R1", "Lagerafslutning", "dat", Utc.AddHours(-3), Utc.AddHours(-2), AxBatchStatus.Error));
|
|
var failed = Job30(Batch30(50, 3, Utc.AddHours(-3)), history) with { EndedUtc = Utc.AddHours(-2) };
|
|
var waiting = Job30(Batch30(51, 1, Utc.AddHours(1)), history);
|
|
|
|
var finding = Assert.Single(Run(new AxBatchFailedRule(), With(Batch(failed, waiting))).Findings);
|
|
|
|
Assert.Equal(Severity.High, finding.Severity);
|
|
Assert.Equal("database: AX30 · batch: 4711|dat|NAT", finding.Subject);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ax004_one_long_earlier_run_does_not_hide_a_stuck_job_and_ax002_leaves_a_stuck_job_to_ax004()
|
|
{
|
|
var row = Batch30(60, 2, Utc.AddHours(-9));
|
|
var oneLongRun = Job30(row, History("4711|dat|NAT", 1, TimeSpan.FromHours(10)), Utc.AddHours(-7));
|
|
Assert.Single(Run(new AxBatchStuckRule(), With(Batch(oneLongRun))).Findings);
|
|
|
|
var stuckWithHistory = Job30(row, History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20)), Utc.AddHours(-7));
|
|
Assert.Single(Run(new AxBatchStuckRule(), With(Batch(stuckWithHistory))).Findings);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLongRunningRule(), With(Batch(stuckWithHistory))).Outcome);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_executing_job_without_any_start_time_is_skipped_not_an_error()
|
|
{
|
|
var unknown = Job30(Batch30(70, 2, Utc.AddHours(-9)), History("4711|dat|NAT", 10, TimeSpan.FromMinutes(20)));
|
|
Assert.Null(unknown.Running(Now));
|
|
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchStuckRule(), With(Batch(unknown))).Outcome);
|
|
Assert.Equal(RuleOutcome.Passed, Run(new AxBatchLongRunningRule(), With(Batch(unknown))).Outcome);
|
|
}
|
|
}
|