sqlinsight-pro/tests/SQLInsight.Core.Tests/DynamicsAxTests.cs
Søren Eggert Lundsteen Olsen 66ed4e11b3 Review 1.4: AX 3.0/4.0 local times in the AX time zone, with its DST rules
Axapta 3.0 and AX 4.0 store batch and cleanup times as a local date plus
seconds. They were turned into UTC with the SQL Server's offset at collection
time, so every time from the other side of a DST change was an hour off (a job
planned 01:50 CET on the spring night looked 70 minutes late at 03:00 CEST),
and a SQL Server running UTC behind a CET AOS shifted every time.

AxLocalTime converts with the rules of the AX time zone on that date:
SQLInsight:DynamicsAx:LocalTimeZone (Windows or IANA id), else the agent's own
zone when its offset matches the SQL Server's, else the server's offset (to the
nearest quarter hour) with a Danish data note. The skipped spring hour and the
repeated autumn hour get standard time (deterministic, never throws, never a
false "late"); seconds outside 0..86400 are unknown and 86400 is midnight. The
AX 3.0 cleanup time is local by version, also when LATESTCLEANTIME is missing.
AX 2009+ UTC times are untouched. Also: remaining numbers cannot overflow for
HIGHEST = Int64.MaxValue, and run keys use the invariant culture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:19:17 +02:00

477 lines
24 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);
}
}
public class AxLocalTimeTests
{
private static readonly TimeZoneInfo Copenhagen = TimeZoneInfo.FindSystemTimeZoneById("Europe/Copenhagen");
private static DateTime Utc(int y, int mo, int d, int h, int mi) => new(y, mo, d, h, mi, 0, DateTimeKind.Utc);
[Fact]
public void A_time_from_before_a_dst_change_uses_the_offset_of_its_own_date()
{
// Read in winter (server offset -60), planned in summer: 10:00 CEST is 08:00Z, not 09:00Z.
Assert.Equal(Utc(2026, 8, 1, 8, 0), AxLocalTime.ToUtc(new DateTime(2026, 8, 1), 36_000, -60, Copenhagen));
Assert.Equal(Utc(2026, 1, 10, 9, 0), AxLocalTime.ToUtc(new DateTime(2026, 1, 10), 36_000, -120, Copenhagen));
}
[Fact]
public void The_skipped_and_the_repeated_hour_get_standard_time_and_never_throw()
{
// 29 March 2026 02:30 does not exist in Copenhagen; 25 October 2026 02:30 exists twice.
Assert.Equal(Utc(2026, 3, 29, 1, 30), AxLocalTime.ToUtc(new DateTime(2026, 3, 29), 9_000, -120, Copenhagen));
Assert.Equal(Utc(2026, 10, 25, 1, 30), AxLocalTime.ToUtc(new DateTime(2026, 10, 25), 9_000, -60, Copenhagen));
// A job planned 01:50 CET on the spring night, read at 01:00Z (03:00 CEST): 10 minutes late, not 70.
var planned = AxLocalTime.ToUtc(new DateTime(2026, 3, 29), 6_600, -120, Copenhagen)!.Value;
Assert.Equal(TimeSpan.FromMinutes(10), Utc(2026, 3, 29, 1, 0) - planned);
}
[Theory]
[InlineData(0, 0)]
[InlineData(86_399, 86_399)]
[InlineData(86_400, 86_400)]
public void Seconds_from_midnight_up_to_the_end_of_the_day(int seconds, int expected) =>
Assert.Equal(Utc(2026, 1, 9, 23, 0).AddSeconds(expected), AxLocalTime.ToUtc(new DateTime(2026, 1, 10), seconds, -60, Copenhagen));
[Theory]
[InlineData(-1)]
[InlineData(90_000)]
public void Seconds_outside_a_day_are_unknown(int seconds) => Assert.Null(AxLocalTime.ToUtc(new DateTime(2026, 1, 10), seconds, -60, Copenhagen));
[Fact]
public void Ax_empty_date_is_unknown_and_utc_versions_are_not_converted_again()
{
Assert.Null(AxLocalTime.ToUtc(new DateTime(1900, 1, 1), 3_600, -60, Copenhagen));
var utc = Utc(2026, 7, 1, 12, 0);
Assert.Equal(utc, AxSchemaAdapter.Ax2009.ToUtc(utc, null, -120, Copenhagen));
}
[Fact]
public void Without_a_zone_the_servers_offset_is_rounded_to_a_quarter_of_an_hour()
{
Assert.Equal(Utc(2026, 10, 2, 0, 30), AxLocalTime.ToUtc(new DateTime(2026, 10, 2), 9_000, -119, null));
Assert.Equal(-120, AxLocalTime.RoundedOffset(-121));
}
[Fact]
public void The_zone_is_the_setting_else_the_agents_when_it_matches_the_server_else_the_servers_offset_with_a_note()
{
var summer = Utc(2026, 7, 1, 12, 0);
Assert.Equal("Europe/Copenhagen", AxLocalTime.Resolve("Europe/Copenhagen", 0, summer).Zone?.Id);
Assert.Same(Copenhagen, AxLocalTime.Resolve(null, -120, summer, Copenhagen).Zone);
var (zone, note) = AxLocalTime.Resolve(null, 0, summer, Copenhagen);
Assert.Null(zone);
Assert.Contains("LocalTimeZone", note, StringComparison.Ordinal);
var (unknown, unknownNote) = AxLocalTime.Resolve("Ingen/Zone", -120, summer, Copenhagen);
Assert.Null(unknown);
Assert.Contains("Ingen/Zone", unknownNote, StringComparison.Ordinal);
}
[Fact]
public void Axapta30_cleanup_without_a_time_column_is_local_midnight_not_utc()
{
var row = new AxNumberSequenceRow(1, "Debitor", "dat", null, 1, 999_999, 10, "######", false, true, false, false, null,
new DateTime(2026, 7, 1), null, null, -120);
var sequence = AxSchemaAdapter.Ax30.Interpret(row, [], Utc(2026, 7, 2, 0, 0), 30, Copenhagen);
Assert.Equal(Utc(2026, 6, 30, 22, 0), sequence.LastCleanUtc);
}
}