diff --git a/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs b/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs index ee39a66678..abf25f773e 100644 --- a/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs +++ b/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs @@ -106,6 +106,15 @@ public abstract class AbpDateTimeConverterBase : JsonConverter var timezoneInfo = TimezoneProvider.GetTimeZoneInfo(CurrentTimezoneProvider.TimeZone); dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime; } + catch (ArgumentOutOfRangeException) + { + // Applying the timezone offset moved the value outside the supported DateTime range. + // This happens for values within the offset distance of DateTime.MinValue/MaxValue, + // typically placeholder values (e.g. DateTime.MinValue) that don't represent a real + // instant. Keep the value unchanged and log at Debug level instead of Warning, so it + // stays traceable without flooding production logs on every serialization. + Logger.LogDebug("Skipped timezone conversion for DateTime '{DateTime}' (kind: Unspecified) using timezone '{TimeZone}': applying the offset would move it outside the supported DateTime range.", dateTime, CurrentTimezoneProvider.TimeZone); + } catch { Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", CurrentTimezoneProvider.TimeZone); diff --git a/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs b/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs new file mode 100644 index 0000000000..3e1a08772b --- /dev/null +++ b/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Volo.Abp.Timing; +using Xunit; + +namespace Volo.Abp.Json; + +/// +/// Regression tests for the warning +/// "Could not convert DateTime with unspecified Kind using timezone '...'." +/// logged by AbpDateTimeConverterBase.Normalize. +/// +/// When is the converter treats +/// an value as local time in the current user's timezone and +/// converts it to UTC via new DateTimeOffset(value, offset).UtcDateTime. For a placeholder +/// such as a positive offset (e.g. Asia/Shanghai = +08:00) pushes +/// the value before and throws , +/// which used to be swallowed and logged as a warning on every serialization. The converter now +/// skips the timezone conversion for the / +/// sentinel values, so no warning is emitted while the serialized output stays unchanged. +/// +public class AbpDateTimeConverterTimezone_Tests : AbpJsonSystemTextJsonTestBase +{ + private const string WarningFragment = "Could not convert DateTime with unspecified Kind"; + + private static readonly CapturingLoggerProvider LogCapture = new(); + + private readonly IJsonSerializer _jsonSerializer; + private readonly ICurrentTimezoneProvider _currentTimezoneProvider; + + public AbpDateTimeConverterTimezone_Tests() + { + _jsonSerializer = GetRequiredService(); + _currentTimezoneProvider = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + // The warning only happens with a UTC clock, where Unspecified values get converted to UTC. + services.Configure(options => options.Kind = DateTimeKind.Utc); + + LogCapture.Clear(); + services.AddSingleton(LogCapture); + + base.AfterAddApplication(services); + } + + private sealed class FileModel + { + public DateTime DateModified { get; set; } + + public DateTime DateCreated { get; set; } + } + + [Theory] + [InlineData("Asia/Shanghai")] // +08:00 + [InlineData("Europe/Brussels")] // +01:00 / +02:00 + public void Should_Not_Warn_When_Serializing_MinValue_Under_Positive_Offset_Timezone(string timeZoneId) + { + _currentTimezoneProvider.TimeZone = timeZoneId; + LogCapture.Clear(); + + DateTime.MinValue.Kind.ShouldBe(DateTimeKind.Unspecified); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = DateTime.MinValue, + DateCreated = DateTime.MinValue + }); + + // The placeholder is serialized unchanged and no warning is logged. + json.ShouldContain("0001-01-01"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_Value_Near_MinValue_Under_Positive_Offset_Timezone() + { + // Not exactly DateTime.MinValue: any value within the offset distance of the lower bound + // overflows the same way, so the converter must absorb it rather than warn. + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00 + LogCapture.Clear(); + + var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows + + _jsonSerializer.Serialize(new FileModel + { + DateModified = nearMin, + DateCreated = nearMin + }); + + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_MaxValue_Under_Negative_Offset_Timezone() + { + // The symmetric case: a negative offset would push MaxValue past DateTime.MaxValue. + _currentTimezoneProvider.TimeZone = "America/New_York"; + LogCapture.Clear(); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = DateTime.MaxValue, + DateCreated = DateTime.MaxValue + }); + + json.ShouldContain("9999-12-31"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_Real_Utc_Timestamp_Under_Positive_Offset_Timezone() + { + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; + LogCapture.Clear(); + + var utc = new DateTime(2026, 6, 27, 10, 28, 7, DateTimeKind.Utc); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = utc, + DateCreated = utc + }); + + json.ShouldContain("2026-06-27T10:28:07Z"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Still_Convert_Real_Unspecified_Timestamp_To_Utc_Under_Positive_Offset_Timezone() + { + // A genuine (non-sentinel) Unspecified value must still be converted to UTC using the offset. + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00 + LogCapture.Clear(); + + var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = unspecified, + DateCreated = unspecified + }); + + // 18:00 in +08:00 == 10:00 UTC. + json.ShouldContain("2026-06-27T10:00:00Z"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Warnings { get; } = new(); + + public void Clear() + { + Warnings.Clear(); + } + + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(Warnings); + } + + public void Dispose() + { + } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _sink; + + public CapturingLogger(ConcurrentQueue sink) + { + _sink = sink; + } + + public IDisposable BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= LogLevel.Warning; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + if (logLevel >= LogLevel.Warning) + { + _sink.Enqueue(formatter(state, exception)); + } + } + } + } +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs index 8d301395c0..8841175d54 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs @@ -173,7 +173,7 @@ namespace Volo.Docs.GitHub.Documents { if (commits == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } var gitHubCommit = isFirstCommit ? @@ -182,20 +182,20 @@ namespace Volo.Docs.GitHub.Documents if (gitHubCommit == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } if (gitHubCommit.Commit == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } if (gitHubCommit.Commit.Author == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } - return gitHubCommit.Commit.Author.Date.DateTime; + return gitHubCommit.Commit.Author.Date.UtcDateTime; } private async Task> GetGitHubCommitsOrNull(Project project, string documentName, string languageCode, string version) @@ -243,7 +243,7 @@ namespace Volo.Docs.GitHub.Documents if (_githubPatchAnalyzer.HasPatchSignificantChanges(fullCommit.Files.First(f => f.Filename == fileName).Patch)) { - return gitHubCommit.Commit.Author.Date.DateTime; + return gitHubCommit.Commit.Author.Date.UtcDateTime; } }