From 32ca1c9f7a17a7f7cbb65c04132fe8d0b7c67a1c Mon Sep 17 00:00:00 2001 From: axunonb Date: Fri, 4 Jul 2025 14:52:07 +0200 Subject: [PATCH 1/2] fix: `CalDateTime` CTOR using ISO 8601 UTC string resolves to UTC Throws for controversion of ISO 8601 UTC string, while timezone ID is not UTC chore: `ExcludeFromCodeCoverage` for private constructor chore: Remove unused method `TruncateTimeToSeconds` Resolves #831 --- Ical.Net.Tests/CalDateTimeTests.cs | 22 +++++++++++++++++++++- Ical.Net/DataTypes/CalDateTime.cs | 19 ++++++++++--------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Ical.Net.Tests/CalDateTimeTests.cs b/Ical.Net.Tests/CalDateTimeTests.cs index 6463a158..2ae01909 100644 --- a/Ical.Net.Tests/CalDateTimeTests.cs +++ b/Ical.Net.Tests/CalDateTimeTests.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. // +#nullable enable using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using NUnit.Framework; @@ -55,7 +56,7 @@ public void ToTimeZoneFloating() [Test, TestCaseSource(nameof(ToTimeZoneTestCases))] public void ToTimeZoneTests(CalendarEvent calendarEvent, string targetTimeZone) { - var startAsUtc = calendarEvent.Start.AsUtc; + var startAsUtc = calendarEvent.Start!.AsUtc; var convertedStart = calendarEvent.Start.ToTimeZone(targetTimeZone); var convertedAsUtc = convertedStart.AsUtc; @@ -369,4 +370,23 @@ public void CalDateTime_FromDateTime_HandlesKindCorrectly(DateTimeKind kind, IRe Assert.That(() => new CalDateTime(dt), constraint); } + + [TestCase("20250703T060000Z", null)] + [TestCase("20250703T060000Z", CalDateTime.UtcTzId)] + public void ConstructorWithIso8601UtcString_ShouldResultInUtc(string value, string? tzId) + { + var dt = new CalDateTime(value, tzId); + Assert.Multiple(() => + { + Assert.That(dt.Value, Is.EqualTo(new DateTime(2025, 7, 3, 6, 0, 0, DateTimeKind.Utc))); +#pragma warning disable CA1305 + Assert.That(dt.ToString("yyyy-MM-dd HH:mm:ss"), Is.EqualTo("2025-07-03 06:00:00 UTC")); +#pragma warning restore CA1305 + Assert.That(dt.IsUtc, Is.True); + }); + } + + [Test] + public void ConstructorWithIso8601UtcString_ButDifferentTzId_ShouldThrow() + => Assert.That(() => _ = new CalDateTime("20250703T060000Z", "CEST"), Throws.ArgumentException); } diff --git a/Ical.Net/DataTypes/CalDateTime.cs b/Ical.Net/DataTypes/CalDateTime.cs index f653bcd4..109b0dec 100644 --- a/Ical.Net/DataTypes/CalDateTime.cs +++ b/Ical.Net/DataTypes/CalDateTime.cs @@ -7,6 +7,7 @@ using Ical.Net.Utility; using NodaTime; using System; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; @@ -55,6 +56,7 @@ public sealed class CalDateTime : IComparable, IFormattable /// /// This constructor is required for the SerializerFactory to work. /// + [ExcludeFromCodeCoverage] private CalDateTime() { // required for the SerializerFactory to work @@ -201,9 +203,15 @@ public CalDateTime(string value, string? tzId = null) { var serializer = new DateTimeSerializer(); CopyFrom(serializer.Deserialize(new StringReader(value)) as CalDateTime - ?? throw new InvalidOperationException($"$Failure for deserializing value '{value}'")); + ?? throw new InvalidOperationException($"$Failure when deserializing value '{value}'")); + // The string may contain a date only, meaning that the tzId should be ignored. - _tzId = HasTime ? tzId : null; + _tzId ??= HasTime ? tzId : null; + + if (IsUtc && tzId != null && !string.Equals(tzId, UtcTzId, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"The value '{value}' is a UTC date/time, but the specified timezone '{tzId}' is not '{UtcTzId}'.", nameof(tzId)); + } } private void Initialize(DateOnly dateOnly, TimeOnly? timeOnly, string? tzId) @@ -218,7 +226,6 @@ private void Initialize(DateOnly dateOnly, TimeOnly? timeOnly, string? tzId) }; } - /// private void CopyFrom(CalDateTime calDt) { // Maintain the private date/time backing fields @@ -436,12 +443,6 @@ public DateTime Value return new TimeOnly(time.Value.Hour, time.Value.Minute, time.Value.Second); } - /// - /// Any values are truncated to seconds, because - /// RFC 5545, Section 3.3.5 does not allow for fractional seconds. - /// - private static TimeOnly? TruncateTimeToSeconds(DateTime dateTime) => new TimeOnly(dateTime.Hour, dateTime.Minute, dateTime.Second); - /// /// Converts the to a date/time /// within the specified timezone. From 9640d30f7974c11c8c337b13e8a2b6a92bc79400 Mon Sep 17 00:00:00 2001 From: axunonb Date: Sat, 5 Jul 2025 12:44:52 +0200 Subject: [PATCH 2/2] Remove redundant `CalDateTime.CopyFrom` * Updated the `CalDateTime(string value, string? tzId = null)` constructor to directly initialize the object using the `Initialize` method. * Removed the unnecessary `CopyFrom` method. --- Ical.Net/DataTypes/CalDateTime.cs | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/Ical.Net/DataTypes/CalDateTime.cs b/Ical.Net/DataTypes/CalDateTime.cs index 109b0dec..14245bdf 100644 --- a/Ical.Net/DataTypes/CalDateTime.cs +++ b/Ical.Net/DataTypes/CalDateTime.cs @@ -202,15 +202,16 @@ public CalDateTime(DateOnly date, TimeOnly? time, string? tzId = null) public CalDateTime(string value, string? tzId = null) { var serializer = new DateTimeSerializer(); - CopyFrom(serializer.Deserialize(new StringReader(value)) as CalDateTime - ?? throw new InvalidOperationException($"$Failure when deserializing value '{value}'")); + var dt = serializer.Deserialize(new StringReader(value)) as CalDateTime + ?? throw new InvalidOperationException($"Failure when deserializing value '{value}'"); - // The string may contain a date only, meaning that the tzId should be ignored. - _tzId ??= HasTime ? tzId : null; + Initialize(dt._dateOnly, dt._timeOnly, dt.IsUtc ? UtcTzId : tzId); - if (IsUtc && tzId != null && !string.Equals(tzId, UtcTzId, StringComparison.OrdinalIgnoreCase)) + if (dt.IsUtc && tzId != null && !string.Equals(tzId, UtcTzId, StringComparison.OrdinalIgnoreCase)) { - throw new ArgumentException($"The value '{value}' is a UTC date/time, but the specified timezone '{tzId}' is not '{UtcTzId}'.", nameof(tzId)); + throw new ArgumentException( + $"The value '{value}' represents UTC date/time, but the specified timezone '{tzId}' is not '{UtcTzId}'.", + nameof(tzId)); } } @@ -226,14 +227,6 @@ private void Initialize(DateOnly dateOnly, TimeOnly? timeOnly, string? tzId) }; } - private void CopyFrom(CalDateTime calDt) - { - // Maintain the private date/time backing fields - _dateOnly = calDt._dateOnly; - _timeOnly = TruncateTimeToSeconds(calDt._timeOnly); - _tzId = calDt._tzId; - } - public bool Equals(CalDateTime? other) => this == other; ///