using System.Text.RegularExpressions;
using SQLInsight.Core.Configuration;
using SQLInsight.Core.Data;
using SQLInsight.Core.DynamicsAx;
namespace SQLInsight.Core.Tests;
/// Builds AX databases as the detector would see them, from the schema files in docs/dynamics-ax.
internal static class AxFixtures
{
public static string Directory => Path.Combine(AppContext.BaseDirectory, "DynamicsAx");
public static AxSchemaFile Schema(string fileName) => AxSchemaFile.Find(Directory, fileName)
?? throw new InvalidOperationException($"{fileName}.tsv mangler i docs/dynamics-ax.");
/// The real AX 3.0 schema from a customer (bmssa, 32-bit RECID).
public static AxSchemaFile Ax30 => Schema("schema-ax30");
public static AxDatabase Recognise(AxSchemaFile schema, string name = "AXDB", bool readable = true, string? user = null, IEnumerable? databases = null,
Func? keep = null) =>
AxDetection.Recognise(name, schema.DetectionRows(readable, user).Where(keep ?? (_ => true)).ToList(), databases ?? [name])
?? throw new InvalidOperationException("Ikke genkendt som AX.");
}
public class AxDetectionTests
{
[Fact]
public void The_real_ax30_schema_is_axapta_30_in_bmssa_and_verified()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30);
Assert.Equal((AxVersion.Axapta30, "bmssa", true), (ax.Version, ax.Schema, ax.Verified));
Assert.Equal("int", ax.Table("BATCH")!.Column("STARTTIME")!.Type);
Assert.Equal(10, ax.Table("SQLDICTIONARY")!.Column("RECID")!.Precision);
Assert.False(AxFixtures.Ax30.Documented);
}
[Fact]
public void Ax2009_and_2012_are_told_apart_by_batchjob_scope_partition_and_the_model_database()
{
var ax2009 = AxFixtures.Schema("schema-ax2009");
var ax2012 = AxFixtures.Schema("schema-ax2012");
Assert.Equal(AxVersion.Ax2009, AxFixtures.Recognise(ax2009).Version);
Assert.Equal(AxVersion.Ax2012R2, AxFixtures.Recognise(ax2012, "AX2012", databases: ["AX2012", "AX2012_model"]).Version);
Assert.Equal("AX2012_model", AxFixtures.Recognise(ax2012, "AX2012", databases: ["AX2012", "ax2012_MODEL"]).ModelDatabase, StringComparer.OrdinalIgnoreCase);
Assert.Equal(AxVersion.FinanceOperations, AxFixtures.Recognise(ax2012, "AxDB").Version);
Assert.Equal(AxVersion.Ax2012, AxFixtures.Recognise(ax2012, keep: r => r.Column != "PARTITION").Version);
Assert.Equal(AxVersion.Ax40, AxFixtures.Recognise(ax2009, keep: r => r.Table is not ("BATCHJOB" or "BATCHJOBHISTORY")).Version);
Assert.All(new[] { AxVersion.Ax40, AxVersion.Ax2009, AxVersion.Ax2012, AxVersion.Ax2012R2, AxVersion.FinanceOperations }, v => Assert.False(AxVersions.IsVerified(v)));
Assert.Contains(AxFixtures.Recognise(ax2009).Notes, n => n.Contains("ikke verificeret mod kundedata", StringComparison.Ordinal));
}
[Fact]
public void A_database_without_sqldictionary_and_dataarea_in_one_schema_is_not_ax()
{
var rows = AxFixtures.Ax30.DetectionRows().Where(r => r.Table != "DATAAREA").ToList();
Assert.Null(AxDetection.Recognise("NotAx", rows, ["NotAx"]));
Assert.Null(AxDetection.Recognise("Empty", [], ["Empty"]));
Assert.Equal(AxVersion.Unknown, AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "SQLDICTIONARY" && r.Column == "RECID")).Version);
}
[Theory]
[InlineData("3.0", AxVersion.Axapta30)]
[InlineData("2012 R2/R3", AxVersion.Ax2012R2)]
[InlineData("F&O", AxVersion.FinanceOperations)]
[InlineData("4.0+", AxVersion.Unknown)]
public void Short_version_texts_round_trip(string text, AxVersion version)
{
Assert.Equal(version, AxVersions.Parse(text));
if (version != AxVersion.Unknown)
{
Assert.Equal(text, AxVersions.ShortText(version));
Assert.True(text.Length <= 16); // insight.ConfiguredDatabases.DynamicsAxVersion varchar(16)
}
}
}
public class AxSchemaAdapterTests
{
public static TheoryData Adapters() => [.. AxSchemaAdapter.All.Select(a => a.GetType().Name)];
private static AxSchemaAdapter Adapter(string name) => AxSchemaAdapter.All.Single(a => a.GetType().Name == name);
[Theory]
[MemberData(nameof(Adapters))]
public void Every_column_an_adapter_needs_is_in_its_schema_file(string name)
{
var adapter = Adapter(name);
var schema = AxSchemaFile.Find(AxFixtures.Directory, adapter.SchemaFileName);
if (schema is null)
{
// AX 4.0 and F&O: no schema file yet (neither from a customer nor written from the documentation).
Assert.Contains(adapter.Versions, v => v is AxVersion.Ax40 or AxVersion.FinanceOperations);
return;
}
Assert.Equal(adapter is not Ax30Adapter, schema.Documented);
foreach (var (table, columns) in adapter.RequiredColumns)
{
Assert.All(columns, c => Assert.True(schema.Has(table, c), $"{schema.Path}: {table}.{c} mangler"));
}
Assert.All(adapter.ReadTables, t => Assert.Contains(t, schema.Tables, StringComparer.OrdinalIgnoreCase));
}
[Fact]
public void Ax30_queries_use_bmssa_top_and_no_nolock()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30);
var adapter = AxSchemaAdapter.For(ax.Version)!;
var queries = new[] { adapter.BatchJobs(ax), adapter.NumberSequences(ax), adapter.RecIds(ax), AxSchemaAdapter.Companies(ax) };
Assert.All(queries, q => Assert.True(q.IsMeasurable, q.NotMeasurable));
Assert.All(queries, q => Assert.Contains("TOP (", q.Sql!, StringComparison.Ordinal));
Assert.All(queries, q => Assert.DoesNotContain("NOLOCK", q.Sql!, StringComparison.OrdinalIgnoreCase));
Assert.Contains("[bmssa].[BATCH]", queries[0].Sql!, StringComparison.Ordinal);
Assert.Contains("[bmssa].[NUMBERSEQUENCEREFERENCE]", queries[1].Sql!, StringComparison.Ordinal);
Assert.Contains("N'SEQNO'", queries[2].Sql!, StringComparison.Ordinal);
Assert.Null(adapter.BatchHistory(ax, null));
Assert.Empty(queries[0].Notes);
}
[Fact]
public void Ax2012_reads_the_company_from_the_scope_and_history_by_recid()
{
var ax = AxFixtures.Recognise(AxFixtures.Schema("schema-ax2012"), "AX2012", databases: ["AX2012", "AX2012_model"]);
var adapter = AxSchemaAdapter.For(ax.Version)!;
var sequences = adapter.NumberSequences(ax);
var first = adapter.BatchHistory(ax, null)!;
var next = adapter.BatchHistory(ax, 10_000)!;
Assert.Contains("LEFT JOIN [dbo].[NUMBERSEQUENCESCOPE]", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains("[NUMBERSEQUENCEID]", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains("ORDER BY h.[RECID] DESC", first.Sql!, StringComparison.Ordinal);
Assert.Contains($"h.[RECID] > {10_000 - AxSchemaAdapter.HistoryOverlap}", next.Sql!, StringComparison.Ordinal);
Assert.Contains("[ORIGSTARTDATETIME]", adapter.BatchJobs(ax).Sql!, StringComparison.Ordinal);
}
[Fact]
public void Without_read_access_a_check_is_not_measurable_with_the_exact_grant()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30, "AX30", readable: false) with { AgentLogin = @"KUNDE\svc-sqlinsight$" };
var batch = AxSchemaAdapter.Ax30.BatchJobs(ax);
Assert.False(batch.IsMeasurable);
Assert.StartsWith("Ikke målbar: agenten må ikke læse bmssa.BATCH i AX30", batch.NotMeasurable, StringComparison.Ordinal);
Assert.Contains(@"CREATE USER [KUNDE\svc-sqlinsight$] FOR LOGIN [KUNDE\svc-sqlinsight$];", batch.NotMeasurable, StringComparison.Ordinal);
Assert.Contains(@"GRANT SELECT ON [bmssa].[BATCH] TO [KUNDE\svc-sqlinsight$];", batch.NotMeasurable, StringComparison.Ordinal);
}
[Fact]
public void A_missing_required_column_names_it_and_a_missing_optional_one_becomes_null_with_a_note()
{
var withoutStartTime = AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "BATCH" && r.Column == "STARTTIME"));
var withoutFormat = AxFixtures.Recognise(AxFixtures.Ax30, keep: r => !(r.Table == "NUMBERSEQUENCETABLE" && r.Column == "FORMAT"));
var batch = AxSchemaAdapter.Ax30.BatchJobs(withoutStartTime);
var sequences = AxSchemaAdapter.Ax30.NumberSequences(withoutFormat);
Assert.Contains("bmssa.BATCH.STARTTIME", batch.NotMeasurable, StringComparison.Ordinal);
Assert.True(sequences.IsMeasurable);
Assert.Contains("CAST(NULL AS nvarchar(40))", sequences.Sql!, StringComparison.Ordinal);
Assert.Contains(sequences.Notes, n => n.Contains("FORMAT", StringComparison.Ordinal));
}
[Fact]
public void Grant_script_covers_exactly_the_adapters_tables_per_version()
{
var lists = Regex.Matches(AxReadAccess.Template(), @"N''\|([A-Z|]+)\|''").Select(m => m.Groups[1].Value.Split('|')).ToList();
Assert.Equal(3, lists.Count);
Assert.Equal(AxSchemaAdapter.Ax30.ReadTables.Order(), lists[0].Order());
Assert.Equal(AxSchemaAdapter.Ax40.ReadTables.Order(), lists[0].Order());
Assert.Equal(AxSchemaAdapter.Ax2009.ReadTables.Order(), lists[1].Order());
Assert.Equal(AxSchemaAdapter.Ax2012.ReadTables.Order(), lists[2].Order());
Assert.Equal(AxSchemaAdapter.FinanceOperations.ReadTables.Order(), lists[2].Order());
Assert.Contains("SET @DatabaseName = N'AX30';", AxReadAccess.Script("AX30", "svc"), StringComparison.Ordinal);
}
[Fact]
public void Grant_text_uses_the_existing_database_user_and_only_existing_tables()
{
var ax = AxFixtures.Recognise(AxFixtures.Ax30, "AX30", user: "sqlinsight") with { AgentLogin = "svc" };
var grant = AxReadAccess.GrantSql(ax, ax.AgentLogin);
Assert.DoesNotContain("CREATE USER", grant, StringComparison.Ordinal);
Assert.Contains("GRANT SELECT ON [bmssa].[SYSTEMSEQUENCES] TO [sqlinsight];", grant, StringComparison.Ordinal);
Assert.DoesNotContain("BATCHJOB", grant, StringComparison.Ordinal);
Assert.Empty(AxReadAccess.Missing(ax));
}
}
public class AxInterpretationTests
{
private static readonly DateTime Now = new(2026, 10, 2, 10, 0, 0, DateTimeKind.Utc);
[Fact]
public void Ax30_times_are_a_local_date_plus_seconds_since_midnight()
{
// 02:30:00 local on a server two hours ahead of UTC (offset -120 minutes) is 00:30 UTC.
Assert.Equal(new DateTime(2026, 10, 2, 0, 30, 0, DateTimeKind.Utc), AxSchemaAdapter.Ax30.ToUtc(new DateTime(2026, 10, 2), 9000, -120));
Assert.Null(AxSchemaAdapter.Ax30.ToUtc(new DateTime(1900, 1, 1), 0, 0));
Assert.Equal(new DateTime(2026, 10, 2, 8, 0, 0, DateTimeKind.Utc), AxSchemaAdapter.Ax2009.ToUtc(new DateTime(2026, 10, 2, 8, 0, 0), null, -120));
}
[Fact]
public void Batch_status_enums_per_version()
{
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax30.BatchStatus(1));
Assert.Equal(AxBatchStatus.Error, AxSchemaAdapter.Ax30.BatchStatus(2));
Assert.Equal(AxBatchStatus.Waiting, AxSchemaAdapter.Ax2009.BatchStatus(1));
Assert.Equal(AxBatchStatus.Executing, AxSchemaAdapter.Ax2012.BatchStatus(2));
Assert.Equal(AxBatchStatus.Canceled, AxSchemaAdapter.Ax2012.BatchStatus(8));
Assert.Equal(AxBatchStatus.Unknown, AxSchemaAdapter.Ax2012.BatchStatus(42));
Assert.All(AxSchemaAdapter.All, a => Assert.False(a.BatchStatusVerified));
}
[Fact]
public void Ax30_jobs_are_keyed_by_class_company_and_group_and_late_jobs_are_measured_from_the_planned_start()
{
var row = new AxBatchJobRow(17, 1234, null, "dat", "NAT", 0, new DateTime(2026, 10, 2), 9 * 3600, null, null, null, null, -120);
var job = AxSchemaAdapter.Ax30.Interpret(row, new AxBatchHistory(), "AX30", null);
Assert.Equal("1234|dat|NAT", job.Key);
Assert.Equal("Klasse 1234 (gruppe NAT)", job.Caption);
Assert.Equal(AxBatchStatus.Waiting, job.Status);
Assert.Equal(new DateTime(2026, 10, 2, 7, 0, 0, DateTimeKind.Utc), job.PlannedStartUtc);
Assert.Equal(TimeSpan.FromHours(3), job.Late(new DateTimeOffset(Now)));
}
[Fact]
public void Job_history_gives_median_and_90th_percentile_of_successful_runs_and_is_bounded()
{
var history = new AxBatchHistory();
for (var i = 1; i <= 30; i++)
{
history.Add("AX", new AxBatchRun("7", "H" + i, "Lager", "dat", Now.AddHours(-i), Now.AddHours(-i).AddMinutes(i <= 25 ? 10 : 200), i == 1 ? AxBatchStatus.Error : AxBatchStatus.Finished));
}
history.Add("AX", new AxBatchRun("7", "H3", "Lager", "dat", Now, Now.AddMinutes(500), AxBatchStatus.Finished)); // seen before: ignored
var runs = history.Runs("AX", "7");
var (median, p90) = AxBatchHistory.Statistics(runs);
Assert.Equal(AxBatchHistory.RunsKept, runs.Count);
Assert.Equal(600, median);
Assert.Equal(600, p90);
Assert.Empty(history.Runs("AX", "8"));
}
[Fact]
public void Observed_run_needs_an_end_and_a_start_from_ax_or_from_sqlinsight()
{
var finished = new AxBatchJob { Key = "k", RecId = 5, Caption = "c", Status = AxBatchStatus.Finished, EndedUtc = Now };
Assert.Null(AxSchemaAdapter.ObservedRun(finished, null));
Assert.Equal(TimeSpan.FromMinutes(30), AxSchemaAdapter.ObservedRun(finished, Now.AddMinutes(-30))!.Duration);
Assert.Equal(TimeSpan.FromMinutes(5), AxSchemaAdapter.ObservedRun(finished with { StartedUtc = Now.AddMinutes(-5) }, Now.AddMinutes(-30))!.Duration);
Assert.Null(AxSchemaAdapter.ObservedRun(finished with { Status = AxBatchStatus.Executing }, Now.AddMinutes(-30)));
}
[Fact]
public void Number_sequence_usage_format_width_and_forecast()
{
var row = new AxNumberSequenceRow(1, "Debitor", "dat", "Debitorer", 1, 9_999_999, 95_001, "DEB-####", false, false, false, false, true, null, null, null, -120);
var samples = new[] { new AxSample(Now.AddDays(-20), 85_001), new AxSample(Now.AddDays(-10), 90_001) };
var sequence = AxSchemaAdapter.Ax30.Interpret(row, samples, Now, 30);
Assert.Equal(4, sequence.FormatDigits);
Assert.Equal(9_999, sequence.FormatCapacity);
Assert.True(sequence.FormatTooShort);
Assert.Equal(9_999, sequence.Max);
Assert.Equal(100, sequence.UsedPercent);
Assert.Equal(0, sequence.Remaining);
Assert.Equal("dat/Debitor", sequence.Key);
Assert.Equal(500, sequence.Forecast!.PerDay, 3);
Assert.Equal(0, sequence.Forecast.DaysLeft);
}
[Fact]
public void Number_sequence_forecast_needs_a_day_of_history_and_ignores_manual_and_cyclic()
{
var row = new AxNumberSequenceRow(1, "Ordre", "dat", null, 1, 1_000_000, 500_001, "######", false, false, false, false, null, null, null, null, 0);
Assert.Null(AxSchemaAdapter.Ax2009.Interpret(row, [new AxSample(Now.AddHours(-2), 1)], Now, 30).Forecast);
Assert.Null(AxSchemaAdapter.Ax2009.Interpret(row with { Manual = true }, [new AxSample(Now.AddDays(-5), 1)], Now, 30).Forecast);
var sequence = AxSchemaAdapter.Ax2009.Interpret(row, [new AxSample(Now.AddDays(-5), 450_001), new AxSample(Now.AddDays(-60), 1)], Now, 30);
Assert.Equal(10_000, sequence.Forecast!.PerDay, 3);
Assert.Equal(50, sequence.Forecast.DaysLeft!.Value, 3);
Assert.Equal(50, sequence.UsedPercent, 3);
Assert.Equal("1", AxSchemaAdapter.Ax2012.SequenceKey(row));
}
[Fact]
public void Ax30_recid_is_limited_by_the_int_range()
{
var recId = AxSchemaAdapter.Interpret(new AxRecIdRow(-1, "dat", 1_610_612_736, 0, null), nextValIsInt: true,
[new AxSample(Now.AddDays(-10), 1_600_612_736)], Now, 30);
var bigint = AxSchemaAdapter.Interpret(new AxRecIdRow(77, null, 5_000_000_000, null, "CUSTTABLE"), nextValIsInt: false, [], Now, 30);
Assert.Equal((32, int.MaxValue, 75.0), (recId.Bits, recId.Max, Math.Round(recId.UsedPercent, 1)));
Assert.Equal(1_000_000, recId.Forecast!.PerDay, 3);
Assert.Equal(536.87, recId.Forecast.DaysLeft!.Value, 2);
Assert.Equal("-1/dat", recId.Key);
Assert.Equal((64, long.MaxValue), (bigint.Bits, bigint.Max));
Assert.True(bigint.UsedPercent < 0.001);
}
}
public class AxSessionMapperTests
{
private static string Hex(string text, bool unicode = false) =>
Convert.ToHexString(unicode ? System.Text.Encoding.Unicode.GetBytes(text) : System.Text.Encoding.ASCII.GetBytes(text)) + "000000";
private static SessionInfo Session(string? context) => new() { SessionId = 61, HostName = "AOS01", ProgramName = "Microsoft Dynamics AX", ContextInfo = context };
[Fact]
public void Ax2012_context_info_gives_the_ax_user_and_session()
{
var identity = AxSessionMapper.Map(Session(Hex("anne 23")), AxVersion.Ax2012R2);
Assert.Equal(("anne", 23, "context_info"), (identity.AxUser, identity.AxSessionId, identity.Source));
Assert.Equal("anne", AxSessionMapper.Map(Session(Hex("23 anne", unicode: true)), AxVersion.Ax2009).AxUser);
}
[Fact]
public void Ax30_is_honest_that_the_user_cannot_be_seen()
{
var identity = AxSessionMapper.Map(Session(null), AxVersion.Axapta30);
Assert.False(identity.Known);
Assert.Contains("sætter ikke CONTEXT_INFO", identity.Explanation, StringComparison.Ordinal);
Assert.Contains("AOS01", identity.Explanation, StringComparison.Ordinal);
}
[Fact]
public void Without_context_info_the_explanation_says_how_to_turn_it_on()
{
var identity = AxSessionMapper.Map(Session(null), AxVersion.Ax2012);
Assert.False(identity.Known);
Assert.Contains("connectioncontext", identity.Explanation, StringComparison.Ordinal);
Assert.Null(AxSessionMapper.Decode("00000000"));
Assert.Null(AxSessionMapper.Decode("zz"));
Assert.Null(AxSessionMapper.Decode("01FF02FE"));
}
}
public class DynamicsAxHubSubjectTests
{
[Theory]
[InlineData("database: AX30", "AX30")]
[InlineData("database: AX30 · batch: 17", "AX30")]
[InlineData("database: AX 30 · nummerserie: dat/Debitor", "AX 30")]
[InlineData("job: x", null)]
public void Database_of_an_ax_subject(string subject, string? database) => Assert.Equal(database, DynamicsAxFindings.DatabaseOf(subject));
[Theory]
[InlineData("SQLI-AX-005", true)]
[InlineData("COM-AX-001", true)]
[InlineData("SQLI-AG-001", false)]
public void Ax_rules(string ruleId, bool ax) => Assert.Equal(ax, DynamicsAxFindings.IsDynamicsAxRule(ruleId));
[Fact]
public void Subjects_round_trip() => Assert.Equal("AX30", DynamicsAxFindings.DatabaseOf(DynamicsAxFindings.Subject("AX30", "batch: 1")));
}