After the first read, BATCHJOBHISTORY was read from the watermark minus an overlap of 2000 RecIds with TOP (1000), oldest first. From AX 4.0 RecIds are handed out per table and are dense, so once 1000 rows existed in the overlap every read returned the same old rows: the watermark never moved and new runs were never read again (every AX 2009/2012 installation with more than ~1000 history rows). The read after the watermark now takes the overlap plus 1000 rows (TOP (3000)), so at least 1000 new runs are read each time. Test with 20,000 dense RecIds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
398 lines
20 KiB
C#
398 lines
20 KiB
C#
using System.Text.RegularExpressions;
|
|
using SQLInsight.Core.Configuration;
|
|
using SQLInsight.Core.Data;
|
|
using SQLInsight.Core.DynamicsAx;
|
|
|
|
namespace SQLInsight.Core.Tests;
|
|
|
|
/// <summary>Builds AX databases as the detector would see them, from the schema files in docs/dynamics-ax.</summary>
|
|
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.");
|
|
|
|
/// <summary>The real AX 3.0 schema from a customer (bmssa, 32-bit RECID).</summary>
|
|
public static AxSchemaFile Ax30 => Schema("schema-ax30");
|
|
|
|
public static AxDatabase Recognise(AxSchemaFile schema, string name = "AXDB", bool readable = true, string? user = null, IEnumerable<string>? databases = null,
|
|
Func<AxDetectionRow, bool>? 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<string> 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")));
|
|
}
|
|
|
|
public class AxBatchHistoryWatermarkTests
|
|
{
|
|
[Fact]
|
|
public void The_history_watermark_keeps_moving_through_dense_recids()
|
|
{
|
|
// AX 4.0 and later: BATCHJOBHISTORY's RecIds are dense. A read returns the rows after watermark - overlap, in RECID
|
|
// order, at most MaxHistoryRowsAfterWatermark of them.
|
|
Assert.True(AxSchemaAdapter.MaxHistoryRowsAfterWatermark > AxSchemaAdapter.HistoryOverlap);
|
|
var history = Enumerable.Range(1, 20_000).Select(i => (long)i).ToList();
|
|
long watermark = history[AxSchemaAdapter.MaxHistoryRows - 1];
|
|
for (var read = 0; read < 25 && watermark < history[^1]; read++)
|
|
{
|
|
var after = Math.Max(0, watermark - AxSchemaAdapter.HistoryOverlap);
|
|
var rows = history.Where(r => r > after).Take(AxSchemaAdapter.MaxHistoryRowsAfterWatermark).ToList();
|
|
Assert.True(rows[^1] > watermark, $"Læsning {read} kom ikke forbi RECID {watermark}.");
|
|
watermark = rows[^1];
|
|
}
|
|
|
|
Assert.Equal(history[^1], watermark);
|
|
|
|
var database = AxFixtures.Recognise(AxFixtures.Schema("schema-ax2012"), databases: ["AXDB", "AXDB_model"]);
|
|
Assert.Contains($"SELECT TOP ({AxSchemaAdapter.MaxHistoryRowsAfterWatermark}) ", AxSchemaAdapter.Ax2012.BatchHistory(database, 50_000)!.Sql, StringComparison.Ordinal);
|
|
}
|
|
}
|