diff --git a/src/Todoist.Net.Tests/Helpers/FakeLocalTimeZone.cs b/src/Todoist.Net.Tests/Helpers/FakeLocalTimeZone.cs new file mode 100644 index 0000000..82117dc --- /dev/null +++ b/src/Todoist.Net.Tests/Helpers/FakeLocalTimeZone.cs @@ -0,0 +1,81 @@ +using System; +using System.Reflection; + +namespace Todoist.Net.Tests.Helpers; + +/// +/// A helper class that changes the local timezone to a fake timezone provided +/// at initialization, and resets the original local timezone when disposed. +/// +/// +/// See this SO question for more details. +/// +public sealed class FakeLocalTimeZone : IDisposable +{ + + /// + /// The fake time zone info that has been set as local. + /// + public TimeZoneInfo FakeTimeZoneInfo { get; } + + + /// + /// Initializes a new instance of the class. + /// + private FakeLocalTimeZone(TimeZoneInfo fakeTimeZoneInfo) + { + FakeTimeZoneInfo = fakeTimeZoneInfo; + + var info = typeof(TimeZoneInfo).GetField("s_cachedData", BindingFlags.NonPublic | BindingFlags.Static); + var cachedData = info.GetValue(null); + + var field = cachedData.GetType().GetField("_localTimeZone", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.Instance); + + field.SetValue(cachedData, fakeTimeZoneInfo); + } + + public void Dispose() + { + TimeZoneInfo.ClearCachedData(); + } + + + /// + /// Changes the local time zone to the given . + /// + /// + /// Disposal of the returned object resets the local time zone to the original one. + /// + /// The time zone to set as local until disposal. + /// + /// A instance that represents the time zone change, + /// and used to reset it back to original at disposal. + /// + public static FakeLocalTimeZone ChangeLocalTimeZone(TimeZoneInfo fakeTimeZoneInfo) + { + return new FakeLocalTimeZone(fakeTimeZoneInfo); + } + + /// + /// Changes the local time zone to a custom time zone with a . + /// + /// + /// Disposal of the returned object resets the local time zone to the original one. + /// + /// UTC offset of the custom time zone. + /// + /// A instance that represents the time zone change, + /// and used to reset it back to original at disposal. + /// + public static FakeLocalTimeZone ChangeLocalTimeZone(TimeSpan baseUtcOffset) + { + var fakeId = "Fake TimeZone"; + var fakeDisplayName = $"(UTC+{baseUtcOffset:hh':'mm})"; + + var fakeTimeZoneInfo = TimeZoneInfo.CreateCustomTimeZone(fakeId, baseUtcOffset, fakeDisplayName, fakeDisplayName); + + return new FakeLocalTimeZone(fakeTimeZoneInfo); + } + +} diff --git a/src/Todoist.Net.Tests/Helpers/FakeLocalTimeZoneTests.cs b/src/Todoist.Net.Tests/Helpers/FakeLocalTimeZoneTests.cs new file mode 100644 index 0000000..ca5b3c9 --- /dev/null +++ b/src/Todoist.Net.Tests/Helpers/FakeLocalTimeZoneTests.cs @@ -0,0 +1,27 @@ +using System; +using System.Linq; + +using Todoist.Net.Tests.Extensions; + +using Xunit; + +namespace Todoist.Net.Tests.Helpers; + +[Trait(Constants.TraitName, Constants.UnitTraitValue)] +public class FakeLocalTimeZoneTests +{ + + [Fact] + public void FakeLocalTimeZone_ShouldChangeLocalTimeZoneWithinScope_AndResetItBackOutsideScope() + { + var fakeTimeZoneOffset = TimeZoneInfo.Local.BaseUtcOffset + TimeSpan.FromHours(2); + var fakeLocalTimeZone = FakeLocalTimeZone.ChangeLocalTimeZone(fakeTimeZoneOffset); + + using (fakeLocalTimeZone) + { + Assert.Equal(fakeLocalTimeZone.FakeTimeZoneInfo, TimeZoneInfo.Local); + } + Assert.NotEqual(fakeLocalTimeZone.FakeTimeZoneInfo, TimeZoneInfo.Local); + } + +} diff --git a/src/Todoist.Net.Tests/Models/DueDateTests.cs b/src/Todoist.Net.Tests/Models/DueDateTests.cs index 359ce82..e3d7dcd 100644 --- a/src/Todoist.Net.Tests/Models/DueDateTests.cs +++ b/src/Todoist.Net.Tests/Models/DueDateTests.cs @@ -2,19 +2,35 @@ using Todoist.Net.Models; using Todoist.Net.Tests.Extensions; +using Todoist.Net.Tests.Helpers; + using Xunit; namespace Todoist.Net.Tests.Models { [Trait(Constants.TraitName, Constants.UnitTraitValue)] - public class DueDateTests + public class DueDateTests : IDisposable { + + private readonly FakeLocalTimeZone _fakeLocalTimeZone; + + public DueDateTests() + { + _fakeLocalTimeZone = FakeLocalTimeZone.ChangeLocalTimeZone(TimeSpan.FromHours(5)); + } + + public void Dispose() + { + _fakeLocalTimeZone?.Dispose(); + } + + [Fact] public void DateTimeAssignment_FullDayEvent_Success() { - var date = new DateTime(2018, 2, 5, 0, 0, 0, DateTimeKind.Utc); + var date = new DateTime(2018, 2, 5); - var dueDate = new DueDate(date, true); + var dueDate = DueDate.CreateFullDay(date); Assert.Equal("2018-02-05", dueDate.StringDate); Assert.True(dueDate.IsFullDay); @@ -23,23 +39,65 @@ public void DateTimeAssignment_FullDayEvent_Success() [Fact] public void DateTimeAssignment_FloatingDueDateEvent_Success() { - var date = new DateTime(2018, 2, 5, 0, 0, 0, DateTimeKind.Utc); + var date = new DateTime(2018, 2, 5); - var dueDate = new DueDate(date); + var dueDate = DueDate.CreateFloating(date); Assert.Equal("2018-02-05T00:00:00", dueDate.StringDate); Assert.False(dueDate.IsFullDay); } [Fact] - public void DateTimeAssignment_FloatingDueDateWithTimezoneEvent_Success() + public void DateTimeAssignment_FixedTimeZoneDueDateEvent_Success() { - var date = new DateTime(2018, 2, 5, 0, 0, 0, DateTimeKind.Utc); + var date = new DateTime(2018, 2, 5); - var dueDate = new DueDate(date, false, "Asia/Jakarta"); + var dueDate = DueDate.CreateFixedTimeZone(date, "Asia/Jakarta"); Assert.Equal("2018-02-05T00:00:00Z", dueDate.StringDate); Assert.False(dueDate.IsFullDay); } + + + [Fact] + public void StringDateProperty_ShouldReturnExactAssignedValue_WhenValueIsFullDayDate() + { + var dueDate = new DueDate(); + string initialValue = "2016-12-01"; + + dueDate.StringDate = initialValue; + string returnedValue = dueDate.StringDate; + dueDate.StringDate = returnedValue; + + Assert.Equal(returnedValue, dueDate.StringDate); + } + + [Fact] + public void StringDateProperty_ShouldReturnExactAssignedValue_WhenValueIsFloatingDate() + { + var dueDate = new DueDate(); + string initialValue = "2016-12-03T12:00:00"; + + dueDate.StringDate = initialValue; + string returnedValue = dueDate.StringDate; + dueDate.StringDate = returnedValue; + + Assert.Equal(returnedValue, dueDate.StringDate); + } + + [Fact] + public void StringDateProperty_ShouldReturnExactAssignedValue_WhenValueIsFixedDate() + { + var dueDate = new DueDate(); + string initialValue = "2016-12-06T13:00:00Z"; + + dueDate.StringDate = initialValue; + dueDate.Timezone = "Asia/Jakarta"; + + string returnedValue = dueDate.StringDate; + dueDate.StringDate = returnedValue; + + Assert.Equal(returnedValue, dueDate.StringDate); + } } } diff --git a/src/Todoist.Net.Tests/Services/ItemsServiceTests.cs b/src/Todoist.Net.Tests/Services/ItemsServiceTests.cs index 242d630..4054978 100644 --- a/src/Todoist.Net.Tests/Services/ItemsServiceTests.cs +++ b/src/Todoist.Net.Tests/Services/ItemsServiceTests.cs @@ -89,7 +89,7 @@ public void CreateItemClearDueDateAndDelete_Success() { var client = TodoistClientFactory.Create(_outputHelper); - var item = new Item("demo task") { DueDate = new DueDate("22 Dec 2021", language: Language.English) }; + var item = new Item("demo task") { DueDate = DueDate.FromText("22 Dec 2021", Language.English) }; client.Items.AddAsync(item).Wait(); var itemInfo = client.Items.GetAsync(item.Id).Result; @@ -113,7 +113,7 @@ public void CreateItem_InvalidPDueDate_ThrowsException() { var client = TodoistClientFactory.Create(_outputHelper); var item = new Item("bad task"); - item.DueDate = new DueDate("Invalid date string"); + item.DueDate = DueDate.FromText("Invalid date string"); var aggregateException = Assert.ThrowsAsync( async () => @@ -133,7 +133,7 @@ public async Task MoveItemsToProject_Success() var item = new Item("demo task"); client.Items.AddAsync(item).Wait(); - item.DueDate = new DueDate("every fri"); + item.DueDate = DueDate.FromText("every fri"); await client.Items.UpdateAsync(item); var project = new Project(Guid.NewGuid().ToString()); @@ -162,7 +162,7 @@ public void QuickAddAsync_Success() Assert.NotNull(item); - client.Items.CompleteRecurringAsync(new CompleteRecurringItemArgument(item.Id, new DueDate(DateTime.UtcNow.AddMonths(1)))).Wait(); + client.Items.CompleteRecurringAsync(new CompleteRecurringItemArgument(item.Id, DueDate.CreateFloating(DateTime.UtcNow.AddMonths(1)))).Wait(); client.Items.CompleteRecurringAsync(item.Id).Wait(); client.Items.DeleteAsync(item.Id).Wait(); @@ -189,12 +189,12 @@ public void CreateNewItem_DueDateIsLocal_DueDateNotChanged() { var client = TodoistClientFactory.Create(_outputHelper); - var item = new Item("New task") { DueDate = new DueDate(DateTime.Now.AddYears(1).Date) }; + var item = new Item("New task") { DueDate = DueDate.CreateFloating(DateTime.Now.AddYears(1).Date) }; var taskId = client.Items.AddAsync(item).Result; var itemInfo = client.Items.GetAsync(taskId).Result; - Assert.Equal(item.DueDate.Date, itemInfo.Item.DueDate.Date?.ToLocalTime()); + Assert.Equal(item.DueDate.Date, itemInfo.Item.DueDate.Date); client.Items.DeleteAsync(item.Id).Wait(); } @@ -208,7 +208,7 @@ public void CreateItemClearDurationAndDelete_Success() var item = new Item("duration task") { - DueDate = new DueDate("22 Dec 2021 at 9:15", language: Language.English), + DueDate = DueDate.FromText("22 Dec 2021 at 9:15", Language.English), Duration = new Duration(45, DurationUnit.Minute) }; client.Items.AddAsync(item).Wait(); diff --git a/src/Todoist.Net.Tests/Services/ReminersServiceTests.cs b/src/Todoist.Net.Tests/Services/ReminersServiceTests.cs index bf3d8e6..a5e1414 100644 --- a/src/Todoist.Net.Tests/Services/ReminersServiceTests.cs +++ b/src/Todoist.Net.Tests/Services/ReminersServiceTests.cs @@ -30,7 +30,7 @@ public async Task CreateDelete_Success() var itemId = await transaction.Items.AddAsync(new Item("Temp")).ConfigureAwait(false); var reminderId = - await transaction.Reminders.AddAsync(new Reminder(itemId) { DueDate = new DueDate(DateTime.UtcNow.AddDays(1)) }).ConfigureAwait(false); + await transaction.Reminders.AddAsync(new Reminder(itemId) { DueDate = DueDate.CreateFloating(DateTime.UtcNow.AddDays(1)) }).ConfigureAwait(false); await transaction.CommitAsync().ConfigureAwait(false); var reminders = await client.Reminders.GetAsync().ConfigureAwait(false); @@ -50,7 +50,7 @@ public async Task AddRelativeReminder_Success() var item = new Item("Test") { - DueDate = new DueDate(DateTime.UtcNow.AddDays(1)) + DueDate = DueDate.CreateFloating(DateTime.UtcNow.AddDays(1)) }; var taskId = await client.Items.AddAsync(item).ConfigureAwait(false); diff --git a/src/Todoist.Net/Models/DueDate.cs b/src/Todoist.Net/Models/DueDate.cs index ea43054..e0f437a 100644 --- a/src/Todoist.Net/Models/DueDate.cs +++ b/src/Todoist.Net/Models/DueDate.cs @@ -11,36 +11,7 @@ namespace Todoist.Net.Models public class DueDate { private const string FullDayEventDateFormat = "yyyy-MM-dd"; - - /// - /// Initializes a new instance of the class. - /// - /// The date time. - /// if set to true then it's a full day event. - /// The timezone. - public DueDate(DateTime date, bool isFullDay = false, string timezone = null) - { - Date = date; - IsFullDay = isFullDay; - Timezone = timezone; - } - - /// - /// Initializes a new instance of the class. - /// - /// The text (every day; each monday) - /// The date time. this can be used with 'text' parameter to create a recurring from specific date - /// if set to true then it's a full day event. - /// The timezone. - /// The language. - public DueDate(string text, DateTime? date = null, bool isFullDay = false, string timezone = null, Language language = null) - { - Text = text; - Date = date; - IsFullDay = isFullDay; - Timezone = timezone; - Language = language; - } + private const string DefaultEventDateFormat = "yyyy-MM-ddTHH:mm:ssK"; internal DueDate() { @@ -124,13 +95,7 @@ internal string StringDate return Date.Value.ToString(FullDayEventDateFormat); } - var date = Date.Value.ToUniversalTime(); - if (string.IsNullOrEmpty(Timezone)) - { - return date.ToString("s"); - } - - return date.ToString("s") + "Z"; + return Date.Value.ToString(DefaultEventDateFormat); } set @@ -143,8 +108,98 @@ internal string StringDate return; } - Date = DateTime.Parse(value, CultureInfo.InvariantCulture); + Date = DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); } } + + + /// + /// Creates a new instance of from text (every day; each Monday). + /// + /// + /// See Todoist documentation for more information. + /// + /// The human-readable representation of due date (every day; each Monday) + /// The language of the text. + /// The created instance. + public static DueDate FromText(string text, Language language = null) => new DueDate + { + Text = text, + Language = language + }; + + /// + /// Creates a full-day . + /// + /// The full-day date. + /// + /// + /// Only the date component of the given is used, and any time data is truncated. + /// + /// + /// See Todoist documentation for more information. + /// + /// + /// The created instance. + public static DueDate CreateFullDay(DateTime date) => new DueDate + { + Date = date.Date, + IsFullDay = true + }; + + /// + /// Creates a floating . + /// + /// The floating date. + /// + /// + /// The given is treated as , and any timezone data is truncated. + /// + /// + /// Floating due dates always represent an event in the current user's timezone. + ///
+ /// Note that it's not quite compatible with RFC 3339, + /// because the concept of timezone is not applicable to this object. + ///
+ /// + /// See Todoist documentation for more information. + /// + ///
+ /// The created instance. + public static DueDate CreateFloating(DateTime dateTime) => new DueDate + { + Date = DateTime.SpecifyKind(dateTime, DateTimeKind.Unspecified), // Floating dates are unspecified. + IsFullDay = false + }; + + /// + /// Creates a fixed timezone . + /// + /// The fixed timezone date. + /// The timezone of the due instance. + /// + /// + /// Fixed due date is stored in UTC, hence, of kind is assumed UTC, + ///
and of kind is converted to UTC based on the system timezone. + ///
+ /// + /// See Todoist documentation for more information. + /// + ///
+ /// The created instance. + public static DueDate CreateFixedTimeZone(DateTime dateTime, string timezone) + { + if (dateTime.Kind == DateTimeKind.Unspecified) + { + dateTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc); // Unspecified dates are assumed UTC. + } + return new DueDate + { + Date = dateTime.ToUniversalTime(), // Local dates are converted to UTC. + IsFullDay = false, + Timezone = timezone + }; + } + } }